From e04fea657c22589ed1d569c8d7a9e746de909375 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 9 Jul 2026 12:09:08 +0700 Subject: [PATCH] feat: enhance dashboard and monitoring components with new analytics skeletons Added the AnalyticsDashboardSkeleton component to improve loading states in the dashboard and monitoring pages. Refactored the dashboard to utilize the new skeleton for initial loading, replacing the previous SectionCardsSkeleton. Updated the overview queries to increase job limit from 10 to 100 for better data handling. Enhanced the network and operations components to incorporate new analytics cards, streamlining the user experience and improving data presentation. --- .../analytics/analytics-activity-list.tsx | 54 +++++++ .../analytics/analytics-card-shell.tsx | 64 ++++++++ .../analytics/analytics-kpi-row.tsx | 50 +++++++ .../analytics/analytics-progress.tsx | 31 ++++ .../analytics/analytics-segment-control.tsx | 35 +++++ .../components/analytics/chart-bar-strip.tsx | 41 +++++ .../analytics/chart-donut-metric.tsx | 100 +++++++++++++ .../dashboard-network-capacity-card.tsx | 107 +++++++++++++ .../dashboard-operations-flow-card.tsx | 59 ++++++++ .../analytics/dashboard-platform-card.tsx | 141 ++++++++++++++++++ apps/web/src/components/analytics/index.ts | 13 ++ .../analytics/monitoring-health-card.tsx | 33 ++++ .../network-overview-analytics-card.tsx | 66 ++++++++ .../analytics/operations-analytics-card.tsx | 65 ++++++++ apps/web/src/components/skeletons.tsx | 37 ++++- .../src/lib/metrics/deployment-progress.ts | 32 ++++ apps/web/src/lib/metrics/index.ts | 8 + .../src/lib/metrics/job-status-breakdown.ts | 52 +++++++ .../src/lib/metrics/module-type-breakdown.ts | 26 ++++ .../web/src/lib/metrics/peer-capacity-bars.ts | 42 ++++++ .../src/lib/metrics/peer-session-breakdown.ts | 41 +++++ .../src/lib/metrics/readiness-breakdown.ts | 71 +++++++++ .../lib/metrics/recent-platform-activity.ts | 64 ++++++++ apps/web/src/lib/metrics/types.ts | 28 ++++ apps/web/src/queries/overview.ts | 2 +- apps/web/src/routes/_auth/dashboard.tsx | 115 ++++---------- apps/web/src/routes/_auth/monitoring.tsx | 84 +++++------ apps/web/src/routes/_auth/network.tsx | 73 ++++----- apps/web/src/routes/_auth/operations.tsx | 37 +---- apps/web/tsconfig.tsbuildinfo | 2 +- packages/ui/src/components/avatar.tsx | 109 ++++++++++++++ packages/ui/src/components/progress.tsx | 81 ++++++++++ 32 files changed, 1561 insertions(+), 202 deletions(-) create mode 100644 apps/web/src/components/analytics/analytics-activity-list.tsx create mode 100644 apps/web/src/components/analytics/analytics-card-shell.tsx create mode 100644 apps/web/src/components/analytics/analytics-kpi-row.tsx create mode 100644 apps/web/src/components/analytics/analytics-progress.tsx create mode 100644 apps/web/src/components/analytics/analytics-segment-control.tsx create mode 100644 apps/web/src/components/analytics/chart-bar-strip.tsx create mode 100644 apps/web/src/components/analytics/chart-donut-metric.tsx create mode 100644 apps/web/src/components/analytics/dashboard-network-capacity-card.tsx create mode 100644 apps/web/src/components/analytics/dashboard-operations-flow-card.tsx create mode 100644 apps/web/src/components/analytics/dashboard-platform-card.tsx create mode 100644 apps/web/src/components/analytics/index.ts create mode 100644 apps/web/src/components/analytics/monitoring-health-card.tsx create mode 100644 apps/web/src/components/analytics/network-overview-analytics-card.tsx create mode 100644 apps/web/src/components/analytics/operations-analytics-card.tsx create mode 100644 apps/web/src/lib/metrics/deployment-progress.ts create mode 100644 apps/web/src/lib/metrics/index.ts create mode 100644 apps/web/src/lib/metrics/job-status-breakdown.ts create mode 100644 apps/web/src/lib/metrics/module-type-breakdown.ts create mode 100644 apps/web/src/lib/metrics/peer-capacity-bars.ts create mode 100644 apps/web/src/lib/metrics/peer-session-breakdown.ts create mode 100644 apps/web/src/lib/metrics/readiness-breakdown.ts create mode 100644 apps/web/src/lib/metrics/recent-platform-activity.ts create mode 100644 apps/web/src/lib/metrics/types.ts create mode 100644 packages/ui/src/components/avatar.tsx create mode 100644 packages/ui/src/components/progress.tsx diff --git a/apps/web/src/components/analytics/analytics-activity-list.tsx b/apps/web/src/components/analytics/analytics-activity-list.tsx new file mode 100644 index 0000000..0f9c985 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-activity-list.tsx @@ -0,0 +1,54 @@ +import { AlertTriangle, CheckCircle, Info } from 'lucide-react' + +import { cn } from '@evobgp/ui/lib/utils' + +import { StatusBadge } from '@/components/status-badge' +import type { PlatformActivityItem } from '@/lib/metrics' + +const KIND_ICON = { + job: Info, + revision: CheckCircle, + network: AlertTriangle, +} as const + +const KIND_ICON_CLASS = { + job: 'text-info', + revision: 'text-success', + network: 'text-warning', +} as const + +export function AnalyticsActivityList({ + items, + className, +}: { + items: PlatformActivityItem[] + className?: string +}) { + if (items.length === 0) { + return

Нет недавних событий

+ } + + return ( + + ) +} diff --git a/apps/web/src/components/analytics/analytics-card-shell.tsx b/apps/web/src/components/analytics/analytics-card-shell.tsx new file mode 100644 index 0000000..030b6a6 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-card-shell.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react' +import { Info } from 'lucide-react' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@evobgp/ui/components/card' +import { cn } from '@evobgp/ui/lib/utils' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@evobgp/ui/components/tooltip' + +export function AnalyticsCardShell({ + title, + description, + info, + actions, + footer, + className, + children, +}: { + title: string + description?: string + info?: string + actions?: ReactNode + footer?: ReactNode + className?: string + children: ReactNode +}) { + return ( + + +
+ + {title} + {info ? ( + + + + + + {info} + + + ) : null} + + {description ? {description} : null} +
+ {actions ?
{actions}
: null} +
+ {children} + {footer ? {footer} : null} +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-kpi-row.tsx b/apps/web/src/components/analytics/analytics-kpi-row.tsx new file mode 100644 index 0000000..0a0d936 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-kpi-row.tsx @@ -0,0 +1,50 @@ +import { Minus, TrendingDown, TrendingUp } from 'lucide-react' + +import { cn } from '@evobgp/ui/lib/utils' + +export type AnalyticsKpiItem = { + label: string + value: string + delta?: { + direction: 'up' | 'down' | 'neutral' + label: string + tone?: 'success' | 'warning' | 'destructive' | 'muted' + } +} + +const TONE_CLASS = { + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', + muted: 'text-muted-foreground', +} as const + +function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) { + if (direction === 'up') return + if (direction === 'down') return + return +} + +export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) { + return ( +
+ {items.map((item) => ( +
+

{item.label}

+

{item.value}

+ {item.delta ? ( +

+ + {item.delta.label} +

+ ) : null} +
+ ))} +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-progress.tsx b/apps/web/src/components/analytics/analytics-progress.tsx new file mode 100644 index 0000000..d9420cd --- /dev/null +++ b/apps/web/src/components/analytics/analytics-progress.tsx @@ -0,0 +1,31 @@ +import { + Progress, + ProgressIndicator, + ProgressTrack, +} from '@evobgp/ui/components/progress' +import { cn } from '@evobgp/ui/lib/utils' + +export function AnalyticsProgress({ + label, + value, + className, +}: { + label: string + value: number + className?: string +}) { + const clamped = Math.max(0, Math.min(100, value)) + return ( +
+
+ {label} + {clamped}% +
+ + + + + +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-segment-control.tsx b/apps/web/src/components/analytics/analytics-segment-control.tsx new file mode 100644 index 0000000..c630d91 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-segment-control.tsx @@ -0,0 +1,35 @@ +import { Button } from '@evobgp/ui/components/button' +import { ButtonGroup } from '@evobgp/ui/components/button-group' +import { cn } from '@evobgp/ui/lib/utils' + +export function AnalyticsSegmentControl({ + value, + onChange, + options, + className, +}: { + value: T + onChange: (value: T) => void + options: { value: T; label: string }[] + className?: string +}) { + return ( + + {options.map((option) => ( + + ))} + + ) +} diff --git a/apps/web/src/components/analytics/chart-bar-strip.tsx b/apps/web/src/components/analytics/chart-bar-strip.tsx new file mode 100644 index 0000000..293c29e --- /dev/null +++ b/apps/web/src/components/analytics/chart-bar-strip.tsx @@ -0,0 +1,41 @@ +import { Bar, BarChart, XAxis } from 'recharts' + +import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart' +import { cn } from '@evobgp/ui/lib/utils' + +import type { CapacityBar } from '@/lib/metrics' + +const chartConfig = { + value: { label: 'Загрузка', color: 'var(--color-chart-2)' }, +} satisfies ChartConfig + +export function ChartBarStrip({ + bars, + className, +}: { + bars: CapacityBar[] + className?: string +}) { + if (bars.length === 0) { + return ( +
+ Нет данных для графика +
+ ) + } + + const data = bars.map((bar, index) => ({ + ...bar, + slot: index + 1, + fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)', + })) + + return ( + + + + + + + ) +} diff --git a/apps/web/src/components/analytics/chart-donut-metric.tsx b/apps/web/src/components/analytics/chart-donut-metric.tsx new file mode 100644 index 0000000..784c8e9 --- /dev/null +++ b/apps/web/src/components/analytics/chart-donut-metric.tsx @@ -0,0 +1,100 @@ +import { Cell, Label, Pie, PieChart } from 'recharts' + +import { + ChartContainer, + type ChartConfig, +} from '@evobgp/ui/components/chart' +import { cn } from '@evobgp/ui/lib/utils' + +import type { BreakdownSlice } from '@/lib/metrics' + +export function ChartDonutMetric({ + slices, + centerLabel, + centerValue, + className, +}: { + slices: BreakdownSlice[] + centerLabel: string + centerValue: string | number + className?: string +}) { + const chartConfig = slices.reduce((acc, slice) => { + acc[slice.key] = { label: slice.label, color: slice.color } + return acc + }, {}) + + const data = slices.map((slice) => ({ + ...slice, + fill: slice.color, + })) + + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + if (total === 0) { + return ( +
+ Нет данных +
+ ) + } + + return ( +
+ + + + {data.map((entry) => ( + + ))} + + + + +
    + {slices.map((slice) => { + const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' + return ( +
  • +
    + + {slice.label} +
    +
    + {slice.count} + {pct}% +
    +
  • + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx b/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx new file mode 100644 index 0000000..f0bf5ff --- /dev/null +++ b/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx @@ -0,0 +1,107 @@ +import { useMemo, useState } from 'react' + +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, +} from '@evobgp/ui/components/avatar' + +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' +import { ChartBarStrip } from '@/components/analytics/chart-bar-strip' +import { + capacityUtilization, + peerCapacityBars, + speakerCapacityBars, +} from '@/lib/metrics' +import { runningJobCount } from '@/queries/overview' +import type { JobRow, PeerRow, SpeakerRow } from '@/types/api' + +type CapacityMode = 'peers' | 'speakers' + +function speakerInitials(speaker: SpeakerRow): string { + const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id + const parts = label.split(/[.\-_@/]/).filter(Boolean) + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() + return label.slice(0, 2).toUpperCase() +} + +export function DashboardNetworkCapacityCard({ + peers, + speakers, + jobs, + loading, +}: { + peers: PeerRow[] + speakers: SpeakerRow[] + jobs: JobRow[] + loading?: boolean +}) { + const [mode, setMode] = useState('peers') + + const bars = useMemo( + () => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)), + [mode, peers, speakers], + ) + const utilization = capacityUtilization(bars) + const queued = runningJobCount(jobs) + const previewSpeakers = speakers.slice(0, 3) + + const deltaLabel = + mode === 'peers' + ? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} Established` + : `${speakers.filter((s) => s.live?.agent_ok).length} online` + + return ( + + } + > +
+

+ {loading ? '—' : `${utilization}%`} +

+

{loading ? '…' : `${deltaLabel} · снимок live`}

+
+ + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} + +
+

+ Активных задач: {loading ? '—' : queued} +

+
+ + {previewSpeakers.map((speaker) => ( + + {speakerInitials(speaker)} + + ))} + {speakers.length > 3 ? ( + +{speakers.length - 3} + ) : null} + + {speakers.length} спикеров +
+
+
+ ) +} diff --git a/apps/web/src/components/analytics/dashboard-operations-flow-card.tsx b/apps/web/src/components/analytics/dashboard-operations-flow-card.tsx new file mode 100644 index 0000000..1f1962f --- /dev/null +++ b/apps/web/src/components/analytics/dashboard-operations-flow-card.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from 'react' + +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' +import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics' +import type { JobRow, ModuleRow } from '@/types/api' + +type FlowMode = 'jobs' | 'modules' + +export function DashboardOperationsFlowCard({ + jobs, + modules, + loading, +}: { + jobs: JobRow[] + modules: ModuleRow[] + loading?: boolean +}) { + const [mode, setMode] = useState('jobs') + + const slices = useMemo( + () => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)), + [mode, jobs, modules], + ) + + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули' + + return ( + + } + > + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/analytics/dashboard-platform-card.tsx b/apps/web/src/components/analytics/dashboard-platform-card.tsx new file mode 100644 index 0000000..f5170bd --- /dev/null +++ b/apps/web/src/components/analytics/dashboard-platform-card.tsx @@ -0,0 +1,141 @@ +import { useNavigate } from '@tanstack/react-router' +import { useMemo } from 'react' + +import { Button } from '@evobgp/ui/components/button' + +import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list' +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row' +import { AnalyticsProgress } from '@/components/analytics/analytics-progress' +import { + deploymentProgress, + recentPlatformActivity, +} from '@/lib/metrics' +import { runningJobCount } from '@/queries/overview' +import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' + +export function DashboardPlatformCard({ + modules, + peers, + speakers, + jobs, + revisions, + loading, +}: { + modules: ModuleRow[] + peers: PeerRow[] + speakers: SpeakerRow[] + jobs: JobRow[] + revisions: RevisionRow[] + loading?: boolean +}) { + const navigate = useNavigate() + + const enabledModules = modules.filter((m) => m.enabled !== false).length + const peersEnabled = peers.filter((p) => p.enabled !== false).length + const peersEstablished = peers.filter( + (p) => p.enabled !== false && p.session_state === 'Established', + ).length + const peersMismatch = peers.filter((p) => p.session_mismatch).length + const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length + const failedJobs = jobs.filter((j) => + ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), + ).length + const running = runningJobCount(jobs) + const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline) + + const bgpPct = + peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null + + const deploy = useMemo(() => deploymentProgress(speakers), [speakers]) + const activity = useMemo( + () => recentPlatformActivity(jobs, revisions, peers, speakers), + [jobs, revisions, peers, speakers], + ) + + const kpis = [ + { + label: 'Модули активны', + value: loading ? '—' : `${enabledModules}/${modules.length || 0}`, + delta: { + direction: 'neutral' as const, + label: `${modules.length} всего`, + tone: 'muted' as const, + }, + }, + { + label: 'BGP готовность', + value: loading || bgpPct === null ? '—' : `${bgpPct}%`, + delta: { + direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as + | 'up' + | 'down' + | 'neutral', + label: + bgpPct === null + ? 'нет включённых пиров' + : `${peersEstablished} Established`, + tone: (bgpPct !== null && bgpPct >= 90 + ? 'success' + : bgpPct !== null && bgpPct < 70 + ? 'warning' + : 'muted') as 'success' | 'warning' | 'muted', + }, + }, + { + label: 'Риски', + value: loading ? '—' : String(riskCount), + delta: { + direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down', + label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме', + tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success', + }, + }, + ] + + const progressLabel = + deploy.mode === 'revision' + ? `Синхронизация ревизий (${deploy.synced}/${deploy.total})` + : `Спикеры online (${deploy.synced}/${deploy.total})` + + return ( + + + + + } + > + + +
+
+ Недавняя активность + {!loading ? ( + {running} активных задач + ) : null} +
+ {loading ? ( +

Загрузка…

+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/components/analytics/index.ts b/apps/web/src/components/analytics/index.ts new file mode 100644 index 0000000..23e271d --- /dev/null +++ b/apps/web/src/components/analytics/index.ts @@ -0,0 +1,13 @@ +export { AnalyticsActivityList } from './analytics-activity-list' +export { AnalyticsCardShell } from './analytics-card-shell' +export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row' +export { AnalyticsProgress } from './analytics-progress' +export { AnalyticsSegmentControl } from './analytics-segment-control' +export { ChartBarStrip } from './chart-bar-strip' +export { ChartDonutMetric } from './chart-donut-metric' +export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card' +export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card' +export { DashboardPlatformCard } from './dashboard-platform-card' +export { MonitoringHealthCard } from './monitoring-health-card' +export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card' +export { OperationsAnalyticsCard } from './operations-analytics-card' diff --git a/apps/web/src/components/analytics/monitoring-health-card.tsx b/apps/web/src/components/analytics/monitoring-health-card.tsx new file mode 100644 index 0000000..d14d6e4 --- /dev/null +++ b/apps/web/src/components/analytics/monitoring-health-card.tsx @@ -0,0 +1,33 @@ +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { readinessBreakdown } from '@/lib/metrics' +import type { ReadyStatus } from '@/queries/monitoring' + +export function MonitoringHealthCard({ + healthOk, + ready, + loading, +}: { + healthOk: boolean + ready: ReadyStatus | null | undefined + loading?: boolean +}) { + const slices = readinessBreakdown(ready, healthOk) + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + return ( + + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/analytics/network-overview-analytics-card.tsx b/apps/web/src/components/analytics/network-overview-analytics-card.tsx new file mode 100644 index 0000000..57acd50 --- /dev/null +++ b/apps/web/src/components/analytics/network-overview-analytics-card.tsx @@ -0,0 +1,66 @@ +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row' +import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { peerSessionBreakdown } from '@/lib/metrics' +import { aggregateNetworkMetrics } from '@/queries/overview' +import type { PeerRow, SpeakerRow } from '@/types/api' + +export function NetworkOverviewAnalyticsCard({ + peers, + speakers, + loading, +}: { + peers: PeerRow[] + speakers: SpeakerRow[] + loading?: boolean +}) { + const net = aggregateNetworkMetrics(peers, speakers) + const slices = peerSessionBreakdown(peers) + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + return ( + + 0 ? 'down' : 'up', + label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме', + tone: net.peersMismatch > 0 ? 'warning' : 'success', + }, + }, + { + label: 'Спикеры online', + value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, + delta: { + direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up', + label: + net.speakersOnline < net.speakersTotal + ? `${net.speakersTotal - net.speakersOnline} offline` + : 'все online', + tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success', + }, + }, + { + label: 'Пиры всего', + value: loading ? '—' : String(net.peersTotal), + delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' }, + }, + ]} + /> + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/analytics/operations-analytics-card.tsx b/apps/web/src/components/analytics/operations-analytics-card.tsx new file mode 100644 index 0000000..8bbcc8b --- /dev/null +++ b/apps/web/src/components/analytics/operations-analytics-card.tsx @@ -0,0 +1,65 @@ +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row' +import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { jobStatusBreakdown } from '@/lib/metrics' +import type { JobRow, RevisionRow } from '@/types/api' + +export function OperationsAnalyticsCard({ + jobs, + revisions, + loading, +}: { + jobs: JobRow[] + revisions: RevisionRow[] + loading?: boolean +}) { + const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length + const failed = jobs.filter((j) => + ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), + ).length + const slices = jobStatusBreakdown(jobs) + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + return ( + + 0 ? 'up' : 'neutral', + label: running > 0 ? 'выполняются' : 'очередь пуста', + tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted', + }, + }, + { + label: 'С ошибкой', + value: loading ? '—' : String(failed), + delta: { + direction: failed > 0 ? 'down' : 'up', + label: failed > 0 ? 'требуют внимания' : 'в норме', + tone: failed > 0 ? 'destructive' : 'success', + }, + }, + ]} + /> + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/skeletons.tsx b/apps/web/src/components/skeletons.tsx index 6fc49dc..543f3d0 100644 --- a/apps/web/src/components/skeletons.tsx +++ b/apps/web/src/components/skeletons.tsx @@ -1,6 +1,7 @@ -import { SectionCards } from './section-cards' -import { Skeleton } from '@evobgp/ui/components/skeleton' import { Card, CardContent } from '@evobgp/ui/components/card' +import { Skeleton } from '@evobgp/ui/components/skeleton' + +import { SectionCards } from './section-cards' export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { return ( @@ -14,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { ) } +export function AnalyticsDashboardSkeleton() { + return ( +
+ + + +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + +
+
+ + + + + + + + + + + + + +
+ ) +} + export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { return ( diff --git a/apps/web/src/lib/metrics/deployment-progress.ts b/apps/web/src/lib/metrics/deployment-progress.ts new file mode 100644 index 0000000..1df26bf --- /dev/null +++ b/apps/web/src/lib/metrics/deployment-progress.ts @@ -0,0 +1,32 @@ +import type { SpeakerRow } from '@/types/api' + +import type { DeploymentProgress } from './types' + +export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress { + if (speakers.length === 0) { + return { percent: 0, synced: 0, total: 0, mode: 'online' } + } + + const withRevision = speakers.filter( + (s) => s.published_revision_id && s.last_applied_revision_id, + ) + if (withRevision.length > 0) { + const synced = withRevision.filter( + (s) => s.published_revision_id === s.last_applied_revision_id, + ).length + return { + percent: Math.round((synced / withRevision.length) * 100), + synced, + total: withRevision.length, + mode: 'revision', + } + } + + const online = speakers.filter((s) => s.live?.agent_ok).length + return { + percent: Math.round((online / speakers.length) * 100), + synced: online, + total: speakers.length, + mode: 'online', + } +} diff --git a/apps/web/src/lib/metrics/index.ts b/apps/web/src/lib/metrics/index.ts new file mode 100644 index 0000000..5f0dc4b --- /dev/null +++ b/apps/web/src/lib/metrics/index.ts @@ -0,0 +1,8 @@ +export * from './types' +export * from './job-status-breakdown' +export * from './module-type-breakdown' +export * from './peer-capacity-bars' +export * from './deployment-progress' +export * from './readiness-breakdown' +export * from './peer-session-breakdown' +export * from './recent-platform-activity' diff --git a/apps/web/src/lib/metrics/job-status-breakdown.ts b/apps/web/src/lib/metrics/job-status-breakdown.ts new file mode 100644 index 0000000..6436578 --- /dev/null +++ b/apps/web/src/lib/metrics/job-status-breakdown.ts @@ -0,0 +1,52 @@ +import type { JobRow } from '@/types/api' + +import type { BreakdownSlice } from './types' + +const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [ + { keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' }, + { keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' }, + { keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' }, +] + +function bucketForStatus(status: string): string { + const s = status.toLowerCase() + for (const bucket of STATUS_BUCKETS) { + if (bucket.keys.includes(s)) return bucket.label + } + return 'Прочее' +} + +export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] { + const counts = new Map() + for (const job of jobs) { + const label = bucketForStatus(job.status) + counts.set(label, (counts.get(label) ?? 0) + 1) + } + + const slices: BreakdownSlice[] = [] + for (const bucket of STATUS_BUCKETS) { + const count = counts.get(bucket.label) ?? 0 + if (count > 0) { + slices.push({ + key: bucket.label, + label: bucket.label, + count, + color: bucket.color, + }) + } + } + const other = counts.get('Прочее') ?? 0 + if (other > 0) { + slices.push({ + key: 'other', + label: 'Прочее', + count: other, + color: 'var(--color-chart-4)', + }) + } + return slices +} + +export function jobStatusTotal(jobs: JobRow[]): number { + return jobs.length +} diff --git a/apps/web/src/lib/metrics/module-type-breakdown.ts b/apps/web/src/lib/metrics/module-type-breakdown.ts new file mode 100644 index 0000000..cbf08b1 --- /dev/null +++ b/apps/web/src/lib/metrics/module-type-breakdown.ts @@ -0,0 +1,26 @@ +import type { ModuleRow, ModuleType } from '@/types/api' + +import type { BreakdownSlice } from './types' + +const TYPE_META: Record = { + AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' }, + DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' }, + CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' }, + IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' }, +} + +export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] { + const counts = new Map() + for (const mod of modules) { + counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1) + } + + return (Object.keys(TYPE_META) as ModuleType[]) + .map((type) => ({ + key: type, + label: TYPE_META[type].label, + count: counts.get(type) ?? 0, + color: TYPE_META[type].color, + })) + .filter((slice) => slice.count > 0) +} diff --git a/apps/web/src/lib/metrics/peer-capacity-bars.ts b/apps/web/src/lib/metrics/peer-capacity-bars.ts new file mode 100644 index 0000000..fd81173 --- /dev/null +++ b/apps/web/src/lib/metrics/peer-capacity-bars.ts @@ -0,0 +1,42 @@ +import type { PeerRow, SpeakerRow } from '@/types/api' + +import type { CapacityBar } from './types' + +function peerLabel(peer: PeerRow): string { + return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8) +} + +function speakerLabel(speaker: SpeakerRow): string { + return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8) +} + +export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] { + return peers + .filter((p) => p.enabled !== false) + .slice(0, max) + .map((peer) => ({ + id: peer.id, + name: peerLabel(peer), + value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10, + })) +} + +export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] { + return speakers.slice(0, max).map((speaker) => { + const online = speaker.live?.agent_ok === true + const established = speaker.live?.bgp_established ?? 0 + const total = speaker.live?.bgp_sessions_total ?? 0 + const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15 + return { + id: speaker.id, + name: speakerLabel(speaker), + value: online ? ratio : 10, + } + }) +} + +export function capacityUtilization(bars: CapacityBar[]): number { + if (bars.length === 0) return 0 + const sum = bars.reduce((acc, bar) => acc + bar.value, 0) + return Math.round(sum / bars.length) +} diff --git a/apps/web/src/lib/metrics/peer-session-breakdown.ts b/apps/web/src/lib/metrics/peer-session-breakdown.ts new file mode 100644 index 0000000..c093c7f --- /dev/null +++ b/apps/web/src/lib/metrics/peer-session-breakdown.ts @@ -0,0 +1,41 @@ +import type { PeerRow } from '@/types/api' + +import type { BreakdownSlice } from './types' + +export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] { + const enabled = peers.filter((p) => p.enabled !== false) + const established = enabled.filter((p) => p.session_state === 'Established').length + const pending = enabled.filter( + (p) => p.session_state && p.session_state !== 'Established', + ).length + const disabled = peers.length - enabled.length + + const slices: BreakdownSlice[] = [] + if (established > 0) { + slices.push({ + key: 'established', + label: 'Established', + count: established, + color: 'var(--color-chart-2)', + }) + } + if (pending > 0) { + slices.push({ + key: 'pending', + label: 'Не Established', + count: pending, + color: 'var(--color-warning)', + }) + } + if (disabled > 0) { + slices.push({ + key: 'disabled', + label: 'Выключены', + count: disabled, + color: 'var(--color-chart-4)', + }) + } + return slices.length > 0 + ? slices + : [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }] +} diff --git a/apps/web/src/lib/metrics/readiness-breakdown.ts b/apps/web/src/lib/metrics/readiness-breakdown.ts new file mode 100644 index 0000000..fc50d47 --- /dev/null +++ b/apps/web/src/lib/metrics/readiness-breakdown.ts @@ -0,0 +1,71 @@ +import type { ReadyStatus } from '@/queries/monitoring' + +import type { BreakdownSlice } from './types' + +function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean { + if (typeof value === 'boolean') return value + if (value && typeof value === 'object') return value.ok === true + return false +} + +export function readinessBreakdown( + ready: ReadyStatus | null | undefined, + healthOk: boolean, +): BreakdownSlice[] { + if (!healthOk) { + return [ + { + key: 'health-fail', + label: 'API недоступен', + count: 1, + color: 'var(--color-destructive)', + }, + ] + } + + const checks = ready?.checks ?? {} + let okCount = 0 + let failCount = 0 + + for (const value of Object.values(checks)) { + if (checkOk(value)) okCount += 1 + else failCount += 1 + } + + const slices: BreakdownSlice[] = [ + { + key: 'health', + label: 'Health OK', + count: 1, + color: 'var(--color-chart-2)', + }, + ] + + if (okCount > 0) { + slices.push({ + key: 'checks-ok', + label: 'Checks OK', + count: okCount, + color: 'var(--color-chart-1)', + }) + } + if (failCount > 0) { + slices.push({ + key: 'checks-fail', + label: 'Checks fail', + count: failCount, + color: 'var(--color-warning)', + }) + } + + if (slices.length === 1 && okCount === 0 && failCount === 0) { + slices.push({ + key: 'ready', + label: ready?.status === 'ok' ? 'Ready' : 'Ready pending', + count: 1, + color: 'var(--color-chart-4)', + }) + } + + return slices +} diff --git a/apps/web/src/lib/metrics/recent-platform-activity.ts b/apps/web/src/lib/metrics/recent-platform-activity.ts new file mode 100644 index 0000000..275c2e2 --- /dev/null +++ b/apps/web/src/lib/metrics/recent-platform-activity.ts @@ -0,0 +1,64 @@ +import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' + +import type { PlatformActivityItem } from './types' + +const JOB_KIND_RU: Record = { + module_refresh: 'Обновление модуля', + apply: 'Применение конфигурации', + rollback: 'Откат ревизии', + bird_reload: 'Перезагрузка BIRD', +} + +function jobMessage(job: JobRow): string { + const kind = JOB_KIND_RU[job.kind] ?? job.kind + return `${kind} · ${job.status}` +} + +export function recentPlatformActivity( + jobs: JobRow[], + revisions: RevisionRow[], + peers: PeerRow[], + speakers: SpeakerRow[], + limit = 5, +): PlatformActivityItem[] { + const items: PlatformActivityItem[] = [] + + for (const job of jobs.slice(0, 3)) { + items.push({ + id: `job-${job.job_id}`, + message: jobMessage(job), + status: job.status, + kind: 'job', + }) + } + + for (const rev of revisions.slice(0, 2)) { + items.push({ + id: `rev-${rev.id}`, + message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`, + status: 'ok', + statusLabel: 'Создана', + kind: 'revision', + }) + } + + for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) { + items.push({ + id: `peer-${peer.id}`, + message: `Mismatch сессии: ${peer.name ?? peer.neighbor}`, + status: 'mismatch', + kind: 'network', + }) + } + + for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) { + items.push({ + id: `speaker-${speaker.id}`, + message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`, + status: speaker.live?.agent_ok === false ? 'error' : 'warning', + kind: 'network', + }) + } + + return items.slice(0, limit) +} diff --git a/apps/web/src/lib/metrics/types.ts b/apps/web/src/lib/metrics/types.ts new file mode 100644 index 0000000..22f766b --- /dev/null +++ b/apps/web/src/lib/metrics/types.ts @@ -0,0 +1,28 @@ +export type BreakdownSlice = { + key: string + label: string + count: number + color: string +} + +export type CapacityBar = { + id: string + name: string + /** 0–100 utilization */ + value: number +} + +export type PlatformActivityItem = { + id: string + message: string + status: string + statusLabel?: string + kind: 'job' | 'revision' | 'network' +} + +export type DeploymentProgress = { + percent: number + synced: number + total: number + mode: 'revision' | 'online' +} diff --git a/apps/web/src/queries/overview.ts b/apps/web/src/queries/overview.ts index c2bb85a..70b52eb 100644 --- a/apps/web/src/queries/overview.ts +++ b/apps/web/src/queries/overview.ts @@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() { export function overviewJobsQueryOptions() { return queryOptions({ queryKey: overviewKeys.jobs(), - queryFn: () => apiJSON('/v1/jobs?limit=10'), + queryFn: () => apiJSON('/v1/jobs?limit=100'), staleTime: 15_000, }) } diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 8d4f2b1..77d75e8 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -1,40 +1,31 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQueries } from '@tanstack/react-query' -import { - Boxes, - CheckCircle, - Clock, - GitBranch, - Info, - Radio, - RefreshCw, - Activity, - XCircle, -} from 'lucide-react' +import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react' import { useState } from 'react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Button } from '@evobgp/ui/components/button' import { Card, - CardContent, CardDescription, CardHeader, CardTitle, } from '@evobgp/ui/components/card' import { Skeleton } from '@evobgp/ui/components/skeleton' +import { + DashboardNetworkCapacityCard, + DashboardOperationsFlowCard, + DashboardPlatformCard, +} from '@/components/analytics' import { DataGridCard } from '@/components/data-grid-shell' -import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel' import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions' import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { PageHeader } from '@/components/page-header' -import { SectionCards, type SectionCardItem } from '@/components/section-cards' -import { SectionCardsSkeleton } from '@/components/skeletons' +import { AnalyticsDashboardSkeleton } from '@/components/skeletons' import { - aggregateNetworkMetrics, moduleNameById, overviewHealthQueryOptions, overviewJobsQueryOptions, @@ -42,7 +33,6 @@ import { overviewPeersQueryOptions, overviewRevisionsQueryOptions, overviewSpeakersQueryOptions, - runningJobCount, } from '@/queries/overview' export const Route = createFileRoute('/_auth/dashboard')({ @@ -50,7 +40,6 @@ export const Route = createFileRoute('/_auth/dashboard')({ }) function DashboardComponent() { - const navigate = useNavigate() const [lastUpdated, setLastUpdated] = useState(null) const results = useQueries({ @@ -83,60 +72,10 @@ function DashboardComponent() { const speakers = speakersQ.data?.items ?? [] const revisions = revisionsQ.data?.items ?? [] const jobs = jobsQ.data?.items ?? [] - const modulesHasMore = modulesQ.data?.has_more ?? false - const peersHasMore = peersQ.data?.has_more ?? false - const speakersHasMore = speakersQ.data?.has_more ?? false - const revisionsHasMore = revisionsQ.data?.has_more ?? false - - const net = aggregateNetworkMetrics(peers, speakers) - const running = runningJobCount(jobs) const nameById = moduleNameById(modules) - const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix) - - const items: SectionCardItem[] = [ - { - label: 'Модули', - value: initialLoading ? '—' : String(modules.length), - icon: , - hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'), - onClick: () => navigate({ to: '/modules' }), - }, - { - label: 'Пиры', - value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, - icon: , - hint: countBadge(peers.length, peersHasMore, 'Established / включённых'), - badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined, - variant: net.peersMismatch > 0 ? 'warning' : 'default', - onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }), - }, - { - label: 'Спикеры', - value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, - icon: , - hint: countBadge(speakers.length, speakersHasMore, 'online / всего'), - variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default', - onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }), - }, - { - label: 'Ревизии', - value: initialLoading ? '—' : String(revisions.length), - icon: , - hint: countBadge(revisions.length, revisionsHasMore, 'configs'), - onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }), - }, - { - label: 'Активных задач', - value: initialLoading ? '—' : String(running), - icon: , - hint: 'queued и running', - onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }), - }, - ] - const activityLoading = - refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0 + refreshing && jobs.length === 0 && revisions.length === 0 return (
@@ -170,9 +109,25 @@ function DashboardComponent() { loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null} /> - {initialLoading ? : } + {initialLoading ? ( + + ) : ( +
+
+ +
+ + +
+ )} -
+
) : ( - + )} @@ -196,20 +151,6 @@ function DashboardComponent() { )} - - - - Состояние сети - BGP-сессии и спикеры - - - {activityLoading ? ( - - ) : ( - - )} - -
diff --git a/apps/web/src/routes/_auth/monitoring.tsx b/apps/web/src/routes/_auth/monitoring.tsx index f44ef93..e6265b8 100644 --- a/apps/web/src/routes/_auth/monitoring.tsx +++ b/apps/web/src/routes/_auth/monitoring.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useSearch } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { Activity, AlertTriangle, Bird, Database, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' +import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Badge } from '@evobgp/ui/components/badge' @@ -9,11 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob import { Separator } from '@evobgp/ui/components/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { + DashboardOperationsFlowCard, + MonitoringHealthCard, +} from '@/components/analytics' import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' -import { SectionCards, type SectionCardItem } from '@/components/section-cards' -import { SectionCardsSkeleton } from '@/components/skeletons' +import { AnalyticsDashboardSkeleton } from '@/components/skeletons' import { monitoringHealthQueryOptions, @@ -24,6 +27,7 @@ import { } from '@/queries/monitoring' import { networkBirdQueryOptions } from '@/queries/network' import { operationsJobsQueryOptions } from '@/queries/operations' +import { overviewModulesQueryOptions } from '@/queries/overview' export const Route = createFileRoute('/_auth/monitoring')({ component: MonitoringComponent, @@ -42,53 +46,25 @@ function MonitoringComponent() { const versionQ = useQuery(monitoringVersionQueryOptions()) const birdQ = useQuery(networkBirdQueryOptions()) const jobsQ = useQuery(operationsJobsQueryOptions()) + const modulesQ = useQuery(overviewModulesQueryOptions()) const refreshing = healthQ.isFetching || readyQ.isFetching || versionQ.isFetching || birdQ.isFetching || - jobsQ.isFetching + jobsQ.isFetching || + modulesQ.isFetching const jobs = jobsQ.data?.items ?? [] - const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length + const modules = modulesQ.data?.items ?? [] const failed = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), ).length const versionText = formatVersion(versionQ.data) - - const items: SectionCardItem[] = [ - { - label: 'Общий статус', - value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }), - icon: , - hint: overallHint({ health: healthQ.data, jobsFailed: failed }), - }, - { - label: 'BGP сессии', - value: birdQ.data - ? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}` - : '—', - icon: , - hint: birdQ.data?.birdc_configured - ? 'Established / total на API-хосте' - : 'birdc не настроен', - }, - { - label: 'Задачи', - value: running, - icon: , - hint: `активных из ${jobs.length}`, - variant: failed > 0 ? 'warning' : 'default', - }, - { - label: 'Версия', - value: versionText, - icon: , - hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version', - }, - ] + const analyticsLoading = + (healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0 function refetchAll() { void healthQ.refetch() @@ -96,6 +72,7 @@ function MonitoringComponent() { void versionQ.refetch() void birdQ.refetch() void jobsQ.refetch() + void modulesQ.refetch() } const failedJobs = jobs @@ -123,7 +100,29 @@ function MonitoringComponent() { - {refreshing ? : } + {analyticsLoading ? ( + + ) : ( +
+ + +
+ )} + + + + + Версия API: {versionText} + {versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''} + + + {overallHint({ health: healthQ.data, jobsFailed: failed })} + +
@@ -179,7 +178,7 @@ function MonitoringComponent() {
- + j.status === 'running' || j.status === 'queued').length} /> 0) return 'Внимание' - if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание' - return 'В норме' -} - function overallHint(input: OverallInput): string { if (!input.health?.ok) return 'API недоступен или возвращает ошибку' if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})` diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx index 35d7c67..d809e30 100644 --- a/apps/web/src/routes/_auth/network.tsx +++ b/apps/web/src/routes/_auth/network.tsx @@ -6,6 +6,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob import { Info, RefreshCw } from 'lucide-react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { + DashboardNetworkCapacityCard, + NetworkOverviewAnalyticsCard, +} from '@/components/analytics' import { DataGridCard } from '@/components/data-grid-shell' import { NetworkPeersGrid } from '@/components/network/network-peers-grid' import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid' @@ -13,7 +17,7 @@ import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network' -import { aggregateNetworkMetrics } from '@/queries/overview' +import { overviewJobsQueryOptions } from '@/queries/overview' export const Route = createFileRoute('/_auth/network')({ component: NetworkComponent, @@ -29,11 +33,13 @@ function NetworkComponent() { const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) + const jobsQ = useQuery(overviewJobsQueryOptions()) const refreshing = peersQ.isFetching || speakersQ.isFetching const peers = peersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? [] - const net = aggregateNetworkMetrics(peers, speakers) + const jobs = jobsQ.data?.items ?? [] + const overviewLoading = peersQ.isLoading || speakersQ.isLoading function refetchAll() { void peersQ.refetch() @@ -71,40 +77,37 @@ function NetworkComponent() { -
- - - Сводка сети - - - - - - - {net.peersMismatch > 0 ? ( - - ) : null} - - - - - BIRD (control plane) - Статус birdc на хосте API - - - } - onRetry={() => birdQ.refetch()} - > - {(bird) => } - - - +
+ +
+ + + BIRD (control plane) + Статус birdc на хосте API + + + } + onRetry={() => birdQ.refetch()} + > + {(bird) => } + + + diff --git a/apps/web/src/routes/_auth/operations.tsx b/apps/web/src/routes/_auth/operations.tsx index 2b9dfa4..876a9e8 100644 --- a/apps/web/src/routes/_auth/operations.tsx +++ b/apps/web/src/routes/_auth/operations.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useSearch } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react' +import { Info, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useState, useMemo } from 'react' @@ -9,14 +9,13 @@ import { Button } from '@evobgp/ui/components/button' import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { OperationsAnalyticsCard } from '@/components/analytics' import { DataGridCard } from '@/components/data-grid-shell' import { SelectMenu } from '@/components/select-field' import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid' import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' -import { SectionCards, type SectionCardItem } from '@/components/section-cards' -import { SectionCardsSkeleton } from '@/components/skeletons' import { ConfirmDialog } from '@/components/confirm-dialog' import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations' @@ -46,32 +45,6 @@ function OperationsComponent() { const nameById = moduleNameById(modulesQ.data?.items ?? []) const refreshing = revisionsQ.isFetching || jobsQ.isFetching - const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length - const failed = jobs.filter( - (j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), - ).length - - const items: SectionCardItem[] = [ - { - label: 'Ревизий', - value: revisions.length, - icon: , - hint: 'история конфигов', - }, - { - label: 'Активных задач', - value: running, - icon: , - hint: 'queued и running', - }, - { - label: 'Задач с ошибкой', - value: failed, - icon: , - hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет', - variant: failed > 0 ? 'warning' : 'default', - }, - ] function refetchAll() { void revisionsQ.refetch() @@ -155,7 +128,11 @@ function OperationsComponent() { />
- {revisionsQ.isLoading ? : } + {revisionsQ.isLoading ? ( + + ) : ( + + )} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index dd491e2..545de94 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.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/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-select-4.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.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-domain-entry-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-peers-grid.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.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/analytics/operations-analytics-card.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-select-4.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.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-domain-entry-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-peers-grid.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx new file mode 100644 index 0000000..f5c16ee --- /dev/null +++ b/packages/ui/src/components/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@evobgp/ui/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx new file mode 100644 index 0000000..55b6435 --- /dev/null +++ b/packages/ui/src/components/progress.tsx @@ -0,0 +1,81 @@ +import { Progress as ProgressPrimitive } from "@base-ui/react/progress" + +import { cn } from "@evobgp/ui/lib/utils" + +function Progress({ + className, + children, + value, + ...props +}: ProgressPrimitive.Root.Props) { + return ( + + {children} + + + + + ) +} + +function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { + return ( + + ) +} + +function ProgressIndicator({ + className, + ...props +}: ProgressPrimitive.Indicator.Props) { + return ( + + ) +} + +function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { + return ( + + ) +} + +function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { + return ( + + ) +} + +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +}