Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established. Co-authored-by: Cursor <[email protected]>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
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 { peerSessionEstablished, speakerAgentOnline } from '@/lib/live-observation'
|
|
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 && peerSessionEstablished(p)).length} установлено`
|
|
: `${speakers.filter(speakerAgentOnline).length} в сети`
|
|
const allHealthy = mode === 'peers'
|
|
? peers.some((p) => p.enabled !== false) && peers.filter((p) => p.enabled !== false).every(peerSessionEstablished)
|
|
: speakers.length > 0 && speakers.every(speakerAgentOnline)
|
|
|
|
return (
|
|
<AnalyticsCardShell
|
|
title="Загрузка BGP"
|
|
description="Текущая утилизация сессий по пирам и спикерам"
|
|
info="Каждый столбец — включённый пир или спикер. Высота отражает установленную сессию или доступность."
|
|
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 ${allHealthy ? 'text-success' : 'text-muted-foreground'}`}>{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>
|
|
)
|
|
}
|