feat: enhance dashboard and monitoring components with new analytics skeletons
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m57s

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.
This commit is contained in:
Denozordec
2026-07-09 12:09:08 +07:00
parent 452f6b2db0
commit e04fea657c
32 changed files with 1561 additions and 202 deletions
@@ -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 <p className="text-sm text-muted-foreground">Нет недавних событий</p>
}
return (
<ul className={cn('space-y-3', className)}>
{items.map((item) => {
const Icon = KIND_ICON[item.kind]
return (
<li key={item.id} className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
KIND_ICON_CLASS[item.kind],
)}
>
<Icon className="size-3.5" />
</span>
<p className="text-sm leading-snug">{item.message}</p>
</div>
<StatusBadge status={item.status} label={item.statusLabel} />
</li>
)
})}
</ul>
)
}
@@ -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 (
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}>
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
{title}
{info ? (
<Tooltip>
<TooltipTrigger
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
aria-label="Подробнее"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{info}
</TooltipContent>
</Tooltip>
) : null}
</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent>
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
</Card>
)
}
@@ -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 <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
)
}
@@ -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 (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{clamped}%</span>
</div>
<Progress value={clamped} className="gap-0">
<ProgressTrack className="h-2">
<ProgressIndicator className="bg-foreground" />
</ProgressTrack>
</Progress>
</div>
)
}
@@ -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<T extends string>({
value,
onChange,
options,
className,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string }[]
className?: string
}) {
return (
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
{options.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={value === option.value ? 'secondary' : 'ghost'}
className={cn(
'h-7 rounded-md px-2.5 text-xs',
value === option.value && 'bg-background shadow-sm',
)}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</ButtonGroup>
)
}
@@ -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 (
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных для графика
</div>
)
}
const data = bars.map((bar, index) => ({
...bar,
slot: index + 1,
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
}))
return (
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<XAxis dataKey="slot" hide />
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
</BarChart>
</ChartContainer>
)
}
@@ -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<ChartConfig>((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 (
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных
</div>
)
}
return (
<div className={cn('flex items-center gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<Pie
data={data}
dataKey="count"
nameKey="label"
innerRadius={52}
outerRadius={72}
strokeWidth={2}
stroke="var(--color-card)"
>
{data.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
const { cx, cy } = viewBox
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
{centerLabel}
</tspan>
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
{centerValue}
</tspan>
</text>
)
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-muted-foreground">{slice.label}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<span className="font-semibold">{slice.count}</span>
<span className="ml-2 text-muted-foreground">{pct}%</span>
</div>
</li>
)
})}
</ul>
</div>
)
}
@@ -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<CapacityMode>('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 (
<AnalyticsCardShell
title="Загрузка BGP"
description="Текущая утилизация сессий по пирам и спикерам"
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'peers', label: 'Пиры' },
{ value: 'speakers', label: 'Спикеры' },
]}
/>
}
>
<div className="space-y-1">
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartBarStrip bars={bars} />
)}
<div className="flex items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
</p>
<div className="flex items-center gap-2">
<AvatarGroup>
{previewSpeakers.map((speaker) => (
<Avatar key={speaker.id} size="sm">
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
</Avatar>
))}
{speakers.length > 3 ? (
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="text-muted-foreground">{speakers.length} спикеров</span>
</div>
</div>
</AnalyticsCardShell>
)
}
@@ -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<FlowMode>('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 (
<AnalyticsCardShell
title="Поток операций"
description="Распределение фоновых задач и типов модулей"
info="Donut строится по текущей выборке API (до 100 последних задач)."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'jobs', label: 'Задачи' },
{ value: 'modules', label: 'Модули' },
]}
/>
}
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric
slices={slices}
centerLabel={centerLabel}
centerValue={total}
/>
)}
</AnalyticsCardShell>
)
}
@@ -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 (
<AnalyticsCardShell
title="Состояние платформы"
description="Сводка модулей, BGP и фоновых задач"
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
footer={
<>
<Button
variant="outline"
className="flex-1"
onClick={() => navigate({ to: '/schedule' })}
>
Расписание
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
>
Мониторинг
</Button>
</>
}
>
<AnalyticsKpiRow items={kpis} />
<AnalyticsProgress label={progressLabel} value={loading ? 0 : deploy.percent} />
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Недавняя активность</span>
{!loading ? (
<span className="text-xs text-muted-foreground">{running} активных задач</span>
) : null}
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Загрузка</p>
) : (
<AnalyticsActivityList items={activity} />
)}
</div>
</AnalyticsCardShell>
)
}
@@ -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'
@@ -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 (
<AnalyticsCardShell
title="Доступность системы"
description="Health и readiness checks"
info="Donut отражает результат GET /v1/health и checks из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Checks" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -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 (
<AnalyticsCardShell
title="Сводка BGP"
description="Established, online и mismatch по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 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 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -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 (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 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 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
+35 -2
View File
@@ -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 (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<Card className="gap-0 lg:row-span-2">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
<Skeleton className="h-2 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
<Card className="gap-0">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-24" />
<Skeleton className="h-36 w-full" />
</CardContent>
</Card>
<Card className="gap-0">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-44 w-full" />
</CardContent>
</Card>
</div>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">