feat(ui): refactor components and enhance UX
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m36s

Updated various UI components to improve user experience and maintainability. Key changes include:
- Refactored `PageHeader` to remove icon handling, simplifying its structure.
- Replaced `KpiMetricsGrid` with `SectionCards` for better layout consistency.
- Enhanced `AppShell` and navigation components to utilize a grouped navigation structure, improving accessibility and organization.
- Introduced new properties in `crud-list-page` for handling empty states and loading indicators.
- Updated styles and classes for better visual consistency across components.

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