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