Compare commits

...
1 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 3b79d834cd feat(web): dashboard as on the shared mockup board
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 1m24s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 3m7s
Панель переписана по доске BgpDashboard: действия в шапке, KPI-полоса, вкладка «Обзор», быстрые действия карточками, график префиксов по последним ревизиям, таблица модулей и лента активности. Графики загрузки BGP и потока операций убраны с панели — они живут в «Сети» и «Мониторинге».

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:38:11 +07:00
3 changed files with 130 additions and 47 deletions

No files matched your search

@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@evobgp/ui/components/chart'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { fmtWhen } from '@/components/portal/format'
import type { RevisionRow } from '@/types/api'
const chartConfig = {
prefixes: { label: 'Префиксов', color: 'var(--chart-1)' },
} satisfies ChartConfig
/** Префиксов в опубликованной конфигурации по последним ревизиям (доска «Панель»). */
export function DashboardPrefixTrend({ revisions, loading }: { revisions: RevisionRow[]; loading?: boolean }) {
const data = useMemo(
() =>
[...revisions]
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.slice(-12)
.map((r) => ({ id: r.id, when: fmtWhen(r.created_at), prefixes: r.materialized_prefix_count })),
[revisions],
)
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-col items-start gap-0.5">
<FrameTitle>Префиксов в опубликованной конфигурации</FrameTitle>
<FrameDescription className="text-pretty">Последние {data.length || 12} ревизий</FrameDescription>
</FrameHeader>
<FramePanel className="p-3">
{data.length < 2 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{loading ? 'Загрузка…' : 'Недостаточно ревизий для графика'}
</div>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
<AreaChart data={data} margin={{ left: 4, right: 12, top: 8 }}>
<defs>
<linearGradient id="prefix-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-prefixes)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-prefixes)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="id" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
<YAxis domain={([min, max]) => [Math.floor(min * 0.98), Math.ceil(max * 1.01)]} tickLine={false} axisLine={false} width={48} tickFormatter={(v: number) => v.toLocaleString('ru-RU')} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_, p) => p?.[0]?.payload?.when ?? ''} />} />
<Area dataKey="prefixes" type="monotone" stroke="var(--color-prefixes)" strokeWidth={2} fill="url(#prefix-fill)" />
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}
+74 -46
View File
@@ -1,24 +1,28 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries, useQuery } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { Boxes, Play, RefreshCw, Search, Share2 } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { DashboardPrefixTrend } from '@/components/dashboard/dashboard-prefix-trend'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { fmtWhen } from '@/components/portal/format'
import type { Tone } from '@/components/portal/tone'
import { OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName } from '@/lib/ui-surface'
import { KpiStatGrid } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
import { PageTab, PageTabsList } from '@/components/portal/bits'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Tabs, TabsContent } from '@evobgp/ui/components/tabs'
import {
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
} from '@/queries/overview'
import { settingsQueryOptions } from '@/queries/settings'
@@ -41,6 +45,7 @@ function parseShowQuickActions(value: unknown): boolean {
* @see https://reui.io/preview/base/chart-27
*/
function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
@@ -54,6 +59,7 @@ function DashboardComponent() {
],
})
const revisionsQ = useQuery(overviewRevisionsQueryOptions())
const [modulesQ, peersQ, speakersQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || jobsQ.isLoading
@@ -78,7 +84,7 @@ function DashboardComponent() {
const running = j.status === 'running' || j.status === 'queued'
const tone: Tone = failed ? 'er' : running ? 'in' : 'ok'
return {
t: fmtWhen(j.finished_at ?? j.started_at ?? j.created_at),
t: fmtWhen(j.finished_at ?? j.started_at ?? j.created_at).replace(/^сегодня /, ''),
title: j.kind,
text: j.error ?? `Статус: ${j.status}`,
label: failed ? 'Error' : running ? 'Running' : 'Info',
@@ -86,48 +92,70 @@ function DashboardComponent() {
}
})
const quickActions: AppCardItem[] = [
{ id: 'new-module', title: 'Создать модуль', desc: 'AS, CDN, домены или IP-диапазоны', badge: 'Модули', meta: '', icon: <Boxes />, onSelect: () => void navigate({ to: '/modules', search: { create: true } }) },
{ id: 'lookup', title: 'Проверить IP или домен', desc: 'Есть ли адрес в списках и community', badge: 'Проверка', meta: '', icon: <Search />, onSelect: () => void navigate({ to: '/lookup', search: { q: '' } }) },
{ id: 'add-peer', title: 'Добавить пира', desc: 'Новый BGP-сосед на спикере', badge: 'Сеть', meta: '', icon: <Share2 />, onSelect: () => void navigate({ to: '/network', search: { tab: 'peers' } }) },
{ id: 'deploy', title: 'Деплой', desc: 'Публикация ревизии на спикеры', badge: 'Операции', badgeVariant: 'warning-light', meta: '', icon: <Play />, onSelect: () => void navigate({ to: '/operations', search: { tab: 'revisions' } }) },
]
if (initialLoading) {
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Обзор" description="Метрики, модули и активность" />
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
)
}
return (
<OpsDashboard
title="Обзор"
description={
lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'
}
headerActions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
kpiCards={kpiCards}
isLoading={initialLoading}
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
queue={
<div className="flex min-w-0 flex-col gap-5">
<div className="flex min-w-0 flex-col gap-4 md:gap-6">
<PageHeader
title="Обзор"
description={
lastUpdated
? `Метрики, модули и активность · обновлено ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Метрики, модули и активность'
}
actions={
<>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button onClick={() => void navigate({ to: '/operations', search: { tab: 'revisions' } })}>Применить конфигурацию</Button>
</>
}
/>
<KpiStatGrid cards={kpiCards} aria-label="Ключевые метрики" />
<Tabs defaultValue="over" className="min-w-0">
<PageTabsList>
<PageTab value="over" label="Обзор" />
</PageTabsList>
<TabsContent value="over" className="flex min-w-0 flex-col gap-4 md:gap-6">
{showQuickActions ? (
<AppCards title="Быстрые действия" description="Частые операции в один клик" items={quickActions} />
) : null}
<DashboardPrefixTrend revisions={revisionsQ.data?.items ?? []} loading={revisionsQ.isLoading} />
<ModulesListGrid items={modules.slice(0, 5)} isLoading={modulesQ.isLoading} title="Модули" />
{activity.length > 0 ? (
<ActivityFeed title="Активность" description="Последние события плоскости управления" items={activity} />
<ActivityFeed
title="Активность"
description="Последние события плоскости управления"
items={activity}
action={
<Button variant="outline" onClick={() => void navigate({ to: '/operations', search: { tab: 'jobs' } })}>
Весь журнал
</Button>
}
/>
) : null}
</div>
}
queueTitle=""
queueDescription=""
charts={
<div className={chartPanelGridClassName}>
<DashboardNetworkHealth
peers={peers}
speakers={speakers}
jobs={jobs}
loading={refreshing && peers.length === 0 && speakers.length === 0}
/>
<DashboardOperationsBreakdown
jobs={jobs}
modules={modules}
loading={refreshing && jobs.length === 0 && modules.length === 0}
/>
</div>
}
/>
</TabsContent>
</Tabs>
</div>
)
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/portal-shell.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/portal/bits.tsx","./src/components/portal/canvas-blocks.tsx","./src/components/portal/chrome-context.tsx","./src/components/portal/chrome-store.ts","./src/components/portal/chrome.tsx","./src/components/portal/data-page-layout.ts","./src/components/portal/data-page.tsx","./src/components/portal/format.ts","./src/components/portal/kpi-strip.tsx","./src/components/portal/kpi.tsx","./src/components/portal/section-grid.tsx","./src/components/portal/tone.ts","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/Line truncated
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-prefix-trend.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/portal-shell.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/portal/bits.tsx","./src/components/portal/canvas-blocks.tsx","./src/components/portal/chrome-context.tsx","./src/components/portal/chrome-store.ts","./src/components/portal/chrome.tsx","./src/components/portal/data-page-layout.ts","./src/components/portal/data-page.tsx","./src/components/portal/format.ts","./src/components/portal/kpi-strip.tsx","./src/components/portal/kpi.tsx","./src/components/portal/section-grid.tsx","./src/components/portal/tone.ts","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/fLine truncated