diff --git a/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte b/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte index ca5518e..22ad4cb 100644 --- a/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte +++ b/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte @@ -18,6 +18,13 @@ vacuumStrategies, type MaintenancePolicyForm } from '$lib/maintenance/policy.schema.js'; + import { + filterAvailablePresets, + isPresetAlreadyApplied, + maintenancePolicyPresets, + presetForm, + type MaintenancePolicyPreset + } from '$lib/maintenance/policy-presets.js'; import { Button } from '$lib/ui/core/button/index.js'; import { Card, @@ -37,6 +44,7 @@ } 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'; @@ -49,6 +57,7 @@ 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; @@ -66,6 +75,50 @@ let saving = $state(false); let hints = $state(null); let hintsLoading = $state(false); + let selectedPresetIds = $state([]); + let applyingPresets = $state(false); + let activePresetId = $state(null); + + 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; + } + + 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'); @@ -98,6 +151,7 @@ editTarget = null; form = emptyMaintenancePolicyForm(); hints = null; + activePresetId = null; dialogOpen = true; } @@ -220,7 +274,74 @@ {/if} - + + {#if isOperator} +
+
+
+

+ + Пресеты стратегий +

+

+ Выберите шаблоны и создайте политики одним действием или примените шаблон в форме. +

+
+ +
+
+ {#each maintenancePolicyPresets as preset (preset.id)} + {@const applied = isPresetAlreadyApplied(preset, policies)} + {@const checked = selectedPresetIds.includes(preset.id)} + + {/each} +
+
+ {/if} + Загрузка подсказок pg_stat…

{/if} + {#if !editTarget} +
+

Шаблон (опционально)

+
+ {#each maintenancePolicyPresets as preset (preset.id)} + + {/each} +
+

+ Поля формы заполняются из шаблона; перед сохранением можно изменить любое значение. +

+
+ {/if} +
diff --git a/web/src/lib/maintenance/policy-presets.ts b/web/src/lib/maintenance/policy-presets.ts new file mode 100644 index 0000000..1c4de6d --- /dev/null +++ b/web/src/lib/maintenance/policy-presets.ts @@ -0,0 +1,130 @@ +import type { MaintenancePolicy } from '$lib/maintenance/policy-api.js'; +import type { MaintenancePolicyForm } from '$lib/maintenance/policy.schema.js'; + +const DAY_SEC = 86_400; + +/** Рекомендуемый шаблон политики (только UI; в БД не seed'ится). */ +export type MaintenancePolicyPreset = { + id: string; + label: string; + description: string; + /** Подсказка: таблица должна быть видна в pg_stat (не блокирует создание). */ + tableHint?: string; + form: MaintenancePolicyForm; +}; + +/** Базовые пресеты EvoBGP — оператор выбирает, какие создать. */ +export const maintenancePolicyPresets: MaintenancePolicyPreset[] = [ + { + id: 'job_audit_retention', + label: 'Job audit — retention 90d', + description: + 'Удаляет завершённые записи job_audit старше 90 дней; VACUUM ANALYZE после очистки. Расписание 03:00 UTC.', + tableHint: 'job_audit', + form: { + name: 'Job audit retention (90d)', + table_name: 'job_audit', + condition: "status IN ('succeeded', 'failed', 'cancelled')", + retention_period_sec: String(90 * DAY_SEC), + max_rows: '10000', + vacuum_strategy: 'vacuum_analyze', + schedule: '0 3 * * *', + enabled: true, + dry_run_enabled: true + } + }, + { + id: 'postgres_maintenance_audit_retention', + label: 'Maintenance audit — 30d', + description: 'Очищает postgres_maintenance_audit старше 30 дней без vacuum.', + tableHint: 'postgres_maintenance_audit', + form: { + name: 'Postgres maintenance audit (30d)', + table_name: 'postgres_maintenance_audit', + condition: 'true', + retention_period_sec: String(30 * DAY_SEC), + max_rows: '5000', + vacuum_strategy: 'none', + schedule: '0 4 * * *', + enabled: true, + dry_run_enabled: true + } + }, + { + id: 'job_audit_vacuum_weekly', + label: 'Job audit — VACUUM weekly', + description: 'Только VACUUM ANALYZE job_audit по воскресеньям, без удаления строк.', + tableHint: 'job_audit', + form: { + name: 'Job audit vacuum (weekly)', + table_name: 'job_audit', + condition: 'true', + retention_period_sec: '', + max_rows: '', + vacuum_strategy: 'vacuum_analyze', + schedule: '0 2 * * 0', + enabled: true, + dry_run_enabled: false + } + }, + { + id: 'postgres_monitor_snapshot', + label: 'PG monitor snapshots — 14d', + description: + 'Удаляет снимки postgres_monitor_snapshot старше 14 дней (фильтр по collected_at в condition).', + tableHint: 'postgres_monitor_snapshot', + form: { + name: 'Postgres monitor snapshots (14d)', + table_name: 'postgres_monitor_snapshot', + condition: "collected_at < NOW() - INTERVAL '14 days'", + retention_period_sec: '', + max_rows: '10000', + vacuum_strategy: 'none', + schedule: '0 5 * * *', + enabled: true, + dry_run_enabled: true + } + }, + { + id: 'config_revision_retention', + label: 'Config revisions — 180d', + description: + 'Долгое хранение старых config_revision (180d). Перед включением проверьте revision_retention в настройках.', + tableHint: 'config_revision', + form: { + name: 'Config revision retention (180d)', + table_name: 'config_revision', + condition: 'true', + retention_period_sec: String(180 * DAY_SEC), + max_rows: '5000', + vacuum_strategy: 'vacuum', + schedule: '0 6 * * 0', + enabled: false, + dry_run_enabled: true + } + } +]; + +export function presetForm(preset: MaintenancePolicyPreset): MaintenancePolicyForm { + return structuredClone(preset.form); +} + +/** Политика с тем же именем и таблицей считается уже созданной из пресета. */ +export function isPresetAlreadyApplied( + preset: MaintenancePolicyPreset, + policies: MaintenancePolicy[] +): boolean { + return policies.some( + (p) => p.name === preset.form.name.trim() && p.table_name === preset.form.table_name.trim() + ); +} + +export function filterAvailablePresets( + policies: MaintenancePolicy[], + selected: Iterable +): MaintenancePolicyPreset[] { + const ids = new Set(selected); + return maintenancePolicyPresets.filter( + (p) => ids.has(p.id) && !isPresetAlreadyApplied(p, policies) + ); +}