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}
+
+
+
+
+
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