Files
EvoBGP/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte
T
Denozordec de64374c91
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m48s
feat(web): implement schedule editor in MaintenancePoliciesTab
Enhanced the MaintenancePoliciesTab by integrating a schedule editor for maintenance policies. Users can now select schedule modes, input custom cron expressions, and dynamically update the schedule preview. This update improves the user interface and experience for managing maintenance schedules.
2026-06-12 18:40:22 +07:00

688 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 {
filterAvailablePresets,
isPresetAlreadyApplied,
maintenancePolicyPresets,
presetForm,
type MaintenancePolicyPreset
} from '$lib/maintenance/policy-presets.js';
import {
applyScheduleEditor,
cronToEditor,
defaultScheduleEditor,
describeCron,
scheduleModeOptions,
weekdayOptions,
type ScheduleEditor,
type ScheduleMode
} from '$lib/maintenance/policy-schedule.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 { Checkbox } from '$lib/ui/core/checkbox/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';
import Layers from '@lucide/svelte/icons/layers';
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);
let selectedPresetIds = $state<string[]>([]);
let applyingPresets = $state(false);
let activePresetId = $state<string | null>(null);
let scheduleEditor = $state<ScheduleEditor>(defaultScheduleEditor());
const schedulePreview = $derived(describeCron(form.schedule));
function loadScheduleEditor(cron: string) {
scheduleEditor = cronToEditor(cron);
}
function patchSchedule(patch: Partial<ScheduleEditor>) {
scheduleEditor = { ...scheduleEditor, ...patch };
syncScheduleToForm();
}
function setScheduleMode(mode: ScheduleMode) {
patchSchedule({ mode });
}
function syncScheduleToForm() {
const { editor, cron } = applyScheduleEditor(scheduleEditor);
scheduleEditor = editor;
form.schedule = cron;
}
function onCustomCronInput(value: string) {
scheduleEditor = { ...scheduleEditor, customCron: value };
form.schedule = value.trim() || '0 3 * * *';
}
const creatablePresets = $derived(filterAvailablePresets(policies, selectedPresetIds));
function togglePresetSelection(id: string, checked: boolean) {
if (checked) {
if (!selectedPresetIds.includes(id)) {
selectedPresetIds = [...selectedPresetIds, id];
}
} else {
selectedPresetIds = selectedPresetIds.filter((x) => x !== id);
}
}
function applyPresetToForm(preset: MaintenancePolicyPreset) {
form = presetForm(preset);
activePresetId = preset.id;
loadScheduleEditor(form.schedule);
}
async function createSelectedPresets() {
const toCreate = creatablePresets;
if (toCreate.length === 0) {
notify.error('Выберите пресеты, которые ещё не созданы');
return;
}
applyingPresets = true;
let created = 0;
try {
for (const preset of toCreate) {
await createMaintenancePolicy(formToPayload(preset.form));
created++;
}
selectedPresetIds = selectedPresetIds.filter((id) => !toCreate.some((p) => p.id === id));
notify.success(`Создано политик: ${created}`);
await loadPolicies();
} catch (e) {
notifyApiError(e, created > 0 ? `Создано ${created} из ${toCreate.length}` : undefined);
if (created > 0) await loadPolicies();
} finally {
applyingPresets = 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;
activePresetId = null;
loadScheduleEditor(form.schedule);
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
};
loadScheduleEditor(form.schedule);
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="flex flex-col gap-4 pt-4">
{#if isOperator}
<div class="rounded-lg border border-border/80 bg-muted/20 p-4">
<div class="mb-3 flex flex-wrap items-start justify-between gap-2">
<div>
<p class="flex items-center gap-2 text-sm font-medium">
<Layers class="size-4 text-muted-foreground" />
Пресеты стратегий
</p>
<p class="mt-1 text-xs text-muted-foreground">
Выберите шаблоны и создайте политики одним действием или примените шаблон в форме.
</p>
</div>
<Button
size="sm"
disabled={creatablePresets.length === 0 || applyingPresets}
onclick={createSelectedPresets}
>
{applyingPresets ? 'Создание…' : `Создать выбранные (${creatablePresets.length})`}
</Button>
</div>
<div class="grid gap-2 sm:grid-cols-2">
{#each maintenancePolicyPresets as preset (preset.id)}
{@const applied = isPresetAlreadyApplied(preset, policies)}
{@const checked = selectedPresetIds.includes(preset.id)}
<label
class="flex cursor-pointer gap-3 rounded-md border border-border/60 bg-background p-3 transition-colors hover:bg-muted/30 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60"
>
<Checkbox
id="preset-{preset.id}"
{checked}
disabled={applied}
onCheckedChange={(v) => togglePresetSelection(preset.id, v === true)}
/>
<span class="min-w-0 flex-1 space-y-1">
<span class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">{preset.label}</span>
{#if applied}
<Badge variant="outline" class="text-xs">уже есть</Badge>
{/if}
</span>
<span class="block text-xs text-muted-foreground">{preset.description}</span>
<span class="block font-mono text-[11px] text-muted-foreground">
{preset.form.table_name} · {describeCron(preset.form.schedule)}
</span>
</span>
<Button
type="button"
variant="ghost"
size="sm"
class="shrink-0 self-start"
disabled={!isOperator}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
editTarget = null;
applyPresetToForm(preset);
dialogOpen = true;
}}
>
В форму
</Button>
</label>
{/each}
</div>
</div>
{/if}
<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'}
<div class="space-y-0.5">
<span class="text-sm">{describeCron(row.schedule)}</span>
<span class="block font-mono text-[11px] text-muted-foreground">{row.schedule}</span>
</div>
{: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}
{#if !editTarget}
<div class="space-y-2">
<p class="text-sm font-medium">Шаблон (опционально)</p>
<div class="flex flex-wrap gap-2">
{#each maintenancePolicyPresets as preset (preset.id)}
<Button
type="button"
variant={activePresetId === preset.id ? 'secondary' : 'outline'}
size="sm"
class="h-auto max-w-full py-1.5 text-left whitespace-normal"
disabled={!isOperator}
onclick={() => applyPresetToForm(preset)}
>
{preset.label}
</Button>
{/each}
</div>
<p class="text-xs text-muted-foreground">
Поля формы заполняются из шаблона; перед сохранением можно изменить любое значение.
</p>
</div>
{/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="Расписание (UTC)" id="mp-schedule" required>
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.mode}
disabled={!isOperator}
onchange={(e) => setScheduleMode(e.currentTarget.value as ScheduleMode)}
>
{#each scheduleModeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if scheduleEditor.mode === 'minutes'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="59"
value={scheduleEditor.intervalMinutes}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalMinutes: e.currentTarget.value })}
/>
<span class="text-muted-foreground">минут</span>
</div>
{:else if scheduleEditor.mode === 'hours'}
<div class="grid gap-3 sm:grid-cols-2">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="23"
value={scheduleEditor.intervalHours}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalHours: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часов</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В минуту</span>
<AppInput
class="w-24"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часа (059)</span>
</div>
</div>
{:else if scheduleEditor.mode === 'daily'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
{:else if scheduleEditor.mode === 'weekly'}
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.weekday}
disabled={!isOperator}
onchange={(e) => patchSchedule({ weekday: e.currentTarget.value })}
>
{#each weekdayOptions as wd (wd.value)}
<option value={wd.value}>{wd.label}</option>
{/each}
</select>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
</div>
{:else}
<AppInput
class="font-mono"
value={scheduleEditor.customCron}
disabled={!isOperator}
placeholder="0 3 * * *"
oninput={(e) => onCustomCronInput(e.currentTarget.value)}
/>
<p class="text-xs text-muted-foreground">
5 полей: минута час день месяц день_недели (UTC). Пример: <code>0 6 * * 0</code> — воскресенье
06:00.
</p>
{/if}
<p class="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{schedulePreview}</span>
<span class="mt-1 block font-mono">{form.schedule}</span>
</p>
</div>
</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>