feat(web): add maintenance policies UI
Вкладка политик обслуживания PostgreSQL: CRUD через /v1/maintenance/policies, run/dry-run, форма с Zod. Hardcoded кнопки vacuum/cleanup в MonitoringPostgresTab заменены на MaintenancePoliciesTab. Co-authored-by: Cursor <cursoragent@cursor.com>
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">
|
||||
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>
|
||||
|
||||
@@ -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 'Откат ревизии';
|
||||
case 'bird_reload':
|
||||
return 'Перезагрузка BIRD';
|
||||
case 'maintenance_policy_run':
|
||||
return 'Обслуживание PostgreSQL (политика)';
|
||||
default:
|
||||
return job.kind;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user