feat(api): add live status tracking for speakers and BGP sessions
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 24s
CI / web (push) Successful in 29s
CI / go (push) Successful in 43s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m29s
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 24s
CI / web (push) Successful in 29s
CI / go (push) Successful in 43s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m29s
- Introduced new schemas for `SpeakerLiveStatus`, `BgpSessionLive`, and `LiveSpeakerPoll` in OpenAPI documentation to support live status queries. - Enhanced the `/v1/speakers` endpoint to include a `live` query parameter, allowing retrieval of real-time speaker and BGP status. - Updated the HTTP API to collect and return live status data for speakers, improving monitoring capabilities. - Modified frontend components to display live status information, enhancing user visibility into speaker health and BGP session states. - Added a new endpoint `/v1/bird/status` for retrieving the local BIRD status, further enriching the network monitoring features.
This commit is contained in:
@@ -173,7 +173,14 @@ export type PeerRow = {
|
||||
established_on_speakers?: PeerSessionOnSpeaker[];
|
||||
session_mismatch?: boolean;
|
||||
};
|
||||
export type PeersResponse = Page<PeerRow>;
|
||||
export type LiveSpeakerPoll = {
|
||||
speaker_id: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
session_count: number;
|
||||
poll_error?: string;
|
||||
};
|
||||
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] };
|
||||
export type BgpPeerCreate = {
|
||||
name?: string;
|
||||
neighbor: string;
|
||||
@@ -184,6 +191,25 @@ export type BgpPeerCreate = {
|
||||
export type BgpPeerPatch = Partial<BgpPeerCreate>;
|
||||
|
||||
// ---- Speakers ----
|
||||
export type BgpSessionLive = {
|
||||
name: string;
|
||||
neighbor?: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type SpeakerLiveStatus = {
|
||||
label?: string;
|
||||
agent_ok?: boolean;
|
||||
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;
|
||||
sessions?: BgpSessionLive[];
|
||||
};
|
||||
|
||||
export type SpeakerRow = {
|
||||
id: string;
|
||||
role: string;
|
||||
@@ -200,6 +226,7 @@ export type SpeakerRow = {
|
||||
last_dispatch_error?: string | null;
|
||||
meta_json?: Record<string, unknown>;
|
||||
agent_secret?: string;
|
||||
live?: SpeakerLiveStatus;
|
||||
};
|
||||
export type SpeakersResponse = Page<SpeakerRow>;
|
||||
export type BgpSpeakerCreate = {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/ui/core/label/index.js';
|
||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
|
||||
|
||||
type Props = {
|
||||
enabled?: boolean;
|
||||
onchange?: (enabled: boolean) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
enabled = $bindable(readNetworkAutoRefresh()),
|
||||
onchange,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
function onToggle(checked: boolean) {
|
||||
enabled = checked;
|
||||
writeNetworkAutoRefresh(checked);
|
||||
onchange?.(checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
|
||||
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
|
||||
Авто (~15 с)
|
||||
</Label>
|
||||
</div>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
aggregateNetworkMetrics,
|
||||
collectNetworkIssues,
|
||||
deriveNetworkOverallStatus,
|
||||
networkOverallStatusHint,
|
||||
networkOverallStatusLabel
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
import GitBranch from '@lucide/svelte/icons/git-branch';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Bird from '@lucide/svelte/icons/bird';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
|
||||
type Props = {
|
||||
peers: PeerRow[];
|
||||
speakers: SpeakerRow[];
|
||||
bird: BirdStatus | null;
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
peers,
|
||||
speakers,
|
||||
bird,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
onSpeakerSelect
|
||||
}: Props = $props();
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-3',
|
||||
bg: 'bg-chart-3/5',
|
||||
iconBg: 'bg-chart-3/15',
|
||||
iconText: 'text-chart-3'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
},
|
||||
{
|
||||
border: 'border-l-warning',
|
||||
bg: 'bg-warning/5',
|
||||
iconBg: 'bg-warning/15',
|
||||
iconText: 'text-warning'
|
||||
},
|
||||
{
|
||||
border: 'border-l-destructive',
|
||||
bg: 'bg-destructive/5',
|
||||
iconBg: 'bg-destructive/15',
|
||||
iconText: 'text-destructive'
|
||||
},
|
||||
{
|
||||
border: 'border-l-info',
|
||||
bg: 'bg-info/10',
|
||||
iconBg: 'bg-info/15',
|
||||
iconText: 'text-info'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
|
||||
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
|
||||
|
||||
const birdText = $derived.by(() => {
|
||||
if (!bird?.birdc_configured) return '—';
|
||||
if (bird.error) return '—';
|
||||
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||
});
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'peers',
|
||||
label: 'BGP-пиры',
|
||||
value: initialLoading ? '—' : String(metrics.peersTotal),
|
||||
description: initialLoading
|
||||
? ''
|
||||
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
|
||||
icon: Share2,
|
||||
accent: statAccents[0],
|
||||
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
|
||||
badgeClass:
|
||||
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
|
||||
},
|
||||
{
|
||||
id: 'established',
|
||||
label: 'Активные сессии',
|
||||
value: initialLoading ? '—' : String(metrics.peersEstablished),
|
||||
description: 'Established среди включённых пиров',
|
||||
icon: CheckCircle2,
|
||||
accent: statAccents[1],
|
||||
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
|
||||
badgeClass:
|
||||
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
label: 'Спикеры online',
|
||||
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
|
||||
description: 'agent + BGP poll',
|
||||
icon: Server,
|
||||
accent: statAccents[2],
|
||||
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
|
||||
badgeClass:
|
||||
metrics.speakersOnline === metrics.speakersTotal
|
||||
? 'border-success/30 bg-success/15 text-success'
|
||||
: 'border-warning/30 bg-warning/15 text-warning'
|
||||
},
|
||||
{
|
||||
id: 'drift',
|
||||
label: 'Drift',
|
||||
value: initialLoading ? '—' : String(metrics.speakersDrift),
|
||||
description: 'applied ≠ published',
|
||||
icon: GitBranch,
|
||||
accent: statAccents[3],
|
||||
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
|
||||
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
|
||||
},
|
||||
{
|
||||
id: 'poll-errors',
|
||||
label: 'Ошибки опроса',
|
||||
value: initialLoading ? '—' : String(metrics.pollErrors),
|
||||
description: 'agent или BGP poll',
|
||||
icon: Activity,
|
||||
accent: statAccents[4],
|
||||
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
|
||||
badgeClass:
|
||||
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||
},
|
||||
{
|
||||
id: 'cp-bird',
|
||||
label: 'BGP на CP',
|
||||
value: initialLoading ? '—' : birdText,
|
||||
description: bird?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: (bird?.message ?? 'birdc не настроен'),
|
||||
icon: Bird,
|
||||
accent: statAccents[5],
|
||||
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
|
||||
href: '/monitoring' as const
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#if !initialLoading && !loading}
|
||||
{#if overallStatus === 'ok'}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription>{overallHint}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if overallStatus === 'warn'}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<AlertTriangle class="text-warning" />
|
||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{overallHint}
|
||||
{#if issues.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc text-sm">
|
||||
{#each issues as issue (issue.id)}
|
||||
<li>{issue.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert variant="destructive">
|
||||
<XCircle />
|
||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{overallHint}
|
||||
{#if issues.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc text-sm">
|
||||
{#each issues as issue (issue.id)}
|
||||
<li>{issue.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading || loading}
|
||||
skeletonCount={6}
|
||||
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-base font-semibold">Ноды</h2>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||
<Gauge class="size-3.5" />
|
||||
Мониторинг API
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if speakers.length === 0 && !initialLoading && !loading}
|
||||
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{#each speakers as speaker (speaker.id)}
|
||||
<NetworkSpeakerStatusCard
|
||||
{speaker}
|
||||
{peers}
|
||||
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
peersForSpeaker,
|
||||
speakerDisplayStatus,
|
||||
speakerHasDrift,
|
||||
speakerLabel
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '$lib/ui/core/sheet/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/ui/core/table/index.js';
|
||||
|
||||
type Props = {
|
||||
speaker: SpeakerRow | null;
|
||||
peers: PeerRow[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onApply?: (speaker: SpeakerRow) => void;
|
||||
};
|
||||
|
||||
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
|
||||
|
||||
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
|
||||
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
||||
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
||||
const sessions = $derived(speaker?.live?.sessions ?? []);
|
||||
|
||||
function driftLabel(s: SpeakerRow): string {
|
||||
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
|
||||
return `${app} / ${pub}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onOpenChange?.(open);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sheet bind:open>
|
||||
<SheetContent class="flex w-full flex-col overflow-y-auto sm:max-w-lg">
|
||||
{#if speaker}
|
||||
<SheetHeader>
|
||||
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if status}
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
{/if}
|
||||
{#if speakerHasDrift(speaker)}
|
||||
<Badge variant="secondary">Drift</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 text-sm">
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">BGP Established</span>
|
||||
<span class="font-medium tabular-nums">
|
||||
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
{#if speaker.live?.agent_last_sync_at}
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Последний sync</span>
|
||||
<span class="text-xs">{speaker.live.agent_last_sync_at}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Drift (app / pub)</span>
|
||||
<span class="font-mono text-xs">{driftLabel(speaker)}</span>
|
||||
</div>
|
||||
{#if speaker.last_dispatch_at}
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Dispatch</span>
|
||||
<span class="text-xs">{speaker.last_dispatch_at}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if speaker.last_dispatch_error}
|
||||
<p class="text-xs text-destructive">{speaker.last_dispatch_error}</p>
|
||||
{/if}
|
||||
{#if speaker.live?.agent_error}
|
||||
<p class="text-xs text-destructive">Agent: {speaker.live.agent_error}</p>
|
||||
{/if}
|
||||
{#if speaker.live?.bgp_poll_error}
|
||||
<p class="text-xs text-destructive">BGP poll: {speaker.live.bgp_poll_error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onApply && speaker.published_revision_id}
|
||||
<Button variant="outline" size="sm" onclick={() => onApply(speaker)}
|
||||
>Apply revision</Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
||||
{:else}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each sessions as sess, i (sess.name + i)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{sess.name}
|
||||
{#if sess.neighbor}
|
||||
<div class="text-muted-foreground">{sess.neighbor}</div>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{sess.state}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
||||
{#if relatedPeers.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each relatedPeers as p (p.id)}
|
||||
<li class="rounded-lg border px-3 py-2 text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium">{p.name?.trim() || p.neighbor}</span>
|
||||
<Badge variant="outline">{p.session_state || '—'}</Badge>
|
||||
</div>
|
||||
{#if p.session_mismatch}
|
||||
<p class="mt-1 text-xs text-warning">Mismatch: сессия не на назначенной ноде</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
speakerBgpText,
|
||||
speakerDisplayStatus,
|
||||
speakerHasDrift,
|
||||
speakerLabel
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
|
||||
type Props = {
|
||||
speaker: SpeakerRow;
|
||||
peers?: PeerRow[];
|
||||
onclick?: () => void;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { speaker, peers = [], onclick, class: className }: Props = $props();
|
||||
|
||||
const status = $derived(speakerDisplayStatus(speaker));
|
||||
const label = $derived(speakerLabel(speaker));
|
||||
const drift = $derived(speakerHasDrift(speaker));
|
||||
const peerCount = $derived(
|
||||
peers.filter(
|
||||
(p) =>
|
||||
p.bgp_speaker_id === speaker.id ||
|
||||
p.bgp_speaker_id === null ||
|
||||
p.bgp_speaker_id === undefined
|
||||
).length
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<Card
|
||||
class={cn(
|
||||
'cursor-pointer transition-colors hover:border-primary/35',
|
||||
onclick ? 'cursor-pointer' : '',
|
||||
className
|
||||
)}
|
||||
role={onclick ? 'button' : undefined}
|
||||
tabindex={onclick ? 0 : undefined}
|
||||
{onclick}
|
||||
onkeydown={(e) => {
|
||||
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onclick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<CardTitle class="flex items-center gap-2 truncate text-sm">
|
||||
<Server class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{label}</span>
|
||||
</CardTitle>
|
||||
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 pt-0 text-sm">
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">BGP</span>
|
||||
<span class="font-medium tabular-nums">{speakerBgpText(speaker)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Пиры</span>
|
||||
<span class="tabular-nums">{peerCount}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Drift</span>
|
||||
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||
{drift ? 'есть' : 'нет'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
||||
import {
|
||||
speakerBgpText,
|
||||
speakerDisplayStatus,
|
||||
speakerHasDrift
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
@@ -29,6 +34,7 @@
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
|
||||
type Props = {
|
||||
items: SpeakerRow[];
|
||||
@@ -36,9 +42,17 @@
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
let {
|
||||
items,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null,
|
||||
onRefresh,
|
||||
onSpeakerSelect
|
||||
}: Props = $props();
|
||||
|
||||
type SpeakerForm = {
|
||||
endpoint: string;
|
||||
@@ -72,6 +86,8 @@
|
||||
|
||||
const columns = [
|
||||
{ id: 'status', label: 'Статус' },
|
||||
{ id: 'live_agent', label: 'Agent' },
|
||||
{ id: 'bgp', label: 'BGP' },
|
||||
{
|
||||
id: 'agent_domain',
|
||||
label: 'Agent domain',
|
||||
@@ -80,7 +96,7 @@
|
||||
},
|
||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||
{ id: 'drift', label: 'Drift' },
|
||||
{ id: 'actions', label: '', class: 'w-40' }
|
||||
{ id: 'actions', label: '', class: 'w-44' }
|
||||
] as const;
|
||||
|
||||
function parseIpv4FromEndpoint(ep: string): string {
|
||||
@@ -152,16 +168,17 @@
|
||||
}
|
||||
|
||||
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
|
||||
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
|
||||
return 'outline';
|
||||
return speakerDisplayStatus(s).variant;
|
||||
}
|
||||
|
||||
function statusLabel(s: SpeakerRow): string {
|
||||
if (s.sync_status === 'synced') return 'Connected';
|
||||
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
|
||||
if (s.dispatch_status === 'ok') return 'Synced';
|
||||
return 'Unknown';
|
||||
return speakerDisplayStatus(s).label;
|
||||
}
|
||||
|
||||
function liveAgentLabel(s: SpeakerRow): string {
|
||||
if (!s.live) return '—';
|
||||
if (s.live.agent_ok === true) return 'OK';
|
||||
return s.live.agent_error ? 'Error' : 'Offline';
|
||||
}
|
||||
|
||||
function driftLabel(s: SpeakerRow): string {
|
||||
@@ -333,16 +350,29 @@ CF_DNS_API_TOKEN=<cloudflare token>
|
||||
{#snippet cell({ row: s, column })}
|
||||
{#if column.id === 'status'}
|
||||
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
|
||||
{:else if column.id === 'live_agent'}
|
||||
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
|
||||
{:else if column.id === 'bgp'}
|
||||
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
|
||||
{:else if column.id === 'agent_domain'}
|
||||
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
|
||||
{:else if column.id === 'role'}
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
{:else if column.id === 'drift'}
|
||||
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
|
||||
<span
|
||||
class="font-mono text-xs text-muted-foreground"
|
||||
title="applied / published"
|
||||
class:text-warning={speakerHasDrift(s)}
|
||||
>
|
||||
{driftLabel(s)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if onSpeakerSelect}
|
||||
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
|
||||
<Eye class="size-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
|
||||
<Copy class="size-3" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
aggregateNetworkMetrics,
|
||||
collectNetworkIssues,
|
||||
deriveNetworkOverallStatus,
|
||||
networkOverallStatusHint,
|
||||
networkOverallStatusLabel
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
|
||||
type Props = {
|
||||
peers: PeerRow[];
|
||||
speakers: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
let { peers, speakers, loading = false, initialLoading = false, error = null }: Props = $props();
|
||||
|
||||
const metrics = $derived(aggregateNetworkMetrics(peers, speakers));
|
||||
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||
const issues = $derived(collectNetworkIssues(peers, speakers, 3));
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<NetworkIcon class="size-4" />
|
||||
Сеть (BGP)
|
||||
</CardTitle>
|
||||
<CardDescription>Live-статус пиров и спикеров</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
|
||||
Подробнее
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 p-4 pt-4">
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{:else if initialLoading || loading}
|
||||
<p class="text-sm text-muted-foreground">Загрузка live-метрик…</p>
|
||||
{:else if overallStatus === 'ok'}
|
||||
<Alert class="border-success/30 bg-success/5 py-3">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription class="text-xs">{overallHint}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if overallStatus === 'warn'}
|
||||
<Alert class="border-warning/30 bg-warning/5 py-3">
|
||||
<AlertTriangle class="text-warning" />
|
||||
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription class="text-xs">
|
||||
{overallHint}
|
||||
{#if issues.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc">
|
||||
{#each issues as issue (issue.id)}
|
||||
<li>{issue.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert variant="destructive" class="py-3">
|
||||
<XCircle />
|
||||
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription class="text-xs">
|
||||
{overallHint}
|
||||
{#if issues.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc">
|
||||
{#each issues as issue (issue.id)}
|
||||
<li>{issue.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Пиры Established</p>
|
||||
<p class="text-xl font-bold tabular-nums">
|
||||
{initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Спикеры online</p>
|
||||
<p class="text-xl font-bold tabular-nums">
|
||||
{initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Drift</p>
|
||||
<p class="text-xl font-bold tabular-nums">{initialLoading ? '—' : metrics.speakersDrift}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
|
||||
export type NetworkOverallStatus = 'ok' | 'warn' | 'error';
|
||||
|
||||
export type NetworkMetrics = {
|
||||
peersTotal: number;
|
||||
peersEnabled: number;
|
||||
peersEstablished: number;
|
||||
peersMismatch: number;
|
||||
speakersTotal: number;
|
||||
speakersOnline: number;
|
||||
speakersRemote: number;
|
||||
speakersRemoteOnline: number;
|
||||
speakersDrift: number;
|
||||
pollErrors: number;
|
||||
hasLiveData: boolean;
|
||||
};
|
||||
|
||||
export type SpeakerStatusBadge = {
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
};
|
||||
|
||||
export type NetworkIssue = {
|
||||
id: string;
|
||||
message: string;
|
||||
severity: 'warn' | 'error';
|
||||
};
|
||||
|
||||
function isRemoteSpeaker(s: SpeakerRow): boolean {
|
||||
const role = (s.role ?? '').toLowerCase();
|
||||
return role !== 'master' && Boolean(s.agent_domain?.trim());
|
||||
}
|
||||
|
||||
export function speakerHasDrift(s: SpeakerRow): boolean {
|
||||
const pub = s.published_revision_id?.trim();
|
||||
if (!pub) return false;
|
||||
return (s.last_applied_revision_id ?? '') !== pub;
|
||||
}
|
||||
|
||||
export function speakerIsOnline(s: SpeakerRow): boolean {
|
||||
if (s.live) {
|
||||
return s.live.agent_ok === true && s.live.bgp_poll_ok !== false;
|
||||
}
|
||||
if (s.sync_status === 'synced') return true;
|
||||
if (s.sync_status === 'error' || s.last_dispatch_error) return false;
|
||||
return s.dispatch_status === 'ok';
|
||||
}
|
||||
|
||||
export function speakerDisplayStatus(s: SpeakerRow): SpeakerStatusBadge {
|
||||
if (s.live) {
|
||||
if (s.live.agent_ok === true && s.live.bgp_poll_ok !== false) {
|
||||
return { label: 'Online', variant: 'default' };
|
||||
}
|
||||
if (s.live.bgp_poll_error || s.live.agent_error) {
|
||||
return { label: 'Offline', variant: 'destructive' };
|
||||
}
|
||||
return { label: 'Degraded', variant: 'secondary' };
|
||||
}
|
||||
if (s.sync_status === 'synced') return { label: 'Connected', variant: 'default' };
|
||||
if (s.sync_status === 'error' || s.last_dispatch_error) {
|
||||
return { label: 'Offline', variant: 'destructive' };
|
||||
}
|
||||
if (s.dispatch_status === 'ok') return { label: 'Synced', variant: 'outline' };
|
||||
return { label: 'Unknown', variant: 'outline' };
|
||||
}
|
||||
|
||||
export function speakerLabel(s: SpeakerRow): string {
|
||||
return s.live?.label ?? s.agent_domain ?? s.endpoint ?? s.id;
|
||||
}
|
||||
|
||||
export function speakerBgpText(s: SpeakerRow): string {
|
||||
if (s.live) {
|
||||
return `${s.live.bgp_established ?? 0}/${s.live.bgp_sessions_total ?? 0}`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
export function peersForSpeaker(peers: PeerRow[], speakerId: string): PeerRow[] {
|
||||
return peers.filter(
|
||||
(p) =>
|
||||
p.bgp_speaker_id === speakerId || p.bgp_speaker_id === null || p.bgp_speaker_id === undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function aggregateNetworkMetrics(
|
||||
peers: PeerRow[],
|
||||
speakers: SpeakerRow[],
|
||||
bird?: BirdStatus | null
|
||||
): NetworkMetrics {
|
||||
const enabledPeers = peers.filter((p) => p.enabled !== false);
|
||||
const established = enabledPeers.filter((p) => p.session_state === 'Established').length;
|
||||
const mismatch = peers.filter((p) => p.session_mismatch).length;
|
||||
const remoteSpeakers = speakers.filter(isRemoteSpeaker);
|
||||
const online = speakers.filter(speakerIsOnline).length;
|
||||
const remoteOnline = remoteSpeakers.filter(speakerIsOnline).length;
|
||||
const drift = speakers.filter(speakerHasDrift).length;
|
||||
const pollErrors = speakers.filter(
|
||||
(s) => s.live?.bgp_poll_error || (s.live && s.live.agent_ok === false)
|
||||
).length;
|
||||
const hasLiveData =
|
||||
speakers.some((s) => s.live != null) || peers.some((p) => p.session_on_speakers);
|
||||
|
||||
void bird;
|
||||
|
||||
return {
|
||||
peersTotal: peers.length,
|
||||
peersEnabled: enabledPeers.length,
|
||||
peersEstablished: established,
|
||||
peersMismatch: mismatch,
|
||||
speakersTotal: speakers.length,
|
||||
speakersOnline: online,
|
||||
speakersRemote: remoteSpeakers.length,
|
||||
speakersRemoteOnline: remoteOnline,
|
||||
speakersDrift: drift,
|
||||
pollErrors,
|
||||
hasLiveData
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveNetworkOverallStatus(metrics: NetworkMetrics): NetworkOverallStatus {
|
||||
if (!metrics.hasLiveData && metrics.speakersTotal === 0 && metrics.peersTotal === 0) {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
const enabledNotEstablished =
|
||||
metrics.peersEnabled > 0 ? metrics.peersEnabled - metrics.peersEstablished : 0;
|
||||
const majorityPeersDown =
|
||||
metrics.peersEnabled > 0 && enabledNotEstablished / metrics.peersEnabled > 0.5;
|
||||
|
||||
if ((metrics.speakersRemote > 0 && metrics.speakersRemoteOnline === 0) || majorityPeersDown) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (
|
||||
metrics.pollErrors > 0 ||
|
||||
metrics.peersMismatch > 0 ||
|
||||
metrics.speakersDrift > 0 ||
|
||||
metrics.speakersOnline < metrics.speakersTotal
|
||||
) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function networkOverallStatusLabel(status: NetworkOverallStatus): string {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
return 'В норме';
|
||||
case 'warn':
|
||||
return 'Требует внимания';
|
||||
case 'error':
|
||||
return 'Проблема';
|
||||
}
|
||||
}
|
||||
|
||||
export function networkOverallStatusHint(
|
||||
status: NetworkOverallStatus,
|
||||
metrics: NetworkMetrics
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
return metrics.hasLiveData
|
||||
? `${metrics.peersEstablished} Established, ${metrics.speakersOnline}/${metrics.speakersTotal} спикеров online`
|
||||
: 'Сеть настроена; обновите для live-статуса';
|
||||
case 'warn':
|
||||
return 'Есть drift, mismatch или недоступные ноды — проверьте детали';
|
||||
case 'error':
|
||||
return 'Критичная деградация BGP или все remote-ноды недоступны';
|
||||
}
|
||||
}
|
||||
|
||||
export function collectNetworkIssues(
|
||||
peers: PeerRow[],
|
||||
speakers: SpeakerRow[],
|
||||
limit = 3
|
||||
): NetworkIssue[] {
|
||||
const issues: NetworkIssue[] = [];
|
||||
|
||||
for (const s of speakers) {
|
||||
if (!speakerIsOnline(s)) {
|
||||
issues.push({
|
||||
id: `speaker-offline-${s.id}`,
|
||||
message: `Нода offline: ${speakerLabel(s)}`,
|
||||
severity: 'error'
|
||||
});
|
||||
} else if (speakerHasDrift(s)) {
|
||||
issues.push({
|
||||
id: `speaker-drift-${s.id}`,
|
||||
message: `Drift ревизии: ${speakerLabel(s)}`,
|
||||
severity: 'warn'
|
||||
});
|
||||
} else if (s.live?.bgp_poll_error) {
|
||||
issues.push({
|
||||
id: `speaker-poll-${s.id}`,
|
||||
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
||||
severity: 'warn'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of peers) {
|
||||
if (p.session_mismatch) {
|
||||
const name = p.name?.trim() || p.neighbor;
|
||||
issues.push({
|
||||
id: `peer-mismatch-${p.id}`,
|
||||
message: `Mismatch сессии: ${name}`,
|
||||
severity: 'warn'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues.slice(0, limit);
|
||||
}
|
||||
|
||||
export const NETWORK_AUTO_REFRESH_KEY = 'evobgp.network.autoRefresh';
|
||||
export const NETWORK_AUTO_REFRESH_MS = 15_000;
|
||||
|
||||
export function readNetworkAutoRefresh(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
return localStorage.getItem(NETWORK_AUTO_REFRESH_KEY) === '1';
|
||||
}
|
||||
|
||||
export function writeNetworkAutoRefresh(enabled: boolean): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
|
||||
}
|
||||
Reference in New Issue
Block a user