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
+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) => {
@@ -73,7 +73,7 @@
$effect(() => {
rows;
pageIndex = 0;
if (pageIndex !== 0) pageIndex = 0;
});
function toggleSort(col: DataTableColumn<T>) {
@@ -1,112 +0,0 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
export type KpiAccent = {
border: string;
bg: string;
iconBg: string;
iconText: string;
};
export type KpiCardItem = {
id: string;
label: string;
value: string;
description: string;
icon: Component;
accent: KpiAccent;
badge: string;
badgeClass?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
valueClass?: string;
error?: string | null;
href?: string;
};
type Props = {
cards: KpiCardItem[];
loading?: boolean;
skeletonCount?: number;
class?: string;
};
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid auto-rows-fr gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each cards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
{#if card.href}
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={card.href}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else}
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{/if}
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="mt-auto space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -4,7 +4,7 @@
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import EmptyState from '$lib/components/empty-state.svelte';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
type Props = {
data: T | undefined;
@@ -29,7 +29,7 @@
const valueVariantClass: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'text-warning',
warning: 'text-warning-foreground',
destructive: 'text-destructive'
};
+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>
+73 -68
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),
@@ -105,9 +105,19 @@
void queryClient.invalidateQueries({ queryKey: modulesKeys.all });
}
$effect(() => {
function pruneSelectedModuleIds() {
const validIds = new Set(rows.map((item) => item.id));
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
for (const id of selectedModuleIds) {
if (!validIds.has(id)) {
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
return;
}
}
}
$effect(() => {
rows;
pruneSelectedModuleIds();
});
function toggleModuleSelection(id: string) {
@@ -161,8 +171,6 @@
description={lastUpdated
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
icon={Boxes}
iconClass="bg-chart-1/15 text-chart-1"
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={refetchModules} disabled={loading}>
@@ -191,15 +199,14 @@
{#if initialLoading}
<SectionCardsSkeleton count={3} class="sm:grid-cols-3" />
{:else}
<SectionCards items={kpiCards} class="sm:grid-cols-3" />
<SectionCards items={sectionCards} class="sm:grid-cols-3" />
{/if}
<Card>
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
<div>
<CardTitle class="text-base">Список модулей</CardTitle>
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
</div>
<DataTableCard
title="Список модулей"
description="Клик по названию открывает карточку модуля и его записи."
>
{#snippet toolbar()}
{#if selectedModulesCount > 0}
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
@@ -214,65 +221,63 @@
</Button>
</div>
{/if}
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...moduleColumns]}
{rows}
rowKey={(m) => m.id}
loading={initialLoading || loading}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль."
>
{#snippet toolbar()}
{#if rows.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allModulesSelected}
onCheckedChange={(v) => toggleAllModules(v === true)}
aria-label="Выбрать все модули"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: m, column })}
{#if column.id === 'select'}
{/snippet}
<AppDataTable
columns={[...moduleColumns]}
{rows}
rowKey={(m) => m.id}
loading={initialLoading || loading}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль."
>
{#snippet toolbar()}
{#if rows.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={selectedModuleIds.has(m.id)}
aria-label={`Выбрать модуль ${m.name}`}
onCheckedChange={() => toggleModuleSelection(m.id)}
checked={allModulesSelected}
onCheckedChange={(v) => toggleAllModules(v === true)}
aria-label="Выбрать все модули"
/>
{:else if column.id === 'name'}
<div class="flex items-center gap-2">
<span class="font-medium">{m.name}</span>
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else if column.id === 'type'}
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
{:else if column.id === 'priority'}
<span class="text-muted-foreground">{m.priority}</span>
{:else if column.id === 'interval'}
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground"
>{formatDateTime(m.last_refreshed_at)}</span
>
{:else if column.id === 'status'}
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
{moduleEnabledRu(!!m.enabled)}
</Badge>
{:else if column.id === 'actions'}
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: m, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedModuleIds.has(m.id)}
aria-label={`Выбрать модуль ${m.name}`}
onCheckedChange={() => toggleModuleSelection(m.id)}
/>
{:else if column.id === 'name'}
<div class="flex items-center gap-2">
<span class="font-medium">{m.name}</span>
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" />
<ExternalLink class="size-3.5" aria-hidden="true" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
</div>
{:else if column.id === 'type'}
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
{:else if column.id === 'priority'}
<span class="text-muted-foreground">{m.priority}</span>
{:else if column.id === 'interval'}
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground"
>{formatDateTime(m.last_refreshed_at)}</span
>
{:else if column.id === 'status'}
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
{moduleEnabledRu(!!m.enabled)}
</Badge>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</DataTableCard>
</PageShell>
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={refetchModules} />
+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 {