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:
@@ -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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 type { AuthSession } from '$lib/api/types.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import MaintenancePoliciesTab from '$lib/components/monitoring/MaintenancePoliciesTab.svelte';
|
||||||
import {
|
import {
|
||||||
POSTGRES_POLL_MS,
|
POSTGRES_POLL_MS,
|
||||||
POSTGRES_SLOW_POLL_MS,
|
POSTGRES_SLOW_POLL_MS,
|
||||||
@@ -117,33 +117,6 @@
|
|||||||
clearInterval(slow);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -406,50 +379,7 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="maintenance" class="mt-4 space-y-4">
|
<TabsContent value="maintenance" class="mt-4 space-y-4">
|
||||||
{#if !isOperator}
|
<MaintenancePoliciesTab {session} {tables} onJobQueued={loadSlow} />
|
||||||
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Журнал обслуживания</CardTitle>
|
<CardTitle>Журнал обслуживания</CardTitle>
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap<string, s
|
|||||||
return 'Откат ревизии';
|
return 'Откат ревизии';
|
||||||
case 'bird_reload':
|
case 'bird_reload':
|
||||||
return 'Перезагрузка BIRD';
|
return 'Перезагрузка BIRD';
|
||||||
|
case 'maintenance_policy_run':
|
||||||
|
return 'Обслуживание PostgreSQL (политика)';
|
||||||
default:
|
default:
|
||||||
return job.kind;
|
return job.kind;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user