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

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

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