diff --git a/web/src/lib/components/network/NetworkOverviewTab.svelte b/web/src/lib/components/network/NetworkOverviewTab.svelte
index 49317fb..56b141e 100644
--- a/web/src/lib/components/network/NetworkOverviewTab.svelte
+++ b/web/src/lib/components/network/NetworkOverviewTab.svelte
@@ -9,7 +9,6 @@
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';
@@ -166,71 +165,76 @@
]);
-{#if !initialLoading && !loading}
- {#if overallStatus === 'ok'}
-
-
- {networkOverallStatusLabel(overallStatus)}
- {overallHint}
-
- {:else if overallStatus === 'warn'}
-
-
- {networkOverallStatusLabel(overallStatus)}
-
- {overallHint}
- {#if issues.length > 0}
-
- {#each issues as issue (issue.id)}
- - {issue.message}
- {/each}
-
- {/if}
-
-
- {:else}
-
-
- {networkOverallStatusLabel(overallStatus)}
-
- {overallHint}
- {#if issues.length > 0}
-
- {#each issues as issue (issue.id)}
- - {issue.message}
- {/each}
-
- {/if}
-
-
+
+ {#if !initialLoading && !loading}
+ {#if overallStatus === 'ok'}
+
+
+ {networkOverallStatusLabel(overallStatus)}
+ {overallHint}
+
+ {:else if overallStatus === 'warn'}
+
+
+ {networkOverallStatusLabel(overallStatus)}
+
+ {overallHint}
+ {#if issues.length > 0}
+
+ {#each issues as issue (issue.id)}
+ - {issue.message}
+ {/each}
+
+ {/if}
+
+
+ {:else}
+
+
+ {networkOverallStatusLabel(overallStatus)}
+
+ {overallHint}
+ {#if issues.length > 0}
+
+ {#each issues as issue (issue.id)}
+ - {issue.message}
+ {/each}
+
+ {/if}
+
+
+ {/if}
{/if}
-{/if}
-
+
-
-
Ноды
-
+
+
+
Ноды
+
+
+
+ {#if speakers.length === 0 && !initialLoading && !loading}
+ Спикеры не зарегистрированы.
+ {:else}
+
+ {#each speakers as speaker (speaker.id)}
+ onSpeakerSelect(speaker) : undefined}
+ />
+ {/each}
+
+ {/if}
+
-
-{#if speakers.length === 0 && !initialLoading && !loading}
-
Спикеры не зарегистрированы.
-{:else}
-
- {#each speakers as speaker (speaker.id)}
- onSpeakerSelect(speaker) : undefined}
- />
- {/each}
-
-{/if}
diff --git a/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte b/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte
index ba385c8..ffe7dfd 100644
--- a/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte
+++ b/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte
@@ -3,9 +3,13 @@
import {
peersForSpeaker,
speakerDisplayStatus,
+ speakerDispatchError,
speakerHasDrift,
- speakerLabel
+ speakerLabel,
+ speakerLiveAgentError,
+ speakerLiveBgpError
} 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 { Separator } from '$lib/ui/core/separator/index.js';
@@ -24,6 +28,7 @@
TableHeader,
TableRow
} from '$lib/ui/core/table/index.js';
+ import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
type Props = {
speaker: SpeakerRow | null;
@@ -39,6 +44,9 @@
const label = $derived(speaker ? speakerLabel(speaker) : '');
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
const sessions = $derived(speaker?.live?.sessions ?? []);
+ const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
+ const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
+ const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
@@ -46,22 +54,28 @@
return `${app} / ${pub}`;
}
+ function formatSyncAt(iso: string | undefined): string {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
+ }
+
$effect(() => {
onOpenChange?.(open);
});
-
+
{#if speaker}
-
- {label}
-
- {speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
-
-
+
+
+ {label}
+
+ {speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
+
+
-
{#if status}
{status.label}
@@ -71,101 +85,118 @@
{/if}
-
-
- BGP Established
-
- {speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
-
-
+
+ - BGP Established
+ -
+ {speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
+
{#if speaker.live?.agent_last_sync_at}
-
- Последний sync
- {speaker.live.agent_last_sync_at}
-
+ - Последний sync
+ -
+ {formatSyncAt(speaker.live.agent_last_sync_at)}
+
{/if}
-
- Drift (app / pub)
- {driftLabel(speaker)}
-
+ - Drift (app / pub)
+ - {driftLabel(speaker)}
{#if speaker.last_dispatch_at}
-
- Dispatch
- {speaker.last_dispatch_at}
-
+ - Dispatch
+ -
+ {formatSyncAt(speaker.last_dispatch_at)}
+
{/if}
- {#if speaker.last_dispatch_error}
- {speaker.last_dispatch_error}
- {/if}
- {#if speaker.live?.agent_error}
- Agent: {speaker.live.agent_error}
- {/if}
- {#if speaker.live?.bgp_poll_error}
- BGP poll: {speaker.live.bgp_poll_error}
- {/if}
-
+
+
+ {#if dispatchError}
+
+
+ {dispatchError.title}
+ {dispatchError.detail}
+
+ {/if}
+ {#if agentError}
+
+ {agentError.title}
+ {agentError.detail}
+
+ {/if}
+ {#if bgpError}
+
+ {bgpError.title}
+ {bgpError.detail}
+
+ {/if}
{#if onApply && speaker.published_revision_id}
-
+
{/if}
-
+
BGP-сессии (live)
{#if sessions.length === 0}
Нет данных или сессий нет.
{:else}
-
-
-
- Имя
- Состояние
-
-
-
- {#each sessions as sess, i (sess.name + i)}
+
+
+
-
- {sess.name}
- {#if sess.neighbor}
- {sess.neighbor}
- {/if}
-
-
- {sess.state}
-
+ Имя
+ Состояние
- {/each}
-
-
+
+
+ {#each sessions as sess, i (sess.name + i)}
+
+
+ {sess.name}
+ {#if sess.neighbor}
+
+ {sess.neighbor}
+
+ {/if}
+
+
+ {sess.state}
+
+
+ {/each}
+
+
+
{/if}
-
+
-
+
Пиры на ноде
{#if relatedPeers.length === 0}
Нет привязанных пиров.
{:else}
-
+
{#each relatedPeers as p (p.id)}
- -
-
-
{p.name?.trim() || p.neighbor}
-
{p.session_state || '—'}
+
-
+
+
+ {p.name?.trim() || p.neighbor}
+
+ {#if p.session_mismatch}
+
+ Mismatch: сессия не на назначенной ноде
+
+ {/if}
- {#if p.session_mismatch}
- Mismatch: сессия не на назначенной ноде
- {/if}
+ {p.session_state || '—'}
{/each}
{/if}
-
+
{/if}
diff --git a/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte b/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte
index 5f8b39e..7c1d2f4 100644
--- a/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte
+++ b/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte
@@ -42,8 +42,8 @@
-
-
-
-
- {label}
+
+
+
+
+ {label}
{speaker.role}
-
{status.label}
+
{status.label}
-
-
- BGP
- {speakerBgpText(speaker)}
-
-
- Пиры
- {peerCount}
-
-
- Drift
-
- {drift ? 'есть' : 'нет'}
-
-
+
+
+ - BGP
+ - {speakerBgpText(speaker)}
+ - Пиры
+ - {peerCount}
+ - Drift
+ -
+
+ {drift ? 'есть' : 'нет'}
+
+
+
diff --git a/web/src/lib/network/network-metrics.ts b/web/src/lib/network/network-metrics.ts
index f67b0f9..93c6c3f 100644
--- a/web/src/lib/network/network-metrics.ts
+++ b/web/src/lib/network/network-metrics.ts
@@ -197,6 +197,13 @@ export function collectNetworkIssues(
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
severity: 'warn'
});
+ } else if (s.last_dispatch_error) {
+ const err = formatSpeakerError(s.last_dispatch_error);
+ issues.push({
+ id: `speaker-dispatch-${s.id}`,
+ message: `Dispatch: ${speakerLabel(s)}${err ? ` — ${err.detail.slice(0, 80)}` : ''}`,
+ severity: 'warn'
+ });
}
}
@@ -226,3 +233,57 @@ export function writeNetworkAutoRefresh(enabled: boolean): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
}
+
+export type FormattedSpeakerError = {
+ title: string;
+ detail: string;
+};
+
+/** Humanize stored dispatch/agent errors (avoid raw JSON in UI). */
+export function formatSpeakerError(raw: string | null | undefined): FormattedSpeakerError | null {
+ if (!raw?.trim()) return null;
+ const text = raw.trim();
+
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
+ if (jsonMatch) {
+ try {
+ const obj = JSON.parse(jsonMatch[0]) as {
+ detail?: string;
+ title?: string;
+ status?: number;
+ };
+ const detail = String(obj.detail ?? text);
+ if (/403/.test(detail) && /bundle/i.test(detail)) {
+ return {
+ title: 'Dispatch: доступ к бандлу',
+ detail:
+ 'Нода не смогла скачать бандл с CP (403). Проверьте node API-ключ (роль node) и EVOBGP_NODE_TOKEN на реплике — см. docs/access.md.'
+ };
+ }
+ const httpPrefix = text.match(/^HTTP \d+:\s*/)?.[0] ?? '';
+ return {
+ title: obj.title && obj.title !== 'Bad Gateway' ? obj.title : 'Ошибка dispatch',
+ detail: httpPrefix ? `${httpPrefix.trim()} ${detail}`.trim() : detail
+ };
+ } catch {
+ /* fall through */
+ }
+ }
+
+ if (/^HTTP \d+:/.test(text)) {
+ return { title: 'Ошибка HTTP', detail: text };
+ }
+ return { title: 'Ошибка', detail: text };
+}
+
+export function speakerDispatchError(s: SpeakerRow): FormattedSpeakerError | null {
+ return formatSpeakerError(s.last_dispatch_error);
+}
+
+export function speakerLiveAgentError(s: SpeakerRow): FormattedSpeakerError | null {
+ return formatSpeakerError(s.live?.agent_error);
+}
+
+export function speakerLiveBgpError(s: SpeakerRow): FormattedSpeakerError | null {
+ return formatSpeakerError(s.live?.bgp_poll_error);
+}
diff --git a/web/src/lib/ui/patterns/kpi/kpi-metrics-grid.svelte b/web/src/lib/ui/patterns/kpi/kpi-metrics-grid.svelte
index 6e9de52..06dafd7 100644
--- a/web/src/lib/ui/patterns/kpi/kpi-metrics-grid.svelte
+++ b/web/src/lib/ui/patterns/kpi/kpi-metrics-grid.svelte
@@ -45,7 +45,7 @@
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
-
+
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
@@ -56,7 +56,7 @@
{@const a = card.accent}
{card.value}
-
+
{card.badge}
diff --git a/web/src/routes/network/+page.svelte b/web/src/routes/network/+page.svelte
index 06d9054..2f103eb 100644
--- a/web/src/routes/network/+page.svelte
+++ b/web/src/routes/network/+page.svelte
@@ -204,7 +204,7 @@
Control plane
-
+