WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring. Co-authored-by: Cursor <cursoragent@cursor.com>
655 lines
20 KiB
Svelte
655 lines
20 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { resolve } from '$app/paths';
|
||
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
|
||
import {
|
||
formatVersionHeadline,
|
||
resolveVersionString,
|
||
type VersionInfo
|
||
} from '$lib/api/version-info.js';
|
||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||
import { jobStatusRu } from '$lib/ui-labels.js';
|
||
import {
|
||
checkDisplayName,
|
||
checkStatusBadge,
|
||
deriveOverallStatus,
|
||
livenessBadge,
|
||
overallBadgeClass,
|
||
overallBadgeVariant,
|
||
overallStatusHint,
|
||
overallStatusLabel,
|
||
readinessBadge,
|
||
summarizeJobsStatuses,
|
||
toErrorMessage,
|
||
type HealthStatus,
|
||
type JobsKpi,
|
||
type ReadyStatus
|
||
} from '$lib/monitoring/status.js';
|
||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||
import { Button } from '$lib/ui/core/button/index.js';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardDescription
|
||
} from '$lib/ui/core/card/index.js';
|
||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||
import { Separator } from '$lib/ui/core/separator/index.js';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow
|
||
} from '$lib/ui/core/table/index.js';
|
||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||
import { cn } from '$lib/utils.js';
|
||
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';
|
||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||
import HeartPulse from '@lucide/svelte/icons/heart-pulse';
|
||
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||
import Database from '@lucide/svelte/icons/database';
|
||
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
||
import ListTodo from '@lucide/svelte/icons/list-todo';
|
||
import type { Component } from 'svelte';
|
||
|
||
let health = $state<HealthStatus | 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 jobItems = $state<JobRow[]>([]);
|
||
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 initialLoading = $state(true);
|
||
let refreshing = $state(false);
|
||
|
||
const statAccents = [
|
||
{
|
||
border: 'border-l-chart-1',
|
||
bg: 'bg-chart-1/5',
|
||
iconBg: 'bg-chart-1/15',
|
||
iconText: 'text-chart-1'
|
||
},
|
||
{
|
||
border: 'border-l-chart-2',
|
||
bg: 'bg-chart-2/5',
|
||
iconBg: 'bg-chart-2/15',
|
||
iconText: 'text-chart-2'
|
||
},
|
||
{
|
||
border: 'border-l-chart-4',
|
||
bg: 'bg-chart-4/5',
|
||
iconBg: 'bg-chart-4/15',
|
||
iconText: 'text-chart-4'
|
||
},
|
||
{
|
||
border: 'border-l-info',
|
||
bg: 'bg-info/10',
|
||
iconBg: 'bg-info/15',
|
||
iconText: 'text-info'
|
||
}
|
||
] as const;
|
||
|
||
const overallStatus = $derived.by(() =>
|
||
deriveOverallStatus({
|
||
health,
|
||
ready,
|
||
jobs,
|
||
birdConfigured: bird?.birdc_configured ?? false,
|
||
birdHealthy: bird?.healthy ?? null
|
||
})
|
||
);
|
||
|
||
const overallHint = $derived.by(() =>
|
||
overallStatusHint(overallStatus, {
|
||
healthOk: health?.ok,
|
||
jobsFailed: jobs?.failed ?? 0
|
||
})
|
||
);
|
||
|
||
const bgpText = $derived.by(() => {
|
||
if (!bird) return '—';
|
||
if (!bird.birdc_configured) return '—';
|
||
if (bird.error) return '—';
|
||
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||
});
|
||
|
||
const bgpRatio = $derived.by(() => {
|
||
if (!bird?.birdc_configured || bird.bgp_sessions_total <= 0) return null;
|
||
return Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100);
|
||
});
|
||
|
||
const versionText = $derived(formatVersionHeadline(version));
|
||
|
||
const versionFooter = $derived.by(() => {
|
||
if (!version) return '';
|
||
const parts: string[] = [];
|
||
const ver = resolveVersionString(version);
|
||
if (ver) parts.push(`version: ${ver}`);
|
||
if (version.git_sha) parts.push(`git_sha: ${version.git_sha}`);
|
||
if (version.build_time) parts.push(`build: ${version.build_time}`);
|
||
return parts.join(' · ');
|
||
});
|
||
|
||
const failedJobs = $derived.by(() =>
|
||
jobItems
|
||
.filter((j) => {
|
||
const s = String(j.status ?? '').toLowerCase();
|
||
return s === 'failed' || s === 'error' || s === 'canceled';
|
||
})
|
||
.slice(0, 5)
|
||
);
|
||
|
||
const checkIconByKey: Record<string, Component> = {
|
||
postgres: Database,
|
||
store: HardDrive,
|
||
jobs: ListTodo
|
||
};
|
||
|
||
const kpiCards = $derived.by(() => [
|
||
{
|
||
id: 'overall',
|
||
label: 'Общий статус',
|
||
value: initialLoading ? '—' : overallStatusLabel(overallStatus),
|
||
description: overallHint,
|
||
icon: Server,
|
||
accent: statAccents[0],
|
||
badge: overallStatusLabel(overallStatus),
|
||
badgeVariant: overallBadgeVariant(overallStatus),
|
||
badgeClass: overallBadgeClass(overallStatus),
|
||
error: null as string | null,
|
||
href: undefined
|
||
},
|
||
{
|
||
id: 'bgp',
|
||
label: 'BGP сессии',
|
||
value: initialLoading ? '—' : bgpText,
|
||
description: bird?.birdc_configured
|
||
? 'Established / total на API-хосте'
|
||
: (bird?.message ?? 'birdc не настроен на API-хосте'),
|
||
icon: Bird,
|
||
accent: statAccents[1],
|
||
badge: !bird ? '—' : !bird.birdc_configured ? 'N/A' : bird.healthy ? 'В норме' : 'Деградация',
|
||
badgeVariant: !bird?.birdc_configured
|
||
? ('outline' as const)
|
||
: bird?.healthy
|
||
? ('default' as const)
|
||
: ('secondary' as const),
|
||
badgeClass:
|
||
bird?.birdc_configured && bird?.healthy
|
||
? 'border-success/30 bg-success/15 text-success'
|
||
: undefined,
|
||
error: birdError ?? bird?.error ?? null,
|
||
href: '/network' as const
|
||
},
|
||
{
|
||
id: 'jobs',
|
||
label: 'Задачи',
|
||
value: initialLoading ? '—' : String(jobs?.running ?? '—'),
|
||
description: `Активных из ${jobs?.total ?? '—'} последних`,
|
||
icon: Activity,
|
||
accent: statAccents[2],
|
||
badge: jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок',
|
||
badgeVariant: jobs && jobs.failed > 0 ? ('secondary' as const) : ('default' as const),
|
||
badgeClass:
|
||
jobs && jobs.failed === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||
error: jobsError,
|
||
href: jobs && jobs.failed > 0 ? ('/operations' as const) : undefined
|
||
},
|
||
{
|
||
id: 'version',
|
||
label: 'Версия',
|
||
value: initialLoading ? '—' : versionText,
|
||
description: versionFooter || 'GET /v1/version',
|
||
icon: Hash,
|
||
accent: statAccents[3],
|
||
badge: version ? 'Загружена' : '—',
|
||
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||
badgeClass: undefined,
|
||
valueClass: 'font-mono text-xl',
|
||
error: versionError,
|
||
href: undefined
|
||
}
|
||
]);
|
||
|
||
async function fetchHealth(): Promise<HealthStatus> {
|
||
const h = await apiFetch('/v1/health');
|
||
const hj = (await h.json().catch(() => ({}))) as { status?: string };
|
||
return { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
|
||
}
|
||
|
||
async function load() {
|
||
if (initialLoading) {
|
||
/* keep skeleton */
|
||
} else {
|
||
refreshing = true;
|
||
}
|
||
readyError = null;
|
||
versionError = null;
|
||
birdError = null;
|
||
jobsError = null;
|
||
|
||
const [healthRes, readyRes, versionRes, birdRes, jobsRes] = await Promise.allSettled([
|
||
fetchHealth(),
|
||
apiJSON<ReadyStatus>('/v1/ready'),
|
||
apiJSON<VersionInfo>('/v1/version'),
|
||
apiJSON<BirdStatus>('/v1/bird/status'),
|
||
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||
]);
|
||
|
||
if (healthRes.status === 'fulfilled') {
|
||
health = healthRes.value;
|
||
} else {
|
||
health = { ok: false, error: toErrorMessage(healthRes.reason) };
|
||
}
|
||
|
||
if (readyRes.status === 'fulfilled') {
|
||
ready = readyRes.value;
|
||
} else {
|
||
ready = null;
|
||
readyError = toErrorMessage(readyRes.reason);
|
||
}
|
||
|
||
if (versionRes.status === 'fulfilled') {
|
||
version = versionRes.value;
|
||
} else {
|
||
version = null;
|
||
versionError = toErrorMessage(versionRes.reason);
|
||
}
|
||
|
||
if (birdRes.status === 'fulfilled') {
|
||
bird = birdRes.value;
|
||
} else {
|
||
bird = null;
|
||
birdError = toErrorMessage(birdRes.reason);
|
||
}
|
||
|
||
if (jobsRes.status === 'fulfilled') {
|
||
const items = jobsRes.value.items ?? [];
|
||
jobItems = items;
|
||
jobs = summarizeJobsStatuses(items);
|
||
} else {
|
||
jobItems = [];
|
||
jobs = null;
|
||
jobsError = toErrorMessage(jobsRes.reason);
|
||
}
|
||
|
||
lastUpdated = new Date();
|
||
initialLoading = false;
|
||
refreshing = false;
|
||
}
|
||
|
||
function truncateError(error: string | null | undefined, max = 120): string {
|
||
if (!error) return '';
|
||
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||
}
|
||
|
||
onMount(load);
|
||
</script>
|
||
|
||
<div class="flex flex-col gap-6">
|
||
<PageHeader
|
||
title="Мониторинг"
|
||
description={lastUpdated
|
||
? `Состояние API, BGP и задач. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||
: 'Состояние API, BGP и задач для быстрой диагностики инцидентов.'}
|
||
icon={Gauge}
|
||
iconClass="bg-info/15 text-info"
|
||
>
|
||
{#snippet actions()}
|
||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||
Обновить
|
||
</Button>
|
||
{/snippet}
|
||
</PageHeader>
|
||
|
||
{#if !initialLoading}
|
||
{#if overallStatus === 'ok'}
|
||
<Alert class="border-success/30 bg-success/5">
|
||
<CheckCircle class="text-success" />
|
||
<AlertTitle>Система в норме</AlertTitle>
|
||
<AlertDescription>{overallHint}</AlertDescription>
|
||
</Alert>
|
||
{:else if overallStatus === 'warn'}
|
||
<Alert class="border-warning/30 bg-warning/5">
|
||
<AlertTriangle class="text-warning" />
|
||
<AlertTitle>Требуется внимание</AlertTitle>
|
||
<AlertDescription>{overallHint}</AlertDescription>
|
||
</Alert>
|
||
{:else if overallStatus === 'error'}
|
||
<Alert variant="destructive">
|
||
<XCircle />
|
||
<AlertTitle>Обнаружена проблема</AlertTitle>
|
||
<AlertDescription>{overallHint}</AlertDescription>
|
||
</Alert>
|
||
{/if}
|
||
{/if}
|
||
|
||
<KpiMetricsGrid
|
||
cards={kpiCards}
|
||
loading={initialLoading}
|
||
skeletonCount={4}
|
||
class="sm:grid-cols-2 xl:grid-cols-4"
|
||
/>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-2">
|
||
{#if initialLoading}
|
||
<CardSkeleton />
|
||
<CardSkeleton />
|
||
{:else}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="text-base">Доступность и готовность</CardTitle>
|
||
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
{#if health?.error || readyError}
|
||
<Alert variant="destructive">
|
||
<XCircle />
|
||
<AlertTitle>Ошибка проверки</AlertTitle>
|
||
<AlertDescription>
|
||
{#if health?.error}{health.error}{/if}
|
||
{#if health?.error && readyError}<br />{/if}
|
||
{#if readyError}{readyError}{/if}
|
||
</AlertDescription>
|
||
</Alert>
|
||
{/if}
|
||
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead class="w-[55%]">Проверка</TableHead>
|
||
<TableHead>Статус</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{@const liveBadge = livenessBadge(health)}
|
||
<TableRow>
|
||
<TableCell>
|
||
<div class="flex items-center gap-2">
|
||
<HeartPulse class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
<div>
|
||
<p class="text-sm font-medium">Liveness</p>
|
||
<p class="text-xs text-muted-foreground">/v1/health</p>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={liveBadge.variant} class={liveBadge.class}
|
||
>{liveBadge.label}</Badge
|
||
>
|
||
</TableCell>
|
||
</TableRow>
|
||
{@const readyBadge = readinessBadge(ready)}
|
||
<TableRow>
|
||
<TableCell>
|
||
<div class="flex items-center gap-2">
|
||
<ShieldCheck class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||
<div>
|
||
<p class="text-sm font-medium">Readiness</p>
|
||
<p class="text-xs text-muted-foreground">/v1/ready</p>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={readyBadge.variant} class={readyBadge.class}
|
||
>{readyBadge.label}</Badge
|
||
>
|
||
</TableCell>
|
||
</TableRow>
|
||
{#if ready?.checks && Object.keys(ready.checks).length > 0}
|
||
<TableRow>
|
||
<TableCell colspan={2} class="bg-muted/30 py-2">
|
||
<p class="text-xs font-medium text-muted-foreground">Зависимости</p>
|
||
</TableCell>
|
||
</TableRow>
|
||
{#each Object.entries(ready.checks) as [key, value] (key)}
|
||
{@const badge = checkStatusBadge(value)}
|
||
{@const CheckIcon = checkIconByKey[key] ?? ListTodo}
|
||
<TableRow>
|
||
<TableCell>
|
||
<div class="flex items-center gap-2">
|
||
<CheckIcon
|
||
class="size-4 shrink-0 text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
<div>
|
||
<p class="text-sm font-medium">{checkDisplayName(key)}</p>
|
||
<p class="text-xs text-muted-foreground">{key}</p>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div class="space-y-1">
|
||
<Badge variant={badge.variant} class={badge.class}>{badge.label}</Badge>
|
||
{#if badge.hint}
|
||
<p class="text-xs text-muted-foreground">{badge.hint}</p>
|
||
{/if}
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
{/each}
|
||
{/if}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<p class="text-xs text-muted-foreground">
|
||
HTTP 503 на readiness означает недоступность одной из зависимостей в checks.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Bird class="size-4" />
|
||
BGP на API-хосте
|
||
</CardTitle>
|
||
<CardDescription>GET /v1/bird/status</CardDescription>
|
||
</div>
|
||
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
{#if birdError}
|
||
<Alert variant="destructive">
|
||
<XCircle />
|
||
<AlertTitle>Ошибка birdc</AlertTitle>
|
||
<AlertDescription>{birdError}</AlertDescription>
|
||
</Alert>
|
||
{:else if bird && !bird.birdc_configured}
|
||
<p class="text-sm text-muted-foreground">
|
||
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
|
||
</p>
|
||
{:else if bird}
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between text-sm">
|
||
<span class="text-muted-foreground">Established / total</span>
|
||
<span class="font-medium tabular-nums">
|
||
{bird.bgp_established} / {bird.bgp_sessions_total}
|
||
{#if bgpRatio !== null}
|
||
<span class="text-muted-foreground">({bgpRatio}%)</span>
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
{#if bgpRatio !== null}
|
||
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
class={cn(
|
||
'h-full rounded-full transition-all',
|
||
bgpRatio >= 100
|
||
? 'bg-success'
|
||
: bgpRatio >= 50
|
||
? 'bg-warning'
|
||
: 'bg-destructive'
|
||
)}
|
||
style="width: {bgpRatio}%"
|
||
></div>
|
||
</div>
|
||
{/if}
|
||
{#if bird.error}
|
||
<p class="text-xs text-destructive">{bird.error}</p>
|
||
{/if}
|
||
</div>
|
||
{#if bird.protocols_excerpt}
|
||
<Separator />
|
||
<div class="space-y-2">
|
||
<p class="text-sm font-medium">Вывод birdc (protocols)</p>
|
||
<ScrollPreBlock variant="preserve" text={bird.protocols_excerpt} class="max-h-48" />
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</CardContent>
|
||
</Card>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="grid gap-4 lg:grid-cols-2">
|
||
{#if initialLoading}
|
||
<CardSkeleton />
|
||
<CardSkeleton />
|
||
{:else}
|
||
<Card>
|
||
<CardHeader>
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<Activity class="size-4" />
|
||
Задачи
|
||
</CardTitle>
|
||
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
|
||
</div>
|
||
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
{#if jobsError}
|
||
<Alert variant="destructive">
|
||
<XCircle />
|
||
<AlertTitle>Не удалось загрузить задачи</AlertTitle>
|
||
<AlertDescription>{jobsError}</AlertDescription>
|
||
</Alert>
|
||
{:else if jobs}
|
||
<div class="flex flex-wrap gap-4 text-sm">
|
||
<div>
|
||
<p class="text-muted-foreground">Активных</p>
|
||
<p class="text-2xl font-bold tabular-nums">{jobs.running}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-muted-foreground">С ошибками</p>
|
||
<p
|
||
class={cn(
|
||
'text-2xl font-bold tabular-nums',
|
||
jobs.failed > 0 ? 'text-warning' : 'text-success'
|
||
)}
|
||
>
|
||
{jobs.failed}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-muted-foreground">В выборке</p>
|
||
<p class="text-2xl font-bold tabular-nums">{jobs.total}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{#if failedJobs.length > 0}
|
||
<div class="space-y-3">
|
||
<p class="text-sm font-medium">Последние ошибки</p>
|
||
<ul class="space-y-2">
|
||
{#each failedJobs as job (job.job_id)}
|
||
<li class="rounded-lg border px-3 py-2 text-sm">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<p class="font-medium">{jobKindTitle(job)}</p>
|
||
<Badge variant="destructive">{jobStatusRu(job.status)}</Badge>
|
||
</div>
|
||
{#if job.error}
|
||
<p class="mt-1 text-xs text-muted-foreground">
|
||
{truncateError(job.error)}
|
||
</p>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
{:else}
|
||
<p class="text-sm text-muted-foreground">
|
||
Критичных сбоев в последних 100 задачах нет.
|
||
</p>
|
||
{/if}
|
||
{/if}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-base">
|
||
<AlertTriangle class="size-4 text-muted-foreground" />
|
||
Что проверять при деградации
|
||
</CardTitle>
|
||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||
</CardHeader>
|
||
<CardContent class="space-y-3">
|
||
<Alert>
|
||
<HeartPulse class="size-4" />
|
||
<AlertTitle>API недоступен</AlertTitle>
|
||
<AlertDescription>
|
||
Если <code class="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
|
||
его логи.
|
||
</AlertDescription>
|
||
</Alert>
|
||
<Alert>
|
||
<Database class="size-4" />
|
||
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
||
<AlertDescription>
|
||
Сначала <code class="text-xs">postgres</code>, затем
|
||
<code class="text-xs">store</code> и <code class="text-xs">jobs</code> в checks.
|
||
</AlertDescription>
|
||
</Alert>
|
||
<Alert>
|
||
<Bird class="size-4" />
|
||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||
<AlertDescription>
|
||
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
||
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
|
||
</AlertDescription>
|
||
</Alert>
|
||
<Alert>
|
||
<ListTodo class="size-4" />
|
||
<AlertTitle>Ошибки задач</AlertTitle>
|
||
<AlertDescription>
|
||
Откройте
|
||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}
|
||
>Операции</Button
|
||
>
|
||
и проверьте последние неуспешные jobs.
|
||
</AlertDescription>
|
||
</Alert>
|
||
</CardContent>
|
||
</Card>
|
||
{/if}
|
||
</div>
|
||
</div>
|