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