feat(web): add maintenance policies UI

Вкладка политик обслуживания PostgreSQL: CRUD через /v1/maintenance/policies, run/dry-run, форма с Zod. Hardcoded кнопки vacuum/cleanup в MonitoringPostgresTab заменены на MaintenancePoliciesTab.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-12 13:30:43 +07:00
co-authored by Cursor
parent aaef47c7a7
commit d38ee68c4e
5 changed files with 499 additions and 74 deletions
@@ -0,0 +1,367 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { AuthSession } from '$lib/api/types.js';
import type { PostgresTableRow } from '$lib/monitoring/postgres.js';
import {
createMaintenancePolicy,
deleteMaintenancePolicy,
fetchPolicyHints,
listMaintenancePolicies,
runMaintenancePolicy,
updateMaintenancePolicy,
type MaintenancePolicy,
type MaintenancePolicyHints
} from '$lib/maintenance/policy-api.js';
import {
emptyMaintenancePolicyForm,
formToPayload,
vacuumStrategies,
type MaintenancePolicyForm
} from '$lib/maintenance/policy.schema.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import type { DataTableColumn } from '$lib/ui/patterns/data-table/types.js';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Play from '@lucide/svelte/icons/play';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import Info from '@lucide/svelte/icons/info';
type Props = {
session: AuthSession | null;
tables: PostgresTableRow[];
onJobQueued?: () => void | Promise<void>;
};
let { session, tables = [], onJobQueued }: Props = $props();
let policies = $state<MaintenancePolicy[]>([]);
let loading = $state(true);
let dialogOpen = $state(false);
let editTarget = $state<MaintenancePolicy | null>(null);
let form = $state<MaintenancePolicyForm>(emptyMaintenancePolicyForm());
let saving = $state(false);
let hints = $state<MaintenancePolicyHints | null>(null);
let hintsLoading = $state(false);
const isOperator = $derived(session?.role === 'operator');
const tableOptions = $derived.by(() => {
const names = new Set(tables.map((t) => t.relname));
if (form.table_name.trim()) names.add(form.table_name.trim());
return [...names].sort();
});
const columns: DataTableColumn<MaintenancePolicy>[] = [
{ id: 'name', label: 'Название', sortable: true, sortValue: (p) => p.name },
{ id: 'table_name', label: 'Таблица', sortable: true, sortValue: (p) => p.table_name },
{ id: 'schedule', label: 'Cron (UTC)' },
{ id: 'status', label: 'Статус' },
{ id: 'actions', label: '', class: 'w-40' }
];
async function loadPolicies() {
loading = true;
try {
policies = await listMaintenancePolicies();
} catch (e) {
notifyApiError(e, 'Не удалось загрузить политики');
} finally {
loading = false;
}
}
function openCreate() {
editTarget = null;
form = emptyMaintenancePolicyForm();
hints = null;
dialogOpen = true;
}
function openEdit(p: MaintenancePolicy) {
editTarget = p;
form = {
name: p.name,
table_name: p.table_name,
condition: p.condition || 'true',
retention_period_sec: p.retention_period_sec ? String(p.retention_period_sec) : '',
max_rows: p.max_rows ? String(p.max_rows) : '',
vacuum_strategy: (vacuumStrategies.includes(
p.vacuum_strategy as (typeof vacuumStrategies)[number]
)
? p.vacuum_strategy
: 'none') as MaintenancePolicyForm['vacuum_strategy'],
schedule: p.schedule,
enabled: p.enabled,
dry_run_enabled: p.dry_run_enabled
};
hints = null;
dialogOpen = true;
void loadHints(p.id);
}
async function loadHints(id: string) {
hintsLoading = true;
try {
hints = await fetchPolicyHints(id);
} catch {
hints = null;
} finally {
hintsLoading = false;
}
}
function requestDelete(p: MaintenancePolicy) {
void confirm({
title: `Удалить политику «${p.name}»?`,
description: 'Расписание и очистка по этой политике прекратятся.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await deleteMaintenancePolicy(p.id);
notify.success('Политика удалена');
await loadPolicies();
}
});
}
async function save() {
if (!form.name.trim() || !form.table_name.trim() || !form.schedule.trim()) {
notify.error('Заполните обязательные поля');
return;
}
saving = true;
try {
const payload = formToPayload(form);
if (editTarget) {
await updateMaintenancePolicy(editTarget.id, payload);
notify.success('Политика обновлена');
} else {
await createMaintenancePolicy(payload);
notify.success('Политика создана');
}
dialogOpen = false;
await loadPolicies();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function queueRun(p: MaintenancePolicy, dryRun: boolean) {
void confirm({
title: dryRun ? `Dry-run: ${p.name}` : `Запуск: ${p.name}`,
description: dryRun
? 'Изменения в БД не применяются — только оценка.'
: 'Задача будет поставлена в очередь jobs.',
confirmLabel: dryRun ? 'Dry-run' : 'Запустить',
destructive: !dryRun,
onConfirm: async () => {
const res = await runMaintenancePolicy(p.id, dryRun);
notify.success(`Задача ${res.job_id}`);
await onJobQueued?.();
}
});
}
function statusBadge(p: MaintenancePolicy) {
if (!p.enabled) return 'выкл';
if (p.dry_run_enabled) return 'dry-run sched';
return p.last_status || '—';
}
onMount(() => {
void loadPolicies();
});
</script>
{#if !isOperator}
<Alert>
<AlertTitle>Только operator</AlertTitle>
<AlertDescription>Политики обслуживания БД настраиваются с ролью operator.</AlertDescription>
</Alert>
{/if}
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<CardTitle>Политики обслуживания</CardTitle>
<CardDescription>
Единственный источник конфигурации retention, vacuum и расписания (UTC cron).
</CardDescription>
</div>
{#if isOperator}
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
{/if}
</CardHeader>
<CardContent class="pt-4">
<AppDataTable
{columns}
rows={policies}
rowKey={(p) => p.id}
{loading}
emptyTitle="Политики не созданы"
emptyDescription="Добавьте первую политику через UI — это единственный способ настройки."
>
{#snippet cell({ row, column })}
{#if column.id === 'status'}
<Badge variant={row.enabled ? 'secondary' : 'outline'}>{statusBadge(row)}</Badge>
{#if row.last_run_at}
<p class="mt-1 text-xs text-muted-foreground">{row.last_run_at}</p>
{/if}
{:else if column.id === 'actions' && isOperator}
<div class="flex flex-wrap gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => openEdit(row)}
aria-label="Изменить"
>
<Pencil class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, true)}
aria-label="Dry-run"
>
<FlaskConical class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, false)}
aria-label="Run"
>
<Play class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => requestDelete(row)}
aria-label="Удалить"
>
<Trash2 class="size-4" />
</Button>
</div>
{:else if column.id === 'name'}
{row.name}
{:else if column.id === 'table_name'}
{row.table_name}
{:else if column.id === 'schedule'}
<span class="font-mono text-xs">{row.schedule}</span>
{:else if column.id !== 'actions'}
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editTarget ? 'Изменить политику' : 'Новая политика'}</DialogTitle>
</DialogHeader>
{#if hints?.recommend_vacuum}
<Alert class="border-warning/30 bg-warning/5">
<Info class="text-warning" />
<AlertTitle>Рекомендация</AlertTitle>
<AlertDescription>{hints.detail ?? 'Рекомендуется VACUUM.'}</AlertDescription>
</Alert>
{:else if hintsLoading}
<p class="text-sm text-muted-foreground">Загрузка подсказок pg_stat…</p>
{/if}
<div class="grid gap-4 py-2">
<FormField label="Название" id="mp-name" required>
<AppInput bind:value={form.name} disabled={!isOperator} />
</FormField>
<FormField label="Таблица" id="mp-table" required>
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.table_name}
disabled={!isOperator}
>
<option value="">— выберите —</option>
{#each tableOptions as name (name)}
<option value={name}>{name}</option>
{/each}
</select>
</FormField>
<FormField label="Condition (SQL WHERE)" id="mp-condition" required>
<textarea
class="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
bind:value={form.condition}
disabled={!isOperator}
></textarea>
</FormField>
<div class="grid gap-4 sm:grid-cols-2">
<FormField label="Retention (сек)" id="mp-retention">
<AppInput bind:value={form.retention_period_sec} type="number" disabled={!isOperator} />
</FormField>
<FormField label="Max rows (batch)" id="mp-max-rows">
<AppInput bind:value={form.max_rows} type="number" disabled={!isOperator} />
</FormField>
</div>
<FormField label="Vacuum strategy" id="mp-vacuum">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.vacuum_strategy}
disabled={!isOperator}
>
{#each vacuumStrategies as s (s)}
<option value={s}>{s}</option>
{/each}
</select>
</FormField>
<FormField label="Schedule (cron, UTC)" id="mp-schedule" required>
<AppInput bind:value={form.schedule} class="font-mono" disabled={!isOperator} />
</FormField>
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-2">
<Switch id="mp-enabled" bind:checked={form.enabled} disabled={!isOperator} />
<Label for="mp-enabled">Включена</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="mp-dry" bind:checked={form.dry_run_enabled} disabled={!isOperator} />
<Label for="mp-dry">Scheduler только dry-run</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
{#if isOperator}
<Button onclick={save} disabled={saving}>{saving ? 'Сохранение…' : 'Сохранить'}</Button>
{/if}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import { apiJSON } 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 { notifyApiError } from '$lib/ui/app/toast.js';
import MaintenancePoliciesTab from '$lib/components/monitoring/MaintenancePoliciesTab.svelte';
import {
POSTGRES_POLL_MS,
POSTGRES_SLOW_POLL_MS,
@@ -117,33 +117,6 @@
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">
@@ -406,50 +379,7 @@
</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}
<MaintenancePoliciesTab {session} {tables} onJobQueued={loadSlow} />
<Card>
<CardHeader>
<CardTitle>Журнал обслуживания</CardTitle>
+68
View File
@@ -0,0 +1,68 @@
import { apiJSON, apiMutate } from '$lib/api/client.js';
export type MaintenancePolicy = {
id: string;
name: string;
table_name: string;
condition: string;
retention_period_sec?: number;
max_rows?: number;
vacuum_strategy: string;
schedule: string;
enabled: boolean;
dry_run_enabled: boolean;
last_run_at?: string;
last_status?: string;
last_error?: string;
created_at?: string;
updated_at?: string;
};
export type MaintenancePolicyHints = {
table_name: string;
n_dead_tup: number;
bloat_ratio?: number;
last_autovacuum?: string;
recommend_vacuum: boolean;
detail?: string;
};
export type MaintenancePoliciesResponse = {
items: MaintenancePolicy[];
next_cursor?: string;
has_more?: boolean;
};
export async function listMaintenancePolicies(limit = 100): Promise<MaintenancePolicy[]> {
const r = await apiJSON<MaintenancePoliciesResponse>(`/v1/maintenance/policies?limit=${limit}`);
return r.items ?? [];
}
export async function createMaintenancePolicy(
body: Record<string, unknown>
): Promise<MaintenancePolicy> {
return apiMutate<MaintenancePolicy>('/v1/maintenance/policies', 'POST', body);
}
export async function updateMaintenancePolicy(
id: string,
body: Record<string, unknown>
): Promise<MaintenancePolicy> {
return apiMutate<MaintenancePolicy>(`/v1/maintenance/policies/${id}`, 'PATCH', body);
}
export async function deleteMaintenancePolicy(id: string): Promise<void> {
await apiMutate(`/v1/maintenance/policies/${id}`, 'DELETE', undefined, { idempotent: false });
}
export async function runMaintenancePolicy(
id: string,
dryRun: boolean
): Promise<{ job_id: string }> {
const path = dryRun ? '/v1/maintenance/dry-run' : '/v1/maintenance/run';
return apiMutate<{ job_id: string; status: string }>(path, 'POST', { policy_id: id });
}
export async function fetchPolicyHints(id: string): Promise<MaintenancePolicyHints> {
return apiJSON<MaintenancePolicyHints>(`/v1/maintenance/policies/${id}/hints`);
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from 'zod';
export const vacuumStrategies = ['none', 'vacuum', 'analyze', 'vacuum_analyze', 'reindex'] as const;
export type VacuumStrategy = (typeof vacuumStrategies)[number];
export const maintenancePolicySchema = z.object({
name: z.string().trim().min(1, 'Укажите название'),
table_name: z.string().trim().min(1, 'Укажите таблицу'),
condition: z
.string()
.trim()
.min(1, 'Укажите условие')
.refine((v) => !/[;]|--|\/\*/.test(v), 'Недопустимые символы в condition'),
retention_period_sec: z.string().optional(),
max_rows: z.string().optional(),
vacuum_strategy: z.enum(vacuumStrategies),
schedule: z.string().trim().min(1, 'Укажите cron (UTC)'),
enabled: z.boolean(),
dry_run_enabled: z.boolean()
});
export type MaintenancePolicyForm = z.infer<typeof maintenancePolicySchema>;
export function emptyMaintenancePolicyForm(): MaintenancePolicyForm {
return {
name: '',
table_name: '',
condition: 'true',
retention_period_sec: '',
max_rows: '10000',
vacuum_strategy: 'none',
schedule: '0 3 * * *',
enabled: true,
dry_run_enabled: true
};
}
export function parseOptionalInt(raw: string | undefined): number | undefined {
const v = String(raw ?? '').trim();
if (!v) return undefined;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
}
export function formToPayload(form: MaintenancePolicyForm) {
return {
name: form.name.trim(),
table_name: form.table_name.trim(),
condition: form.condition.trim() || 'true',
retention_period_sec: parseOptionalInt(form.retention_period_sec),
max_rows: parseOptionalInt(form.max_rows),
vacuum_strategy: form.vacuum_strategy,
schedule: form.schedule.trim(),
enabled: form.enabled,
dry_run_enabled: form.dry_run_enabled
};
}
+2
View File
@@ -23,6 +23,8 @@ export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap<string, s
return 'Откат ревизии';
case 'bird_reload':
return 'Перезагрузка BIRD';
case 'maintenance_policy_run':
return 'Обслуживание PostgreSQL (политика)';
default:
return job.kind;
}