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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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<string, number>()
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ModuleRow, ModuleType } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
||||
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<ModuleType, number>()
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)' }]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
import type { PlatformActivityItem } from './types'
|
||||
|
||||
const JOB_KIND_RU: Record<string, string> = {
|
||||
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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
|
||||
export function overviewJobsQueryOptions() {
|
||||
return queryOptions<JobsResponse>({
|
||||
queryKey: overviewKeys.jobs(),
|
||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
|
||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Date | null>(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: <Boxes className="size-4" />,
|
||||
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
|
||||
onClick: () => navigate({ to: '/modules' }),
|
||||
},
|
||||
{
|
||||
label: 'Пиры',
|
||||
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
icon: <GitBranch className="size-4" />,
|
||||
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: <Radio className="size-4" />,
|
||||
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: <Activity className="size-4" />,
|
||||
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
|
||||
onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }),
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: initialLoading ? '—' : String(running),
|
||||
icon: <Clock className="size-4" />,
|
||||
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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -170,9 +109,25 @@ function DashboardComponent() {
|
||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||
/>
|
||||
|
||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} className="gap-3" />}
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
|
||||
<div className="lg:row-span-2">
|
||||
<DashboardPlatformCard
|
||||
modules={modules}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
/>
|
||||
</div>
|
||||
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DataGridCard
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
@@ -181,7 +136,7 @@ function DashboardComponent() {
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} />
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
|
||||
@@ -196,20 +151,6 @@ function DashboardComponent() {
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
|
||||
<Card className="h-full gap-0">
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Состояние сети</CardTitle>
|
||||
<CardDescription>BGP-сессии и спикеры</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardNetworkPanel peers={peers} speakers={speakers} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="gap-0">
|
||||
|
||||
@@ -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: <Gauge className="size-4" />,
|
||||
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
|
||||
},
|
||||
{
|
||||
label: 'BGP сессии',
|
||||
value: birdQ.data
|
||||
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
|
||||
: '—',
|
||||
icon: <Bird className="size-4" />,
|
||||
hint: birdQ.data?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: 'birdc не настроен',
|
||||
},
|
||||
{
|
||||
label: 'Задачи',
|
||||
value: running,
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: `активных из ${jobs.length}`,
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
label: 'Версия',
|
||||
value: versionText,
|
||||
icon: <Gauge className="size-4" />,
|
||||
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() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
||||
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
||||
{analyticsLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<MonitoringHealthCard
|
||||
healthOk={healthQ.data?.ok ?? false}
|
||||
ready={readyQ.data}
|
||||
loading={healthQ.isLoading || readyQ.isLoading}
|
||||
/>
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert className="border-muted bg-muted/30">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle className="text-sm">
|
||||
Версия API: {versionText}
|
||||
{versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs">
|
||||
{overallHint({ health: healthQ.data, jobsFailed: failed })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
@@ -179,7 +178,7 @@ function MonitoringComponent() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<Metric label="Активных" value={running} />
|
||||
<Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
|
||||
<Metric
|
||||
label="С ошибками"
|
||||
value={failed}
|
||||
@@ -322,13 +321,6 @@ interface OverallInput {
|
||||
jobsFailed: number
|
||||
}
|
||||
|
||||
function overallStatusLabel(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'Ошибка'
|
||||
if (input.jobsFailed > 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})`
|
||||
|
||||
@@ -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() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сводка сети</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
||||
<Field label="Пиры всего" value={String(net.peersTotal)} />
|
||||
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
|
||||
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
|
||||
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
|
||||
{net.peersMismatch > 0 ? (
|
||||
<Field label="Mismatches" value={String(net.peersMismatch)} />
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<NetworkOverviewAnalyticsCard
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
loading={overviewLoading}
|
||||
/>
|
||||
<DashboardNetworkCapacityCard
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
loading={overviewLoading}
|
||||
/>
|
||||
</div>
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" className="mt-4">
|
||||
|
||||
@@ -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: <Activity className="size-4" />,
|
||||
hint: 'история конфигов',
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: running,
|
||||
icon: <Clock className="size-4" />,
|
||||
hint: 'queued и running',
|
||||
},
|
||||
{
|
||||
label: 'Задач с ошибкой',
|
||||
value: failed,
|
||||
icon: <AlertTriangle className="size-4" />,
|
||||
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]
|
||||
|
||||
function refetchAll() {
|
||||
void revisionsQ.refetch()
|
||||
@@ -155,7 +128,11 @@ function OperationsComponent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
{revisionsQ.isLoading ? (
|
||||
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
||||
) : (
|
||||
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
||||
)}
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>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 (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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,
|
||||
}
|
||||
@@ -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 (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
Reference in New Issue
Block a user