feat(web): show live BGP observation and deployment progress

Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 17:24:11 +07:00
1 parent f8e0520817
commit bda45d633e
26 files changed
+940 -137

No files matched your search

@@ -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 (
<AnalyticsCardShell
@@ -73,7 +77,7 @@ export function DashboardNetworkCapacityCard({
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
<p className={`text-sm ${allHealthy ? 'text-success' : 'text-muted-foreground'}`}>{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
@@ -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',
},
},
@@ -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 ? 'в норме' : 'есть неустановленные сессии'}
/>
<SegmentedProgressCard
title="Спикеры"
description="Доступность агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
value: `${net.speakersOnline}/${net.speakersObserved}`,
label: 'В сети',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
value: net.speakersObserved - net.speakersOnline,
label: 'Не в сети',
percent: 100 - speakersPct,
percent: net.speakersObserved > 0 ? 100 - speakersPct : 0,
}}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersObserved} · без наблюдения: ${net.peersEnabled - net.peersObserved} пиров, ${net.speakersTotal - net.speakersObserved} спикеров`}
/>
</div>
)
@@ -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: (
<Badge
variant={
bgpPct !== null && bgpPct >= 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} установлено`}
</Badge>
),
},
@@ -124,7 +125,7 @@ function buildKpis({
{loading
? '…'
: riskCount > 0
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
? `${failedJobs} задач · ${network.peersMismatch} расхождений · ${peersUnknown} без наблюдения`
: 'в норме'}
</Badge>
),
@@ -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 (
<SegmentedProgressCard
@@ -64,7 +68,7 @@ export function DashboardNetworkHealth({
variant={
loading || total === 0
? 'outline'
: offline === 0
: offline === 0 && unknown === 0
? 'success-light'
: 'warning-light'
}
@@ -75,10 +79,12 @@ export function DashboardNetworkHealth({
? '…'
: total === 0
? 'нет данных'
: offline === 0
: offline === 0 && unknown === 0
? mode === 'peers'
? 'все установлены'
: 'все в сети'
: unknown > 0
? `${unknown} без наблюдения`
: mode === 'peers'
? `${offline} не установлены`
: `${offline} офлайн`}
@@ -93,7 +99,7 @@ export function DashboardNetworkHealth({
footer={
loading
? 'Загрузка…'
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
: `Активных задач: ${queued} · сегментов в графике: ${bars.length} · без наблюдения: ${unknown}`
}
/>
)
@@ -28,8 +28,10 @@ export function DashboardNetworkPanel({
const m = aggregateNetworkMetrics(peers, speakers)
return (
<div className="flex flex-col gap-2 p-3">
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersObserved}`} />
<Row label="Пиры без наблюдения" value={String(m.peersEnabled - m.peersObserved)} />
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersObserved}`} />
<Row label="Спикеры без наблюдения" value={String(m.speakersTotal - m.speakersObserved)} />
{m.peersMismatch > 0 ? (
<Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
) : null}
+30 -13
View File
@@ -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<string, { badge: string; text: string }> = {
'/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<ChromeConfig>(() => {
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<string, NavItem['problem']> = {
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 — блоки общего макета. */
@@ -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 (
<StatusBadge
status={current && state ? state : quality === 'stale' ? 'stale' : 'unknown'}
label={state ? bgpSessionStateRu(state) : quality === 'stale' ? 'Устарело' : 'Нет данных'}
hint={hint || undefined}
/>
)
}
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 <StatusBadge status={status} label={label} hint={observationHint(observation)} />
}
@@ -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: <Share2 aria-hidden />,
iconClassName: 'text-success',
iconClassName: peersHealthy ? 'text-success' : 'text-warning',
label: 'Пиры установлены',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
value: loading ? '—' : `${net.peersEstablished}/${net.peersObserved}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
<Badge variant={peersHealthy ? 'success-light' : 'warning-light'} size="sm">
{loading ? '…' : `${net.peersEnabled - net.peersObserved} без наблюдения`}
</Badge>
),
to: '/network',
@@ -45,17 +46,17 @@ export function NetworkKpi({
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
value: loading ? '—' : `${net.speakersOnline}/${net.speakersObserved}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
net.speakersOnline === net.speakersObserved && net.speakersObserved > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'в сети'}
{loading ? '…' : `${net.speakersTotal - net.speakersObserved} без наблюдения`}
</Badge>
),
to: '/network',
@@ -70,7 +71,7 @@ export function NetworkKpi({
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : net.peersObserved === 0 && net.peersEnabled > 0 ? 'нет наблюдения' : 'в норме'}
</Badge>
),
to: '/network',
@@ -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<PeerRow>[] = [
@@ -41,10 +41,7 @@ export const peerColumns: DataGridColumnDef<PeerRow>[] = [
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
<PeerObservationBadge peer={row.original} />
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : 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
}
@@ -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<SpeakerRow>[] = [
@@ -32,20 +32,7 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) {
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
}
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
—
</Badge>
)
},
cell: ({ row }) => <AgentObservationBadge speaker={row.original} />,
meta: { headerTitle: 'Агент' },
},
{
@@ -53,12 +40,16 @@ export const speakerColumns: DataGridColumnDef<SpeakerRow>[] = [
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
const count = speakerBgpCounts(row.original)
return (
<DataGridMonoCell className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</DataGridMonoCell>
<div className="flex flex-col gap-0.5">
<DataGridMonoCell className="text-xs">{count ?? '—'}</DataGridMonoCell>
{row.original.live?.bgp_observation ? (
<span className="text-xs text-muted-foreground">
{observationHint(row.original.live.bgp_observation)}
</span>
) : null}
</div>
)
},
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
}
@@ -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<string, unknown>
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<string, unknown>).results
if (!Array.isArray(results)) return []
return results.flatMap((item) => {
if (!item || typeof item !== 'object') return []
const result = item as Record<string, unknown>
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 (
<div className="py-4 ps-3 pe-2">
@@ -65,7 +101,26 @@ export function JobTimeline({ job }: { job: JobRow }) {
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
{subtitle || counts || confirmations.length > 0 ? (
<TimelineContent>
<div className="flex flex-col gap-2">
{subtitle ? <span>{subtitle}</span> : null}
{counts ? (
<div className="flex flex-wrap gap-2">
{counts.published != null ? <Badge variant="info-light" size="sm">Опубликовано: {counts.published}</Badge> : null}
{counts.applied != null ? <Badge variant="success-light" size="sm">Подтверждено: {counts.applied}</Badge> : null}
{counts.unconfirmed != null && counts.unconfirmed > 0 ? <Badge variant="warning-light" size="sm">Без подтверждения агента: {counts.unconfirmed}</Badge> : null}
{counts.failed != null && counts.failed > 0 ? <Badge variant="destructive-light" size="sm">Ошибок: {counts.failed}</Badge> : null}
</div>
) : null}
{confirmations.length > 0 ? (
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{confirmations.map((confirmation, index) => <li key={`${index}-${confirmation}`}>{confirmation}</li>)}
</ul>
) : null}
</div>
</TimelineContent>
) : null}
</TimelineItem>
</Timeline>
</div>
@@ -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 <DashboardNetworkHealth peers={[...peersForDisplay([peer], false)]} speakers={[]} jobs={[]} />
}
function SpeakerHarness() {
useLiveObservationState([], [unhealthySpeaker])
return <span>{speakerAgentKnown(speakersForDisplay([unhealthySpeaker], false)[0]) ? 'known' : 'unknown'}</span>
}
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(<NetworkHarness />))
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(<SpeakerHarness />))
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()
}
})
})
@@ -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<number | null>(() =>
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 }
}
+115
View File
@@ -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()
}
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
const QUALITY_LABEL: Record<LiveObservation['quality'], string> = {
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}`
}
@@ -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,
@@ -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,
}
})
}
@@ -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',
@@ -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',
})
}
+29
View File
@@ -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<TelemetryRefreshResponse>('/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<PeersResponse>({
queryKey: networkKeys.peers(),
+10 -3
View File
@@ -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,
}
}
+24 -5
View File
@@ -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<Date | null>(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={
+13 -18
View File
@@ -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 (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, автообнаружение и спикеры"
actions={
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
<Button variant="outline" onClick={() => telemetryRefresh.mutate()} disabled={telemetryRefresh.isPending}>
<RefreshCw className={telemetryRefresh.isPending ? 'animate-spin' : undefined} />
Запросить обновление
</Button>
}
/>
@@ -99,7 +94,7 @@ function NetworkComponent() {
<NetworkKpi
peers={peers}
speakers={speakers}
bird={birdQ.data}
bird={snapshotUnavailable(birdQ.isError, offline, disconnectedAt, birdQ.dataUpdatedAt) ? undefined : birdQ.data}
loading={overviewLoading || birdQ.isLoading}
/>
+265 -9
View File
@@ -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;
+18 -3
View File
@@ -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[]
}