Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fef952403 | ||
|
|
07ee6acddb |
@@ -31,7 +31,7 @@ apps/web/src/lib/components/ ← shared + domain + layout
|
||||
| Элемент | Компонент | Primitive |
|
||||
|---------|-----------|-----------|
|
||||
| Page wrapper | `PageShell` | — |
|
||||
| Page title | `PageHeader` (`ui/app/page-header`) | — |
|
||||
| Page title | `PageHeader` (`ui/app/page-header`) — **без icon** | — |
|
||||
| Stat metrics | `SectionCards` | `Card` |
|
||||
| Data list | `DataTableCard` + `AppDataTable` | `Table` |
|
||||
| Filters | `ListFiltersBar` | `Select`, `Badge` |
|
||||
@@ -40,8 +40,16 @@ apps/web/src/lib/components/ ← shared + domain + layout
|
||||
| Status | `StatusBadge` | `Badge` |
|
||||
| Create/Edit form | `FormSheet` / Dialog | `Sheet`, `Field` |
|
||||
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
||||
| Nav | `AppShell` | `Sidebar` |
|
||||
| Breadcrumbs | `AppShell` header | `Breadcrumb` |
|
||||
| Nav | `AppShell` + `navGroups` | `Sidebar` |
|
||||
| Breadcrumbs | `AppShell` header (parent route) | `Breadcrumb` |
|
||||
| Tabs (dashboard, ops) | `LINE_TAB_TRIGGER_CLASS` | `Tabs` |
|
||||
|
||||
## Запрещено
|
||||
|
||||
- `icon` / `iconClass` в `PageHeader`
|
||||
- `KpiMetricsGrid` — только `SectionCards`
|
||||
- `mx-auto max-w-*` на `PageShell` list-страниц
|
||||
- Голый `AppDataTable` без `DataTableCard` на list-экранах
|
||||
|
||||
## Spacing
|
||||
|
||||
|
||||
@@ -39,9 +39,14 @@ pnpm --filter @evobgp/web lint
|
||||
|
||||
После правок `apps/web/**` — **обязательно** `check` и `lint` (WEB-19).
|
||||
|
||||
## UX-эталон
|
||||
## UX-эталон (vps-tracker, без ReUI)
|
||||
|
||||
vps-tracker: `PageShell`, `SectionCards`, `QueryState`, `DataTableCard`, sidebar-07 layout. ReUI не используется — Data Table на shadcn-svelte.
|
||||
- **Shell:** `layout/app-shell.svelte` — sidebar-07, `collapsible="icon"`, 5 nav-групп (`nav.ts`), `AppBrand`, `ThemeMenu` в top header, breadcrumb с parent
|
||||
- **Страница:** `PageShell` → `PageHeader` (без icon) → `QueryState` / контент
|
||||
- **Метрики:** `SectionCards` (не KpiMetricsGrid)
|
||||
- **Таблицы:** `DataTableCard` + `AppDataTable` (не голый `Card` + `Table`)
|
||||
- **Tabs:** line-variant через `LINE_TAB_TRIGGER_CLASS` (`$lib/ui/app/tabs.ts`)
|
||||
- **Тема:** `packages/ui/src/styles/globals.css` синхронизирован с vps-tracker (semantic tokens)
|
||||
|
||||
## Коммиты
|
||||
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import PageShell from '$lib/components/page-shell.svelte';
|
||||
import QueryState from '$lib/components/query-state.svelte';
|
||||
import TableSkeleton from '$lib/components/table-skeleton.svelte';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: Snippet;
|
||||
data: unknown;
|
||||
data: T | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
empty?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyAction?: Snippet;
|
||||
skeleton?: Snippet;
|
||||
content: Snippet;
|
||||
sheet?: Snippet;
|
||||
children: Snippet<[T]>;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -26,8 +32,13 @@
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
empty = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
skeleton,
|
||||
content
|
||||
sheet,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -39,15 +50,24 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
{#if isLoading}
|
||||
{#if skeleton}
|
||||
{@render skeleton()}
|
||||
{/if}
|
||||
{:else if isError}
|
||||
<QueryState {data} isLoading={false} isError={true} {error} {onRetry} children={emptyChild} />
|
||||
{:else}
|
||||
{@render content()}
|
||||
<QueryState
|
||||
{data}
|
||||
{isLoading}
|
||||
{isError}
|
||||
{error}
|
||||
{onRetry}
|
||||
{empty}
|
||||
{emptyTitle}
|
||||
{emptyDescription}
|
||||
{emptyAction}
|
||||
skeleton={skeleton ?? tableSkeleton}
|
||||
{children}
|
||||
/>
|
||||
{#if sheet}
|
||||
{@render sheet()}
|
||||
{/if}
|
||||
</PageShell>
|
||||
|
||||
{#snippet emptyChild()}{/snippet}
|
||||
{#snippet tableSkeleton()}
|
||||
<TableSkeleton />
|
||||
{/snippet}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: Component;
|
||||
action?: Snippet;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { title = 'Нет данных', description, icon: Icon, action, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('flex flex-col items-center justify-center gap-2 px-4 py-12 text-center', className)}
|
||||
>
|
||||
{#if Icon}
|
||||
<div class="mb-1 text-muted-foreground/60" aria-hidden="true">
|
||||
<Icon class="size-10" />
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-sm font-medium">{title}</p>
|
||||
{#if description}
|
||||
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
{#if action}
|
||||
<div class="mt-2">{@render action()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,7 +6,8 @@
|
||||
import * as Breadcrumb from '@evobgp/ui/components/breadcrumb/index.js';
|
||||
import { Separator } from '@evobgp/ui/components/separator/index.js';
|
||||
import type { ThemePreference } from '$lib/theme.js';
|
||||
import { mainNav, bottomNav } from '$lib/ui/app/layout/nav.js';
|
||||
import { allNavItems, navGroups, parentRoute, routeLabels } from '$lib/ui/app/layout/nav.js';
|
||||
import AppBrand from '$lib/ui/app/layout/app-brand.svelte';
|
||||
import ThemeMenu from '$lib/ui/app/layout/theme-menu.svelte';
|
||||
import AppVersion from '$lib/ui/app/layout/app-version.svelte';
|
||||
import AppMobileNav from '$lib/ui/app/layout/app-mobile-nav.svelte';
|
||||
@@ -20,14 +21,6 @@
|
||||
|
||||
let mobileNavOpen = $state(false);
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
'/': 'Обзор',
|
||||
...Object.fromEntries(mainNav.map((i) => [i.href, i.label])),
|
||||
...Object.fromEntries(bottomNav.map((i) => [i.href, i.label]))
|
||||
};
|
||||
|
||||
const breadcrumbLabel = $derived(routeLabels[page.url.pathname] ?? 'EvoBGP');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const navHref = (href: string) => resolve(href as any);
|
||||
|
||||
@@ -36,78 +29,74 @@
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
}
|
||||
|
||||
const activeItem = $derived(allNavItems.find((i) => isActive(i.href)) ?? allNavItems[0]);
|
||||
|
||||
const parentHref = $derived(parentRoute[activeItem.href]);
|
||||
const parentLabel = $derived(parentHref ? routeLabels[parentHref] : null);
|
||||
const currentLabel = $derived(routeLabels[activeItem.href] ?? 'EvoBGP');
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<Sidebar.Root>
|
||||
<Sidebar.Header class="border-b border-sidebar-border">
|
||||
<div class="flex items-center gap-2 px-2 py-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
|
||||
<a href={navHref('/')} class="truncate font-semibold tracking-tight">EvoBGP</a>
|
||||
<p class="truncate text-xs text-muted-foreground">Панель управления</p>
|
||||
</div>
|
||||
<ThemeMenu bind:theme />
|
||||
</div>
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header>
|
||||
<AppBrand />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>Операции</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each mainNav as item (item.href)}
|
||||
{@const Icon = item.icon}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={isActive(item.href)}>
|
||||
{#snippet child({ props })}
|
||||
<a href={navHref(item.href)} {...props}>
|
||||
<Icon class="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
{#each navGroups as group (group.label)}
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>{group.label}</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each group.items as item (item.href)}
|
||||
{@const Icon = item.icon}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={isActive(item.href)} tooltipContent={item.label}>
|
||||
{#snippet child({ props })}
|
||||
<a href={navHref(item.href)} {...props}>
|
||||
<Icon class="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
{/each}
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer class="border-t border-sidebar-border">
|
||||
<Sidebar.Menu>
|
||||
{#each bottomNav as item (item.href)}
|
||||
{@const Icon = item.icon}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={isActive(item.href)}>
|
||||
{#snippet child({ props })}
|
||||
<a href={navHref(item.href)} {...props}>
|
||||
<Icon class="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
<Sidebar.Footer>
|
||||
<AppVersion />
|
||||
</Sidebar.Footer>
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
||||
<Sidebar.Inset>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
class="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
>
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
|
||||
<span class="font-semibold tracking-tight">EvoBGP</span>
|
||||
</div>
|
||||
<Sidebar.Trigger class="-ms-1 hidden md:flex" />
|
||||
<Separator orientation="vertical" class="mx-2 hidden h-4 md:block" />
|
||||
<Separator orientation="vertical" class="mr-2 hidden h-4 md:block" />
|
||||
<Breadcrumb.Root class="hidden min-w-0 md:flex">
|
||||
<Breadcrumb.List>
|
||||
{#if parentLabel && parentHref}
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
<Breadcrumb.Link href={navHref(parentHref)}>{parentLabel}</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
{/if}
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Page>{breadcrumbLabel}</Breadcrumb.Page>
|
||||
<Breadcrumb.Page>{currentLabel}</Breadcrumb.Page>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<ThemeMenu bind:theme class="hidden md:flex" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
|
||||
{@render children()}
|
||||
|
||||
@@ -26,13 +26,7 @@
|
||||
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
|
||||
<ArrowLeft class="size-4" />
|
||||
</Button>
|
||||
<PageHeader
|
||||
class="min-w-0 flex-1"
|
||||
title={mod.name}
|
||||
description={mod.id}
|
||||
icon={Blocks}
|
||||
iconClass="bg-chart-2/15 text-chart-2"
|
||||
>
|
||||
<PageHeader class="min-w-0 flex-1" title={mod.name} description={mod.id}>
|
||||
{#snippet actions()}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
@@ -41,45 +42,6 @@
|
||||
onSpeakerSelect
|
||||
}: Props = $props();
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-3',
|
||||
bg: 'bg-chart-3/5',
|
||||
iconBg: 'bg-chart-3/15',
|
||||
iconText: 'text-chart-3'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
},
|
||||
{
|
||||
border: 'border-l-warning',
|
||||
bg: 'bg-warning/5',
|
||||
iconBg: 'bg-warning/15',
|
||||
iconText: 'text-warning'
|
||||
},
|
||||
{
|
||||
border: 'border-l-destructive',
|
||||
bg: 'bg-destructive/5',
|
||||
iconBg: 'bg-destructive/15',
|
||||
iconText: 'text-destructive'
|
||||
},
|
||||
{
|
||||
border: 'border-l-info',
|
||||
bg: 'bg-info/10',
|
||||
iconBg: 'bg-info/15',
|
||||
iconText: 'text-info'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
|
||||
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||
@@ -91,76 +53,51 @@
|
||||
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||
});
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
const sectionCards = $derived.by((): SectionCardItem[] => [
|
||||
{
|
||||
id: 'peers',
|
||||
label: 'BGP-пиры',
|
||||
value: initialLoading ? '—' : String(metrics.peersTotal),
|
||||
description: initialLoading
|
||||
hint: initialLoading
|
||||
? ''
|
||||
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
|
||||
: `${metrics.peersEstablished} Established / ${metrics.peersEnabled} вкл.`,
|
||||
icon: Share2,
|
||||
accent: statAccents[0],
|
||||
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
|
||||
badgeClass:
|
||||
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
|
||||
variant: metrics.peersMismatch > 0 ? 'warning' : 'default'
|
||||
},
|
||||
{
|
||||
id: 'established',
|
||||
label: 'Активные сессии',
|
||||
value: initialLoading ? '—' : String(metrics.peersEstablished),
|
||||
description: 'Established среди включённых пиров',
|
||||
icon: CheckCircle2,
|
||||
accent: statAccents[1],
|
||||
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
|
||||
badgeClass:
|
||||
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||
hint: 'Established',
|
||||
icon: CheckCircle2
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
label: 'Спикеры online',
|
||||
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
|
||||
description: 'agent + BGP poll',
|
||||
hint: 'agent + BGP poll',
|
||||
icon: Server,
|
||||
accent: statAccents[2],
|
||||
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
|
||||
badgeClass:
|
||||
metrics.speakersOnline === metrics.speakersTotal
|
||||
? 'border-success/30 bg-success/15 text-success'
|
||||
: 'border-warning/30 bg-warning/15 text-warning'
|
||||
variant: metrics.speakersOnline < metrics.speakersTotal ? 'warning' : 'default'
|
||||
},
|
||||
{
|
||||
id: 'drift',
|
||||
label: 'Drift',
|
||||
value: initialLoading ? '—' : String(metrics.speakersDrift),
|
||||
description: 'applied ≠ published',
|
||||
hint: 'applied ≠ published',
|
||||
icon: GitBranch,
|
||||
accent: statAccents[3],
|
||||
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
|
||||
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
|
||||
variant: metrics.speakersDrift > 0 ? 'warning' : 'default'
|
||||
},
|
||||
{
|
||||
id: 'poll-errors',
|
||||
label: 'Ошибки опроса',
|
||||
value: initialLoading ? '—' : String(metrics.pollErrors),
|
||||
description: 'agent или BGP poll',
|
||||
hint: 'agent / BGP poll',
|
||||
icon: Activity,
|
||||
accent: statAccents[4],
|
||||
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
|
||||
badgeClass:
|
||||
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||
variant: metrics.pollErrors > 0 ? 'destructive' : 'default'
|
||||
},
|
||||
{
|
||||
id: 'cp-bird',
|
||||
label: 'BGP на CP',
|
||||
value: initialLoading ? '—' : birdText,
|
||||
description: bird?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: (bird?.message ?? 'birdc не настроен'),
|
||||
hint: bird?.birdc_configured ? 'Established / total' : (bird?.message ?? 'birdc N/A'),
|
||||
icon: Bird,
|
||||
accent: statAccents[5],
|
||||
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
|
||||
href: '/monitoring' as const
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/monitoring');
|
||||
}
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
@@ -206,12 +143,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading || loading}
|
||||
skeletonCount={6}
|
||||
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||
/>
|
||||
{#if initialLoading || loading}
|
||||
<SectionCardsSkeleton count={6} class="sm:grid-cols-2 xl:grid-cols-3" />
|
||||
{:else}
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-2 xl:grid-cols-3" />
|
||||
{/if}
|
||||
|
||||
<section class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import DataTableCard from '$lib/components/data-table-card.svelte';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
@@ -51,44 +45,36 @@
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Последние задачи</CardTitle>
|
||||
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
|
||||
</div>
|
||||
<DataTableCard title="Последние задачи" description="Фоновые задачи ingest, refresh и apply">
|
||||
{#snippet toolbar()}
|
||||
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
|
||||
Все
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription="Задачи появятся после refresh или деплоя."
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(j.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/snippet}
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription="Задачи появятся после refresh или деплоя."
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(j.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</DataTableCard>
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
import type { RevisionRow } from '$lib/api/types.js';
|
||||
import { formatDateTime } from '$lib/modules/display.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import DataTableCard from '$lib/components/data-table-card.svelte';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
@@ -46,44 +40,39 @@
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Последние ревизии</CardTitle>
|
||||
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
|
||||
</div>
|
||||
<DataTableCard
|
||||
title="Последние ревизии"
|
||||
description="Снимки конфигурации BIRD после обновления модулей"
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||
Все
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(rev) => rev.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет ревизий"
|
||||
emptyDescription="Ревизии появятся после обновления модулей."
|
||||
>
|
||||
{#snippet cell({ row: rev, column })}
|
||||
{#if column.id === 'id'}
|
||||
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(rev.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'prefixes'}
|
||||
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/snippet}
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(rev) => rev.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет ревизий"
|
||||
emptyDescription="Ревизии появятся после обновления модулей."
|
||||
>
|
||||
{#snippet cell({ row: rev, column })}
|
||||
{#if column.id === 'id'}
|
||||
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(rev.created_at)}
|
||||
</span>
|
||||
{:else if column.id === 'prefixes'}
|
||||
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</DataTableCard>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class={state.destructive
|
||||
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: ''}
|
||||
disabled={state.loading}
|
||||
onclick={(e) => {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
$effect(() => {
|
||||
rows;
|
||||
pageIndex = 0;
|
||||
if (pageIndex !== 0) pageIndex = 0;
|
||||
});
|
||||
|
||||
function toggleSort(col: DataTableColumn<T>) {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
export type KpiAccent = {
|
||||
border: string;
|
||||
bg: string;
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
};
|
||||
|
||||
export type KpiCardItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
accent: KpiAccent;
|
||||
badge: string;
|
||||
badgeClass?: string;
|
||||
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
valueClass?: string;
|
||||
error?: string | null;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cards: KpiCardItem[];
|
||||
loading?: boolean;
|
||||
skeletonCount?: number;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('grid auto-rows-fr gap-4', className)}>
|
||||
{#if loading}
|
||||
{#each Array(skeletonCount) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each cards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
|
||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||
a.border,
|
||||
a.bg
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
{#if card.href}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
<Button variant="ghost" size="icon-sm" href={card.href}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{/if}
|
||||
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
|
||||
>{card.value}</CardTitle
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="mt-auto space-y-2">
|
||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||
>{card.badge}</Badge
|
||||
>
|
||||
{#if card.error}
|
||||
<p class="text-xs text-destructive">{card.error}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
|
||||
import EmptyState from '$lib/components/empty-state.svelte';
|
||||
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
|
||||
|
||||
type Props = {
|
||||
data: T | undefined;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
const valueVariantClass: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'text-warning',
|
||||
warning: 'text-warning-foreground',
|
||||
destructive: 'text-destructive'
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { BadgeVariant } from '@evobgp/ui/components/badge/badge.svelte';
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
type SemanticStatus = 'success' | 'warning' | 'info';
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'default',
|
||||
ok: 'default',
|
||||
enabled: 'default',
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant | SemanticStatus> = {
|
||||
active: 'success',
|
||||
ok: 'success',
|
||||
enabled: 'success',
|
||||
paid: 'success',
|
||||
paused: 'secondary',
|
||||
disabled: 'secondary',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
failed: 'destructive',
|
||||
running: 'outline',
|
||||
warning: 'outline',
|
||||
stale: 'outline'
|
||||
running: 'info',
|
||||
warning: 'warning',
|
||||
stale: 'warning',
|
||||
overdue: 'warning'
|
||||
};
|
||||
|
||||
const SEMANTIC_CLASS: Record<SemanticStatus, string> = {
|
||||
success: 'border-success/30 bg-success/10 text-success-foreground',
|
||||
warning: 'border-warning/30 bg-warning/10 text-warning-foreground',
|
||||
info: 'border-info/30 bg-info/10 text-info-foreground'
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -23,8 +34,12 @@
|
||||
|
||||
let { status, label }: Props = $props();
|
||||
|
||||
const variant = $derived(STATUS_VARIANT[status] ?? 'outline');
|
||||
const mapped = $derived(STATUS_VARIANT[status] ?? 'outline');
|
||||
const isSemantic = $derived(mapped === 'success' || mapped === 'warning' || mapped === 'info');
|
||||
const variant = $derived(isSemantic ? 'outline' : (mapped as BadgeVariant));
|
||||
const text = $derived(label ?? status);
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{text}</Badge>
|
||||
<Badge {variant} class={cn(isSemantic ? SEMANTIC_CLASS[mapped as SemanticStatus] : undefined)}>
|
||||
{text}
|
||||
</Badge>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import TenantRevisionSettingsCard from '$lib/components/tenant-settings/TenantRevisionSettingsCard.svelte';
|
||||
import TenantAdditionalSettingsCard from '$lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte';
|
||||
import TenantRuntimeLogsSettingsCard from '$lib/components/tenant-settings/TenantRuntimeLogsSettingsCard.svelte';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import { LINE_TAB_TRIGGER_CLASS } from '$lib/ui/app/tabs.js';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
type TenantSettingsTab = 'bird' | 'revision' | 'runtime-logs' | 'additional';
|
||||
@@ -49,8 +49,6 @@
|
||||
<PageHeader
|
||||
title="Параметры tenant"
|
||||
description="Параметры control plane для текущего tenant (API /v1/settings). Токен и тема интерфейса — в разделе «Настройки»."
|
||||
icon={SlidersHorizontal}
|
||||
iconClass="bg-chart-5/15 text-chart-5"
|
||||
/>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
@@ -63,12 +61,12 @@
|
||||
</Alert>
|
||||
|
||||
<Tabs bind:value={activeTab}>
|
||||
<div class="[scrollbar-gutter:stable] overflow-x-auto pb-1">
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||
<div class="border-b">
|
||||
<TabsList class="h-auto w-full justify-start rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="bird" class={LINE_TAB_TRIGGER_CLASS}>BIRD</TabsTrigger>
|
||||
<TabsTrigger value="revision" class={LINE_TAB_TRIGGER_CLASS}>Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs" class={LINE_TAB_TRIGGER_CLASS}>Файловые логи</TabsTrigger>
|
||||
<TabsTrigger value="additional" class={LINE_TAB_TRIGGER_CLASS}>Дополнительно</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import * as Sidebar from '@evobgp/ui/components/sidebar/index.js';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const homeHref = resolve('/' as any);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton size="lg">
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href={homeHref}
|
||||
{...props}
|
||||
class={cn(String(props.class ?? ''), 'hover:bg-sidebar-accent')}
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Share2 class="size-4" />
|
||||
</div>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<span class="truncate font-semibold">EvoBGP</span>
|
||||
<span class="truncate text-xs text-muted-foreground">Панель управления BGP</span>
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -10,8 +10,10 @@
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import type { ThemePreference } from '$lib/theme.js';
|
||||
import Menu from '@lucide/svelte/icons/menu';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import AppNavLinks from './app-nav-links.svelte';
|
||||
import ThemeMenu from './theme-menu.svelte';
|
||||
import AppVersion from './app-version.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -29,17 +31,28 @@
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="flex w-72 flex-col gap-0 bg-sidebar p-0 text-sidebar-foreground">
|
||||
<SheetHeader class="border-b border-border p-4 text-left">
|
||||
<SheetTitle>
|
||||
<a href={resolve('/')} class="font-semibold tracking-tight no-underline">EvoBGP</a>
|
||||
</SheetTitle>
|
||||
<p class="text-xs font-normal text-sidebar-foreground/50">Панель управления</p>
|
||||
<SheetHeader class="border-b border-sidebar-border p-4 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Share2 class="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle>
|
||||
<a href={resolve('/')} class="font-semibold tracking-tight no-underline">EvoBGP</a>
|
||||
</SheetTitle>
|
||||
<p class="text-xs font-normal text-muted-foreground">Панель управления BGP</p>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<ThemeMenu bind:theme />
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<AppNavLinks onNavigate={() => (open = false)} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-sidebar-border p-4">
|
||||
<ThemeMenu bind:theme />
|
||||
<AppVersion />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import { page } from '$app/state';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { buttonVariants } from '@evobgp/ui/components/button/index.js';
|
||||
import { Separator } from '@evobgp/ui/components/separator/index.js';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@evobgp/ui/components/tooltip/index.js';
|
||||
import { bottomNav, mainNav } from './nav.js';
|
||||
import { navGroups } from './nav.js';
|
||||
|
||||
type Props = {
|
||||
collapsed?: boolean;
|
||||
@@ -40,57 +39,39 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{#each mainNav as item (item.href)}
|
||||
{@const Icon = item.icon}
|
||||
{@const active = isActive(item.href)}
|
||||
{#if collapsed}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<nav class="flex flex-1 flex-col gap-4 overflow-y-auto p-2">
|
||||
{#each navGroups as group (group.label)}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
||||
{#each group.items as item (item.href)}
|
||||
{@const Icon = item.icon}
|
||||
{@const active = isActive(item.href)}
|
||||
{#if collapsed}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<a
|
||||
href={navHref(item.href)}
|
||||
class={linkClass(active, true)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onclick={onNavigate}
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<a
|
||||
href={navHref(item.href)}
|
||||
class={linkClass(active, true)}
|
||||
class={linkClass(active, false)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onclick={onNavigate}
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<a
|
||||
href={navHref(item.href)}
|
||||
class={linkClass(active, false)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onclick={onNavigate}
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-0.5 p-2">
|
||||
<Separator class="mb-2" />
|
||||
{#each bottomNav as item (item.href)}
|
||||
{@const Icon = item.icon}
|
||||
{@const active = isActive(item.href)}
|
||||
{#if collapsed}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<a href={navHref(item.href)} class={linkClass(active, true)} onclick={onNavigate}>
|
||||
<Icon class="size-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<a href={navHref(item.href)} class={linkClass(active, false)} onclick={onNavigate}>
|
||||
<Icon class="size-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: Snippet;
|
||||
mobileNav?: Snippet;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { title, description, actions, mobileNav, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class={cn(
|
||||
'sticky top-0 z-30 flex flex-col gap-3 border-b border-border bg-background/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
{#if mobileNav}
|
||||
<div class="shrink-0 pt-0.5 md:hidden">{@render mobileNav()}</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if title}
|
||||
<h1 class="text-lg font-semibold tracking-tight md:text-xl">{title}</h1>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-0.5 text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -9,24 +9,75 @@ import Network from '@lucide/svelte/icons/network';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: Component;
|
||||
};
|
||||
|
||||
export const mainNav: NavItem[] = [
|
||||
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
|
||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
||||
{ href: '/network', label: 'Сеть', icon: Network },
|
||||
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
|
||||
{ href: '/tenant-settings', label: 'Параметры', icon: SlidersHorizontal }
|
||||
export type NavGroup = {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ href: '/', label: 'Обзор', icon: LayoutDashboard }]
|
||||
},
|
||||
{
|
||||
label: 'BGP',
|
||||
items: [
|
||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
||||
{ href: '/network', label: 'Сеть', icon: Network }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Мониторинг',
|
||||
items: [{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }]
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ href: '/tenant-settings', label: 'Параметры', icon: SlidersHorizontal },
|
||||
{ href: '/access', label: 'Права доступа', icon: Shield },
|
||||
{ href: '/settings', label: 'Настройки', icon: Settings }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const bottomNav: NavItem[] = [
|
||||
{ href: '/access', label: 'Права доступа', icon: Shield },
|
||||
{ href: '/settings', label: 'Настройки', icon: Settings }
|
||||
];
|
||||
export const allNavItems = navGroups.flatMap((g) => g.items);
|
||||
|
||||
export const routeLabels: Record<string, string> = Object.fromEntries(
|
||||
allNavItems.map((i) => [i.href, i.label])
|
||||
);
|
||||
|
||||
/** Parent breadcrumb route (most sections → overview). */
|
||||
export const parentRoute: Record<string, string> = {
|
||||
'/modules': '/',
|
||||
'/directories': '/',
|
||||
'/network': '/',
|
||||
'/operations': '/',
|
||||
'/schedule': '/',
|
||||
'/monitoring': '/',
|
||||
'/tenant-settings': '/',
|
||||
'/access': '/',
|
||||
'/settings': '/'
|
||||
};
|
||||
|
||||
/** @deprecated Use navGroups */
|
||||
export const mainNav = navGroups
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.href !== '/access' && i.href !== '/settings');
|
||||
|
||||
/** @deprecated Use navGroups */
|
||||
export const bottomNav = allNavItems.filter((i) => i.href === '/access' || i.href === '/settings');
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
actions?: Snippet;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { title, description, icon: Icon, iconClass, actions, class: className }: Props = $props();
|
||||
let { title, description, actions, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between', className)}>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
{#if Icon}
|
||||
<div
|
||||
class={cn(
|
||||
'flex size-11 shrink-0 items-center justify-center rounded-xl',
|
||||
iconClass ?? 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class="size-6" />
|
||||
</div>
|
||||
<div class={cn('flex flex-col gap-2 md:flex-row md:items-center md:justify-between', className)}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{#if description}
|
||||
<p class="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>
|
||||
<div class="flex items-center gap-2">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Line-variant tab triggers (vps-tracker dashboard-01 pattern). */
|
||||
export const LINE_TAB_TRIGGER_CLASS =
|
||||
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:border-foreground dark:data-[state=active]:bg-transparent';
|
||||
+185
-103
@@ -14,19 +14,15 @@
|
||||
} from '$lib/api/types.js';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
|
||||
import PageShell from '$lib/components/page-shell.svelte';
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import DataTableCard from '$lib/components/data-table-card.svelte';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
|
||||
import OverviewNetworkStatusCard from '$lib/components/overview/OverviewNetworkStatusCard.svelte';
|
||||
@@ -35,22 +31,22 @@
|
||||
import { modulesQueryOptions } from '$lib/queries/modules.js';
|
||||
import { jobsQueryOptions, revisionsQueryOptions } from '$lib/queries/operations.js';
|
||||
import { peersQueryOptions, speakersQueryOptions } from '$lib/queries/network.js';
|
||||
import { LINE_TAB_TRIGGER_CLASS } from '$lib/ui/app/tabs.js';
|
||||
import Boxes from '@lucide/svelte/icons/boxes';
|
||||
import GitBranch from '@lucide/svelte/icons/git-branch';
|
||||
import Radio from '@lucide/svelte/icons/radio';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Tags from '@lucide/svelte/icons/tags';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
|
||||
type InventoryIssue = { key: string; title: string; count: number; href: string; hint?: string };
|
||||
|
||||
let activeTab = $state('issues');
|
||||
|
||||
const healthQuery = createQuery(healthQueryOptions());
|
||||
const modulesQuery = createQuery(modulesQueryOptions(200));
|
||||
@@ -117,6 +113,55 @@
|
||||
);
|
||||
const networkMetrics = $derived(aggregateNetworkMetrics(peerItems, speakerItems));
|
||||
|
||||
const inventoryIssues = $derived.by((): InventoryIssue[] => {
|
||||
const issues: InventoryIssue[] = [];
|
||||
if (!healthy && !isLoading) {
|
||||
issues.push({
|
||||
key: 'api',
|
||||
title: 'API недоступен',
|
||||
count: 1,
|
||||
href: '/monitoring',
|
||||
hint: 'health-check'
|
||||
});
|
||||
}
|
||||
if (networkMetrics.peersMismatch > 0) {
|
||||
issues.push({
|
||||
key: 'peers',
|
||||
title: 'Пиры не в Established',
|
||||
count: networkMetrics.peersMismatch,
|
||||
href: '/network?tab=peers'
|
||||
});
|
||||
}
|
||||
if (networkMetrics.speakersOnline < networkMetrics.speakersTotal) {
|
||||
issues.push({
|
||||
key: 'speakers',
|
||||
title: 'Спикеры offline',
|
||||
count: networkMetrics.speakersTotal - networkMetrics.speakersOnline,
|
||||
href: '/network?tab=overview'
|
||||
});
|
||||
}
|
||||
if (networkMetrics.speakersDrift > 0) {
|
||||
issues.push({
|
||||
key: 'drift',
|
||||
title: 'Drift конфигурации спикеров',
|
||||
count: networkMetrics.speakersDrift,
|
||||
href: '/network?tab=overview'
|
||||
});
|
||||
}
|
||||
const failedJobs = jobItems.filter((j) => j.status === 'failed').length;
|
||||
if (failedJobs > 0) {
|
||||
issues.push({
|
||||
key: 'jobs',
|
||||
title: 'Задачи с ошибкой',
|
||||
count: failedJobs,
|
||||
href: '/operations?tab=jobs'
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
});
|
||||
|
||||
const issuesCount = $derived(inventoryIssues.reduce((s, i) => s + i.count, 0));
|
||||
|
||||
function countBadge(count: number, hasMore: boolean, suffix: string) {
|
||||
if (hasMore) return '200+';
|
||||
return suffix;
|
||||
@@ -126,7 +171,7 @@
|
||||
{
|
||||
label: 'Модули',
|
||||
value: isLoading ? '—' : String(moduleItems.length),
|
||||
hint: 'AS, CDN, домены, IP',
|
||||
hint: countBadge(moduleItems.length, modulesHasMore, 'AS, CDN, домены'),
|
||||
icon: Boxes,
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/modules');
|
||||
@@ -164,14 +209,31 @@
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: isLoading ? '—' : String(runningJobs),
|
||||
hint: 'running',
|
||||
hint: 'running / queued',
|
||||
icon: Clock,
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/operations?tab=jobs');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Проблемы',
|
||||
value: isLoading ? '—' : String(issuesCount),
|
||||
hint: issuesCount > 0 ? 'требуют внимания' : 'нет',
|
||||
icon: AlertTriangle,
|
||||
variant: issuesCount > 0 ? 'destructive' : 'default',
|
||||
active: issuesCount > 0,
|
||||
onClick: () => {
|
||||
activeTab = 'issues';
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const issueColumns = [
|
||||
{ id: 'title', label: 'Проблема', sortable: true, sortValue: (i: InventoryIssue) => i.title },
|
||||
{ id: 'count', label: 'Кол-во', sortable: true, sortValue: (i: InventoryIssue) => i.count },
|
||||
{ id: 'actions', label: 'Действие', class: 'w-24' }
|
||||
] as const;
|
||||
|
||||
function refreshAll() {
|
||||
void queryClient.invalidateQueries();
|
||||
}
|
||||
@@ -183,8 +245,6 @@
|
||||
description={lastUpdated
|
||||
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Состояние панели управления EvoBGP.'}
|
||||
icon={LayoutDashboard}
|
||||
iconClass="bg-primary/10 text-primary"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={refreshAll} disabled={isRefetching}>
|
||||
@@ -194,113 +254,135 @@
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сводка по модулям, сети и фоновым задачам. BGP и ноды —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}>Сеть</Button
|
||||
>, префиксы —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
|
||||
здоровье API —
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{#if $healthQuery.isLoading}
|
||||
{#if issuesCount > 0}
|
||||
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
|
||||
<AlertTriangle class="text-destructive" />
|
||||
<AlertTitle>Требуется внимание!</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>
|
||||
Обнаружено {issuesCount}
|
||||
{issuesCount === 1 ? 'проблема' : issuesCount < 5 ? 'проблемы' : 'проблем'} в инфраструктуре
|
||||
BGP.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onclick={() => (activeTab = 'issues')}>
|
||||
К проблемам
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else if !isLoading && healthy && !loadError}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>Инфраструктура в порядке</AlertTitle>
|
||||
<AlertDescription>
|
||||
API доступен, критических проблем в сети и задачах не обнаружено.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else if $healthQuery.isLoading}
|
||||
<Alert>
|
||||
<Skeleton class="size-5 rounded-full" />
|
||||
<AlertTitle>Проверка API…</AlertTitle>
|
||||
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
|
||||
</Alert>
|
||||
{:else if healthy && !loadError}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>API работает</AlertTitle>
|
||||
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||
</Alert>
|
||||
{:else if healthy && loadError}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<Info class="text-warning" />
|
||||
<Info class="text-warning-foreground" />
|
||||
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
||||
<AlertDescription>
|
||||
{loadError}. Для локального демо укажите Bearer-токен
|
||||
<code class="text-xs">dev</code> в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/settings')}>Настройках</Button>
|
||||
(нужен <code class="text-xs">EVOBGP_DEV_INSECURE=1</code> на API).
|
||||
{loadError}. Укажите Bearer-токен в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/settings')}>Настройках</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
{:else if !healthy}
|
||||
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
|
||||
<XCircle class="text-destructive" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
{loadError ??
|
||||
'Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080), в dev — `npm run dev` с прокси Vite, в Docker — контейнер evobgp-api / evobgp-all и nginx в evobgp-web.'}
|
||||
{loadError ?? 'Не удалось получить ответ от сервера. Проверьте, что API запущен.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<SectionCardsSkeleton count={5} />
|
||||
<SectionCardsSkeleton count={6} />
|
||||
{:else}
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-2 lg:grid-cols-3" />
|
||||
<SectionCards items={sectionCards} />
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<OverviewRecentJobsCard
|
||||
items={recentJobs}
|
||||
{moduleNameById}
|
||||
loading={isRefetching}
|
||||
initialLoading={isLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
<OverviewRecentRevisionsCard
|
||||
items={recentRevisions}
|
||||
loading={isRefetching}
|
||||
initialLoading={isLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
<OverviewNetworkStatusCard
|
||||
peers={peerItems}
|
||||
speakers={speakerItems}
|
||||
loading={isRefetching}
|
||||
initialLoading={isLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
</div>
|
||||
<Tabs bind:value={activeTab}>
|
||||
<div class="border-b">
|
||||
<TabsList class="h-auto w-full justify-start rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="issues" class={LINE_TAB_TRIGGER_CLASS}>
|
||||
Проблемы
|
||||
{#if issuesCount > 0}
|
||||
<Badge variant="destructive" class="ms-1.5 size-5 justify-center p-0 text-xs">
|
||||
{issuesCount}
|
||||
</Badge>
|
||||
{/if}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="jobs" class={LINE_TAB_TRIGGER_CLASS}>Последние задачи</TabsTrigger>
|
||||
<TabsTrigger value="revisions" class={LINE_TAB_TRIGGER_CLASS}>Последние ревизии</TabsTrigger
|
||||
>
|
||||
<TabsTrigger value="network" class={LINE_TAB_TRIGGER_CLASS}>Сеть</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="border-b py-3">
|
||||
<CardTitle class="text-base">Быстрые действия</CardTitle>
|
||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2 p-4 pt-4">
|
||||
<Button variant="outline" size="sm" href={resolve('/modules')}>
|
||||
<Plus class="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/directories')}>
|
||||
<Tags class="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
|
||||
<NetworkIcon class="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/network?tab=peers')}>
|
||||
<Share2 class="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>
|
||||
<Play class="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||
<Gauge class="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="issues" class="mt-4">
|
||||
<DataTableCard title="Проблемы инфраструктуры" description="Требуют проверки оператора">
|
||||
<AppDataTable
|
||||
columns={[...issueColumns]}
|
||||
rows={inventoryIssues}
|
||||
rowKey={(i) => i.key}
|
||||
loading={isLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Проблем не обнаружено"
|
||||
emptyDescription="BGP-инфраструктура в норме."
|
||||
>
|
||||
{#snippet cell({ row: issue, column })}
|
||||
{#if column.id === 'title'}
|
||||
<span class="font-medium">{issue.title}</span>
|
||||
{#if issue.hint}
|
||||
<span class="ms-1 text-xs text-muted-foreground">· {issue.hint}</span>
|
||||
{/if}
|
||||
{:else if column.id === 'count'}
|
||||
<span class="tabular-nums">{issue.count}</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="link" class="h-auto p-0" href={resolve(issue.href as '/')}>
|
||||
Открыть
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</DataTableCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" class="mt-4">
|
||||
<OverviewRecentJobsCard
|
||||
items={recentJobs}
|
||||
{moduleNameById}
|
||||
loading={isRefetching}
|
||||
initialLoading={isLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revisions" class="mt-4">
|
||||
<OverviewRecentRevisionsCard
|
||||
items={recentRevisions}
|
||||
loading={isRefetching}
|
||||
initialLoading={isLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" class="mt-4">
|
||||
<OverviewNetworkStatusCard
|
||||
peers={peerItems}
|
||||
speakers={speakerItems}
|
||||
loading={isRefetching}
|
||||
initialLoading={isLoading}
|
||||
error={loadError}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
|
||||
@@ -42,12 +42,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageShell class="mx-auto max-w-4xl">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Права доступа"
|
||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||
icon={Shield}
|
||||
iconClass="bg-primary/10 text-primary"
|
||||
/>
|
||||
|
||||
{#if session}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
|
||||
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import PageShell from '$lib/components/page-shell.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -42,55 +42,33 @@
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
|
||||
const statAccents = [
|
||||
const sectionCards = $derived.by((): SectionCardItem[] => [
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-3',
|
||||
bg: 'bg-chart-3/5',
|
||||
iconBg: 'bg-chart-3/15',
|
||||
iconText: 'text-chart-3'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'communities',
|
||||
label: 'Сообщества BGP',
|
||||
value: initialLoading ? '—' : String(communities.length),
|
||||
description: 'теги префиксов в AS- и CDN-модулях',
|
||||
hint: 'community',
|
||||
icon: Tags,
|
||||
accent: statAccents[0],
|
||||
badge: 'community'
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/directories');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'doh',
|
||||
label: 'DoH профили',
|
||||
value: initialLoading ? '—' : String(dohProfiles.length),
|
||||
description: 'резолвинг доменных модулей',
|
||||
hint: 'DNS-over-HTTPS',
|
||||
icon: Globe,
|
||||
accent: statAccents[1],
|
||||
badge: 'DNS-over-HTTPS'
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/directories');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'shared',
|
||||
label: 'Справочники',
|
||||
value: 'Общие',
|
||||
description: 'используются всеми модулями tenant',
|
||||
hint: 'tenant-wide',
|
||||
icon: Library,
|
||||
accent: statAccents[2],
|
||||
badge: 'tenant-wide',
|
||||
href: '/modules' as const
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/modules');
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -150,8 +128,6 @@
|
||||
description={lastUpdated
|
||||
? `Сообщества BGP и DoH-профили для резолвинга доменов. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Сообщества BGP и DoH-профили для резолвинга доменов.'}
|
||||
icon={BookOpen}
|
||||
iconClass="bg-chart-2/15 text-chart-2"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
@@ -171,12 +147,11 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
{#if initialLoading}
|
||||
<SectionCardsSkeleton count={3} />
|
||||
{:else}
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-3" />
|
||||
{/if}
|
||||
|
||||
<Tabs value="communities">
|
||||
<TabsList>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||
import DataTableCard from '$lib/components/data-table-card.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -59,7 +59,7 @@
|
||||
const enabledCount = $derived(rows.filter((m) => m.enabled).length);
|
||||
const disabledCount = $derived(rows.filter((m) => !m.enabled).length);
|
||||
|
||||
const kpiCards = $derived.by((): SectionCardItem[] => [
|
||||
const sectionCards = $derived.by((): SectionCardItem[] => [
|
||||
{
|
||||
label: 'Всего модулей',
|
||||
value: initialLoading ? '—' : String(rows.length),
|
||||
@@ -105,9 +105,19 @@
|
||||
void queryClient.invalidateQueries({ queryKey: modulesKeys.all });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
function pruneSelectedModuleIds() {
|
||||
const validIds = new Set(rows.map((item) => item.id));
|
||||
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||
for (const id of selectedModuleIds) {
|
||||
if (!validIds.has(id)) {
|
||||
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
rows;
|
||||
pruneSelectedModuleIds();
|
||||
});
|
||||
|
||||
function toggleModuleSelection(id: string) {
|
||||
@@ -161,8 +171,6 @@
|
||||
description={lastUpdated
|
||||
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
|
||||
icon={Boxes}
|
||||
iconClass="bg-chart-1/15 text-chart-1"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={refetchModules} disabled={loading}>
|
||||
@@ -191,15 +199,14 @@
|
||||
{#if initialLoading}
|
||||
<SectionCardsSkeleton count={3} class="sm:grid-cols-3" />
|
||||
{:else}
|
||||
<SectionCards items={kpiCards} class="sm:grid-cols-3" />
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-3" />
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||
<div>
|
||||
<CardTitle class="text-base">Список модулей</CardTitle>
|
||||
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
|
||||
</div>
|
||||
<DataTableCard
|
||||
title="Список модулей"
|
||||
description="Клик по названию открывает карточку модуля и его записи."
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if selectedModulesCount > 0}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
|
||||
@@ -214,65 +221,63 @@
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...moduleColumns]}
|
||||
{rows}
|
||||
rowKey={(m) => m.id}
|
||||
loading={initialLoading || loading}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль."
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if rows.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allModulesSelected}
|
||||
onCheckedChange={(v) => toggleAllModules(v === true)}
|
||||
aria-label="Выбрать все модули"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet cell({ row: m, column })}
|
||||
{#if column.id === 'select'}
|
||||
{/snippet}
|
||||
<AppDataTable
|
||||
columns={[...moduleColumns]}
|
||||
{rows}
|
||||
rowKey={(m) => m.id}
|
||||
loading={initialLoading || loading}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль."
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
{#if rows.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedModuleIds.has(m.id)}
|
||||
aria-label={`Выбрать модуль ${m.name}`}
|
||||
onCheckedChange={() => toggleModuleSelection(m.id)}
|
||||
checked={allModulesSelected}
|
||||
onCheckedChange={(v) => toggleAllModules(v === true)}
|
||||
aria-label="Выбрать все модули"
|
||||
/>
|
||||
{:else if column.id === 'name'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'priority'}
|
||||
<span class="text-muted-foreground">{m.priority}</span>
|
||||
{:else if column.id === 'interval'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||
{:else if column.id === 'refreshed'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(m.last_refreshed_at)}</span
|
||||
>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
|
||||
{moduleEnabledRu(!!m.enabled)}
|
||||
</Badge>
|
||||
{:else if column.id === 'actions'}
|
||||
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet cell({ row: m, column })}
|
||||
{#if column.id === 'select'}
|
||||
<Checkbox
|
||||
checked={selectedModuleIds.has(m.id)}
|
||||
aria-label={`Выбрать модуль ${m.name}`}
|
||||
onCheckedChange={() => toggleModuleSelection(m.id)}
|
||||
/>
|
||||
{:else if column.id === 'name'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'priority'}
|
||||
<span class="text-muted-foreground">{m.priority}</span>
|
||||
{:else if column.id === 'interval'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||
{:else if column.id === 'refreshed'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(m.last_refreshed_at)}</span
|
||||
>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
|
||||
{moduleEnabledRu(!!m.enabled)}
|
||||
</Badge>
|
||||
{:else if column.id === 'actions'}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</DataTableCard>
|
||||
</PageShell>
|
||||
|
||||
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={refetchModules} />
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
TableRow
|
||||
} from '@evobgp/ui/components/table/index.js';
|
||||
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import { LINE_TAB_TRIGGER_CLASS } from '$lib/ui/app/tabs.js';
|
||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||
import PageShell from '$lib/components/page-shell.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
@@ -96,33 +98,6 @@
|
||||
let mainTab = $state<MainTab>('system');
|
||||
let tabSyncReady = $state(false);
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
},
|
||||
{
|
||||
border: 'border-l-info',
|
||||
bg: 'bg-info/10',
|
||||
iconBg: 'bg-info/15',
|
||||
iconText: 'text-info'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const overallStatus = $derived.by(() =>
|
||||
deriveOverallStatus({
|
||||
health,
|
||||
@@ -179,69 +154,43 @@
|
||||
jobs: ListTodo
|
||||
};
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
const sectionCards = $derived.by((): SectionCardItem[] => [
|
||||
{
|
||||
id: 'overall',
|
||||
label: 'Общий статус',
|
||||
value: initialLoading ? '—' : overallStatusLabel(overallStatus),
|
||||
description: overallHint,
|
||||
hint: overallHint,
|
||||
icon: Server,
|
||||
accent: statAccents[0],
|
||||
badge: overallStatusLabel(overallStatus),
|
||||
badgeVariant: overallBadgeVariant(overallStatus),
|
||||
badgeClass: overallBadgeClass(overallStatus),
|
||||
error: null as string | null,
|
||||
href: undefined
|
||||
variant:
|
||||
overallStatus === 'error' ? 'destructive' : overallStatus === 'warn' ? 'warning' : 'default'
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
label: 'BGP сессии',
|
||||
value: initialLoading ? '—' : bgpText,
|
||||
description: bird?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: (bird?.message ?? 'birdc не настроен на API-хосте'),
|
||||
hint: bird?.birdc_configured ? 'Established / total' : (bird?.message ?? 'birdc N/A'),
|
||||
icon: Bird,
|
||||
accent: statAccents[1],
|
||||
badge: !bird ? '—' : !bird.birdc_configured ? 'N/A' : bird.healthy ? 'В норме' : 'Деградация',
|
||||
badgeVariant: !bird?.birdc_configured
|
||||
? ('outline' as const)
|
||||
: bird?.healthy
|
||||
? ('default' as const)
|
||||
: ('secondary' as const),
|
||||
badgeClass:
|
||||
bird?.birdc_configured && bird?.healthy
|
||||
? 'border-success/30 bg-success/15 text-success'
|
||||
: undefined,
|
||||
error: birdError ?? bird?.error ?? null,
|
||||
href: '/network?tab=overview' as const
|
||||
variant: bird?.birdc_configured && !bird?.healthy ? 'warning' : 'default',
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/network?tab=overview');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Задачи',
|
||||
value: initialLoading ? '—' : String(jobs?.running ?? '—'),
|
||||
description: `Активных из ${jobs?.total ?? '—'} последних`,
|
||||
hint: `активных из ${jobs?.total ?? '—'}`,
|
||||
icon: Activity,
|
||||
accent: statAccents[2],
|
||||
badge: jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок',
|
||||
badgeVariant: jobs && jobs.failed > 0 ? ('secondary' as const) : ('default' as const),
|
||||
badgeClass:
|
||||
jobs && jobs.failed === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||
error: jobsError,
|
||||
href: jobs && jobs.failed > 0 ? ('/operations' as const) : undefined
|
||||
variant: jobs && jobs.failed > 0 ? 'warning' : 'default',
|
||||
onClick:
|
||||
jobs && jobs.failed > 0
|
||||
? () => {
|
||||
window.location.href = resolve('/operations');
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
{
|
||||
id: 'version',
|
||||
label: 'Версия',
|
||||
value: initialLoading ? '—' : versionText,
|
||||
description: versionFooter || 'GET /v1/version',
|
||||
icon: Hash,
|
||||
accent: statAccents[3],
|
||||
badge: version ? 'Загружена' : '—',
|
||||
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||||
badgeClass: undefined,
|
||||
valueClass: 'font-mono text-xl',
|
||||
error: versionError,
|
||||
href: undefined
|
||||
hint: versionFooter || 'GET /v1/version',
|
||||
icon: Hash
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -346,8 +295,6 @@
|
||||
description={lastUpdated
|
||||
? `Состояние API, BGP и задач. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Состояние API, BGP и задач для быстрой диагностики инцидентов.'}
|
||||
icon={Gauge}
|
||||
iconClass="bg-info/15 text-info"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||
@@ -358,11 +305,13 @@
|
||||
</PageHeader>
|
||||
|
||||
<Tabs bind:value={mainTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">Система</TabsTrigger>
|
||||
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
</TabsList>
|
||||
<div class="border-b">
|
||||
<TabsList class="h-auto w-full justify-start rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="system" class={LINE_TAB_TRIGGER_CLASS}>Система</TabsTrigger>
|
||||
<TabsTrigger value="postgres" class={LINE_TAB_TRIGGER_CLASS}>PostgreSQL</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs" class={LINE_TAB_TRIGGER_CLASS}>Файловые логи</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="system" class="mt-4 flex flex-col gap-6">
|
||||
{#if !initialLoading}
|
||||
@@ -387,12 +336,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={4}
|
||||
class="sm:grid-cols-2 xl:grid-cols-4"
|
||||
/>
|
||||
{#if initialLoading}
|
||||
<SectionCardsSkeleton count={4} />
|
||||
{:else}
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-2 xl:grid-cols-4" />
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{#if initialLoading}
|
||||
|
||||
@@ -172,8 +172,6 @@
|
||||
description={lastUpdated
|
||||
? `BGP-топология CP и нод. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'BGP-пиры, спикеры и live-метрики нод.'}
|
||||
icon={NetworkIcon}
|
||||
iconClass="bg-chart-3/15 text-chart-3"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" size="sm" href={resolve('/')}>
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||
import VirtualPrefixList from '$lib/components/patterns/virtual-list/virtual-prefix-list.svelte';
|
||||
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import { LINE_TAB_TRIGGER_CLASS } from '$lib/ui/app/tabs.js';
|
||||
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||
import PageShell from '$lib/components/page-shell.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
@@ -376,27 +378,6 @@
|
||||
void refreshActiveTab();
|
||||
});
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const runningJobsCount = $derived(
|
||||
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
);
|
||||
@@ -408,36 +389,31 @@
|
||||
}).length
|
||||
);
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
const sectionCards = $derived.by((): SectionCardItem[] => [
|
||||
{
|
||||
id: 'revisions',
|
||||
label: 'Ревизий',
|
||||
value: initialLoading ? '—' : String(revisions.length),
|
||||
description: 'в последней выборке',
|
||||
icon: Activity,
|
||||
accent: statAccents[0],
|
||||
badge: 'история конфигов'
|
||||
hint: 'история конфигов',
|
||||
icon: Activity
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
label: 'Активных задач',
|
||||
value: initialLoading ? '—' : String(runningJobsCount),
|
||||
description: 'queued и running',
|
||||
icon: Clock,
|
||||
accent: statAccents[1],
|
||||
badge: 'в работе'
|
||||
hint: 'queued / running',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
id: 'failed',
|
||||
label: 'Задач с ошибкой',
|
||||
value: initialLoading ? '—' : String(failedJobsCount),
|
||||
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||
hint: failedJobsCount > 0 ? 'требуют внимания' : 'нет',
|
||||
icon: AlertTriangle,
|
||||
accent: statAccents[2],
|
||||
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
|
||||
badgeClass:
|
||||
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||
href: failedJobsCount > 0 ? ('/schedule' as const) : undefined
|
||||
variant: failedJobsCount > 0 ? 'destructive' : 'default',
|
||||
onClick:
|
||||
failedJobsCount > 0
|
||||
? () => {
|
||||
window.location.href = resolve('/schedule');
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -922,8 +898,6 @@
|
||||
description={lastUpdated
|
||||
? `Деплой, сравнение конфигураций и задачи. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Деплой конфигурации, управление ревизиями и задачами.'}
|
||||
icon={Activity}
|
||||
iconClass="bg-chart-2/15 text-chart-2"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={() => refreshAll()} disabled={refreshing}>
|
||||
@@ -945,12 +919,11 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
{#if initialLoading}
|
||||
<SectionCardsSkeleton count={3} />
|
||||
{:else}
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-3" />
|
||||
{/if}
|
||||
|
||||
<OperationsQuickActions
|
||||
{applying}
|
||||
@@ -966,11 +939,14 @@
|
||||
/>
|
||||
|
||||
<Tabs bind:value={activeTab}>
|
||||
<div class="[scrollbar-gutter:stable] overflow-x-auto pb-1">
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||
<div class="border-b">
|
||||
<TabsList class="h-auto w-full justify-start rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="revisions" class={LINE_TAB_TRIGGER_CLASS}>
|
||||
Ревизии ({revisions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diff" class={LINE_TAB_TRIGGER_CLASS}>Сравнение</TabsTrigger>
|
||||
<TabsTrigger value="jobs" class={LINE_TAB_TRIGGER_CLASS}>Задачи ({jobs.length})</TabsTrigger
|
||||
>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import DataTableCard from '$lib/components/data-table-card.svelte';
|
||||
import SectionCards, { type SectionCardItem } from '$lib/components/section-cards.svelte';
|
||||
import SectionCardsSkeleton from '$lib/components/section-cards-skeleton.svelte';
|
||||
import PageShell from '$lib/components/page-shell.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
@@ -48,27 +49,6 @@
|
||||
let refreshing = $state(false);
|
||||
let jobsTab = $state<JobsTab>('all');
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const moduleColumns = [
|
||||
{ id: 'name', label: 'Модуль', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||
{ id: 'type', label: 'Тип', sortable: true, sortValue: (m: ModuleRow) => m.type },
|
||||
@@ -125,38 +105,34 @@
|
||||
return jobs;
|
||||
});
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
const sectionCards = $derived.by((): SectionCardItem[] => [
|
||||
{
|
||||
id: 'total',
|
||||
label: 'Всего задач',
|
||||
value: initialLoading ? '—' : String(jobs.length),
|
||||
description: 'в последней выборке',
|
||||
hint: 'в выборке',
|
||||
icon: ListTodo,
|
||||
accent: statAccents[0],
|
||||
badge: 'в выборке',
|
||||
href: '/operations' as const
|
||||
onClick: () => {
|
||||
window.location.href = resolve('/operations');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
label: 'В работе',
|
||||
value: initialLoading ? '—' : String(runningJobsCount),
|
||||
description: 'queued и running',
|
||||
icon: Clock,
|
||||
accent: statAccents[1],
|
||||
badge: 'активных',
|
||||
href: undefined
|
||||
hint: 'queued / running',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
id: 'failed',
|
||||
label: 'С ошибкой',
|
||||
value: initialLoading ? '—' : String(failedJobsCount),
|
||||
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||
hint: failedJobsCount > 0 ? 'требуют внимания' : 'нет',
|
||||
icon: AlertTriangle,
|
||||
accent: statAccents[2],
|
||||
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
|
||||
badgeClass:
|
||||
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||
href: failedJobsCount > 0 ? ('/operations' as const) : undefined
|
||||
variant: failedJobsCount > 0 ? 'destructive' : 'default',
|
||||
onClick:
|
||||
failedJobsCount > 0
|
||||
? () => {
|
||||
window.location.href = resolve('/operations');
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -211,8 +187,6 @@
|
||||
description={lastUpdated
|
||||
? `Интервалы обновления модулей и ручной запуск. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Интервалы обновления модулей и ручной запуск обновления.'}
|
||||
icon={CalendarClock}
|
||||
iconClass="bg-chart-4/15 text-chart-4"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||
@@ -236,124 +210,110 @@
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading}
|
||||
skeletonCount={3}
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
{#if initialLoading}
|
||||
<SectionCardsSkeleton count={3} />
|
||||
{:else}
|
||||
<SectionCards items={sectionCards} class="sm:grid-cols-3" />
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Модули</CardTitle>
|
||||
<CardDescription>
|
||||
Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...moduleColumns]}
|
||||
rows={modules}
|
||||
rowKey={(m) => m.id}
|
||||
loading={initialLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте модуль на странице «Модули»."
|
||||
>
|
||||
{#snippet cell({ row: m, column })}
|
||||
{#if column.id === 'name'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'schedule'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||
{:else if column.id === 'refreshed'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(m.last_refreshed_at)}</span
|
||||
>
|
||||
{:else if column.id === 'status'}
|
||||
{#if m.enabled}
|
||||
<Badge variant="default" class="text-xs">Вкл</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="text-xs">Выкл</Badge>
|
||||
{/if}
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!!moduleRefreshing[m.id]}
|
||||
onclick={() => refreshModule(m.id)}
|
||||
>
|
||||
<RefreshCw class={moduleRefreshing[m.id] ? 'animate-spin' : ''} />
|
||||
{moduleRefreshing[m.id] ? '…' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle class="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Последние 100 задач из API</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4 p-4 pt-0">
|
||||
<Tabs bind:value={jobsTab}>
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">
|
||||
Обновление модулей ({jobs.filter((j) => j.kind === 'module_refresh').length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({failedJobsCount})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={jobsTab} class="mt-4">
|
||||
<AppDataTable
|
||||
columns={[...jobColumns]}
|
||||
rows={filteredJobs}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription={jobsTab === 'failed'
|
||||
? 'В выборке нет задач с ошибкой.'
|
||||
: jobsTab === 'refresh'
|
||||
? 'Задач обновления модулей пока нет.'
|
||||
: 'Задачи появятся после refresh или деплоя.'}
|
||||
<DataTableCard
|
||||
title="Модули"
|
||||
description="Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204)"
|
||||
>
|
||||
<AppDataTable
|
||||
columns={[...moduleColumns]}
|
||||
rows={modules}
|
||||
rowKey={(m) => m.id}
|
||||
loading={initialLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте модуль на странице «Модули»."
|
||||
>
|
||||
{#snippet cell({ row: m, column })}
|
||||
{#if column.id === 'name'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'schedule'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||
{:else if column.id === 'refreshed'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(m.last_refreshed_at)}</span
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(j.created_at)}</span
|
||||
>
|
||||
{:else if column.id === 'finished'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(j.finished_at)}</span
|
||||
>
|
||||
{:else if column.id === 'error'}
|
||||
<span class="text-xs text-destructive">{truncateError(j.error)}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if column.id === 'status'}
|
||||
{#if m.enabled}
|
||||
<Badge variant="default" class="text-xs">Вкл</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="text-xs">Выкл</Badge>
|
||||
{/if}
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!!moduleRefreshing[m.id]}
|
||||
onclick={() => refreshModule(m.id)}
|
||||
>
|
||||
<RefreshCw class={moduleRefreshing[m.id] ? 'animate-spin' : ''} />
|
||||
{moduleRefreshing[m.id] ? '…' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</DataTableCard>
|
||||
|
||||
<DataTableCard title="Задачи" description="Последние 100 задач из API">
|
||||
{#snippet toolbar()}
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||||
{/snippet}
|
||||
<Tabs bind:value={jobsTab}>
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">
|
||||
Обновление модулей ({jobs.filter((j) => j.kind === 'module_refresh').length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({failedJobsCount})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={jobsTab} class="mt-4">
|
||||
<AppDataTable
|
||||
columns={[...jobColumns]}
|
||||
rows={filteredJobs}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription={jobsTab === 'failed'
|
||||
? 'В выборке нет задач с ошибкой.'
|
||||
: jobsTab === 'refresh'
|
||||
? 'Задач обновления модулей пока нет.'
|
||||
: 'Задачи появятся после refresh или деплоя.'}
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(j.created_at)}</span
|
||||
>
|
||||
{:else if column.id === 'finished'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(j.finished_at)}</span
|
||||
>
|
||||
{:else if column.id === 'error'}
|
||||
<span class="text-xs text-destructive">{truncateError(j.error)}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DataTableCard>
|
||||
</PageShell>
|
||||
|
||||
@@ -57,13 +57,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageShell class="mx-auto max-w-3xl">
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Параметры интерфейса и подключения браузера к API."
|
||||
icon={SettingsIcon}
|
||||
iconClass="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<PageShell>
|
||||
<PageHeader title="Настройки" description="Параметры интерфейса и подключения браузера к API." />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.72 0.16 75);
|
||||
--info: oklch(0.55 0.16 250);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
@@ -42,8 +39,17 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--page-padding: 1.5rem;
|
||||
--section-gap: 1.5rem;
|
||||
--destructive-foreground: var(--color-red-800);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-900);
|
||||
--info: var(--color-violet-500);
|
||||
--info-foreground: var(--color-violet-900);
|
||||
--warning: var(--color-yellow-500);
|
||||
--warning-foreground: var(--color-yellow-900);
|
||||
--invert: var(--color-zinc-900);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--focus-foreground: var(--color-blue-900);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -51,7 +57,7 @@
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.269 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
@@ -59,7 +65,7 @@
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
@@ -70,9 +76,6 @@
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--success: oklch(0.72 0.17 150);
|
||||
--warning: oklch(0.82 0.16 85);
|
||||
--info: oklch(0.72 0.12 250);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
@@ -80,14 +83,21 @@
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
--info: var(--color-violet-500);
|
||||
--info-foreground: var(--color-violet-600);
|
||||
--warning: var(--color-yellow-500);
|
||||
--warning-foreground: var(--color-yellow-600);
|
||||
--invert: var(--color-zinc-700);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--focus-foreground: var(--color-blue-600);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -111,9 +121,6 @@
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
@@ -122,8 +129,21 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--spacing-page: var(--page-padding);
|
||||
--spacing-section: var(--section-gap);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-invert-foreground: var(--invert-foreground);
|
||||
--color-invert: var(--invert);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-info: var(--info);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-focus: var(--focus);
|
||||
--color-focus-foreground: var(--focus-foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user