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

- 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:
Denozordec
2026-05-21 17:50:27 +07:00
parent a1ada06a76
commit fb108ec5ab
6 changed files with 260 additions and 166 deletions
@@ -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 @@
]);
</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>
<div class="flex min-w-0 flex-col gap-6">
{#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}
{/if}
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading || loading}
skeletonCount={6}
class="sm:grid-cols-2 xl:grid-cols-3"
/>
<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>
<section class="flex min-w-0 flex-col gap-4">
<div class="flex flex-wrap 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 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>
{#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 {
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);
});
</script>
<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}
<SheetHeader>
<SheetTitle class="truncate">{label}</SheetTitle>
<SheetDescription>
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
</SheetDescription>
</SheetHeader>
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
<SheetHeader class="space-y-1 pr-8 text-left">
<SheetTitle class="truncate">{label}</SheetTitle>
<SheetDescription class="truncate">
{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>
@@ -71,101 +85,118 @@
{/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>
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP Established</dt>
<dd class="text-right font-medium tabular-nums">
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
</dd>
{#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>
<dt class="text-muted-foreground">Последний sync</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.live.agent_last_sync_at)}
</dd>
{/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>
<dt class="text-muted-foreground">Drift (app / pub)</dt>
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
{#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>
<dt class="text-muted-foreground">Dispatch</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.last_dispatch_at)}
</dd>
{/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>
</dl>
{#if dispatchError}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
<AlertDescription class="text-xs leading-relaxed"
>{dispatchError.detail}</AlertDescription
>
</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}
<Button variant="outline" size="sm" onclick={() => onApply(speaker)}
>Apply revision</Button
>
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
Apply revision
</Button>
{/if}
<Separator />
<div class="space-y-2">
<section class="min-w-0 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)}
<div class="rounded-md border">
<Table class="table-fixed">
<TableHeader>
<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>
<TableHead class="w-[65%]">Имя</TableHead>
<TableHead class="w-[35%] text-right">Состояние</TableHead>
</TableRow>
{/each}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{#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}
</div>
</section>
<Separator />
<div class="space-y-2">
<section class="min-w-0 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">
<ul class="divide-y rounded-md border">
{#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>
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
<div class="min-w-0 flex-1">
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
{p.name?.trim() || p.neighbor}
</p>
{#if p.session_mismatch}
<p class="mt-0.5 text-xs text-warning">
Mismatch: сессия не на назначенной ноде
</p>
{/if}
</div>
{#if p.session_mismatch}
<p class="mt-1 text-xs text-warning">Mismatch: сессия не на назначенной ноде</p>
{/if}
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
</li>
{/each}
</ul>
{/if}
</div>
</section>
</div>
{/if}
</SheetContent>
@@ -42,8 +42,8 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<Card
class={cn(
'cursor-pointer transition-colors hover:border-primary/35',
onclick ? 'cursor-pointer' : '',
'flex h-full flex-col transition-colors',
onclick ? 'cursor-pointer hover:border-primary/35' : '',
className
)}
role={onclick ? 'button' : undefined}
@@ -57,31 +57,29 @@
}}
>
<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>
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-sm">
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="truncate" title={label}>{label}</span>
</CardTitle>
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
</div>
<Badge variant={status.variant}>{status.label}</Badge>
<Badge variant={status.variant} class="shrink-0">{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 class="mt-auto pt-0">
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP</dt>
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
<dt class="text-muted-foreground">Пиры</dt>
<dd class="tabular-nums">{peerCount}</dd>
<dt class="text-muted-foreground">Drift</dt>
<dd>
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
{drift ? 'есть' : 'нет'}
</Badge>
</dd>
</dl>
</CardContent>
</Card>
+61
View File
@@ -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);
}
@@ -45,7 +45,7 @@
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid gap-4', className)}>
<div class={cn('grid auto-rows-fr gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
@@ -56,7 +56,7 @@
{@const a = card.accent}
<Card
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' : '',
a.border,
a.bg
@@ -96,7 +96,7 @@
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="space-y-2">
<CardContent class="mt-auto space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
+1 -1
View File
@@ -204,7 +204,7 @@
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-4">
<TabsContent value="overview" class="mt-4 min-w-0">
<NetworkOverviewTab
{peers}
{speakers}