feat: enhance monitoring page with detailed status tracking and error handling
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m9s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m9s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped

Updated the monitoring page to include comprehensive tracking of API health, BGP sessions, and job statuses. Introduced new state variables for error handling and last updated timestamps. Enhanced the UI to display overall system status, including detailed hints for potential issues, improving incident diagnosis capabilities. Added functions to summarize job statuses and derive overall health indicators, ensuring better visibility into system performance.
This commit is contained in:
Denozordec
2026-04-09 16:42:37 +07:00
parent 1e13ef9e70
commit 458a885868
+244 -49
View File
@@ -1,41 +1,135 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiFetch } from '$lib/api/client.js';
import type { BirdStatus, JobsResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
import { toast } from 'svelte-sonner';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Gauge from '@lucide/svelte/icons/gauge';
import Activity from '@lucide/svelte/icons/activity';
import Bird from '@lucide/svelte/icons/bird';
import Server from '@lucide/svelte/icons/server';
import Hash from '@lucide/svelte/icons/hash';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
type HealthStatus = { status: string };
type ReadyStatus = { status: string; checks?: Record<string, unknown> };
type VersionInfo = { version?: string; git_sha?: string; build_time?: string; [k: string]: unknown };
type JobsKpi = { running: number; failed: number; total: number };
let health = $state<{ ok: boolean; status?: string } | null>(null);
let health = $state<{ ok: boolean; status?: string; error?: string } | null>(null);
let ready = $state<ReadyStatus | null>(null);
let version = $state<VersionInfo | null>(null);
let bird = $state<BirdStatus | null>(null);
let jobs = $state<JobsKpi | null>(null);
let readyError = $state<string | null>(null);
let versionError = $state<string | null>(null);
let birdError = $state<string | null>(null);
let jobsError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
let loading = $state(false);
function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim() !== '') return error.message;
return 'Ошибка запроса';
}
function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
let running = 0;
let failed = 0;
for (const row of items) {
const status = String(row.status ?? '').toLowerCase();
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
running += 1;
}
if (status === 'failed' || status === 'error' || status === 'canceled') {
failed += 1;
}
}
return { running, failed, total: items.length };
}
function overallBadgeVariant(status: 'ok' | 'warn' | 'error' | 'unknown') {
if (status === 'ok') return 'default';
if (status === 'warn') return 'secondary';
if (status === 'error') return 'destructive';
return 'outline';
}
const overallStatus = $derived.by(() => {
if (health === null && ready === null && bird === null && jobs === null) return 'unknown';
if (!health?.ok) return 'error';
if (ready !== null && ready.status !== 'ready') return 'error';
if (jobs !== null && jobs.failed > 0) return 'warn';
if (bird !== null && bird.birdc_configured && bird.healthy === false) return 'warn';
return 'ok';
});
const overallHint = $derived.by(() => {
if (overallStatus === 'unknown') return 'Нет данных. Запустите обновление.';
if (overallStatus === 'error') {
if (!health?.ok) return 'Проверьте доступность API и логи сервиса.';
return 'Readiness не в норме: проверьте postgres/store/jobs.';
}
if (overallStatus === 'warn') {
if (jobs && jobs.failed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
return 'Проверьте BGP-сессии и вывод birdc.';
}
return 'Критичных отклонений не обнаружено.';
});
const bgpText = $derived.by(() => {
if (!bird) return '—';
if (!bird.birdc_configured) return 'birdc не настроен';
if (bird.error) return 'ошибка birdc';
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
});
const versionText = $derived.by(() => {
if (!version) return '—';
const sha = typeof version.git_sha === 'string' && version.git_sha ? version.git_sha : 'unknown';
return sha.slice(0, 12);
});
async function load() {
loading = true;
readyError = null;
versionError = null;
birdError = null;
jobsError = null;
try {
const h = await apiFetch('/v1/health');
const hj = await h.json().catch(() => ({}));
health = { ok: h.ok, status: hj?.status };
} catch {
health = { ok: false };
health = { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
} catch (error) {
health = { ok: false, error: toErrorMessage(error) };
}
try {
ready = await apiJSON<ReadyStatus>('/v1/ready');
} catch {
} catch (error) {
ready = null;
readyError = toErrorMessage(error);
}
try {
version = await apiJSON<VersionInfo>('/v1/version');
} catch {
} catch (error) {
version = null;
versionError = toErrorMessage(error);
}
try {
bird = await apiJSON<BirdStatus>('/v1/bird/status');
} catch (error) {
bird = null;
birdError = toErrorMessage(error);
}
try {
const jobsPage = await apiJSON<JobsResponse>('/v1/jobs?limit=100');
jobs = summarizeJobsStatuses(jobsPage.items ?? []);
} catch (error) {
jobs = null;
jobsError = toErrorMessage(error);
}
lastUpdated = new Date();
loading = false;
}
@@ -53,7 +147,14 @@
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Мониторинг</h1>
<p class="text-muted-foreground mt-1 text-sm">Доступность API и версия сборки.</p>
<p class="text-muted-foreground mt-1 text-sm">
Состояние API, BGP и задач для быстрой диагностики инцидентов.
</p>
{#if lastUpdated}
<p class="text-muted-foreground mt-1 text-xs">
Обновлено: {lastUpdated.toLocaleTimeString('ru-RU')}
</p>
{/if}
</div>
</div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
@@ -62,70 +163,164 @@
</Button>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<!-- Health -->
<Card>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card class="border-l-4 border-l-chart-1 bg-chart-1/5 shadow-sm">
<CardHeader>
<CardTitle class="text-base">Доступность (liveness)</CardTitle>
<CardDescription>GET /v1/health</CardDescription>
<CardTitle class="flex items-center gap-2 text-base">
<Server class="size-4" />
Общий статус
</CardTitle>
<CardDescription>Liveness + readiness + jobs/BGP</CardDescription>
</CardHeader>
<CardContent>
{#if health === null}
<Badge variant="outline"></Badge>
{:else if health.ok}
<Badge variant="default">ОК</Badge>
{#if health.status}<p class="text-muted-foreground mt-1 text-xs">{health.status}</p>{/if}
{:else}
<Badge variant="destructive">Сбой</Badge>
{/if}
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold uppercase">{overallStatus}</p>
<Badge variant={overallBadgeVariant(overallStatus)}>{overallStatus}</Badge>
</div>
<p class="text-muted-foreground mt-2 text-xs">{overallHint}</p>
</CardContent>
</Card>
<!-- Ready -->
<Card>
<Card class="border-l-4 border-l-chart-2 bg-chart-2/5 shadow-sm">
<CardHeader>
<CardTitle class="text-base">Готовность (readiness)</CardTitle>
<CardDescription>GET /v1/ready</CardDescription>
<CardTitle class="flex items-center gap-2 text-base">
<Bird class="size-4" />
BGP сессии
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</CardHeader>
<CardContent>
{#if ready === null}
<Badge variant="secondary"></Badge>
{:else}
<Badge variant={ready.status === 'ok' ? 'default' : 'destructive'}>{ready.status}</Badge>
{#if ready.checks}
<div class="mt-2 space-y-1">
{#each Object.entries(ready.checks) as [k, v]}
<div class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground">{k}</span>
<Badge variant="outline" class="text-xs">{JSON.stringify(v)}</Badge>
</div>
{/each}
</div>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold">{bgpText}</p>
{#if bird?.birdc_configured}
<Badge variant={bird.healthy ? 'default' : 'secondary'}>{bird.healthy ? 'healthy' : 'degraded'}</Badge>
{:else}
<Badge variant="outline">n/a</Badge>
{/if}
</div>
{#if birdError}
<p class="text-destructive mt-2 text-xs">{birdError}</p>
{:else if bird && !bird.birdc_configured}
<p class="text-muted-foreground mt-2 text-xs">{bird.message ?? 'birdc не настроен на API-хосте.'}</p>
{:else if bird?.error}
<p class="text-destructive mt-2 text-xs">{bird.error}</p>
{:else}
<p class="text-muted-foreground mt-2 text-xs">Established / total на API-хосте.</p>
{/if}
</CardContent>
</Card>
<!-- Version -->
<Card>
<Card class="border-l-4 border-l-chart-4 bg-chart-4/5 shadow-sm">
<CardHeader>
<CardTitle class="text-base">Версия</CardTitle>
<CardTitle class="flex items-center gap-2 text-base">
<Activity class="size-4" />
Задачи
</CardTitle>
<CardDescription>GET /v1/jobs?limit=100</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold">{jobs ? jobs.running : '—'}</p>
<Badge variant={jobs && jobs.failed > 0 ? 'secondary' : 'default'}>
{jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок'}
</Badge>
</div>
{#if jobsError}
<p class="text-destructive mt-2 text-xs">{jobsError}</p>
{:else}
<p class="text-muted-foreground mt-2 text-xs">
Активных: {jobs?.running ?? '—'} из {jobs?.total ?? '—'} последних.
</p>
{/if}
</CardContent>
</Card>
<Card class="border-l-4 border-l-info bg-info/10 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Hash class="size-4" />
Версия
</CardTitle>
<CardDescription>GET /v1/version</CardDescription>
</CardHeader>
<CardContent>
{#if version === null}
<Badge variant="outline"></Badge>
{:else}
<div class="space-y-1 text-xs">
{#each Object.entries(version) as [k, v]}
<div class="grid grid-cols-[auto_1fr] gap-2">
<div class="flex items-center justify-between gap-2">
<p class="font-mono text-2xl font-semibold">{versionText}</p>
<Badge variant={version ? 'outline' : 'secondary'}>{version ? 'loaded' : '—'}</Badge>
</div>
{#if versionError}
<p class="text-destructive mt-2 text-xs">{versionError}</p>
{:else if version}
<p class="text-muted-foreground mt-2 truncate text-xs">api_version: {String(version.api_version ?? 'unknown')}</p>
{/if}
</CardContent>
</Card>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health и GET /v1/ready</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Liveness:</span>
{#if health === null}
<Badge variant="outline">—</Badge>
{:else if health.ok}
<Badge variant="default">ok</Badge>
{:else}
<Badge variant="destructive">failed</Badge>
{/if}
{#if health?.status}
<span class="text-muted-foreground">{health.status}</span>
{/if}
</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Readiness:</span>
{#if ready === null}
<Badge variant="outline">—</Badge>
{:else}
<Badge variant={ready.status === 'ready' ? 'default' : 'destructive'}>{ready.status}</Badge>
{/if}
</div>
{#if health?.error}
<p class="text-destructive text-xs">{health.error}</p>
{/if}
{#if readyError}
<p class="text-destructive text-xs">{readyError}</p>
{/if}
{#if ready?.checks}
<div class="space-y-1">
{#each Object.entries(ready.checks) as [k, v] (k)}
<div class="grid grid-cols-[auto_1fr] items-center gap-2 text-xs">
<span class="text-muted-foreground">{k}</span>
<span class="font-mono truncate">{String(v)}</span>
<Badge variant="outline" class="justify-start font-normal">{String(v)}</Badge>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<AlertTriangle class="text-muted-foreground size-4" />
Что проверять при деградации
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-sm">
<p><span class="text-muted-foreground">Если </span><code>health=failed</code> — проверьте доступность процесса API и его логи.</p>
<p><span class="text-muted-foreground">Если </span><code>ready!=ready</code> — сначала `postgres`, затем `store/jobs` в checks.</p>
<p><span class="text-muted-foreground">Если low BGP ratio</span> — проверьте `bird/status`, затем BGP peer состояния.</p>
<p><span class="text-muted-foreground">Если ошибки jobs</span> — откройте операции и проверьте последние неуспешные задачи.</p>
</CardContent>
</Card>
</div>
</div>