feat(db): implement PostgreSQL monitoring and maintenance features
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 33s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m27s

Added PostgreSQL monitoring and maintenance capabilities to the API, including new endpoints for instance-level metrics, maintenance operations, and job scheduling. Updated the HTTP API to support PostgreSQL monitoring routes and integrated a background scheduler for metrics collection. Enhanced the CLI with database commands for maintenance tasks. Updated documentation to reflect these changes.
This commit is contained in:
Denozordec
2026-06-01 13:43:33 +07:00
parent 930e42b0b0
commit fad2bd3353
36 changed files with 3742 additions and 322 deletions
@@ -0,0 +1,500 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import {
POSTGRES_POLL_MS,
POSTGRES_SLOW_POLL_MS,
formatBytes,
connUsagePct,
type PostgresOverview,
type PostgresQueriesResponse,
type PostgresLockRow,
type PostgresTableRow,
type PostgresRecommendationsResponse,
type PostgresMaintLog,
type CorrelationResponse
} from '$lib/monitoring/postgres.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/ui/core/table/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import Database from '@lucide/svelte/icons/database';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let pgTab = $state('overview');
let autoRefresh = $state(true);
let session = $state<AuthSession | null>(null);
let unavailable = $state(false);
let overview = $state<PostgresOverview | null>(null);
let queries = $state<PostgresQueriesResponse | null>(null);
let locks = $state<PostgresLockRow[]>([]);
let tables = $state<PostgresTableRow[]>([]);
let recommendations = $state<PostgresRecommendationsResponse | null>(null);
let maintLogs = $state<PostgresMaintLog[]>([]);
let correlation = $state<CorrelationResponse | null>(null);
let loading = $state(true);
async function loadCore() {
try {
overview = await apiJSON<PostgresOverview>('/v1/monitoring/postgres/overview');
locks = (await apiJSON<{ items: PostgresLockRow[] }>('/v1/monitoring/postgres/locks')).items;
unavailable = false;
} catch (e) {
unavailable = true;
overview = null;
throw e;
}
}
async function loadSlow() {
queries = await apiJSON<PostgresQueriesResponse>('/v1/monitoring/postgres/queries?limit=20');
tables = (
await apiJSON<{ items: PostgresTableRow[] }>('/v1/monitoring/postgres/tables?limit=30')
).items;
recommendations = await apiJSON<PostgresRecommendationsResponse>(
'/v1/monitoring/postgres/recommendations'
);
correlation = await apiJSON<CorrelationResponse>('/v1/monitoring/correlation?window=60');
maintLogs = (
await apiJSON<{ items: PostgresMaintLog[] }>('/v1/postgres/maintenance/logs?limit=20')
).items;
}
async function loadAll() {
loading = true;
try {
await loadCore();
await loadSlow();
} catch (e) {
notifyApiError(e, 'PostgreSQL monitoring');
} finally {
loading = false;
}
}
onMount(() => {
void (async () => {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
await loadAll();
})();
});
$effect(() => {
if (!autoRefresh || unavailable) return;
const fast = setInterval(() => {
void loadCore().catch(() => {});
}, POSTGRES_POLL_MS);
const slow = setInterval(() => {
void loadSlow().catch(() => {});
}, POSTGRES_SLOW_POLL_MS);
return () => {
clearInterval(fast);
clearInterval(slow);
};
});
const isOperator = $derived(session?.role === 'operator');
function runMaint(
title: string,
path: string,
body: Record<string, unknown>,
destructive = true
) {
void confirm({
title,
description: body.dry_run
? 'Dry-run: изменения не применяются, только план.'
: 'Операция выполняется асинхронно через jobs. Убедитесь, что выбрано maintenance-окно.',
confirmLabel: body.dry_run ? 'Dry-run' : 'Выполнить',
destructive,
onConfirm: async () => {
try {
const res = await apiMutate<{ job_id: string; status: string }>(path, 'POST', body);
notify.success(`Задача ${res.job_id} (${res.status})`);
await loadSlow();
} catch (e) {
notifyApiError(e, title);
}
}
});
}
</script>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Database class="size-4" />
<span>Instance-level PostgreSQL (control plane)</span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Switch id="pg-auto" bind:checked={autoRefresh} />
<Label for="pg-auto">Автообновление</Label>
</div>
<Button variant="outline" size="sm" onclick={() => loadAll()} disabled={loading}>
<RefreshCw class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
{#if unavailable}
<Alert variant="destructive" class="mt-4">
<AlertTitle>PostgreSQL недоступен</AlertTitle>
<AlertDescription>
Мониторинг требует <code class="text-xs">EVOBGP_DATABASE_URL</code> (не memory backend).
</AlertDescription>
</Alert>
{:else}
<Tabs bind:value={pgTab} class="mt-4">
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="queries">Запросы</TabsTrigger>
<TabsTrigger value="locks">Блокировки</TabsTrigger>
<TabsTrigger value="tables">Таблицы</TabsTrigger>
<TabsTrigger value="maintenance">Обслуживание</TabsTrigger>
<TabsTrigger value="correlation">Корреляция</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-4 space-y-4">
{#if overview}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Подключения</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.connections.active} / {overview.connections.max_connections}
</p>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-chart-1 transition-all"
style="width: {connUsagePct(overview)}%"
></div>
</div>
<p class="mt-1 text-xs text-muted-foreground">
idle {overview.connections.idle}, total {overview.connections.total}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Cache hit</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.database.cache_hit_pct ?? '—'}%
</p>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-chart-2 transition-all"
style="width: {overview.database.cache_hit_pct ?? 0}%"
></div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">TPS (commits)</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.database.xact_commit.toLocaleString()}
</p>
<p class="text-xs text-muted-foreground">
rollback {overview.database.xact_rollback.toLocaleString()}, deadlocks {overview
.database.deadlocks}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Размер БД</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold">{formatBytes(overview.database_size_bytes)}</p>
<p class="text-xs text-muted-foreground">
shared_buffers {overview.memory_settings.shared_buffers}
</p>
</CardContent>
</Card>
</div>
{#if overview.replication?.length}
<Card>
<CardHeader>
<CardTitle>Репликация</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Адрес</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Lag ms</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each overview.replication as r (r.client_addr ?? r.state)}
<TableRow>
<TableCell>{r.client_addr ?? '—'}</TableCell>
<TableCell>{r.state}</TableCell>
<TableCell>{r.lag_ms ?? '—'}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
{/if}
</TabsContent>
<TabsContent value="queries" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Медленные запросы</CardTitle>
<CardDescription>
Источник: {queries?.source ?? '—'}
{#if overview && !overview.pg_stat_statements_enabled}
· pg_stat_statements не включён
{/if}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>mean ms</TableHead>
<TableHead>calls</TableHead>
<TableHead>query</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each queries?.items ?? [] as q (q.queryid ?? q.query)}
<TableRow>
<TableCell class="tabular-nums">{q.mean_exec_ms.toFixed(1)}</TableCell>
<TableCell>{q.calls}</TableCell>
<TableCell class="max-w-md truncate font-mono text-xs">{q.query}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="locks" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Блокировки</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>pid</TableHead>
<TableHead>mode</TableHead>
<TableHead>granted</TableHead>
<TableHead>query</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each locks as l (l.pid)}
<TableRow>
<TableCell>{l.pid}</TableCell>
<TableCell>
<Badge variant={l.blocked ? 'destructive' : 'secondary'}>{l.mode}</Badge>
</TableCell>
<TableCell>{l.granted ? 'да' : 'нет'}</TableCell>
<TableCell class="max-w-lg truncate font-mono text-xs">{l.query ?? '—'}</TableCell
>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground"
>Нет активных блокировок</TableCell
>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tables" class="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Таблицы и хранилище</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>table</TableHead>
<TableHead>size</TableHead>
<TableHead>seq_scan</TableHead>
<TableHead>idx_scan</TableHead>
<TableHead>bloat</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each tables as t (t.relname)}
<TableRow>
<TableCell class="font-mono text-xs">{t.relname}</TableCell>
<TableCell>{formatBytes(t.total_bytes)}</TableCell>
<TableCell>{t.seq_scan}</TableCell>
<TableCell>{t.idx_scan}</TableCell>
<TableCell>{(t.bloat_ratio ?? 0).toFixed(2)}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{#if recommendations?.items?.length}
<Card>
<CardHeader>
<CardTitle>Рекомендации</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
{#each recommendations.items as item (item.code + item.title)}
<Alert>
<AlertTitle>{item.title}</AlertTitle>
<AlertDescription>{item.detail}</AlertDescription>
</Alert>
{/each}
</CardContent>
</Card>
{/if}
</TabsContent>
<TabsContent value="maintenance" class="mt-4 space-y-4">
{#if !isOperator}
<Alert>
<AlertTitle>Только operator</AlertTitle>
<AlertDescription>Обслуживание БД доступно с ролью operator.</AlertDescription>
</Alert>
{:else}
<Card>
<CardHeader>
<CardTitle>Операции</CardTitle>
<CardDescription>Все операции — async job (202). По умолчанию dry-run.</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button
variant="outline"
onclick={() => runMaint('VACUUM', '/v1/postgres/vacuum', { dry_run: true })}
>
Vacuum (dry-run)
</Button>
<Button
variant="outline"
onclick={() => runMaint('ANALYZE', '/v1/postgres/analyze', { dry_run: true })}
>
Analyze (dry-run)
</Button>
<Button
variant="destructive"
onclick={() => runMaint('VACUUM', '/v1/postgres/vacuum', { dry_run: false }, true)}
>
Vacuum
</Button>
<Button
variant="destructive"
onclick={() =>
runMaint('Cleanup job_audit', '/v1/postgres/cleanup', {
policy: 'job_audit_retention',
dry_run: true,
limit: 10000
})}
>
Cleanup audit (dry-run)
</Button>
</CardContent>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>Журнал обслуживания</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>время</TableHead>
<TableHead>kind</TableHead>
<TableHead>status</TableHead>
<TableHead>dry_run</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each maintLogs as log (log.id)}
<TableRow>
<TableCell class="text-xs">{log.created_at}</TableCell>
<TableCell>{log.kind}</TableCell>
<TableCell>{log.status}</TableCell>
<TableCell>{log.dry_run ? 'да' : 'нет'}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground">Пусто</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="correlation" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Корреляция (1ч)</CardTitle>
<CardDescription>Pipeline refresh p99 vs cache hit по минутам</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#each correlation?.points ?? [] as p (p.timestamp)}
<div class="grid gap-2 rounded-md border p-2 text-xs md:grid-cols-3">
<span>{p.timestamp}</span>
<span>p99 refresh: {p.pipeline_refresh_p99_ms?.toFixed(0) ?? '—'} ms</span>
<span>cache hit: {p.cache_hit_pct?.toFixed(1) ?? '—'}%</span>
</div>
{:else}
<p class="text-muted-foreground">Нет точек за окно</p>
{/each}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
+116
View File
@@ -0,0 +1,116 @@
/** Types and helpers for PostgreSQL monitoring API. */
export const POSTGRES_POLL_MS = 20_000;
export const POSTGRES_SLOW_POLL_MS = 60_000;
export type PostgresOverview = {
collected_at: string;
connections: {
active: number;
idle: number;
total: number;
max_connections: number;
};
database: {
backends: number;
xact_commit: number;
xact_rollback: number;
deadlocks: number;
blks_hit: number;
blks_read: number;
cache_hit_pct: number;
};
database_size_bytes: number;
memory_settings: {
shared_buffers: string;
work_mem: string;
effective_cache_size: string;
};
replication: Array<{
client_addr?: string;
state: string;
sync_state?: string;
lag_ms?: number;
}>;
pg_stat_statements_enabled: boolean;
};
export type PostgresQueryRow = {
queryid?: number;
query: string;
calls: number;
total_exec_ms: number;
mean_exec_ms: number;
rows: number;
};
export type PostgresQueriesResponse = {
collected_at: string;
source: string;
items: PostgresQueryRow[];
};
export type PostgresLockRow = {
locktype: string;
mode: string;
granted: boolean;
pid: number;
usename?: string;
state?: string;
query?: string;
blocked: boolean;
};
export type PostgresTableRow = {
relname: string;
total_bytes: number;
idx_scan: number;
seq_scan: number;
n_dead_tup: number;
bloat_ratio?: number;
last_autovacuum?: string;
};
export type PostgresRecommendation = {
severity: string;
code: string;
title: string;
detail: string;
refs?: string[];
};
export type PostgresRecommendationsResponse = {
collected_at: string;
items: PostgresRecommendation[];
};
export type PostgresMaintLog = {
id: string;
kind: string;
target_table?: string;
dry_run: boolean;
status: string;
error?: string;
created_at: string;
};
export type CorrelationResponse = {
window_minutes: number;
points: Array<{
timestamp: string;
pipeline_refresh_p99_ms?: number;
cache_hit_pct?: number;
}>;
};
export function formatBytes(n: number): string {
if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(1)} GiB`;
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MiB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KiB`;
return `${n} B`;
}
export function connUsagePct(ov: PostgresOverview | null): number {
if (!ov?.connections.max_connections) return 0;
return Math.min(100, (ov.connections.total / ov.connections.max_connections) * 100);
}
+343 -315
View File
@@ -49,6 +49,8 @@
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 MonitoringPostgresTab from '$lib/components/monitoring/MonitoringPostgresTab.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Gauge from '@lucide/svelte/icons/gauge';
@@ -80,6 +82,7 @@
let lastUpdated = $state<Date | null>(null);
let initialLoading = $state(true);
let refreshing = $state(false);
let mainTab = $state('system');
const statAccents = [
{
@@ -322,337 +325,362 @@
{/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}
<Tabs bind:value={mainTab}>
<TabsList>
<TabsTrigger value="system">Система</TabsTrigger>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
</TabsList>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={4}
class="sm:grid-cols-2 xl:grid-cols-4"
/>
<TabsContent value="system" class="mt-4 flex flex-col gap-6">
{#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}
<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}
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={4}
class="sm:grid-cols-2 xl:grid-cols-4"
/>
<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}
<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">
<CheckIcon
<HeartPulse
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>
<p class="text-sm font-medium">Liveness</p>
<p class="text-xs text-muted-foreground">/v1/health</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>
<Badge variant={liveBadge.variant} class={liveBadge.class}
>{liveBadge.label}</Badge
>
</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?tab=overview')}
>Пиры и спикеры</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>
{@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>
{#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>
</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>
<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?tab=overview')}
>Сети</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>
<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?tab=overview')}
>Пиры и спикеры</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?tab=overview')}
>Сети</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>
</TabsContent>
<TabsContent value="postgres" class="mt-4">
<MonitoringPostgresTab />
</TabsContent>
</Tabs>
</div>