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
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user