Compare commits
1
Commits
aa4e3d0180
...
v1.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132559cb8e |
No files matched your search
@@ -18,6 +18,13 @@
|
|||||||
vacuumStrategies,
|
vacuumStrategies,
|
||||||
type MaintenancePolicyForm
|
type MaintenancePolicyForm
|
||||||
} from '$lib/maintenance/policy.schema.js';
|
} 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 { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -37,6 +44,7 @@
|
|||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/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 FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.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 Play from '@lucide/svelte/icons/play';
|
||||||
import FlaskConical from '@lucide/svelte/icons/flask-conical';
|
import FlaskConical from '@lucide/svelte/icons/flask-conical';
|
||||||
import Info from '@lucide/svelte/icons/info';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
import Layers from '@lucide/svelte/icons/layers';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: AuthSession | null;
|
session: AuthSession | null;
|
||||||
@@ -66,6 +75,50 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let hints = $state<MaintenancePolicyHints | null>(null);
|
let hints = $state<MaintenancePolicyHints | null>(null);
|
||||||
let hintsLoading = $state(false);
|
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');
|
const isOperator = $derived(session?.role === 'operator');
|
||||||
|
|
||||||
@@ -98,6 +151,7 @@
|
|||||||
editTarget = null;
|
editTarget = null;
|
||||||
form = emptyMaintenancePolicyForm();
|
form = emptyMaintenancePolicyForm();
|
||||||
hints = null;
|
hints = null;
|
||||||
|
activePresetId = null;
|
||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +274,74 @@
|
|||||||
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
|
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</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
|
<AppDataTable
|
||||||
{columns}
|
{columns}
|
||||||
rows={policies}
|
rows={policies}
|
||||||
@@ -300,6 +421,29 @@
|
|||||||
<p class="text-sm text-muted-foreground">Загрузка подсказок pg_stat…</p>
|
<p class="text-sm text-muted-foreground">Загрузка подсказок pg_stat…</p>
|
||||||
{/if}
|
{/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">
|
<div class="grid gap-4 py-2">
|
||||||
<FormField label="Название" id="mp-name" required>
|
<FormField label="Название" id="mp-name" required>
|
||||||
<AppInput bind:value={form.name} disabled={!isOperator} />
|
<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