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
|
||||
} 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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user