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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 17:24:11 +07:00
1 parent f8e0520817
commit bda45d633e
26 files changed
+940 -137

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest'
import {
peerSessionEstablished,
peerSessionKnown,
speakerAgentKnown,
speakerAgentOnline,
speakerBgpCounts,
observationQuality,
nextObservationExpiry,
peersForDisplay,
speakersForDisplay,
snapshotUnavailable,
} from './live-observation'
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
import { aggregateNetworkMetrics } from '@/queries/overview'
import { deploymentProgress } from './metrics/deployment-progress'
import { speakerCapacityBars } from './metrics/peer-capacity-bars'
const observation = (quality: LiveObservation['quality']): LiveObservation => ({
observed_at: null,
received_at: null,
last_success_at: null,
source: 'agent_bird_protocols',
quality,
})
describe('live observation compatibility', () => {
it('uses legacy live fields when observation metadata is absent', () => {
const peer = { session_state: 'Established' } as PeerRow
const speaker = { live: { agent_ok: true, bgp_established: 2, bgp_sessions_total: 3 } } as SpeakerRow
expect(peerSessionEstablished(peer)).toBe(true)
expect(speakerAgentOnline(speaker)).toBe(true)
expect(speakerBgpCounts(speaker)).toBe('2 / 3')
})
it('keeps stale and missing observations separate from down or zero', () => {
const peer = { session_state: null, last_known_session_state: 'Established', live_observation: observation('stale') } as PeerRow
const speaker = { live: { agent_ok: false, agent_observation: observation('unknown'), bgp_established: null, bgp_sessions_total: null, bgp_observation: observation('unreachable') } } as SpeakerRow
expect(peerSessionKnown(peer)).toBe(false)
expect(peerSessionEstablished(peer)).toBe(false)
expect(speakerAgentKnown(speaker)).toBe(false)
expect(speakerAgentOnline(speaker)).toBe(false)
expect(speakerBgpCounts(speaker)).toBeUndefined()
})
it('counts only enabled, observed peers in established metrics', () => {
const peers = [
{ enabled: false, session_state: 'Established' },
{ enabled: true, session_state: 'Established', live_observation: observation('stale') },
{ enabled: true, session_state: 'Established', live_observation: observation('ok') },
] as PeerRow[]
const metrics = aggregateNetworkMetrics(peers, [])
expect(metrics.peersObserved).toBe(1)
expect(metrics.peersEstablished).toBe(1)
})
it('includes published but unconfirmed speakers in deployment denominator', () => {
const speakers = [
{ published_revision_id: 'rev-1', last_applied_revision_id: 'rev-1' },
{ published_revision_id: 'rev-1', last_applied_revision_id: null },
] as SpeakerRow[]
expect(deploymentProgress(speakers)).toMatchObject({ mode: 'revision', synced: 1, total: 2, percent: 50 })
expect(deploymentProgress([speakers[1]])).toMatchObject({ mode: 'revision', synced: 0, total: 1, percent: 0 })
})
it('treats explicit agent health failure as a known negative result', () => {
const speaker = { live: {
agent_ok: false,
agent_observation: { ...observation('stale'), error_code: 'reported_unhealthy' },
} } as SpeakerRow
expect(speakerAgentKnown(speaker)).toBe(true)
expect(speakerAgentOnline(speaker)).toBe(false)
})
it('masks cached fresh status after a snapshot API failure', () => {
const peer = { session_state: 'Established', live_observation: observation('ok'), session_mismatch: true } as PeerRow
const speaker = { live: { agent_ok: true, agent_observation: observation('ok'), bgp_established: 2, bgp_sessions_total: 2, bgp_observation: observation('ok') } } as SpeakerRow
expect(aggregateNetworkMetrics([peer], [speaker])).toMatchObject({ peersEstablished: 1, speakersOnline: 1 })
const peers = peersForDisplay([peer], true)
const speakers = speakersForDisplay([speaker], true)
expect(aggregateNetworkMetrics(peers, speakers)).toMatchObject({ peersEstablished: 0, peersMismatch: 0, speakersOnline: 0 })
expect(peerSessionKnown(peers[0])).toBe(false)
expect(speakerAgentKnown(speakers[0])).toBe(false)
expect(speakerBgpCounts(speakers[0])).toBeUndefined()
expect(snapshotUnavailable(true, false, null, 100)).toBe(true)
expect(snapshotUnavailable(false, true, 200, 100)).toBe(true)
expect(snapshotUnavailable(false, false, 200, 100)).toBe(true)
expect(snapshotUnavailable(false, false, 200, 201)).toBe(false)
})
it('expires an ok observation at the server deadline', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-10-26T10:00:00.000Z'))
const live = { ...observation('ok'), expires_at: '2026-10-26T10:00:10.000Z' }
const peer = { session_state: 'Established', live_observation: live } as PeerRow
const speaker = { id: 'speaker-1', endpoint: 'speaker.example', live: { agent_ok: true, agent_observation: live, bgp_established: 2, bgp_sessions_total: 2, bgp_observation: live } } as SpeakerRow
expect(nextObservationExpiry([peer], [speaker])).toBe(Date.parse(live.expires_at))
expect(peerSessionEstablished(peer)).toBe(true)
expect(speakerAgentOnline(speaker)).toBe(true)
expect(speakerCapacityBars([speaker])).toHaveLength(1)
vi.setSystemTime(new Date(live.expires_at))
expect(observationQuality(live)).toBe('stale')
expect(peerSessionEstablished(peer)).toBe(false)
expect(speakerAgentOnline(speaker)).toBe(false)
expect(speakerCapacityBars([speaker])).toHaveLength(0)
expect(nextObservationExpiry([peer], [speaker])).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { LiveObservation, PeerRow, SpeakerRow } from '@/types/api'
const QUALITY_LABEL: Record<LiveObservation['quality'], string> = {
ok: 'актуально',
stale: 'данные устарели',
unreachable: 'опрос недоступен',
unknown: 'нет наблюдения',
disabled: 'опрос выключен',
}
function expired(observation: LiveObservation, now = Date.now()): boolean {
if (!observation.expires_at) return false
const deadline = Date.parse(observation.expires_at)
return !Number.isFinite(deadline) || deadline <= now
}
export function observationQuality(observation: LiveObservation, now = Date.now()): LiveObservation['quality'] {
return observation.quality === 'ok' && expired(observation, now) ? 'stale' : observation.quality
}
const unavailableObservation: LiveObservation = {
observed_at: null,
received_at: null,
last_success_at: null,
source: 'none',
quality: 'unknown',
}
/** A successful GET after reconnect is required before cached live values are trusted again. */
export function snapshotUnavailable(queryFailed: boolean, offline: boolean, disconnectedAt: number | null, dataUpdatedAt: number): boolean {
return queryFailed || offline || (disconnectedAt !== null && dataUpdatedAt <= disconnectedAt)
}
/** Hide cached live values when their snapshot request fails or the browser goes offline. */
export function peersForDisplay(peers: PeerRow[], unavailable: boolean): PeerRow[] {
if (!unavailable) return peers
return peers.map((peer) => ({
...peer,
session_state: null,
live_observation: unavailableObservation,
session_on_speakers: [],
established_on_speakers: [],
session_mismatch: false,
}))
}
export function speakersForDisplay(speakers: SpeakerRow[], unavailable: boolean): SpeakerRow[] {
if (!unavailable) return speakers
return speakers.map((speaker) => ({
...speaker,
live: speaker.live && {
...speaker.live,
agent_ok: undefined,
agent_observation: unavailableObservation,
bgp_poll_ok: undefined,
bgp_established: null,
bgp_sessions_total: null,
sessions: [],
bgp_observation: unavailableObservation,
},
}))
}
/** Earliest server deadline; callers can schedule a render without inventing a client TTL. */
export function nextObservationExpiry(peers: PeerRow[], speakers: SpeakerRow[], now = Date.now()): number | undefined {
let next: number | undefined
const visit = (observation?: LiveObservation) => {
if (!observation?.expires_at || (observation.quality !== 'ok' && observation.error_code !== 'reported_unhealthy')) return
const deadline = Date.parse(observation.expires_at)
if (Number.isFinite(deadline) && deadline > now) next = Math.min(next ?? deadline, deadline)
}
for (const peer of peers) visit(peer.live_observation)
for (const speaker of speakers) {
visit(speaker.live?.agent_observation)
visit(speaker.live?.bgp_observation)
}
return next
}
function observedTime(observation: LiveObservation): string | undefined {
const timestamp = observation.observed_at ?? observation.received_at ?? observation.last_success_at
if (!timestamp) return undefined
const date = new Date(timestamp)
return Number.isNaN(date.getTime()) ? undefined : date.toLocaleString('ru-RU')
}
export function observationHint(observation?: LiveObservation): string | undefined {
if (!observation) return undefined
const at = observedTime(observation)
const label = QUALITY_LABEL[observationQuality(observation)]
return at ? `${label} · ${at}` : label
}
export function peerSessionCurrent(peer: PeerRow): boolean {
return !peer.live_observation || observationQuality(peer.live_observation) === 'ok'
}
export function peerSessionEstablished(peer: PeerRow): boolean {
return peerSessionCurrent(peer) && peer.session_state === 'Established'
}
export function peerSessionKnown(peer: PeerRow): boolean {
return peerSessionCurrent(peer) && peer.session_state != null && peer.session_state !== ''
}
export function speakerAgentKnown(speaker: SpeakerRow): boolean {
const observation = speaker.live?.agent_observation
return (!observation || (!expired(observation) && (observationQuality(observation) === 'ok' || observation.error_code === 'reported_unhealthy'))) && speaker.live?.agent_ok != null
}
export function speakerAgentOnline(speaker: SpeakerRow): boolean {
return speakerAgentKnown(speaker) && speaker.live?.agent_ok === true
}
export function speakerBgpCounts(speaker: SpeakerRow): string | undefined {
const live = speaker.live
if (!live || (live.bgp_observation && observationQuality(live.bgp_observation) !== 'ok')) return undefined
if (live.bgp_established == null || live.bgp_sessions_total == null) return undefined
return `${live.bgp_established} / ${live.bgp_sessions_total}`
}
@@ -1,4 +1,5 @@
import type { SpeakerRow } from '@/types/api'
import { speakerAgentOnline } from '@/lib/live-observation'
import type { DeploymentProgress } from './types'
@@ -7,9 +8,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
return { percent: 0, synced: 0, total: 0, mode: 'online' }
}
const withRevision = speakers.filter(
(s) => s.published_revision_id && s.last_applied_revision_id,
)
const withRevision = speakers.filter((s) => s.published_revision_id)
if (withRevision.length > 0) {
const synced = withRevision.filter(
(s) => s.published_revision_id === s.last_applied_revision_id,
@@ -22,7 +21,7 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
}
}
const online = speakers.filter((s) => s.live?.agent_ok).length
const online = speakers.filter(speakerAgentOnline).length
return {
percent: Math.round((online / speakers.length) * 100),
synced: online,
@@ -1,4 +1,5 @@
import type { PeerRow, SpeakerRow } from '@/types/api'
import { observationQuality, peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
import type { CapacityBar } from './types'
@@ -12,25 +13,27 @@ function speakerLabel(speaker: SpeakerRow): string {
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
return peers
.filter((p) => p.enabled !== false)
.filter((p) => p.enabled !== false && peerSessionKnown(p))
.slice(0, max)
.map((peer) => ({
id: peer.id,
name: peerLabel(peer),
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
value: peerSessionEstablished(peer) ? 100 : 40,
}))
}
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
return speakers.slice(0, max).map((speaker) => {
const online = speaker.live?.agent_ok === true
return speakers.filter((speaker) =>
(!speaker.live?.bgp_observation || observationQuality(speaker.live.bgp_observation) === 'ok') &&
speaker.live?.bgp_established != null && speaker.live.bgp_sessions_total != null,
).slice(0, max).map((speaker) => {
const established = speaker.live?.bgp_established ?? 0
const total = speaker.live?.bgp_sessions_total ?? 0
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
const ratio = total > 0 ? Math.round((established / total) * 100) : 0
return {
id: speaker.id,
name: speakerLabel(speaker),
value: online ? ratio : 10,
value: ratio,
}
})
}
@@ -1,13 +1,15 @@
import type { PeerRow } from '@/types/api'
import { peerSessionEstablished, peerSessionKnown } from '@/lib/live-observation'
import type { BreakdownSlice } from './types'
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
const enabled = peers.filter((p) => p.enabled !== false)
const established = enabled.filter((p) => p.session_state === 'Established').length
const established = enabled.filter(peerSessionEstablished).length
const pending = enabled.filter(
(p) => p.session_state && p.session_state !== 'Established',
(p) => peerSessionKnown(p) && !peerSessionEstablished(p),
).length
const unknown = enabled.length - established - pending
const disabled = peers.length - enabled.length
const slices: BreakdownSlice[] = []
@@ -27,6 +29,14 @@ export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
color: 'var(--color-warning)',
})
}
if (unknown > 0) {
slices.push({
key: 'unknown',
label: 'Нет наблюдения',
count: unknown,
color: 'var(--color-muted-foreground)',
})
}
if (disabled > 0) {
slices.push({
key: 'disabled',
@@ -1,6 +1,7 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
import { jobKindRu, jobStatusRu } from '@/lib/ui-labels'
import { speakerAgentKnown } from '@/lib/live-observation'
import type { PlatformActivityItem } from './types'
@@ -45,11 +46,11 @@ export function recentPlatformActivity(
})
}
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || (speakerAgentKnown(s) && s.live?.agent_ok === false)).slice(0, 2)) {
items.push({
id: `speaker-${speaker.id}`,
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
message: `Проблема опроса ноды: ${speaker.live?.label ?? speaker.endpoint}`,
status: speakerAgentKnown(speaker) && speaker.live?.agent_ok === false ? 'error' : 'warning',
kind: 'network',
})
}