Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132559cb8e |
@@ -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