feat(web): show live BGP observation and deployment progress
Панель и сеть читают сохранённое наблюдение и прогресс deploy. Качество и свежесть сессии видны на спикерах и пирах, без подмены статуса неопрошенным Established. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user