From d38ee68c4e1fbe68edbbacb1156c89543ed5d649 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 12 Jun 2026 13:30:43 +0700 Subject: [PATCH] feat(web): add maintenance policies UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вкладка политик обслуживания PostgreSQL: CRUD через /v1/maintenance/policies, run/dry-run, форма с Zod. Hardcoded кнопки vacuum/cleanup в MonitoringPostgresTab заменены на MaintenancePoliciesTab. Co-authored-by: Cursor --- .../monitoring/MaintenancePoliciesTab.svelte | 367 ++++++++++++++++++ .../monitoring/MonitoringPostgresTab.svelte | 78 +--- web/src/lib/maintenance/policy-api.ts | 68 ++++ web/src/lib/maintenance/policy.schema.ts | 58 +++ web/src/lib/operations/job-kind-label.ts | 2 + 5 files changed, 499 insertions(+), 74 deletions(-) create mode 100644 web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte create mode 100644 web/src/lib/maintenance/policy-api.ts create mode 100644 web/src/lib/maintenance/policy.schema.ts diff --git a/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte b/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte new file mode 100644 index 0000000..ca5518e --- /dev/null +++ b/web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte @@ -0,0 +1,367 @@ + + +{#if !isOperator} + + Только operator + Политики обслуживания БД настраиваются с ролью operator. + +{/if} + + + +
+ Политики обслуживания + + Единственный источник конфигурации retention, vacuum и расписания (UTC cron). + +
+ {#if isOperator} + + {/if} +
+ + p.id} + {loading} + emptyTitle="Политики не созданы" + emptyDescription="Добавьте первую политику через UI — это единственный способ настройки." + > + {#snippet cell({ row, column })} + {#if column.id === 'status'} + {statusBadge(row)} + {#if row.last_run_at} +

{row.last_run_at}

+ {/if} + {:else if column.id === 'actions' && isOperator} +
+ + + + +
+ {:else if column.id === 'name'} + {row.name} + {:else if column.id === 'table_name'} + {row.table_name} + {:else if column.id === 'schedule'} + {row.schedule} + {:else if column.id !== 'actions'} + — + {/if} + {/snippet} +
+
+
+ + + + + {editTarget ? 'Изменить политику' : 'Новая политика'} + + + {#if hints?.recommend_vacuum} + + + Рекомендация + {hints.detail ?? 'Рекомендуется VACUUM.'} + + {:else if hintsLoading} +

Загрузка подсказок pg_stat…

+ {/if} + +
+ + + + + + + + + +
+ + + + + + +
+ + + + + + +
+
+ + +
+
+ + +
+
+
+ + + + {#if isOperator} + + {/if} + +
+
diff --git a/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte b/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte index 2551a6c..14d9941 100644 --- a/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte +++ b/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte @@ -1,9 +1,9 @@
@@ -406,50 +379,7 @@ - {#if !isOperator} - - Только operator - Обслуживание БД доступно с ролью operator. - - {:else} - - - Операции - Все операции — async job (202). По умолчанию dry-run. - - - - - - - - - {/if} + Журнал обслуживания diff --git a/web/src/lib/maintenance/policy-api.ts b/web/src/lib/maintenance/policy-api.ts new file mode 100644 index 0000000..4a6cd72 --- /dev/null +++ b/web/src/lib/maintenance/policy-api.ts @@ -0,0 +1,68 @@ +import { apiJSON, apiMutate } from '$lib/api/client.js'; + +export type MaintenancePolicy = { + id: string; + name: string; + table_name: string; + condition: string; + retention_period_sec?: number; + max_rows?: number; + vacuum_strategy: string; + schedule: string; + enabled: boolean; + dry_run_enabled: boolean; + last_run_at?: string; + last_status?: string; + last_error?: string; + created_at?: string; + updated_at?: string; +}; + +export type MaintenancePolicyHints = { + table_name: string; + n_dead_tup: number; + bloat_ratio?: number; + last_autovacuum?: string; + recommend_vacuum: boolean; + detail?: string; +}; + +export type MaintenancePoliciesResponse = { + items: MaintenancePolicy[]; + next_cursor?: string; + has_more?: boolean; +}; + +export async function listMaintenancePolicies(limit = 100): Promise { + const r = await apiJSON(`/v1/maintenance/policies?limit=${limit}`); + return r.items ?? []; +} + +export async function createMaintenancePolicy( + body: Record +): Promise { + return apiMutate('/v1/maintenance/policies', 'POST', body); +} + +export async function updateMaintenancePolicy( + id: string, + body: Record +): Promise { + return apiMutate(`/v1/maintenance/policies/${id}`, 'PATCH', body); +} + +export async function deleteMaintenancePolicy(id: string): Promise { + await apiMutate(`/v1/maintenance/policies/${id}`, 'DELETE', undefined, { idempotent: false }); +} + +export async function runMaintenancePolicy( + id: string, + dryRun: boolean +): Promise<{ job_id: string }> { + const path = dryRun ? '/v1/maintenance/dry-run' : '/v1/maintenance/run'; + return apiMutate<{ job_id: string; status: string }>(path, 'POST', { policy_id: id }); +} + +export async function fetchPolicyHints(id: string): Promise { + return apiJSON(`/v1/maintenance/policies/${id}/hints`); +} diff --git a/web/src/lib/maintenance/policy.schema.ts b/web/src/lib/maintenance/policy.schema.ts new file mode 100644 index 0000000..30ecb64 --- /dev/null +++ b/web/src/lib/maintenance/policy.schema.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +export const vacuumStrategies = ['none', 'vacuum', 'analyze', 'vacuum_analyze', 'reindex'] as const; + +export type VacuumStrategy = (typeof vacuumStrategies)[number]; + +export const maintenancePolicySchema = z.object({ + name: z.string().trim().min(1, 'Укажите название'), + table_name: z.string().trim().min(1, 'Укажите таблицу'), + condition: z + .string() + .trim() + .min(1, 'Укажите условие') + .refine((v) => !/[;]|--|\/\*/.test(v), 'Недопустимые символы в condition'), + retention_period_sec: z.string().optional(), + max_rows: z.string().optional(), + vacuum_strategy: z.enum(vacuumStrategies), + schedule: z.string().trim().min(1, 'Укажите cron (UTC)'), + enabled: z.boolean(), + dry_run_enabled: z.boolean() +}); + +export type MaintenancePolicyForm = z.infer; + +export function emptyMaintenancePolicyForm(): MaintenancePolicyForm { + return { + name: '', + table_name: '', + condition: 'true', + retention_period_sec: '', + max_rows: '10000', + vacuum_strategy: 'none', + schedule: '0 3 * * *', + enabled: true, + dry_run_enabled: true + }; +} + +export function parseOptionalInt(raw: string | undefined): number | undefined { + const v = String(raw ?? '').trim(); + if (!v) return undefined; + const n = Number(v); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined; +} + +export function formToPayload(form: MaintenancePolicyForm) { + return { + name: form.name.trim(), + table_name: form.table_name.trim(), + condition: form.condition.trim() || 'true', + retention_period_sec: parseOptionalInt(form.retention_period_sec), + max_rows: parseOptionalInt(form.max_rows), + vacuum_strategy: form.vacuum_strategy, + schedule: form.schedule.trim(), + enabled: form.enabled, + dry_run_enabled: form.dry_run_enabled + }; +} diff --git a/web/src/lib/operations/job-kind-label.ts b/web/src/lib/operations/job-kind-label.ts index 0f377b7..3320b88 100644 --- a/web/src/lib/operations/job-kind-label.ts +++ b/web/src/lib/operations/job-kind-label.ts @@ -23,6 +23,8 @@ export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap