Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec 2fef952403 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.
2026-07-01 00:42:50 +07:00
Denozordec 07ee6acddb fix(data-table): prevent unnecessary page reset on data change
Updated the data table component to only reset the page index to 0 when it is not already at 0, improving user experience during data updates. Additionally, refactored the module selection logic to prune invalid selected module IDs more efficiently, enhancing the overall functionality of the module management interface.
2026-07-01 00:27:39 +07:00
33 changed files with 886 additions and 1101 deletions

No files matched your search

+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) => {
@@ -73,7 +73,7 @@
$effect(() => { $effect(() => {
rows; rows;
pageIndex = 0; if (pageIndex !== 0) pageIndex = 0;
}); });
function toggleSort(col: DataTableColumn<T>) { function toggleSort(col: DataTableColumn<T>) {
@@ -1,112 +0,0 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
export type KpiAccent = {
border: string;
bg: string;
iconBg: string;
iconText: string;
};
export type KpiCardItem = {
id: string;
label: string;
value: string;
description: string;
icon: Component;
accent: KpiAccent;
badge: string;
badgeClass?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
valueClass?: string;
error?: string | null;
href?: string;
};
type Props = {
cards: KpiCardItem[];
loading?: boolean;
skeletonCount?: number;
class?: string;
};
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid auto-rows-fr gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each cards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
{#if card.href}
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={card.href}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else}
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{/if}
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="mt-auto space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -4,7 +4,7 @@
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import 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>
+73 -68
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),
@@ -105,9 +105,19 @@
void queryClient.invalidateQueries({ queryKey: modulesKeys.all }); void queryClient.invalidateQueries({ queryKey: modulesKeys.all });
} }
$effect(() => { function pruneSelectedModuleIds() {
const validIds = new Set(rows.map((item) => item.id)); const validIds = new Set(rows.map((item) => item.id));
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id))); for (const id of selectedModuleIds) {
if (!validIds.has(id)) {
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
return;
}
}
}
$effect(() => {
rows;
pruneSelectedModuleIds();
}); });
function toggleModuleSelection(id: string) { function toggleModuleSelection(id: string) {
@@ -161,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}>
@@ -191,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>
@@ -214,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 {