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
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.
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user