Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec 132559cb8e feat(web): enhance MaintenancePoliciesTab with preset management
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 29s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m46s
Added functionality for selecting and applying maintenance policy presets in the MaintenancePoliciesTab. Users can now create policies from selected presets, apply presets to the form, and receive notifications on the creation process. Updated UI components to support these features, improving user experience and efficiency in managing maintenance policies.
2026-06-12 14:07:24 +07:00
2 changed files with 275 additions and 1 deletions
@@ -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<MaintenancePolicyHints | null>(null);
let hintsLoading = $state(false);
let selectedPresetIds = $state<string[]>([]);
let applyingPresets = $state(false);
let activePresetId = $state<string | null>(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 @@
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
{/if}
</CardHeader>
<CardContent class="pt-4">
<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} · cron {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}
@@ -300,6 +421,29 @@
<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} />
+130
View File
@@ -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<string>
): MaintenancePolicyPreset[] {
const ids = new Set(selected);
return maintenancePolicyPresets.filter(
(p) => ids.has(p.id) && !isPresetAlreadyApplied(p, policies)
);
}