refactor(NetworkOverviewTab, NetworkSpeakerDetailSheet, NetworkSpeakerStatusCard): improve layout and error handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 39s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 19s
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 39s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 19s
- Refactored the layout of NetworkOverviewTab and NetworkSpeakerDetailSheet for better structure and readability. - Enhanced error handling in NetworkSpeakerDetailSheet by introducing new error types for dispatch and agent errors. - Updated NetworkSpeakerStatusCard to improve the display of speaker information and status. - Adjusted styles in various components to ensure consistent spacing and alignment. - Modified network-metrics.ts to include new functions for formatting speaker errors, improving user feedback on dispatch issues.
This commit is contained in:
@@ -9,7 +9,6 @@
|
|||||||
networkOverallStatusLabel
|
networkOverallStatusLabel
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.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 { Button } from '$lib/ui/core/button/index.js';
|
||||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||||
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||||
@@ -166,71 +165,76 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !initialLoading && !loading}
|
<div class="flex min-w-0 flex-col gap-6">
|
||||||
{#if overallStatus === 'ok'}
|
{#if !initialLoading && !loading}
|
||||||
<Alert class="border-success/30 bg-success/5">
|
{#if overallStatus === 'ok'}
|
||||||
<CheckCircle class="text-success" />
|
<Alert class="border-success/30 bg-success/5">
|
||||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
<CheckCircle class="text-success" />
|
||||||
<AlertDescription>{overallHint}</AlertDescription>
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
</Alert>
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
{:else if overallStatus === 'warn'}
|
</Alert>
|
||||||
<Alert class="border-warning/30 bg-warning/5">
|
{:else if overallStatus === 'warn'}
|
||||||
<AlertTriangle class="text-warning" />
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
<AlertTriangle class="text-warning" />
|
||||||
<AlertDescription>
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
{overallHint}
|
<AlertDescription>
|
||||||
{#if issues.length > 0}
|
{overallHint}
|
||||||
<ul class="mt-2 list-inside list-disc text-sm">
|
{#if issues.length > 0}
|
||||||
{#each issues as issue (issue.id)}
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
<li>{issue.message}</li>
|
{#each issues as issue (issue.id)}
|
||||||
{/each}
|
<li>{issue.message}</li>
|
||||||
</ul>
|
{/each}
|
||||||
{/if}
|
</ul>
|
||||||
</AlertDescription>
|
{/if}
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
{:else}
|
</Alert>
|
||||||
<Alert variant="destructive">
|
{:else}
|
||||||
<XCircle />
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
<XCircle />
|
||||||
<AlertDescription>
|
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||||
{overallHint}
|
<AlertDescription>
|
||||||
{#if issues.length > 0}
|
{overallHint}
|
||||||
<ul class="mt-2 list-inside list-disc text-sm">
|
{#if issues.length > 0}
|
||||||
{#each issues as issue (issue.id)}
|
<ul class="mt-2 list-inside list-disc text-sm">
|
||||||
<li>{issue.message}</li>
|
{#each issues as issue (issue.id)}
|
||||||
{/each}
|
<li>{issue.message}</li>
|
||||||
</ul>
|
{/each}
|
||||||
{/if}
|
</ul>
|
||||||
</AlertDescription>
|
{/if}
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<KpiMetricsGrid
|
<KpiMetricsGrid
|
||||||
cards={kpiCards}
|
cards={kpiCards}
|
||||||
loading={initialLoading || loading}
|
loading={initialLoading || loading}
|
||||||
skeletonCount={6}
|
skeletonCount={6}
|
||||||
class="sm:grid-cols-2 xl:grid-cols-3"
|
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<section class="flex min-w-0 flex-col gap-4">
|
||||||
<h2 class="text-base font-semibold">Ноды</h2>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
<h2 class="text-base font-semibold">Ноды</h2>
|
||||||
<Gauge class="size-3.5" />
|
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||||
Мониторинг API
|
<Gauge class="size-3.5" />
|
||||||
</Button>
|
Мониторинг API
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if speakers.length === 0 && !initialLoading && !loading}
|
||||||
|
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{#each speakers as speaker (speaker.id)}
|
||||||
|
<NetworkSpeakerStatusCard
|
||||||
|
{speaker}
|
||||||
|
{peers}
|
||||||
|
class="h-full"
|
||||||
|
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</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}
|
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import {
|
import {
|
||||||
peersForSpeaker,
|
peersForSpeaker,
|
||||||
speakerDisplayStatus,
|
speakerDisplayStatus,
|
||||||
|
speakerDispatchError,
|
||||||
speakerHasDrift,
|
speakerHasDrift,
|
||||||
speakerLabel
|
speakerLabel,
|
||||||
|
speakerLiveAgentError,
|
||||||
|
speakerLiveBgpError
|
||||||
} from '$lib/network/network-metrics.js';
|
} 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 { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Separator } from '$lib/ui/core/separator/index.js';
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '$lib/ui/core/table/index.js';
|
} from '$lib/ui/core/table/index.js';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
speaker: SpeakerRow | null;
|
speaker: SpeakerRow | null;
|
||||||
@@ -39,6 +44,9 @@
|
|||||||
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
||||||
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
||||||
const sessions = $derived(speaker?.live?.sessions ?? []);
|
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 {
|
function driftLabel(s: SpeakerRow): string {
|
||||||
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||||
@@ -46,22 +54,28 @@
|
|||||||
return `${app} / ${pub}`;
|
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(() => {
|
$effect(() => {
|
||||||
onOpenChange?.(open);
|
onOpenChange?.(open);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sheet bind:open>
|
<Sheet bind:open>
|
||||||
<SheetContent class="flex w-full flex-col overflow-y-auto sm:max-w-lg">
|
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
|
||||||
{#if speaker}
|
{#if speaker}
|
||||||
<SheetHeader>
|
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
|
||||||
<SheetTitle class="truncate">{label}</SheetTitle>
|
<SheetHeader class="space-y-1 pr-8 text-left">
|
||||||
<SheetDescription>
|
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||||
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
<SheetDescription class="truncate">
|
||||||
</SheetDescription>
|
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||||
</SheetHeader>
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if status}
|
{#if status}
|
||||||
<Badge variant={status.variant}>{status.label}</Badge>
|
<Badge variant={status.variant}>{status.label}</Badge>
|
||||||
@@ -71,101 +85,118 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2 text-sm">
|
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">BGP Established</dt>
|
||||||
<span class="text-muted-foreground">BGP Established</span>
|
<dd class="text-right font-medium tabular-nums">
|
||||||
<span class="font-medium tabular-nums">
|
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||||
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
</dd>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if speaker.live?.agent_last_sync_at}
|
{#if speaker.live?.agent_last_sync_at}
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">Последний sync</dt>
|
||||||
<span class="text-muted-foreground">Последний sync</span>
|
<dd class="text-right text-xs tabular-nums">
|
||||||
<span class="text-xs">{speaker.live.agent_last_sync_at}</span>
|
{formatSyncAt(speaker.live.agent_last_sync_at)}
|
||||||
</div>
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">Drift (app / pub)</dt>
|
||||||
<span class="text-muted-foreground">Drift (app / pub)</span>
|
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
|
||||||
<span class="font-mono text-xs">{driftLabel(speaker)}</span>
|
|
||||||
</div>
|
|
||||||
{#if speaker.last_dispatch_at}
|
{#if speaker.last_dispatch_at}
|
||||||
<div class="flex justify-between gap-2">
|
<dt class="text-muted-foreground">Dispatch</dt>
|
||||||
<span class="text-muted-foreground">Dispatch</span>
|
<dd class="text-right text-xs tabular-nums">
|
||||||
<span class="text-xs">{speaker.last_dispatch_at}</span>
|
{formatSyncAt(speaker.last_dispatch_at)}
|
||||||
</div>
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if speaker.last_dispatch_error}
|
</dl>
|
||||||
<p class="text-xs text-destructive">{speaker.last_dispatch_error}</p>
|
|
||||||
{/if}
|
{#if dispatchError}
|
||||||
{#if speaker.live?.agent_error}
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
<p class="text-xs text-destructive">Agent: {speaker.live.agent_error}</p>
|
<AlertTriangle class="text-warning" />
|
||||||
{/if}
|
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
|
||||||
{#if speaker.live?.bgp_poll_error}
|
<AlertDescription class="text-xs leading-relaxed"
|
||||||
<p class="text-xs text-destructive">BGP poll: {speaker.live.bgp_poll_error}</p>
|
>{dispatchError.detail}</AlertDescription
|
||||||
{/if}
|
>
|
||||||
</div>
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if agentError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{#if bgpError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
|
||||||
|
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if onApply && speaker.published_revision_id}
|
{#if onApply && speaker.published_revision_id}
|
||||||
<Button variant="outline" size="sm" onclick={() => onApply(speaker)}
|
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
|
||||||
>Apply revision</Button
|
Apply revision
|
||||||
>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="space-y-2">
|
<section class="min-w-0 space-y-2">
|
||||||
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Table>
|
<div class="rounded-md border">
|
||||||
<TableHeader>
|
<Table class="table-fixed">
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>Имя</TableHead>
|
|
||||||
<TableHead>Состояние</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{#each sessions as sess, i (sess.name + i)}
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono text-xs">
|
<TableHead class="w-[65%]">Имя</TableHead>
|
||||||
{sess.name}
|
<TableHead class="w-[35%] text-right">Состояние</TableHead>
|
||||||
{#if sess.neighbor}
|
|
||||||
<div class="text-muted-foreground">{sess.neighbor}</div>
|
|
||||||
{/if}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{sess.state}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{#each sessions as sess, i (sess.name + i)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="align-top">
|
||||||
|
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
|
||||||
|
{#if sess.neighbor}
|
||||||
|
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
|
||||||
|
{sess.neighbor}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right align-top">
|
||||||
|
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="space-y-2">
|
<section class="min-w-0 space-y-2">
|
||||||
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
||||||
{#if relatedPeers.length === 0}
|
{#if relatedPeers.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="divide-y rounded-md border">
|
||||||
{#each relatedPeers as p (p.id)}
|
{#each relatedPeers as p (p.id)}
|
||||||
<li class="rounded-lg border px-3 py-2 text-sm">
|
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="font-medium">{p.name?.trim() || p.neighbor}</span>
|
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
|
||||||
<Badge variant="outline">{p.session_state || '—'}</Badge>
|
{p.name?.trim() || p.neighbor}
|
||||||
|
</p>
|
||||||
|
{#if p.session_mismatch}
|
||||||
|
<p class="mt-0.5 text-xs text-warning">
|
||||||
|
Mismatch: сессия не на назначенной ноде
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if p.session_mismatch}
|
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
|
||||||
<p class="mt-1 text-xs text-warning">Mismatch: сессия не на назначенной ноде</p>
|
|
||||||
{/if}
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn(
|
||||||
'cursor-pointer transition-colors hover:border-primary/35',
|
'flex h-full flex-col transition-colors',
|
||||||
onclick ? 'cursor-pointer' : '',
|
onclick ? 'cursor-pointer hover:border-primary/35' : '',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role={onclick ? 'button' : undefined}
|
role={onclick ? 'button' : undefined}
|
||||||
@@ -57,31 +57,29 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="flex items-center gap-2 truncate text-sm">
|
<CardTitle class="flex items-center gap-2 text-sm">
|
||||||
<Server class="size-4 shrink-0 text-muted-foreground" />
|
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
<span class="truncate">{label}</span>
|
<span class="truncate" title={label}>{label}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={status.variant}>{status.label}</Badge>
|
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2 pt-0 text-sm">
|
<CardContent class="mt-auto pt-0">
|
||||||
<div class="flex justify-between gap-2">
|
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
|
||||||
<span class="text-muted-foreground">BGP</span>
|
<dt class="text-muted-foreground">BGP</dt>
|
||||||
<span class="font-medium tabular-nums">{speakerBgpText(speaker)}</span>
|
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
|
||||||
</div>
|
<dt class="text-muted-foreground">Пиры</dt>
|
||||||
<div class="flex justify-between gap-2">
|
<dd class="tabular-nums">{peerCount}</dd>
|
||||||
<span class="text-muted-foreground">Пиры</span>
|
<dt class="text-muted-foreground">Drift</dt>
|
||||||
<span class="tabular-nums">{peerCount}</span>
|
<dd>
|
||||||
</div>
|
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||||
<div class="flex justify-between gap-2">
|
{drift ? 'есть' : 'нет'}
|
||||||
<span class="text-muted-foreground">Drift</span>
|
</Badge>
|
||||||
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
</dd>
|
||||||
{drift ? 'есть' : 'нет'}
|
</dl>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -197,6 +197,13 @@ export function collectNetworkIssues(
|
|||||||
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
|
||||||
severity: 'warn'
|
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;
|
if (typeof localStorage === 'undefined') return;
|
||||||
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('grid gap-4', className)}>
|
<div class={cn('grid auto-rows-fr gap-4', className)}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
{#each Array(skeletonCount) as _, i (i)}
|
{#each Array(skeletonCount) as _, i (i)}
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
{@const a = card.accent}
|
{@const a = card.accent}
|
||||||
<Card
|
<Card
|
||||||
class={cn(
|
class={cn(
|
||||||
'overflow-hidden border-l-4 shadow-sm',
|
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
|
||||||
card.href ? 'transition-colors hover:border-primary/35' : '',
|
card.href ? 'transition-colors hover:border-primary/35' : '',
|
||||||
a.border,
|
a.border,
|
||||||
a.bg
|
a.bg
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
>{card.value}</CardTitle
|
>{card.value}</CardTitle
|
||||||
>
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2">
|
<CardContent class="mt-auto space-y-2">
|
||||||
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
|
||||||
>{card.badge}</Badge
|
>{card.badge}</Badge
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" class="mt-4">
|
<TabsContent value="overview" class="mt-4 min-w-0">
|
||||||
<NetworkOverviewTab
|
<NetworkOverviewTab
|
||||||
{peers}
|
{peers}
|
||||||
{speakers}
|
{speakers}
|
||||||
|
|||||||
Reference in New Issue
Block a user