From bda45d633e12c67ae4f8828bac1bcb0a49cc3fc4 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 2 Nov 2026 17:24:11 +0700 Subject: [PATCH] feat(web): show live BGP observation and deployment progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established. Co-authored-by: Cursor --- .../dashboard-network-capacity-card.tsx | 10 +- .../analytics/dashboard-platform-card.tsx | 16 +- .../network-overview-analytics-card.tsx | 15 +- .../dashboard/dashboard-kpi-grid.tsx | 11 +- .../dashboard/dashboard-network-health.tsx | 18 +- .../dashboard/dashboard-network-panel.tsx | 6 +- .../src/components/layout/portal-shell.tsx | 43 ++- .../network/live-observation-badge.tsx | 39 +++ .../src/components/network/network-kpi.tsx | 17 +- .../components/network/network-peers-grid.tsx | 15 +- .../network/network-speakers-grid.tsx | 39 +-- .../components/operations/job-timeline.tsx | 57 +++- .../hooks/use-live-observation-state.test.tsx | 79 +++++ .../src/hooks/use-live-observation-state.ts | 37 +++ apps/web/src/lib/live-observation.test.ts | 115 ++++++++ apps/web/src/lib/live-observation.ts | 120 ++++++++ .../src/lib/metrics/deployment-progress.ts | 7 +- .../web/src/lib/metrics/peer-capacity-bars.ts | 15 +- .../src/lib/metrics/peer-session-breakdown.ts | 14 +- .../lib/metrics/recent-platform-activity.ts | 7 +- apps/web/src/queries/network.ts | 29 ++ apps/web/src/queries/overview.ts | 13 +- apps/web/src/routes/_auth/dashboard.tsx | 29 +- apps/web/src/routes/_auth/network.tsx | 31 +- apps/web/src/types/api.gen.ts | 274 +++++++++++++++++- apps/web/src/types/api.ts | 21 +- 26 files changed, 940 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/components/network/live-observation-badge.tsx create mode 100644 apps/web/src/hooks/use-live-observation-state.test.tsx create mode 100644 apps/web/src/hooks/use-live-observation-state.ts create mode 100644 apps/web/src/lib/live-observation.test.ts create mode 100644 apps/web/src/lib/live-observation.ts diff --git a/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx b/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx index 54dac40..42f2a9d 100644 --- a/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx +++ b/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx @@ -16,6 +16,7 @@ import { 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' @@ -50,8 +51,11 @@ export function DashboardNetworkCapacityCard({ const deltaLabel = mode === 'peers' - ? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено` - : `${speakers.filter((s) => s.live?.agent_ok).length} в сети` + ? `${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 ( {loading ? '—' : `${utilization}%`}

-

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

+

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

{loading ? ( diff --git a/apps/web/src/components/analytics/dashboard-platform-card.tsx b/apps/web/src/components/analytics/dashboard-platform-card.tsx index b19924f..bba116c 100644 --- a/apps/web/src/components/analytics/dashboard-platform-card.tsx +++ b/apps/web/src/components/analytics/dashboard-platform-card.tsx @@ -13,6 +13,7 @@ import { recentPlatformActivity, } from '@/lib/metrics' import { runningJobCount } from '@/queries/overview' +import { peerSessionCurrent, peerSessionEstablished, peerSessionKnown, speakerAgentOnline } from '@/lib/live-observation' import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' export function DashboardPlatformCard({ @@ -35,15 +36,16 @@ export function DashboardPlatformCard({ 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', + (p) => p.enabled !== false && peerSessionEstablished(p), ).length - const peersMismatch = peers.filter((p) => p.session_mismatch).length - const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length + const peersUnknown = peersEnabled - peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length + const peersMismatch = peers.filter((p) => p.session_mismatch && peerSessionCurrent(p)).length + const speakersOnline = speakers.filter(speakerAgentOnline).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 riskCount = peersMismatch + peersUnknown + failedJobs + Math.max(0, speakers.length - speakersOnline) const bgpPct = peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null @@ -69,7 +71,7 @@ export function DashboardPlatformCard({ label: 'BGP готовность', value: loading || bgpPct === null ? '—' : `${bgpPct}%`, delta: { - direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as + direction: (bgpPct !== null && bgpPct >= 90 && peersUnknown === 0 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as | 'up' | 'down' | 'neutral', @@ -77,7 +79,7 @@ export function DashboardPlatformCard({ bgpPct === null ? 'нет включённых пиров' : `${peersEstablished} установлено`, - tone: (bgpPct !== null && bgpPct >= 90 + tone: (bgpPct !== null && bgpPct >= 90 && peersUnknown === 0 ? 'success' : bgpPct !== null && bgpPct < 70 ? 'warning' @@ -89,7 +91,7 @@ export function DashboardPlatformCard({ value: loading ? '—' : String(riskCount), delta: { direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down', - label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме', + label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений, ${peersUnknown} без наблюдения` : 'в норме', tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success', }, }, diff --git a/apps/web/src/components/analytics/network-overview-analytics-card.tsx b/apps/web/src/components/analytics/network-overview-analytics-card.tsx index 52c76e3..e1705ca 100644 --- a/apps/web/src/components/analytics/network-overview-analytics-card.tsx +++ b/apps/web/src/components/analytics/network-overview-analytics-card.tsx @@ -16,7 +16,7 @@ export function NetworkOverviewAnalyticsCard({ const net = aggregateNetworkMetrics(peers, speakers) const slices = peerSessionBreakdown(peers) const speakersPct = - net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0 + net.speakersObserved > 0 ? Math.round((net.speakersOnline / net.speakersObserved) * 100) : 0 if (loading) { return ( @@ -36,22 +36,25 @@ export function NetworkOverviewAnalyticsCard({ description="Распределение состояний пиров" slices={slices} centerLabel="Пиры" - badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'} + badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` + : net.peersObserved < net.peersEnabled ? 'есть пиры без наблюдения' + : net.peersEnabled === 0 ? 'нет включённых пиров' + : net.peersEstablished === net.peersObserved ? 'в норме' : 'есть неустановленные сессии'} /> 0 ? 100 - speakersPct : 0, }} - footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`} + footer={`Пиры установлены: ${net.peersEstablished}/${net.peersObserved} · без наблюдения: ${net.peersEnabled - net.peersObserved} пиров, ${net.speakersTotal - net.speakersObserved} спикеров`} /> ) diff --git a/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx b/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx index a488b8e..58ff278 100644 --- a/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx @@ -35,6 +35,7 @@ function buildKpis({ const enabledModules = modules.filter((m) => m.enabled !== false).length const network = aggregateNetworkMetrics(peers, speakers) const peersEnabled = network.peersEnabled + const peersUnknown = Math.max(0, peersEnabled - network.peersObserved) const bgpPct = peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null const running = runningJobCount(jobs) @@ -42,7 +43,7 @@ function buildKpis({ ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), ).length const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline) - const riskCount = network.peersMismatch + failedJobs + offlineSpeakers + const riskCount = network.peersMismatch + peersUnknown + failedJobs + offlineSpeakers const disabledModules = Math.max(0, modules.length - enabledModules) return [ @@ -84,7 +85,7 @@ function buildKpis({ footer: ( = 90 + bgpPct !== null && bgpPct >= 90 && peersUnknown === 0 ? 'success-light' : bgpPct !== null && bgpPct < 70 ? 'warning-light' @@ -94,9 +95,9 @@ function buildKpis({ > {loading || bgpPct === null ? 'нет включённых пиров' - : bgpPct >= 90 + : bgpPct >= 90 && peersUnknown === 0 ? 'сессии в норме' - : `${network.peersEstablished} установлено`} + : peersUnknown > 0 ? `${peersUnknown} без наблюдения` : `${network.peersEstablished} установлено`} ), }, @@ -124,7 +125,7 @@ function buildKpis({ {loading ? '…' : riskCount > 0 - ? `${failedJobs} задач · ${network.peersMismatch} расхождений` + ? `${failedJobs} задач · ${network.peersMismatch} расхождений · ${peersUnknown} без наблюдения` : 'в норме'} ), diff --git a/apps/web/src/components/dashboard/dashboard-network-health.tsx b/apps/web/src/components/dashboard/dashboard-network-health.tsx index eefe4ff..d3dca96 100644 --- a/apps/web/src/components/dashboard/dashboard-network-health.tsx +++ b/apps/web/src/components/dashboard/dashboard-network-health.tsx @@ -9,6 +9,7 @@ import { speakerCapacityBars, } from '@/lib/metrics' import { runningJobCount } from '@/queries/overview' +import { peerSessionEstablished, peerSessionKnown, speakerAgentKnown, speakerAgentOnline } from '@/lib/live-observation' import type { JobRow, PeerRow, SpeakerRow } from '@/types/api' type CapacityMode = 'peers' | 'speakers' @@ -35,11 +36,14 @@ export function DashboardNetworkHealth({ const established = mode === 'peers' - ? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length - : speakers.filter((s) => s.live?.agent_ok).length + ? peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length + : speakers.filter(speakerAgentOnline).length const total = - mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length + mode === 'peers' ? peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length : speakers.filter(speakerAgentKnown).length const offline = Math.max(0, total - established) + const unknown = mode === 'peers' + ? peers.filter((p) => p.enabled !== false).length - total + : speakers.length - total return ( 0 + ? `${unknown} без наблюдения` : mode === 'peers' ? `${offline} не установлены` : `${offline} офлайн`} @@ -93,7 +99,7 @@ export function DashboardNetworkHealth({ footer={ loading ? 'Загрузка…' - : `Активных задач: ${queued} · сегментов в графике: ${bars.length}` + : `Активных задач: ${queued} · сегментов в графике: ${bars.length} · без наблюдения: ${unknown}` } /> ) diff --git a/apps/web/src/components/dashboard/dashboard-network-panel.tsx b/apps/web/src/components/dashboard/dashboard-network-panel.tsx index 47bc8bb..585e007 100644 --- a/apps/web/src/components/dashboard/dashboard-network-panel.tsx +++ b/apps/web/src/components/dashboard/dashboard-network-panel.tsx @@ -28,8 +28,10 @@ export function DashboardNetworkPanel({ const m = aggregateNetworkMetrics(peers, speakers) return (
- - + + + + {m.peersMismatch > 0 ? ( ) : null} diff --git a/apps/web/src/components/layout/portal-shell.tsx b/apps/web/src/components/layout/portal-shell.tsx index 21b3227..2ef0a64 100644 --- a/apps/web/src/components/layout/portal-shell.tsx +++ b/apps/web/src/components/layout/portal-shell.tsx @@ -19,6 +19,8 @@ import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context' import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store' import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth' +import { useLiveObservationState } from '@/hooks/use-live-observation-state' +import { peerSessionEstablished, peerSessionKnown, peersForDisplay, snapshotUnavailable } from '@/lib/live-observation' import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' import { monitoringHealthQueryOptions } from '@/queries/monitoring' import { networkBirdQueryOptions } from '@/queries/network' @@ -36,7 +38,7 @@ const FRESH: Record = { '/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' }, '/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' }, '/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' }, - '/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · состояние сессий опрашивается живьём' }, + '/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · сохранённые снимки состояния сессий' }, '/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' }, '/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' }, '/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' }, @@ -85,18 +87,24 @@ function useChromeData(): ChromeConfig { const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 }) const modulesQ = useQuery(overviewModulesQueryOptions()) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) + const { offline, disconnectedAt, revision } = useLiveObservationState(peersQ.data?.items ?? [], []) return useMemo(() => { - const peers = peersQ.data?.items ?? [] + const peersUnavailable = snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt) + const healthUnavailable = snapshotUnavailable(healthQ.isError, offline, disconnectedAt, healthQ.dataUpdatedAt) + const birdUnavailable = snapshotUnavailable(birdQ.isError, offline, disconnectedAt, birdQ.dataUpdatedAt) + const peers = peersForDisplay(peersQ.data?.items ?? [], peersUnavailable) const jobs = jobsQ.data?.items ?? [] const modules = modulesQ.data?.items ?? [] const net = aggregateNetworkMetrics(peers, []) const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length - const peersDown = Math.max(0, net.peersEnabled - net.peersEstablished) - const healthOk = healthQ.data?.ok ?? false + const peersDown = Math.max(0, net.peersObserved - net.peersEstablished) + const peersUnknown = Math.max(0, net.peersEnabled - net.peersObserved) + const healthOk = !healthUnavailable && (healthQ.data?.ok ?? false) + const bird = birdUnavailable ? undefined : birdQ.data const birdRatio = - birdQ.data && birdQ.data.bgp_sessions_total > 0 - ? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100) + bird && bird.bgp_sessions_total > 0 + ? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100) : null const bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0 @@ -106,7 +114,7 @@ function useChromeData(): ChromeConfig { return !perm || can(perm) }) const problems: Record = { - network: peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : undefined, + network: peersUnavailable ? { tone: 'wn', label: 'Снимок сети недоступен' } : peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : peersUnknown > 0 ? { tone: 'wn', label: `${peersUnknown} пиров без наблюдения` } : undefined, schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined, monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined, } @@ -118,18 +126,25 @@ function useChromeData(): ChromeConfig { monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' }) } if (peersDown > 0) { - monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Включено ${net.peersEnabled}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' }) + monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Наблюдается ${net.peersObserved}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' }) + } + if (peersUnknown > 0) { + monitorItems.push({ id: 'peers-unknown', tone: 'wn', title: `Пиров без наблюдения: ${peersUnknown}`, text: 'Состояние сессий пока неизвестно', age: 'сейчас', to: '/network' }) } if (failedJobs > 0) { monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' }) } if (monitorItems.length === 0) { - monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' }) + if (healthUnavailable || peersUnavailable || birdUnavailable) { + monitorItems.push({ id: 'unavailable', tone: 'wn', title: 'Данные мониторинга недоступны', text: 'Проверьте соединение и повторите запрос', age: 'сейчас', to: '/monitoring' }) + } else { + monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' }) + } } const metrics: MonitorMetric[] = [ { l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' }, - { l: 'BGP пиры', v: `${bgpPct}%`, p: bgpPct, tone: bgpPct >= 90 ? 'success' : 'warning' }, + { l: 'BGP пиры', v: net.peersObserved === 0 && net.peersEnabled > 0 ? '—' : `${bgpPct}%`, p: bgpPct, tone: peersUnknown === 0 && bgpPct >= 90 ? 'success' : 'warning' }, { l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' }, { l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' }, ] @@ -152,7 +167,7 @@ function useChromeData(): ChromeConfig { group: 'BGP-пиры', label: p.name ?? p.neighbor, hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`, - dot: p.session_state === 'Established' ? 'ok' : 'er', + dot: !peerSessionKnown(p) ? 'mu' : peerSessionEstablished(p) ? 'ok' : 'er', to: '/network', })), ] @@ -165,7 +180,7 @@ function useChromeData(): ChromeConfig { securityTo: '/settings', onLogout: logout, fresh: freshFor, - monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' }, + monitor: { items: monitorItems, metrics, updated: healthUnavailable || peersUnavailable || birdUnavailable ? 'данные недоступны' : 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' }, palette: { actions: [ { id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' }, @@ -177,7 +192,9 @@ function useChromeData(): ChromeConfig { example: 'Подсказка: «пиры не в Established»', }, } - }, [healthQ.data, healthQ.isSuccess, peersQ.data, jobsQ.data, modulesQ.data, birdQ.data]) + // Recompute cached metrics when the server-provided observation deadline passes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [healthQ.data, healthQ.isSuccess, healthQ.isError, healthQ.dataUpdatedAt, peersQ.data, peersQ.isError, peersQ.dataUpdatedAt, jobsQ.data, modulesQ.data, birdQ.data, birdQ.isError, birdQ.dataUpdatedAt, offline, disconnectedAt, revision]) } /** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */ diff --git a/apps/web/src/components/network/live-observation-badge.tsx b/apps/web/src/components/network/live-observation-badge.tsx new file mode 100644 index 0000000..be02bd6 --- /dev/null +++ b/apps/web/src/components/network/live-observation-badge.tsx @@ -0,0 +1,39 @@ +import { StatusBadge } from '@/components/status-badge' +import { observationHint, observationQuality, peerSessionCurrent, speakerAgentKnown } from '@/lib/live-observation' +import { bgpSessionStateRu, speakerOnlineLabel } from '@/lib/ui-labels' +import type { PeerRow, SpeakerRow } from '@/types/api' + +export function PeerObservationBadge({ peer }: { peer: PeerRow }) { + const current = peerSessionCurrent(peer) + const state = current ? peer.session_state : null + const quality = peer.live_observation && observationQuality(peer.live_observation) + const lastKnown = peer.last_known_session_state + const hint = [ + observationHint(peer.live_observation), + !current && lastKnown ? `последнее: ${bgpSessionStateRu(lastKnown)}` : undefined, + ].filter(Boolean).join(' · ') + return ( + + ) +} + +export function AgentObservationBadge({ speaker }: { speaker: SpeakerRow }) { + const observation = speaker.live?.agent_observation + const quality = observation && observationQuality(observation) + const ok = speaker.live?.agent_ok + const reportedUnhealthy = observation?.error_code === 'reported_unhealthy' && ok === false && speakerAgentKnown(speaker) + const current = !observation || quality === 'ok' || reportedUnhealthy + const label = reportedUnhealthy + ? 'Агент сообщил об ошибке' + : !current + ? quality === 'stale' ? 'Устарело' : 'Нет данных' + : ok == null ? 'Нет данных' : speakerOnlineLabel(ok) + const status = !current + ? quality === 'stale' ? 'stale' : 'unknown' + : ok == null ? 'unknown' : ok ? 'ok' : 'error' + return +} diff --git a/apps/web/src/components/network/network-kpi.tsx b/apps/web/src/components/network/network-kpi.tsx index ea9e992..7741ac0 100644 --- a/apps/web/src/components/network/network-kpi.tsx +++ b/apps/web/src/components/network/network-kpi.tsx @@ -21,6 +21,7 @@ export function NetworkKpi({ loading?: boolean }) { const net = aggregateNetworkMetrics(peers, speakers) + const peersHealthy = net.peersEnabled > 0 && net.peersObserved === net.peersEnabled && net.peersEstablished === net.peersObserved const birdHealthy = bird?.healthy const birdSessions = bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—' @@ -29,12 +30,12 @@ export function NetworkKpi({ { id: 'peers-established', icon: , - iconClassName: 'text-success', + iconClassName: peersHealthy ? 'text-success' : 'text-warning', label: 'Пиры установлены', - value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, + value: loading ? '—' : `${net.peersEstablished}/${net.peersObserved}`, footer: ( - - {loading ? '…' : `${net.peersTotal} в каталоге`} + + {loading ? '…' : `${net.peersEnabled - net.peersObserved} без наблюдения`} ), to: '/network', @@ -45,17 +46,17 @@ export function NetworkKpi({ icon: , iconClassName: 'text-info', label: 'Спикеры в сети', - value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, + value: loading ? '—' : `${net.speakersOnline}/${net.speakersObserved}`, footer: ( 0 + net.speakersOnline === net.speakersObserved && net.speakersObserved > 0 ? 'success-light' : 'warning-light' } size="sm" > - {loading ? '…' : 'в сети'} + {loading ? '…' : `${net.speakersTotal - net.speakersObserved} без наблюдения`} ), to: '/network', @@ -70,7 +71,7 @@ export function NetworkKpi({ variant: net.peersMismatch > 0 ? 'warning' : 'default', footer: ( 0 ? 'warning-light' : 'outline'} size="sm"> - {loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'} + {loading ? '…' : net.peersMismatch > 0 ? 'проверить' : net.peersObserved === 0 && net.peersEnabled > 0 ? 'нет наблюдения' : 'в норме'} ), to: '/network', diff --git a/apps/web/src/components/network/network-peers-grid.tsx b/apps/web/src/components/network/network-peers-grid.tsx index be95b0e..aad2ead 100644 --- a/apps/web/src/components/network/network-peers-grid.tsx +++ b/apps/web/src/components/network/network-peers-grid.tsx @@ -2,10 +2,10 @@ import { Share2 } from 'lucide-react' import { CategoryBadge } from '@/components/category-badge' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell' -import { StatusBadge } from '@/components/status-badge' +import { PeerObservationBadge } from '@/components/network/live-observation-badge' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import type { DataGridColumnDef } from '@/components/reui-kit' -import { bgpSessionStateRu } from '@/lib/ui-labels' +import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation' import type { PeerRow } from '@/types/api' export const peerColumns: DataGridColumnDef[] = [ @@ -41,10 +41,7 @@ export const peerColumns: DataGridColumnDef[] = [ header: ({ column }) => , cell: ({ row }) => (
- + {row.original.session_mismatch ? ( расхождение ) : null} @@ -61,7 +58,7 @@ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown { case 'neighbor': return item.neighbor case 'session_state': - return item.session_state + return peerSessionKnown(item) ? item.session_state : undefined default: return undefined } @@ -70,7 +67,7 @@ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown { export function peerTabFilter(item: PeerRow, tabId: string): boolean { if (tabId === 'disabled') return item.enabled === false const enabled = item.enabled !== false - if (tabId === 'established') return enabled && item.session_state === 'Established' - if (tabId === 'pending') return enabled && item.session_state !== 'Established' + if (tabId === 'established') return enabled && peerSessionEstablished(item) + if (tabId === 'pending') return enabled && peerSessionKnown(item) && !peerSessionEstablished(item) return true } diff --git a/apps/web/src/components/network/network-speakers-grid.tsx b/apps/web/src/components/network/network-speakers-grid.tsx index 4d7caef..9fb14c4 100644 --- a/apps/web/src/components/network/network-speakers-grid.tsx +++ b/apps/web/src/components/network/network-speakers-grid.tsx @@ -2,11 +2,11 @@ import { ServerCog } from 'lucide-react' import { CategoryBadge } from '@/components/category-badge' import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell' -import { StatusBadge } from '@/components/status-badge' -import { Badge } from '@/components/reui/badge' +import { AgentObservationBadge } from '@/components/network/live-observation-badge' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import type { DataGridColumnDef } from '@/components/reui-kit' -import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels' +import { observationHint, speakerAgentKnown, speakerAgentOnline, speakerBgpCounts } from '@/lib/live-observation' +import { speakerRoleRu } from '@/lib/ui-labels' import type { SpeakerRow } from '@/types/api' export const speakerColumns: DataGridColumnDef[] = [ @@ -32,20 +32,7 @@ export const speakerColumns: DataGridColumnDef[] = [ id: 'agent', enableSorting: false, header: 'Агент', - cell: ({ row }) => { - const live = row.original.live - if (live?.agent_ok === true) { - return - } - if (live?.agent_ok === false) { - return - } - return ( - - — - - ) - }, + cell: ({ row }) => , meta: { headerTitle: 'Агент' }, }, { @@ -53,12 +40,16 @@ export const speakerColumns: DataGridColumnDef[] = [ enableSorting: false, header: 'BGP', cell: ({ row }) => { - const live = row.original.live - if (!live) return '—' + const count = speakerBgpCounts(row.original) return ( - - {live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0} - +
+ {count ?? '—'} + {row.original.live?.bgp_observation ? ( + + {observationHint(row.original.live.bgp_observation)} + + ) : null} +
) }, meta: { headerTitle: 'BGP' }, @@ -77,7 +68,7 @@ export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unk } export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean { - if (tabId === 'online') return item.live?.agent_ok === true - if (tabId === 'offline') return item.live?.agent_ok !== true + if (tabId === 'online') return speakerAgentOnline(item) + if (tabId === 'offline') return speakerAgentKnown(item) && !speakerAgentOnline(item) return true } diff --git a/apps/web/src/components/operations/job-timeline.tsx b/apps/web/src/components/operations/job-timeline.tsx index b4e6e60..ef3f4da 100644 --- a/apps/web/src/components/operations/job-timeline.tsx +++ b/apps/web/src/components/operations/job-timeline.tsx @@ -8,6 +8,7 @@ import { TimelineSeparator, TimelineTitle, } from '@/components/reui/timeline' +import { Badge } from '@/components/reui/badge' import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels' import type { JobRow } from '@/types/api' @@ -30,11 +31,46 @@ function resultSubtitle(job: JobRow): string | undefined { return undefined } +function applyCounts(job: JobRow) { + const value = job.meta?.apply_summary + if (!value || typeof value !== 'object') return undefined + const summary = value as Record + const count = (key: string) => typeof summary[key] === 'number' ? summary[key] as number : undefined + return { + published: count('published_count'), + applied: count('applied_count'), + unconfirmed: count('unconfirmed_count'), + failed: count('failed_count'), + } +} + +function dispatchConfirmations(job: JobRow): string[] { + const dispatch = job.meta?.node_dispatch + if (!dispatch || typeof dispatch !== 'object') return [] + const results = (dispatch as Record).results + if (!Array.isArray(results)) return [] + return results.flatMap((item) => { + if (!item || typeof item !== 'object') return [] + const result = item as Record + const speaker = typeof result.speaker_id === 'string' ? result.speaker_id.slice(0, 8) : 'Спикер' + const label = result.confirmation === 'agent_reported' + ? 'агент подтвердил' + : result.confirmation === 'local_applied' + ? 'применено локально' + : result.confirmation === 'unconfirmed' + ? 'без подтверждения агента' + : 'подтверждение не указано' + return [`${speaker}: ${label}`] + }) +} + export function JobTimeline({ job }: { job: JobRow }) { const trigger = jobTriggerRu(job.meta?.trigger) const completed = job.finished_at ? 3 : job.started_at ? 2 : 1 const failed = ['failed', 'error'].includes(job.status.toLowerCase()) const subtitle = resultSubtitle(job) + const counts = applyCounts(job) + const confirmations = dispatchConfirmations(job) return (
@@ -65,7 +101,26 @@ export function JobTimeline({ job }: { job: JobRow }) { - {subtitle ? {subtitle} : null} + {subtitle || counts || confirmations.length > 0 ? ( + +
+ {subtitle ? {subtitle} : null} + {counts ? ( +
+ {counts.published != null ? Опубликовано: {counts.published} : null} + {counts.applied != null ? Подтверждено: {counts.applied} : null} + {counts.unconfirmed != null && counts.unconfirmed > 0 ? Без подтверждения агента: {counts.unconfirmed} : null} + {counts.failed != null && counts.failed > 0 ? Ошибок: {counts.failed} : null} +
+ ) : null} + {confirmations.length > 0 ? ( +
    + {confirmations.map((confirmation, index) =>
  • {confirmation}
  • )} +
+ ) : null} +
+
+ ) : null}
diff --git a/apps/web/src/hooks/use-live-observation-state.test.tsx b/apps/web/src/hooks/use-live-observation-state.test.tsx new file mode 100644 index 0000000..599cb98 --- /dev/null +++ b/apps/web/src/hooks/use-live-observation-state.test.tsx @@ -0,0 +1,79 @@ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health' +import { useLiveObservationState } from '@/hooks/use-live-observation-state' +import { peersForDisplay, speakerAgentKnown, speakersForDisplay } from '@/lib/live-observation' +import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api' + +const deadline = '2026-10-26T10:00:10.000Z' + +function observation(quality: LiveObservation['quality'], errorCode?: LiveObservation['error_code']): LiveObservation { + return { + observed_at: '2026-10-26T10:00:00.000Z', + received_at: '2026-10-26T10:00:00.000Z', + last_success_at: '2026-10-26T10:00:00.000Z', + expires_at: deadline, + source: 'agent_bird_protocols', + quality, + error_code: errorCode, + } +} + +const peer = { id: 'peer-1', enabled: true, session_state: 'Established', live_observation: observation('ok') } as PeerRow +const unhealthySpeaker = { id: 'speaker-1', live: { agent_ok: false, agent_observation: observation('stale', 'reported_unhealthy') } } as SpeakerRow + +function NetworkHarness() { + useLiveObservationState([peer], []) + // New array identities let memoized chart bars recompute at the deadline. + return +} + +function SpeakerHarness() { + useLiveObservationState([], [unhealthySpeaker]) + return {speakerAgentKnown(speakersForDisplay([unhealthySpeaker], false)[0]) ? 'known' : 'unknown'} +} + +describe('live observation deadline rendering', () => { + beforeEach(() => vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('rerenders memoized network bars when a fresh peer snapshot expires', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z')) + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + try { + await act(async () => root.render()) + expect(host.textContent).toContain('100%') + await act(async () => vi.advanceTimersByTime(10_001)) + expect(host.textContent).toContain('0%') + expect(host.textContent).toContain('нет данных') + } finally { + await act(async () => root.unmount()) + host.remove() + } + }) + + it('rerenders a reported unhealthy speaker at its deadline', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z')) + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + try { + await act(async () => root.render()) + expect(host.textContent).toBe('known') + await act(async () => vi.advanceTimersByTime(10_001)) + expect(host.textContent).toBe('unknown') + } finally { + await act(async () => root.unmount()) + host.remove() + } + }) +}) diff --git a/apps/web/src/hooks/use-live-observation-state.ts b/apps/web/src/hooks/use-live-observation-state.ts new file mode 100644 index 0000000..af723ae --- /dev/null +++ b/apps/web/src/hooks/use-live-observation-state.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' + +import { nextObservationExpiry } from '@/lib/live-observation' +import type { PeerRow, SpeakerRow } from '@/types/api' + +/** Rerender at the server-provided observation deadline and on browser connectivity changes. */ +export function useLiveObservationState(peers: PeerRow[], speakers: SpeakerRow[]): { offline: boolean; disconnectedAt: number | null; revision: number } { + const [offline, setOffline] = useState(() => typeof navigator !== 'undefined' && !navigator.onLine) + const [disconnectedAt, setDisconnectedAt] = useState(() => + typeof navigator !== 'undefined' && !navigator.onLine ? Date.now() : null, + ) + const [clockTick, setClockTick] = useState(0) + + useEffect(() => { + const update = () => { + const disconnected = !navigator.onLine + setOffline(disconnected) + if (disconnected) setDisconnectedAt(Date.now()) + } + window.addEventListener('online', update) + window.addEventListener('offline', update) + return () => { + window.removeEventListener('online', update) + window.removeEventListener('offline', update) + } + }, []) + + useEffect(() => { + const next = nextObservationExpiry(peers, speakers) + if (next === undefined) return + const delay = Math.min(2_147_483_647, Math.max(1, next - Date.now() + 1)) + const timer = window.setTimeout(() => setClockTick((tick) => tick + 1), delay) + return () => window.clearTimeout(timer) + }, [peers, speakers, clockTick]) + + return { offline, disconnectedAt, revision: clockTick } +} diff --git a/apps/web/src/lib/live-observation.test.ts b/apps/web/src/lib/live-observation.test.ts new file mode 100644 index 0000000..b0e8b5a --- /dev/null +++ b/apps/web/src/lib/live-observation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + peerSessionEstablished, + peerSessionKnown, + speakerAgentKnown, + speakerAgentOnline, + speakerBgpCounts, + observationQuality, + nextObservationExpiry, + peersForDisplay, + speakersForDisplay, + snapshotUnavailable, +} from './live-observation' +import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api' +import { aggregateNetworkMetrics } from '@/queries/overview' +import { deploymentProgress } from './metrics/deployment-progress' +import { speakerCapacityBars } from './metrics/peer-capacity-bars' + +const observation = (quality: LiveObservation['quality']): LiveObservation => ({ + observed_at: null, + received_at: null, + last_success_at: null, + source: 'agent_bird_protocols', + quality, +}) + +describe('live observation compatibility', () => { + it('uses legacy live fields when observation metadata is absent', () => { + const peer = { session_state: 'Established' } as PeerRow + const speaker = { live: { agent_ok: true, bgp_established: 2, bgp_sessions_total: 3 } } as SpeakerRow + expect(peerSessionEstablished(peer)).toBe(true) + expect(speakerAgentOnline(speaker)).toBe(true) + expect(speakerBgpCounts(speaker)).toBe('2 / 3') + }) + + it('keeps stale and missing observations separate from down or zero', () => { + const peer = { session_state: null, last_known_session_state: 'Established', live_observation: observation('stale') } as PeerRow + const speaker = { live: { agent_ok: false, agent_observation: observation('unknown'), bgp_established: null, bgp_sessions_total: null, bgp_observation: observation('unreachable') } } as SpeakerRow + expect(peerSessionKnown(peer)).toBe(false) + expect(peerSessionEstablished(peer)).toBe(false) + expect(speakerAgentKnown(speaker)).toBe(false) + expect(speakerAgentOnline(speaker)).toBe(false) + expect(speakerBgpCounts(speaker)).toBeUndefined() + }) + + it('counts only enabled, observed peers in established metrics', () => { + const peers = [ + { enabled: false, session_state: 'Established' }, + { enabled: true, session_state: 'Established', live_observation: observation('stale') }, + { enabled: true, session_state: 'Established', live_observation: observation('ok') }, + ] as PeerRow[] + const metrics = aggregateNetworkMetrics(peers, []) + expect(metrics.peersObserved).toBe(1) + expect(metrics.peersEstablished).toBe(1) + }) + + it('includes published but unconfirmed speakers in deployment denominator', () => { + const speakers = [ + { published_revision_id: 'rev-1', last_applied_revision_id: 'rev-1' }, + { published_revision_id: 'rev-1', last_applied_revision_id: null }, + ] as SpeakerRow[] + expect(deploymentProgress(speakers)).toMatchObject({ mode: 'revision', synced: 1, total: 2, percent: 50 }) + expect(deploymentProgress([speakers[1]])).toMatchObject({ mode: 'revision', synced: 0, total: 1, percent: 0 }) + }) + + it('treats explicit agent health failure as a known negative result', () => { + const speaker = { live: { + agent_ok: false, + agent_observation: { ...observation('stale'), error_code: 'reported_unhealthy' }, + } } as SpeakerRow + expect(speakerAgentKnown(speaker)).toBe(true) + expect(speakerAgentOnline(speaker)).toBe(false) + }) + + it('masks cached fresh status after a snapshot API failure', () => { + const peer = { session_state: 'Established', live_observation: observation('ok'), session_mismatch: true } as PeerRow + const speaker = { live: { agent_ok: true, agent_observation: observation('ok'), bgp_established: 2, bgp_sessions_total: 2, bgp_observation: observation('ok') } } as SpeakerRow + expect(aggregateNetworkMetrics([peer], [speaker])).toMatchObject({ peersEstablished: 1, speakersOnline: 1 }) + + const peers = peersForDisplay([peer], true) + const speakers = speakersForDisplay([speaker], true) + expect(aggregateNetworkMetrics(peers, speakers)).toMatchObject({ peersEstablished: 0, peersMismatch: 0, speakersOnline: 0 }) + expect(peerSessionKnown(peers[0])).toBe(false) + expect(speakerAgentKnown(speakers[0])).toBe(false) + expect(speakerBgpCounts(speakers[0])).toBeUndefined() + expect(snapshotUnavailable(true, false, null, 100)).toBe(true) + expect(snapshotUnavailable(false, true, 200, 100)).toBe(true) + expect(snapshotUnavailable(false, false, 200, 100)).toBe(true) + expect(snapshotUnavailable(false, false, 200, 201)).toBe(false) + }) + + it('expires an ok observation at the server deadline', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z')) + const live = { ...observation('ok'), expires_at: '2026-10-26T10:00:10.000Z' } + const peer = { session_state: 'Established', live_observation: live } as PeerRow + const speaker = { id: 'speaker-1', endpoint: 'speaker.example', live: { agent_ok: true, agent_observation: live, bgp_established: 2, bgp_sessions_total: 2, bgp_observation: live } } as SpeakerRow + expect(nextObservationExpiry([peer], [speaker])).toBe(Date.parse(live.expires_at)) + expect(peerSessionEstablished(peer)).toBe(true) + expect(speakerAgentOnline(speaker)).toBe(true) + expect(speakerCapacityBars([speaker])).toHaveLength(1) + + vi.setSystemTime(new Date(live.expires_at)) + expect(observationQuality(live)).toBe('stale') + expect(peerSessionEstablished(peer)).toBe(false) + expect(speakerAgentOnline(speaker)).toBe(false) + expect(speakerCapacityBars([speaker])).toHaveLength(0) + expect(nextObservationExpiry([peer], [speaker])).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/web/src/lib/live-observation.ts b/apps/web/src/lib/live-observation.ts new file mode 100644 index 0000000..8f6c71e --- /dev/null +++ b/apps/web/src/lib/live-observation.ts @@ -0,0 +1,120 @@ +import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api' + +const QUALITY_LABEL: Record = { + ok: 'актуально', + stale: 'данные устарели', + unreachable: 'опрос недоступен', + unknown: 'нет наблюдения', + disabled: 'опрос выключен', +} + +function expired(observation: LiveObservation, now = Date.now()): boolean { + if (!observation.expires_at) return false + const deadline = Date.parse(observation.expires_at) + return !Number.isFinite(deadline) || deadline <= now +} + +export function observationQuality(observation: LiveObservation, now = Date.now()): LiveObservation['quality'] { + return observation.quality === 'ok' && expired(observation, now) ? 'stale' : observation.quality +} + +const unavailableObservation: LiveObservation = { + observed_at: null, + received_at: null, + last_success_at: null, + source: 'none', + quality: 'unknown', +} + +/** A successful GET after reconnect is required before cached live values are trusted again. */ +export function snapshotUnavailable(queryFailed: boolean, offline: boolean, disconnectedAt: number | null, dataUpdatedAt: number): boolean { + return queryFailed || offline || (disconnectedAt !== null && dataUpdatedAt <= disconnectedAt) +} + +/** Hide cached live values when their snapshot request fails or the browser goes offline. */ +export function peersForDisplay(peers: PeerRow[], unavailable: boolean): PeerRow[] { + if (!unavailable) return peers + return peers.map((peer) => ({ + ...peer, + session_state: null, + live_observation: unavailableObservation, + session_on_speakers: [], + established_on_speakers: [], + session_mismatch: false, + })) +} + +export function speakersForDisplay(speakers: SpeakerRow[], unavailable: boolean): SpeakerRow[] { + if (!unavailable) return speakers + return speakers.map((speaker) => ({ + ...speaker, + live: speaker.live && { + ...speaker.live, + agent_ok: undefined, + agent_observation: unavailableObservation, + bgp_poll_ok: undefined, + bgp_established: null, + bgp_sessions_total: null, + sessions: [], + bgp_observation: unavailableObservation, + }, + })) +} + +/** Earliest server deadline; callers can schedule a render without inventing a client TTL. */ +export function nextObservationExpiry(peers: PeerRow[], speakers: SpeakerRow[], now = Date.now()): number | undefined { + let next: number | undefined + const visit = (observation?: LiveObservation) => { + if (!observation?.expires_at || (observation.quality !== 'ok' && observation.error_code !== 'reported_unhealthy')) return + const deadline = Date.parse(observation.expires_at) + if (Number.isFinite(deadline) && deadline > now) next = Math.min(next ?? deadline, deadline) + } + for (const peer of peers) visit(peer.live_observation) + for (const speaker of speakers) { + visit(speaker.live?.agent_observation) + visit(speaker.live?.bgp_observation) + } + return next +} + +function observedTime(observation: LiveObservation): string | undefined { + const timestamp = observation.observed_at ?? observation.received_at ?? observation.last_success_at + if (!timestamp) return undefined + const date = new Date(timestamp) + return Number.isNaN(date.getTime()) ? undefined : date.toLocaleString('ru-RU') +} + +export function observationHint(observation?: LiveObservation): string | undefined { + if (!observation) return undefined + const at = observedTime(observation) + const label = QUALITY_LABEL[observationQuality(observation)] + return at ? `${label} · ${at}` : label +} + +export function peerSessionCurrent(peer: PeerRow): boolean { + return !peer.live_observation || observationQuality(peer.live_observation) === 'ok' +} + +export function peerSessionEstablished(peer: PeerRow): boolean { + return peerSessionCurrent(peer) && peer.session_state === 'Established' +} + +export function peerSessionKnown(peer: PeerRow): boolean { + return peerSessionCurrent(peer) && peer.session_state != null && peer.session_state !== '' +} + +export function speakerAgentKnown(speaker: SpeakerRow): boolean { + const observation = speaker.live?.agent_observation + return (!observation || (!expired(observation) && (observationQuality(observation) === 'ok' || observation.error_code === 'reported_unhealthy'))) && speaker.live?.agent_ok != null +} + +export function speakerAgentOnline(speaker: SpeakerRow): boolean { + return speakerAgentKnown(speaker) && speaker.live?.agent_ok === true +} + +export function speakerBgpCounts(speaker: SpeakerRow): string | undefined { + const live = speaker.live + if (!live || (live.bgp_observation && observationQuality(live.bgp_observation) !== 'ok')) return undefined + if (live.bgp_established == null || live.bgp_sessions_total == null) return undefined + return `${live.bgp_established} / ${live.bgp_sessions_total}` +} diff --git a/apps/web/src/lib/metrics/deployment-progress.ts b/apps/web/src/lib/metrics/deployment-progress.ts index 0a403d8..190a74d 100644 --- a/apps/web/src/lib/metrics/deployment-progress.ts +++ b/apps/web/src/lib/metrics/deployment-progress.ts @@ -1,4 +1,5 @@ import type { SpeakerRow } from '@/types/api' +import { speakerAgentOnline } from '@/lib/live-observation' import type { DeploymentProgress } from './types' @@ -7,9 +8,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress { return { percent: 0, synced: 0, total: 0, mode: 'online' } } - const withRevision = speakers.filter( - (s) => s.published_revision_id && s.last_applied_revision_id, - ) + const withRevision = speakers.filter((s) => s.published_revision_id) if (withRevision.length > 0) { const synced = withRevision.filter( (s) => s.published_revision_id === s.last_applied_revision_id, @@ -22,7 +21,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress { } } - const online = speakers.filter((s) => s.live?.agent_ok).length + const online = speakers.filter(speakerAgentOnline).length return { percent: Math.round((online / speakers.length) * 100), synced: online, diff --git a/apps/web/src/lib/metrics/peer-capacity-bars.ts b/apps/web/src/lib/metrics/peer-capacity-bars.ts index fd81173..b93f650 100644 --- a/apps/web/src/lib/metrics/peer-capacity-bars.ts +++ b/apps/web/src/lib/metrics/peer-capacity-bars.ts @@ -1,4 +1,5 @@ import type { PeerRow, SpeakerRow } from '@/types/api' +import { observationQuality, peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation' import type { CapacityBar } from './types' @@ -12,25 +13,27 @@ function speakerLabel(speaker: SpeakerRow): string { export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] { return peers - .filter((p) => p.enabled !== false) + .filter((p) => p.enabled !== false && peerSessionKnown(p)) .slice(0, max) .map((peer) => ({ id: peer.id, name: peerLabel(peer), - value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10, + value: peerSessionEstablished(peer) ? 100 : 40, })) } export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] { - return speakers.slice(0, max).map((speaker) => { - const online = speaker.live?.agent_ok === true + return speakers.filter((speaker) => + (!speaker.live?.bgp_observation || observationQuality(speaker.live.bgp_observation) === 'ok') && + speaker.live?.bgp_established != null && speaker.live.bgp_sessions_total != null, + ).slice(0, max).map((speaker) => { 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 + const ratio = total > 0 ? Math.round((established / total) * 100) : 0 return { id: speaker.id, name: speakerLabel(speaker), - value: online ? ratio : 10, + value: ratio, } }) } diff --git a/apps/web/src/lib/metrics/peer-session-breakdown.ts b/apps/web/src/lib/metrics/peer-session-breakdown.ts index 86f9c14..fa82da6 100644 --- a/apps/web/src/lib/metrics/peer-session-breakdown.ts +++ b/apps/web/src/lib/metrics/peer-session-breakdown.ts @@ -1,13 +1,15 @@ import type { PeerRow } from '@/types/api' +import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation' 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 established = enabled.filter(peerSessionEstablished).length const pending = enabled.filter( - (p) => p.session_state && p.session_state !== 'Established', + (p) => peerSessionKnown(p) && !peerSessionEstablished(p), ).length + const unknown = enabled.length - established - pending const disabled = peers.length - enabled.length const slices: BreakdownSlice[] = [] @@ -27,6 +29,14 @@ export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] { color: 'var(--color-warning)', }) } + if (unknown > 0) { + slices.push({ + key: 'unknown', + label: 'Нет наблюдения', + count: unknown, + color: 'var(--color-muted-foreground)', + }) + } if (disabled > 0) { slices.push({ key: 'disabled', diff --git a/apps/web/src/lib/metrics/recent-platform-activity.ts b/apps/web/src/lib/metrics/recent-platform-activity.ts index 2c6a82f..409f330 100644 --- a/apps/web/src/lib/metrics/recent-platform-activity.ts +++ b/apps/web/src/lib/metrics/recent-platform-activity.ts @@ -1,6 +1,7 @@ import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' import { jobKindRu, jobStatusRu } from '@/lib/ui-labels' +import { speakerAgentKnown } from '@/lib/live-observation' import type { PlatformActivityItem } from './types' @@ -45,11 +46,11 @@ export function recentPlatformActivity( }) } - for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) { + for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || (speakerAgentKnown(s) && 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', + message: `Проблема опроса ноды: ${speaker.live?.label ?? speaker.endpoint}`, + status: speakerAgentKnown(speaker) && speaker.live?.agent_ok === false ? 'error' : 'warning', kind: 'network', }) } diff --git a/apps/web/src/queries/network.ts b/apps/web/src/queries/network.ts index 75206bb..08e3676 100644 --- a/apps/web/src/queries/network.ts +++ b/apps/web/src/queries/network.ts @@ -2,6 +2,8 @@ import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query import { toast } from 'sonner' import { apiJSON, apiMutate } from '@/lib/api-client' +import { overviewKeys } from '@/queries/overview' +import type { operations } from '@/types/api.gen' import type { BgpPeerCreate, BgpPeerPatch, @@ -27,6 +29,33 @@ export const networkKeys = { bird: () => [...networkKeys.all, 'bird'] as const, } +type TelemetryRefreshResponse = operations['requestTelemetryRefresh']['responses'][202]['content']['application/json'] + +/** Request a background poll; GET queries continue reading the latest saved snapshots. */ +export function useRequestTelemetryRefreshMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => + apiMutate('/v1/telemetry/refresh', 'POST', undefined, { + idempotent: false, + }), + onSuccess: ({ requested }) => { + if (requested) { + toast.success('Запрос на обновление телеметрии принят. Данные появятся после фонового опроса.') + } else { + toast.message('Запрос объединён с недавним обновлением или нет доступных целей.') + } + void qc.invalidateQueries({ queryKey: networkKeys.peers() }) + void qc.invalidateQueries({ queryKey: networkKeys.discovered() }) + void qc.invalidateQueries({ queryKey: networkKeys.speakers() }) + void qc.invalidateQueries({ queryKey: overviewKeys.peers() }) + void qc.invalidateQueries({ queryKey: overviewKeys.speakers() }) + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : 'Не удалось запросить обновление телеметрии'), + }) +} + export function networkPeersQueryOptions() { return queryOptions({ queryKey: networkKeys.peers(), diff --git a/apps/web/src/queries/overview.ts b/apps/web/src/queries/overview.ts index 70b52eb..ca18bf7 100644 --- a/apps/web/src/queries/overview.ts +++ b/apps/web/src/queries/overview.ts @@ -1,5 +1,6 @@ import { queryOptions } from '@tanstack/react-query' import { apiJSON } from '@/lib/api-client' +import { peerSessionCurrent, peerSessionEstablished, peerSessionKnown, speakerAgentKnown, speakerAgentOnline } from '@/lib/live-observation' import type { JobRow, JobsResponse, @@ -78,9 +79,11 @@ export function overviewHealthQueryOptions() { export type NetworkMetrics = { peersTotal: number peersEnabled: number + peersObserved: number peersEstablished: number peersMismatch: number speakersTotal: number + speakersObserved: number speakersOnline: number } @@ -89,15 +92,19 @@ export function aggregateNetworkMetrics( speakers: SpeakerRow[], ): NetworkMetrics { const peersEnabled = peers.filter((p) => p.enabled !== false).length - const peersEstablished = peers.filter((p) => 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 peersObserved = peers.filter((p) => p.enabled !== false && peerSessionKnown(p)).length + const peersEstablished = peers.filter((p) => p.enabled !== false && peerSessionEstablished(p)).length + const peersMismatch = peers.filter((p) => p.session_mismatch && peerSessionCurrent(p)).length + const speakersOnline = speakers.filter(speakerAgentOnline).length + const speakersObserved = speakers.filter(speakerAgentKnown).length return { peersTotal: peers.length, peersEnabled, + peersObserved, peersEstablished, peersMismatch, speakersTotal: speakers.length, + speakersObserved, speakersOnline, } } diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 39d9f4a..4f70f0c 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -16,6 +16,10 @@ import { KpiStatGrid } from '@/components/reui-kit' import { PageHeader } from '@/components/page-header' import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks' import { Skeleton } from '@evobgp/ui/components/skeleton' +import { can } from '@/lib/auth' +import { useLiveObservationState } from '@/hooks/use-live-observation-state' +import { peersForDisplay, snapshotUnavailable, speakersForDisplay } from '@/lib/live-observation' +import { useRequestTelemetryRefreshMutation } from '@/queries/network' import { overviewJobsQueryOptions, overviewModulesQueryOptions, @@ -45,6 +49,8 @@ function parseShowQuickActions(value: unknown): boolean { function DashboardComponent() { const navigate = useNavigate() const [lastUpdated, setLastUpdated] = useState(null) + const telemetryRefresh = useRequestTelemetryRefreshMutation() + const canRefreshTelemetry = can('bgp:network:read') const settingsQ = useQuery(settingsQueryOptions()) const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions) @@ -61,7 +67,7 @@ function DashboardComponent() { const [modulesQ, peersQ, speakersQ, jobsQ] = results const initialLoading = modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || jobsQ.isLoading - const refreshing = results.some((r) => r.isFetching && !r.isLoading) + const refreshing = telemetryRefresh.isPending || results.some((r) => r.isFetching && !r.isLoading) if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) { setTimeout(() => setLastUpdated(new Date()), 0) @@ -69,12 +75,25 @@ function DashboardComponent() { function refetchAll() { setLastUpdated(null) - results.forEach((r) => r.refetch()) + void modulesQ.refetch() + void jobsQ.refetch() + void revisionsQ.refetch() + if (canRefreshTelemetry) { + telemetryRefresh.mutate() + } else { + void peersQ.refetch() + void speakersQ.refetch() + } } const modules = modulesQ.data?.items ?? [] - const peers = peersQ.data?.items ?? [] - const speakers = speakersQ.data?.items ?? [] + const rawPeers = peersQ.data?.items ?? [] + const rawSpeakers = speakersQ.data?.items ?? [] + const { offline, disconnectedAt } = useLiveObservationState(rawPeers, rawSpeakers) + const peersUnavailable = snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt) + const speakersUnavailable = snapshotUnavailable(speakersQ.isError, offline, disconnectedAt, speakersQ.dataUpdatedAt) + const peers = [...peersForDisplay(rawPeers, peersUnavailable)] + const speakers = [...speakersForDisplay(rawSpeakers, speakersUnavailable)] const jobs = jobsQ.data?.items ?? [] const kpiCards = buildDashboardKpiCards({ modules, peers, speakers, jobs }) const activity = jobs.slice(0, 4).map((j) => { @@ -114,7 +133,7 @@ function DashboardComponent() { title="Обзор" description={ lastUpdated - ? `Метрики, модули и активность · обновлено ${lastUpdated.toLocaleTimeString('ru-RU')}` + ? `Метрики, модули и активность · данные проверены ${lastUpdated.toLocaleTimeString('ru-RU')}` : 'Метрики, модули и активность' } actions={ diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx index 1b115a1..bc595d2 100644 --- a/apps/web/src/routes/_auth/network.tsx +++ b/apps/web/src/routes/_auth/network.tsx @@ -10,11 +10,14 @@ import { NetworkPeersCard } from '@/components/network/network-peers-card' import { NetworkSpeakersCard } from '@/components/network/network-speakers-card' import { PageHeader } from '@/components/page-header' import { SignalAlert } from '@/components/portal/bits' +import { useLiveObservationState } from '@/hooks/use-live-observation-state' +import { peersForDisplay, snapshotUnavailable, speakersForDisplay } from '@/lib/live-observation' import { networkBirdQueryOptions, networkDiscoveredPeersQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions, + useRequestTelemetryRefreshMutation, } from '@/queries/network' type NetworkTab = 'peers' | 'discovered' | 'speakers' @@ -50,33 +53,25 @@ function NetworkComponent() { }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) + const telemetryRefresh = useRequestTelemetryRefreshMutation() - const refreshing = - peersQ.isFetching || - discoveredQ.isFetching || - speakersQ.isFetching || - birdQ.isFetching - const peers = peersQ.data?.items ?? [] + const rawPeers = peersQ.data?.items ?? [] const discovered = discoveredQ.data?.items ?? [] - const speakers = speakersQ.data?.items ?? [] + const rawSpeakers = speakersQ.data?.items ?? [] + const { offline, disconnectedAt } = useLiveObservationState(rawPeers, rawSpeakers) + const peers = [...peersForDisplay(rawPeers, snapshotUnavailable(peersQ.isError, offline, disconnectedAt, peersQ.dataUpdatedAt))] + const speakers = [...speakersForDisplay(rawSpeakers, snapshotUnavailable(speakersQ.isError, offline, disconnectedAt, speakersQ.dataUpdatedAt))] const overviewLoading = peersQ.isLoading || speakersQ.isLoading - function refetchAll() { - void peersQ.refetch() - void discoveredQ.refetch() - void speakersQ.refetch() - void birdQ.refetch() - } - return (
- - Обновить + } /> @@ -99,7 +94,7 @@ function NetworkComponent() { diff --git a/apps/web/src/types/api.gen.ts b/apps/web/src/types/api.gen.ts index 20c5c93..be5e119 100644 --- a/apps/web/src/types/api.gen.ts +++ b/apps/web/src/types/api.gen.ts @@ -141,6 +141,29 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/telemetry/refresh": { + parameters: { + query?: never; + header?: { + /** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */ + "X-Tenant-Id"?: components["parameters"]["TenantId"]; + }; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Запросить фоновое обновление телеметрии tenant + * @description Требует bgp:network:read. Объединяет повторные запросы в пределах 10 секунд; HTTP handler не опрашивает агентов или BIRD. GET speakers/peers/discoveries читает сохранённые snapshots, включая live=1. Запрос во время выполняющегося poll сохраняется для следующего цикла. Не изменяет конфигурацию speaker и не требует нового протокола агента. + */ + post: operations["requestTelemetryRefresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/router-lists/catalog": { parameters: { query?: never; @@ -457,6 +480,9 @@ export interface paths { * - `ipRange,community` * * В поле `community` можно передавать либо ID community, либо её значение. + * Предел запроса — 8 MiB (8388608 байт), 10000 строк данных без заголовка и 64 поля в записи. + * Превышение возвращает 413 до создания записей; большой импорт следует разбить на части. + * Ошибка в данных или БД после начала создания записей может оставить частичный импорт. */ post: operations["importModuleEntriesCsv"]; delete?: never; @@ -647,7 +673,7 @@ export interface paths { }; /** * Список обнаруженных (неодобренных) пиров - * @description Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`. По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending. + * @description Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`. По умолчанию возвращает `status=pending`. Читает сохранённые discoveries. Фоновый collector выполняет опрос и fenced upsert; GET не опрашивает ноды и не изменяет данные. */ get: operations["listDiscoveredPeers"]; put?: never; @@ -1111,6 +1137,31 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/jobs/{job_id}/report": { + parameters: { + query?: never; + header?: { + /** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */ + "X-Tenant-Id"?: components["parameters"]["TenantId"]; + }; + path: { + job_id: components["parameters"]["JobId"]; + }; + cookie?: never; + }; + /** + * Отчёт задачи и сохранённый прогресс доставки + * @description Требует bgp:operations:read. Прогресс хранится независимо от meta задачи. Для reconciliation без собственного прогресса возвращается прогресс исходной задачи в том же tenant; после начала восстановления приоритет имеет собственный прогресс. agent_reported не означает проверку состояния BIRD. + */ + get: operations["getJobReport"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/jobs/{job_id}/cancel": { parameters: { query?: never; @@ -1136,6 +1187,31 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/jobs/{job_id}/retry": { + parameters: { + query?: never; + header?: { + /** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */ + "X-Tenant-Id"?: components["parameters"]["TenantId"]; + }; + path: { + job_id: components["parameters"]["JobId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Повторить завершившуюся ошибкой задачу + * @description Операторский повтор сохраняет ID и исходный запрос, сбрасывая бюджет попыток. + */ + post: operations["postJobRetry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/speakers/{speaker_id}/revisions/latest": { parameters: { query?: never; @@ -2228,11 +2304,19 @@ export interface components { connected_speaker_id?: string | null; /** @description Человекочитаемая метка ноды из live-опроса. */ connected_speaker_label?: string; + /** @description Последнее сохранённое состояние; не является текущим наблюдением. */ + last_known_session_state?: string | null; + /** @description При live-ответе текущее наблюдение: BGP state, absent после успешного пустого опроса или null, если актуальное состояние неизвестно. */ + session_state?: string | null; + /** @description Качество BGP-наблюдений при GET /v1/peers?live=1. Для назначенного пира учитывается только bgp_speaker_id; для общего — все speakers. Смешанный успех и сбой не дают quality=ok или session_state=absent. Timestamps относятся только к ожидаемым speakers. */ + live_observation?: components["schemas"]["LiveObservation"]; /** @description Состояние протокола пира на каждой опрошенной ноде. */ session_on_speakers?: { speaker_id?: string; label?: string; state?: string; + poll_error?: string; + observation?: components["schemas"]["LiveObservation"]; }[]; /** @description Ноды, где сессия в состоянии Established (один пир может быть на нескольких). */ established_on_speakers?: { @@ -2281,8 +2365,10 @@ export interface components { SpeakerLiveStatus: { /** @description Человекочитаемая метка ноды (agent domain или CP master). */ label?: string; - /** @description true если agent /v1/agent/health успешен (master — local birdc poll). */ + /** @description true только после успешного agent /v1/agent/health (master — local birdc poll). */ agent_ok?: boolean; + agent_observation?: components["schemas"]["LiveObservation"]; + bgp_observation?: components["schemas"]["LiveObservation"]; agent_error?: string; /** Format: date-time */ agent_last_sync_at?: string; @@ -2290,12 +2376,34 @@ export interface components { /** @description true если birdc (CP) или GET /v1/agent/bird/protocols (replica) успешен. */ bgp_poll_ok?: boolean; bgp_poll_error?: string; - bgp_sessions_total?: number; - bgp_established?: number; + /** @description null при отсутствии успешного опроса. */ + bgp_sessions_total?: number | null; + /** @description null при отсутствии успешного опроса. */ + bgp_established?: number | null; sessions?: components["schemas"]["BgpSessionLive"][]; } & { [key: string]: unknown; }; + /** @description Метаданные конкретного опроса. observed_at — время завершения опроса; received_at — время получения результата CP; last_success_at — время последнего успешного опроса. Старые агенты не обязаны передавать эти поля: CP формирует их по собственным часам. При отсутствии опроса timestamps null. */ + LiveObservation: { + /** Format: date-time */ + observed_at: string | null; + /** Format: date-time */ + received_at: string | null; + /** Format: date-time */ + last_success_at: string | null; + /** + * Format: date-time + * @description Срок актуальности snapshot по часам CP и настроенному интервалу collector; клиент не должен считать cached quality=ok актуальным после этого времени. Для агрегата — самый ранний срок источников. + */ + expires_at?: string | null; + /** @enum {string} */ + source: "none" | "cp_birdc" | "agent_bird_protocols" | "agent_health" | "mixed"; + /** @enum {string} */ + quality: "ok" | "stale" | "unreachable" | "unknown" | "disabled"; + /** @enum {string} */ + error_code?: "not_configured" | "not_polled" | "poll_failed" | "reported_unhealthy" | "snapshot_expired" | "configuration_changed" | "ambiguous_authority"; + }; BgpSessionLive: { name?: string; neighbor?: string; @@ -2312,8 +2420,10 @@ export interface components { speaker_id?: string; label?: string; ok?: boolean; - session_count?: number; + /** @description null, если успешного опроса не было. */ + session_count?: number | null; poll_error?: string; + observation?: components["schemas"]["LiveObservation"]; } & { [key: string]: unknown; }; @@ -2831,6 +2941,17 @@ export interface components { }; }; responses: { + /** @description Локальная квота процесса API — по умолчанию 4 активных preview/lookup, 2 на tenant; 120 lookup и по 30 CDN/revision preview в минуту на tenant. Квоты умножаются на число реплик. Повторить после Retry-After, без очереди ожидания. */ + ExpensiveOperationLimited: { + headers: { + /** @description Минимальная задержка повторной попытки в секундах; не гарантия свободного места. */ + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Нет или невалидный Bearer-токен. */ Unauthorized: { headers: { @@ -2885,7 +3006,7 @@ export interface components { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Ошибка (см. тело Problem). */ + /** @description Ошибка (см. тело Problem). Общие для authenticated `/v1` ответы middleware: 413 при request body свыше 8 MiB, 400 при ошибке чтения тела, 408 при отмене/таймауте чтения. 429 с Retry-After: 1 при занятых слотах обработки request body. */ DefaultProblem: { headers: { [name: string]: unknown; @@ -3085,6 +3206,34 @@ export interface operations { }; 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["ExpensiveOperationLimited"]; + default: components["responses"]["DefaultProblem"]; + }; + }; + requestTelemetryRefresh: { + parameters: { + query?: never; + header?: { + /** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */ + "X-Tenant-Id"?: components["parameters"]["TenantId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Запрос принят либо объединён с уже принятым. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description true при постановке нового запроса; false при объединении или отсутствии целей. */ + requested: boolean; + }; + }; + }; default: components["responses"]["DefaultProblem"]; }; }; @@ -3903,6 +4052,15 @@ export interface operations { }; }; 404: components["responses"]["NotFound"]; + /** @description Превышен лимит байт, строк или полей CSV; записи не созданы. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; 422: components["responses"]["UnprocessableEntity"]; default: components["responses"]["DefaultProblem"]; }; @@ -4002,6 +4160,7 @@ export interface operations { 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"]; 422: components["responses"]["UnprocessableEntity"]; + 429: components["responses"]["ExpensiveOperationLimited"]; default: components["responses"]["DefaultProblem"]; }; }; @@ -4291,7 +4450,7 @@ export interface operations { /** @description Размер страницы (по умолчанию 50). */ limit?: components["parameters"]["Limit"]; speaker_id?: components["parameters"]["SpeakerFilter"]; - /** @description Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*. */ + /** @description Сохранённые результаты фонового опроса birdc на CP и agent protocols на репликах; обогащает session_state и connected_speaker_*. GET не запускает опрос; live=1 сохранён для совместимости. */ live?: "1"; }; header?: { @@ -4313,7 +4472,7 @@ export interface operations { items: components["schemas"]["BgpPeer"][]; next_cursor?: string | null; has_more: boolean; - /** @description При live=1 — результат опроса каждой ноды (CP birdc + agent protocols). */ + /** @description Сохранённый результат фонового опроса каждой ноды (CP birdc + agent protocols); live=1 не запускает новый опрос. */ live_speaker_poll?: components["schemas"]["LiveSpeakerPoll"][]; }; }; @@ -4557,7 +4716,7 @@ export interface operations { cursor?: components["parameters"]["Cursor"]; /** @description Размер страницы (по умолчанию 50). */ limit?: components["parameters"]["Limit"]; - /** @description Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc. Обогащает каждый item полем `live`. */ + /** @description Сохранённые результаты фоновых опросов agent health/BGP и local birdc. GET не запускает опрос. Обогащает каждый item полем `live`. */ live?: "1"; }; header?: { @@ -4884,6 +5043,7 @@ export interface operations { }; }; 404: components["responses"]["NotFound"]; + 429: components["responses"]["ExpensiveOperationLimited"]; default: components["responses"]["DefaultProblem"]; }; }; @@ -5144,6 +5304,66 @@ export interface operations { default: components["responses"]["DefaultProblem"]; }; }; + getJobReport: { + parameters: { + query?: never; + header?: { + /** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */ + "X-Tenant-Id"?: components["parameters"]["TenantId"]; + }; + path: { + job_id: components["parameters"]["JobId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Успешно. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + job_id: string; + kind: string; + status: string; + meta?: { + [key: string]: unknown; + } | null; + error?: string | null; + /** Format: date-time */ + created_at?: string; + revision_id?: string; + log_entries?: { + [key: string]: unknown; + }[]; + log_total?: number; + source_deployment_job_id?: string; + deployment_progress: { + job_id: string; + speaker_id: string; + revision_id: string; + /** Format: int64 */ + generation: number; + state: string; + confirmation: string; + error?: string; + dispatch_attempts: number; + retry_round: number; + /** Format: date-time */ + health_received_at?: string | null; + health_revision_id?: string; + health_source_at?: string; + health_error?: string; + }[]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + default: components["responses"]["DefaultProblem"]; + }; + }; postJobCancel: { parameters: { query?: never; @@ -5173,6 +5393,42 @@ export interface operations { default: components["responses"]["DefaultProblem"]; }; }; + postJobRetry: { + parameters: { + query?: never; + header?: { + /** @description Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу. **Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md). */ + "X-Tenant-Id"?: components["parameters"]["TenantId"]; + }; + path: { + job_id: components["parameters"]["JobId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Повтор поставлен в очередь. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Job"]; + }; + }; + 409: components["responses"]["Conflict"]; + /** @description Очередь задач заполнена. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + default: components["responses"]["DefaultProblem"]; + }; + }; getSpeakerLatestRevision: { parameters: { query?: never; diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 718d5b0..0194296 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -198,6 +198,17 @@ export type PeerSessionOnSpeaker = { label: string state: string poll_error?: string + observation?: LiveObservation +} + +export type LiveObservation = { + observed_at: string | null + received_at: string | null + last_success_at: string | null + expires_at?: string | null + source: 'none' | 'cp_birdc' | 'agent_bird_protocols' | 'agent_health' | 'mixed' + quality: 'ok' | 'stale' | 'unreachable' | 'unknown' | 'disabled' + error_code?: 'not_configured' | 'not_polled' | 'poll_failed' | 'reported_unhealthy' | 'snapshot_expired' | 'configuration_changed' | 'ambiguous_authority' } export type PeerRow = { @@ -206,7 +217,9 @@ export type PeerRow = { neighbor: string remote_asn?: number enabled?: boolean - session_state: string + session_state: string | null + last_known_session_state?: string | null + live_observation?: LiveObservation bgp_speaker_id: string | null connected_speaker_id?: string | null connected_speaker_label?: string @@ -267,13 +280,15 @@ export type BgpSessionLive = { export type SpeakerLiveStatus = { label?: string agent_ok?: boolean + agent_observation?: LiveObservation + bgp_observation?: LiveObservation agent_error?: string agent_last_sync_at?: string agent_last_applied_revision_id?: string bgp_poll_ok?: boolean bgp_poll_error?: string - bgp_sessions_total?: number - bgp_established?: number + bgp_sessions_total?: number | null + bgp_established?: number | null sessions?: BgpSessionLive[] }