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)
+ );
+}