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} +
+
+ + {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 - +