Compare commits

...
9 Commits
Author SHA1 Message Date
Denozordec aa4e3d0180 docs(agents): update engineering rules and add Context7 documentation references
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 31s
CI / go (push) Successful in 2m14s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m53s
Added Context7 documentation links for stack IDs and skills to the agents guide, enhancing clarity on library usage and integration.
2026-06-12 13:48:37 +07:00
DenozordecandCursor 1ccffc85da test(maintenance): add policy executor and handler tests
Табличные тесты PolicyExecutor, ConfigProvider reload, memory CRUD политик и 503 для /v1/maintenance/* на memory-бэкенде без PostgreSQL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:32:01 +07:00
DenozordecandCursor d38ee68c4e feat(web): add maintenance policies UI
Вкладка политик обслуживания PostgreSQL: CRUD через /v1/maintenance/policies, run/dry-run, форма с Zod. Hardcoded кнопки vacuum/cleanup в MonitoringPostgresTab заменены на MaintenancePoliciesTab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:30:43 +07:00
DenozordecandCursor aaef47c7a7 feat(observability): add maintenance policy metrics
Prometheus: runs, duration, rows_deleted, config_changes; инкремент при CRUD и Execute.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:28:20 +07:00
DenozordecandCursor cbf345b25f refactor(maintenance): remove hardcoded retention and wire scheduler
RunPeriodicMaintenance и RunCleanup удалены; scheduler политик в StartBackground; deprecated /postgres/cleanup принимает policy_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:27:49 +07:00
DenozordecandCursor f548d0671f feat(api): add /v1/maintenance policies and run endpoints
OpenAPI, httpapi CRUD/run/dry-run, job maintenance_policy_run и audit с policy_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:23:10 +07:00
DenozordecandCursor 6510a9ca22 feat(maintenance): add policy executor and config provider
ConfigProvider, PolicyExecutor, DBStatsProvider, scheduler и safety; зависимость robfig/cron/v3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:21:32 +07:00
DenozordecandCursor 07c3de4939 feat(store): add MaintenancePolicy CRUD backend
Типы maintenance_policy, методы store.Backend и реализации для PostgreSQL и in-memory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:20:27 +07:00
DenozordecandCursor 948dac34fd feat(db): add maintenance_policy migration 000025
Добавлены таблицы maintenance_policy и maintenance_policy_config_audit для postgres и sqlite; в postgres_maintenance_audit — колонка policy_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 13:19:27 +07:00
51 changed files with 3147 additions and 252 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"mcp__codegraph__codegraph_explore",
"mcp__codegraph__codegraph_search",
"mcp__codegraph__codegraph_node",
"mcp__codegraph__codegraph_callers",
"mcp__codegraph__codegraph_callees",
"mcp__codegraph__codegraph_impact",
"mcp__codegraph__codegraph_files",
"mcp__codegraph__codegraph_status"
]
}
}
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+6
View File
@@ -0,0 +1,6 @@
{
"pid": 44608,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1781240018712
}
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp",
"--path",
"C:\\Users\\shats\\Dev\\EvoBGP"
]
}
}
}
+93
View File
@@ -0,0 +1,93 @@
---
description: Context7 — закреплённые ID библиотек и документации стека EvoBGP
alwaysApply: true
---
# Context7 — стек EvoBGP
При вопросах об API, синтаксисе, конфигурации и миграциях библиотек **сначала** `query-docs` с ID из таблицы ниже. Шаг `resolve-library-id` **пропускать**, если библиотека уже перечислена (кроме неоднозначного случая).
Локальные версии: `go.mod`, `web/package.json`. При расхождении с ID — предпочитать версию из репозитория.
---
## Backend (Go)
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| Go stdlib | `/golang/go/go1_24_6` | Go 1.24 | `net/http`, `context`, тесты, concurrency |
| pgx | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` | v5.7.2 | PostgreSQL, pool, транзакции, типы |
| Prometheus Go client | `/prometheus/client_golang` | v1.20.5 | метрики, `/metrics`, middleware |
| modernc SQLite | `/websites/pkg_go_dev_modernc_org_sqlite` | v1.34.5 | SQLite-бэкенд, миграции sqlite |
| miekg/dns | `/miekg/dns` | v1.1.72 | DNS-запросы, DoH, pipeline |
---
## HTTP-контракт и спецификации
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
---
## Web UI (`web/`)
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming |
| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn |
| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions |
| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм |
| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации |
| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка |
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов).
---
## Data plane / BGP
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| BIRD 2 | `/llmstxt/bird_xmsl_dev_llms_txt` | BIRD2 в compose | `birdfmt`, фильтры, протоколы |
| BIRD (исходники) | `/cz-nic/bird` | — | низкоуровневый синтаксис daemon |
Сетевые правила: `.cursor/rules/networking-bird.mdc`.
---
## DevOps
| Библиотека | Context7 ID | Версия в проекте | Когда |
|------------|-------------|------------------|-------|
| Docker Compose | `/docker/compose` | `deploy/compose/` | сервисы, profiles, volumes |
| Docker | `/docker/docs` | — | образы, bake, networking |
---
## Приоритет источников
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
2. **Context7** — синтаксис и API библиотек из таблицы.
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
## Примеры запросов
```
/docs /websites/svelte_dev runes $state $derived
/docs /golang/go/go1_24_6 net/http ServeMux pattern matching
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
```
## Не через Context7
Рефакторинг `internal/*`, бизнес-логика EvoBGP, code review — код репозитория и `codegraph`. Context7 — только внешние библиотеки и инструменты.
+3
View File
@@ -2,6 +2,9 @@
"plugins": {
"svelte": {
"enabled": true
},
"claude-plugins-official/gopls-lsp": {
"enabled": true
}
}
}
+31
View File
@@ -0,0 +1,31 @@
---
name: context7-evobgp
description: Context7 lookup для стека EvoBGP — использовать закреплённые library ID из .cursor/rules/context7-stack.mdc вместо resolve-library-id.
---
# Context7 — EvoBGP stack
Перед `query-docs` открой `.cursor/rules/context7-stack.mdc` и выбери ID из таблицы по области задачи.
## Workflow
1. Определи область: `internal/` (Go), `web/` (Svelte), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
2. Найди строку в таблице `context7-stack.mdc`.
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
## Быстрые ID (частые)
| Задача | libraryId |
|--------|-----------|
| Svelte 5 runes | `/websites/svelte_dev` |
| SvelteKit load/forms | `/sveltejs/kit` |
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
| Go net/http | `/golang/go/go1_24_6` |
| OpenAPI lint | `/redocly/redocly-cli` |
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
| Zod 4 schema | `/websites/zod_dev_v4` |
Полный список и версии — в `context7-stack.mdc`.
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
## С чего начать (минимум чтения)
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `web/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc).
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `web/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc). **Context7 (документация библиотек)** — закреплённые ID стека: [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc); скилл [.cursor/skills/context7-evobgp/SKILL.md](.cursor/skills/context7-evobgp/SKILL.md).
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md), [web/README.md](web/README.md) — только если меняете API, доступ или фронт.
+295 -1
View File
@@ -51,6 +51,8 @@ tags:
description: Сессия текущего API-ключа (tenant и роль).
- name: Monitoring
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
- name: Maintenance
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
security:
- bearerAuth: []
@@ -966,10 +968,98 @@ components:
default: false
policy:
type: string
description: job_audit_retention | asn_cache_retention
description: Deprecated; use maintenance policies API.
limit:
type: integer
MaintenancePolicy:
type: object
required: [name, table_name, schedule, vacuum_strategy]
properties:
id:
$ref: "#/components/schemas/ResourceId"
name:
type: string
table_name:
type: string
condition:
type: string
default: "true"
retention_period_sec:
type: integer
minimum: 1
max_rows:
type: integer
minimum: 1
maximum: 100000
vacuum_strategy:
type: string
enum: [none, vacuum, analyze, vacuum_analyze, reindex]
schedule:
type: string
description: Cron expression (5-field, UTC).
enabled:
type: boolean
default: true
dry_run_enabled:
type: boolean
default: false
last_run_at:
type: string
format: date-time
last_status:
type: string
last_error:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
MaintenancePolicyPatch:
type: object
properties:
name:
type: string
table_name:
type: string
condition:
type: string
retention_period_sec:
type: integer
max_rows:
type: integer
vacuum_strategy:
type: string
enum: [none, vacuum, analyze, vacuum_analyze, reindex]
schedule:
type: string
enabled:
type: boolean
dry_run_enabled:
type: boolean
MaintenanceRunBody:
type: object
required: [policy_id]
properties:
policy_id:
$ref: "#/components/schemas/ResourceId"
MaintenancePolicyList:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/MaintenancePolicy"
next_cursor:
type: string
has_more:
type: boolean
BirdLocalStatus:
type: object
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
@@ -3467,6 +3557,210 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/maintenance/policies:
get:
tags: [Maintenance]
summary: List maintenance policies
operationId: listMaintenancePolicies
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenancePolicyList"
default:
$ref: "#/components/responses/DefaultProblem"
post:
tags: [Maintenance]
summary: Create maintenance policy
operationId: createMaintenancePolicy
parameters:
- $ref: "#/components/parameters/TenantId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenancePolicy"
responses:
"201":
description: Создано.
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenancePolicy"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/maintenance/policies/{id}:
get:
tags: [Maintenance]
summary: Get maintenance policy
operationId: getMaintenancePolicy
parameters:
- $ref: "#/components/parameters/TenantId"
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenancePolicy"
default:
$ref: "#/components/responses/DefaultProblem"
patch:
tags: [Maintenance]
summary: Update maintenance policy
operationId: patchMaintenancePolicy
parameters:
- $ref: "#/components/parameters/TenantId"
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenancePolicyPatch"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenancePolicy"
default:
$ref: "#/components/responses/DefaultProblem"
delete:
tags: [Maintenance]
summary: Delete maintenance policy
operationId: deleteMaintenancePolicy
parameters:
- $ref: "#/components/parameters/TenantId"
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"204":
description: Удалено.
default:
$ref: "#/components/responses/DefaultProblem"
/v1/maintenance/policies/{id}/hints:
get:
tags: [Maintenance]
summary: PostgreSQL hints for policy table
operationId: getMaintenancePolicyHints
parameters:
- $ref: "#/components/parameters/TenantId"
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
additionalProperties: true
default:
$ref: "#/components/responses/DefaultProblem"
/v1/maintenance/config-audit:
get:
tags: [Maintenance]
summary: Maintenance policy configuration audit log
operationId: listMaintenanceConfigAudit
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
type: object
additionalProperties: true
next_cursor:
type: string
has_more:
type: boolean
default:
$ref: "#/components/responses/DefaultProblem"
/v1/maintenance/run:
post:
tags: [Maintenance]
summary: Run maintenance policy (async job)
operationId: postMaintenanceRun
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenanceRunBody"
responses:
"202":
description: Задача поставлена.
content:
application/json:
schema:
$ref: "#/components/schemas/AsyncJobAccepted"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/maintenance/dry-run:
post:
tags: [Maintenance]
summary: Dry-run maintenance policy (async job)
operationId: postMaintenanceDryRun
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MaintenanceRunBody"
responses:
"202":
description: Задача поставлена.
content:
application/json:
schema:
$ref: "#/components/schemas/AsyncJobAccepted"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+1
View File
@@ -25,6 +25,7 @@ require (
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
+2
View File
@@ -46,6 +46,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+9 -6
View File
@@ -115,22 +115,25 @@ func cmdMaint(args []string, _ string, path string) int {
func cmdCleanup(args []string) int {
fs := flag.NewFlagSet("cleanup", flag.ExitOnError)
policy := fs.String("policy", "", "cleanup policy name")
policyID := fs.String("policy-id", "", "maintenance policy UUID")
dryRun := fs.Bool("dry-run", true, "dry run")
limit := fs.Int("limit", 10000, "max rows")
apiURL := fs.String("api-url", "", "control plane base URL")
token := fs.String("token", "", "Bearer token (operator)")
_ = fs.Parse(args)
if *policy == "" {
fmt.Fprintln(os.Stderr, "cleanup: --policy is required")
if *policyID == "" {
fmt.Fprintln(os.Stderr, "cleanup: --policy-id is required")
return 2
}
if *apiURL == "" || *token == "" {
fmt.Fprintln(os.Stderr, "cleanup: --api-url and --token are required")
return 2
}
payload := map[string]any{"policy": *policy, "dry_run": *dryRun, "limit": *limit}
body, err := apiPOST(*apiURL, *token, "/v1/postgres/cleanup", payload)
path := "/v1/maintenance/run"
if *dryRun {
path = "/v1/maintenance/dry-run"
}
payload := map[string]any{"policy_id": *policyID}
body, err := apiPOST(*apiURL, *token, path, payload)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
+1
View File
@@ -78,6 +78,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
s.registerCRUDRoutes(m)
s.registerPostgresMonitoringRoutes(m)
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+274
View File
@@ -0,0 +1,274 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
func (s *Server) registerMaintenanceRoutes(m *http.ServeMux) {
m.HandleFunc("GET /maintenance/policies", s.handleListMaintenancePolicies)
m.HandleFunc("POST /maintenance/policies", s.handleCreateMaintenancePolicy)
m.HandleFunc("GET /maintenance/policies/{id}", s.handleGetMaintenancePolicy)
m.HandleFunc("PATCH /maintenance/policies/{id}", s.handlePatchMaintenancePolicy)
m.HandleFunc("DELETE /maintenance/policies/{id}", s.handleDeleteMaintenancePolicy)
m.HandleFunc("GET /maintenance/policies/{id}/hints", s.handleMaintenancePolicyHints)
m.HandleFunc("GET /maintenance/config-audit", s.handleListMaintenanceConfigAudit)
m.HandleFunc("POST /maintenance/run", s.handleMaintenanceRun)
m.HandleFunc("POST /maintenance/dry-run", s.handleMaintenanceDryRun)
}
func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
if p == nil {
return map[string]any{}
}
out := map[string]any{
"id": p.ID,
"name": p.Name,
"table_name": p.TableName,
"condition": p.Condition,
"vacuum_strategy": p.VacuumStrategy,
"schedule": p.Schedule,
"enabled": p.Enabled,
"dry_run_enabled": p.DryRunEnabled,
}
if p.RetentionPeriodSec != nil {
out["retention_period_sec"] = *p.RetentionPeriodSec
}
if p.MaxRows != nil {
out["max_rows"] = *p.MaxRows
}
if p.LastRunAt != nil {
out["last_run_at"] = p.LastRunAt.UTC().Format("2006-01-02T15:04:05Z")
}
if p.LastStatus != "" {
out["last_status"] = p.LastStatus
}
if p.LastError != "" {
out["last_error"] = p.LastError
}
if !p.CreatedAt.IsZero() {
out["created_at"] = p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")
}
if !p.UpdatedAt.IsZero() {
out["updated_at"] = p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z")
}
return out
}
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
limit := parseLimitQuery(r, 20, 100)
items, next, hasMore, err := s.store.ListMaintenancePolicies(cursor, limit)
if err != nil {
writeInternalError(w, "maintenance_policies_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, p := range items {
out = append(out, maintenancePolicyJSON(p))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
}
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
return
}
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, maintenancePolicyJSON(p))
}
func (s *Server) handleCreateMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
return
}
var body store.MaintenancePolicy
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
return
}
p, err := s.store.CreateMaintenancePolicy(&body)
if err != nil {
writeStoreErr(w, err)
return
}
_ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), p.ID, "create", nil, maintenancePolicyJSON(p))
observability.IncMaintenanceConfigChange("create")
s.reloadMaintenanceConfig(r)
writeJSON(w, http.StatusCreated, maintenancePolicyJSON(p))
}
func (s *Server) handlePatchMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
return
}
id := r.PathValue("id")
before, err := s.store.GetMaintenancePolicy(id)
if err != nil {
writeStoreErr(w, err)
return
}
var patch store.MaintenancePolicyPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
return
}
updated, err := s.store.UpdateMaintenancePolicy(id, &patch)
if err != nil {
writeStoreErr(w, err)
return
}
_ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), id, "update", maintenancePolicyJSON(before), maintenancePolicyJSON(updated))
observability.IncMaintenanceConfigChange("update")
s.reloadMaintenanceConfig(r)
writeJSON(w, http.StatusOK, maintenancePolicyJSON(updated))
}
func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
return
}
id := r.PathValue("id")
before, err := s.store.GetMaintenancePolicy(id)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.store.DeleteMaintenancePolicy(id); err != nil {
writeStoreErr(w, err)
return
}
_ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), id, "delete", maintenancePolicyJSON(before), nil)
observability.IncMaintenanceConfigChange("delete")
s.reloadMaintenanceConfig(r)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
return
}
if s.maintStats == nil {
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "postgresql backend required")
return
}
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
hints, err := s.maintStats.Hints(r.Context(), p.TableName)
if err != nil {
writeInternalError(w, "maintenance_policy_hints", err)
return
}
writeJSON(w, http.StatusOK, hints)
}
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
limit := parseLimitQuery(r, 20, 100)
items, next, hasMore, err := s.store.ListMaintenancePolicyConfigAudit(cursor, limit)
if err != nil {
writeInternalError(w, "maintenance_config_audit", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, row := range items {
out = append(out, map[string]any{
"id": row.ID,
"policy_id": row.PolicyID,
"actor_prefix": row.ActorPrefix,
"action": row.Action,
"before": row.Before,
"after": row.After,
"created_at": row.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
}
type maintenanceRunBody struct {
PolicyID string `json:"policy_id"`
}
func (s *Server) handleMaintenanceRun(w http.ResponseWriter, r *http.Request) {
s.enqueueMaintenancePolicy(w, r, false)
}
func (s *Server) handleMaintenanceDryRun(w http.ResponseWriter, r *http.Request) {
s.enqueueMaintenancePolicy(w, r, true)
}
func (s *Server) enqueueMaintenancePolicy(w http.ResponseWriter, r *http.Request, dryRun bool) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
return
}
var body maintenanceRunBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
return
}
policyID := strings.TrimSpace(body.PolicyID)
if policyID == "" {
writeProblem(w, http.StatusBadRequest, "Bad Request", "policy_id is required")
return
}
if _, err := s.store.GetMaintenancePolicy(policyID); err != nil {
writeStoreErr(w, err)
return
}
kind := "maintenance_policy_run"
if !s.checkPgMaintRateLimit(a.TenantID, kind+":"+policyID) {
writeProblem(w, http.StatusTooManyRequests, "Too Many Requests", "wait before repeating this maintenance operation")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
title := "Maintenance policy run"
if dryRun {
title = "Maintenance policy dry-run"
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindMaintenancePolicyRun, idemPtr, nil, map[string]any{
"policy_id": policyID, "dry_run": dryRun, "actor_prefix": actorPrefix(a), "job_title": title,
})
if err != nil {
writeInternalError(w, "maintenance_policy_enqueue", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) reloadMaintenanceConfig(r *http.Request) {
if s.maintConfig != nil {
_ = s.maintConfig.Reload(r.Context())
}
}
@@ -0,0 +1,46 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMaintenancePoliciesMemoryBackend503(t *testing.T) {
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
handler := srv.Handler()
tests := []struct {
method string
path string
body string
}{
{http.MethodGet, "/v1/maintenance/policies", ""},
{http.MethodPost, "/v1/maintenance/policies", `{"name":"x","table_name":"job_audit","schedule":"0 3 * * *"}`},
{http.MethodPost, "/v1/maintenance/run", `{"policy_id":"00000000-0000-0000-0000-000000000001"}`},
{http.MethodGet, "/v1/maintenance/config-audit", ""},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
var req *http.Request
if tc.body != "" {
req = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(tc.method, tc.path, nil)
}
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
}
+31 -10
View File
@@ -47,11 +47,12 @@ func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
}
type pgMaintBody struct {
Table string `json:"table"`
DryRun bool `json:"dry_run"`
Index string `json:"index"`
Policy string `json:"policy"`
Limit int `json:"limit"`
Table string `json:"table"`
DryRun bool `json:"dry_run"`
Index string `json:"index"`
Policy string `json:"policy"`
PolicyID string `json:"policy_id"`
Limit int `json:"limit"`
}
func (s *Server) decodePgMaintBody(r *http.Request) (pgMaintBody, bool) {
@@ -168,14 +169,34 @@ func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
return
}
if strings.TrimSpace(body.Policy) == "" {
writeProblem(w, http.StatusBadRequest, "Bad Request", "policy is required")
policyID := strings.TrimSpace(body.PolicyID)
if policyID == "" {
policyID = strings.TrimSpace(body.Policy)
}
if policyID == "" {
writeProblem(w, http.StatusBadRequest, "Bad Request", "policy_id is required")
return
}
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresCleanup, map[string]any{
"policy": body.Policy, "dry_run": body.DryRun, "limit": body.Limit,
"job_title": "PostgreSQL cleanup",
if _, err := s.store.GetMaintenancePolicy(policyID); err != nil {
writeStoreErr(w, err)
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindMaintenancePolicyRun, idemPtr, nil, map[string]any{
"policy_id": policyID, "dry_run": body.DryRun, "actor_prefix": actorPrefix(a),
"job_title": "PostgreSQL cleanup (deprecated path)",
})
if err != nil {
writeInternalError(w, "postgres_maint_enqueue", err)
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
+22 -1
View File
@@ -8,8 +8,10 @@ import (
"errors"
"net/http"
"strings"
"time"
"evobgp/internal/jobs"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
"evobgp/internal/store"
@@ -21,6 +23,8 @@ type Server struct {
store store.Backend
pgPool *pgxpool.Pool
pgMonitor *pgmonitor.Service
maintConfig *maintenance.ConfigProvider
maintStats *maintenance.DBStatsProvider
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
keyResolver *apiKeyResolver
@@ -66,13 +70,20 @@ func New(opts Options) (*Server, error) {
return nil, err
}
var pgMon *pgmonitor.Service
var maintCfg *maintenance.ConfigProvider
var maintStats *maintenance.DBStatsProvider
if pool != nil {
pgMon = pgmonitor.NewService(pool)
maintCfg = maintenance.NewConfigProvider(backend)
_ = maintCfg.Reload(context.Background())
maintStats = maintenance.NewDBStatsProvider(pgMon)
}
s := &Server{
store: backend,
pgPool: pool,
pgMonitor: pgMon,
maintConfig: maintCfg,
maintStats: maintStats,
jobs: reg,
bundlePriv: priv,
keyResolver: resolver,
@@ -97,9 +108,19 @@ func (s *Server) Store() store.Backend { return s.store }
// Jobs exposes the in-process async job registry (for scheduler / evobgp-all).
func (s *Server) Jobs() *jobs.Registry { return s.jobs }
// StartBackground starts PostgreSQL monitoring scheduler until ctx is cancelled.
// StartBackground starts PostgreSQL monitoring and maintenance schedulers until ctx is cancelled.
func (s *Server) StartBackground(ctx context.Context) {
if s != nil && s.pgPool != nil {
pgmonitor.StartScheduler(ctx, s.pgPool)
}
if s != nil && s.maintConfig != nil && s.jobs != nil {
maintenance.StartScheduler(ctx, s.maintConfig, func(policyID string, dryRun bool, idem string) {
key := idem
_, _, _ = s.jobs.Enqueue("", jobs.KindMaintenancePolicyRun, &key, nil, map[string]any{
"policy_id": policyID,
"dry_run": dryRun,
"trigger": "scheduler",
})
}, 30*time.Second)
}
}
-6
View File
@@ -17,8 +17,6 @@ type Deps struct {
Store store.Backend
}
var lastMaintenance time.Time
// Run blocks until ctx is cancelled.
func Run(ctx context.Context, deps *Deps) {
cfg := config.Load()
@@ -36,10 +34,6 @@ func Run(ctx context.Context, deps *Deps) {
log.Printf("evobgp-ingest: stopped")
return
case <-t.C:
if deps.Store != nil && time.Since(lastMaintenance) > time.Hour {
deps.Store.RunPeriodicMaintenance(ctx)
lastMaintenance = time.Now()
}
prefetchCtx, cancel := context.WithTimeout(ctx, 50*time.Second)
err := pipeline.PrefetchCDNSourceETags(prefetchCtx, deps.Store, hc)
cancel()
+63
View File
@@ -0,0 +1,63 @@
package jobs
import (
"errors"
"strings"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
"evobgp/internal/store"
)
func (w *Worker) maintenanceExecutor() *maintenance.PolicyExecutor {
if w == nil {
return nil
}
return &maintenance.PolicyExecutor{Store: w.Store, Pool: w.PgPool}
}
func (w *Worker) runMaintenancePolicy(j *Job) {
if w == nil || w.PgPool == nil {
j.Fail("postgresql not configured")
return
}
policyID, _ := j.Meta["policy_id"].(string)
policyID = strings.TrimSpace(policyID)
if policyID == "" {
j.Fail("missing policy_id in job meta")
return
}
dryRun, _ := j.Meta["dry_run"].(bool)
actor, _ := j.Meta["actor_prefix"].(string)
ctx, cancel := j.workContext()
defer cancel()
pol, err := w.Store.GetMaintenancePolicy(policyID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
j.Fail("maintenance policy not found")
return
}
j.Fail(err.Error())
return
}
auditID, _ := pgmonitor.InsertMaintenanceAuditWithPolicy(ctx, w.PgPool, j.TenantID, actor, "maintenance_policy_run", pol.TableName, policyID, dryRun)
exec := w.maintenanceExecutor()
detail, err := exec.Execute(ctx, pol, dryRun)
var errMsg *string
status := StatusSucceeded
if err != nil {
s := err.Error()
errMsg = &s
status = StatusFailed
_ = w.Store.TouchMaintenancePolicyRun(policyID, status, s)
j.Fail(s)
} else {
j.mergeMeta(map[string]any{"maintenance": detail, "audit_id": auditID, "policy_id": policyID})
j.Succeed()
}
if auditID != "" {
_ = pgmonitor.FinishMaintenanceAudit(ctx, w.PgPool, auditID, status, detail, errMsg)
}
}
+1 -30
View File
@@ -2,7 +2,6 @@ package jobs
import (
"fmt"
"strings"
"evobgp/internal/pgmonitor"
)
@@ -118,35 +117,7 @@ func (w *Worker) runPostgresMaint(j *Job, kind string) {
}
func (w *Worker) runPostgresCleanup(j *Job) {
if w == nil || w.PgPool == nil {
j.Fail("postgresql not configured")
return
}
policy, _ := j.Meta["policy"].(string)
dryRun, _ := j.Meta["dry_run"].(bool)
limit := 0
if v, ok := j.Meta["limit"].(float64); ok {
limit = int(v)
}
actor, _ := j.Meta["actor_prefix"].(string)
ctx, cancel := j.workContext()
defer cancel()
auditID, _ := pgmonitor.InsertMaintenanceAudit(ctx, w.PgPool, j.TenantID, actor, "cleanup", policy, dryRun)
detail, err := pgmonitor.RunCleanup(ctx, w.PgPool, strings.TrimSpace(policy), dryRun, limit)
var errMsg *string
status := StatusSucceeded
if err != nil {
s := err.Error()
errMsg = &s
status = StatusFailed
j.Fail(s)
} else {
j.mergeMeta(map[string]any{"cleanup": detail, "audit_id": auditID})
j.Succeed()
}
if auditID != "" {
_ = pgmonitor.FinishMaintenanceAudit(ctx, w.PgPool, auditID, status, detail, errMsg)
}
j.Fail("postgres_cleanup deprecated: configure maintenance_policy in UI and use maintenance_policy_run")
}
// EnqueuePostgresAnalyzerJobs enqueues periodic analyzer jobs (global tenant id).
+3
View File
@@ -61,6 +61,7 @@ const (
KindPostgresAnalyze = "postgres_analyze"
KindPostgresReindex = "postgres_reindex"
KindPostgresCleanup = "postgres_cleanup"
KindMaintenancePolicyRun = "maintenance_policy_run"
)
// Worker executes queued jobs against store.Backend (memory or SQL).
@@ -188,6 +189,8 @@ func (w *Worker) Process(j *Job) {
w.runPostgresMaint(j, "reindex")
case KindPostgresCleanup:
w.runPostgresCleanup(j)
case KindMaintenancePolicyRun:
w.runMaintenancePolicy(j)
default:
j.Fail("unknown job kind")
}
+67
View File
@@ -0,0 +1,67 @@
package maintenance
import (
"context"
"sync"
"evobgp/internal/store"
)
// ConfigProvider caches maintenance policies from store.Backend with hot reload.
type ConfigProvider struct {
store store.Backend
mu sync.RWMutex
items []*store.MaintenancePolicy
}
// NewConfigProvider constructs a provider; call Reload before use.
func NewConfigProvider(st store.Backend) *ConfigProvider {
return &ConfigProvider{store: st}
}
// Reload loads all policies from the database into memory.
func (c *ConfigProvider) Reload(ctx context.Context) error {
if c == nil || c.store == nil {
return nil
}
_ = ctx
items, _, _, err := c.store.ListMaintenancePolicies("", 1000)
if err != nil {
return err
}
cp := make([]*store.MaintenancePolicy, len(items))
copy(cp, items)
c.mu.Lock()
c.items = cp
c.mu.Unlock()
return nil
}
// Snapshot returns a copy of cached policies.
func (c *ConfigProvider) Snapshot() []*store.MaintenancePolicy {
if c == nil {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]*store.MaintenancePolicy, len(c.items))
copy(out, c.items)
return out
}
// Get returns one policy by id from cache or store.
func (c *ConfigProvider) Get(ctx context.Context, id string) (*store.MaintenancePolicy, error) {
if c == nil || c.store == nil {
return nil, store.ErrNotFound
}
c.mu.RLock()
for _, p := range c.items {
if p.ID == id {
cp := *p
c.mu.RUnlock()
return &cp, nil
}
}
c.mu.RUnlock()
return c.store.GetMaintenancePolicy(id)
}
@@ -0,0 +1,43 @@
package maintenance
import (
"context"
"testing"
"evobgp/internal/store"
)
func TestConfigProviderReloadAndSnapshot(t *testing.T) {
mem := store.NewMemory()
ret := 3600
if _, err := mem.CreateMaintenancePolicy(&store.MaintenancePolicy{
Name: "p1",
TableName: "job_audit",
Schedule: "0 3 * * *",
RetentionPeriodSec: &ret,
Enabled: true,
}); err != nil {
t.Fatal(err)
}
cp := NewConfigProvider(mem)
if err := cp.Reload(context.Background()); err != nil {
t.Fatal(err)
}
snap := cp.Snapshot()
if len(snap) != 1 || snap[0].Name != "p1" {
t.Fatalf("snapshot: %+v", snap)
}
newName := "p1-updated"
if _, err := mem.UpdateMaintenancePolicy(snap[0].ID, &store.MaintenancePolicyPatch{Name: &newName}); err != nil {
t.Fatal(err)
}
if err := cp.Reload(context.Background()); err != nil {
t.Fatal(err)
}
snap2 := cp.Snapshot()
if len(snap2) != 1 || snap2[0].Name != newName {
t.Fatalf("after reload: %+v", snap2)
}
}
+62
View File
@@ -0,0 +1,62 @@
package maintenance
import (
"context"
"fmt"
"evobgp/internal/pgmonitor"
)
// TableHints are PostgreSQL statistics hints for UI recommendations.
type TableHints struct {
TableName string `json:"table_name"`
DeadTuples int64 `json:"n_dead_tup"`
BloatRatio float64 `json:"bloat_ratio,omitempty"`
LastAutovacuum string `json:"last_autovacuum,omitempty"`
RecommendVacuum bool `json:"recommend_vacuum"`
Detail string `json:"detail,omitempty"`
Refs []string `json:"refs,omitempty"`
}
// DBStatsProvider wraps pgmonitor for maintenance policy hints.
type DBStatsProvider struct {
pg *pgmonitor.Service
}
// NewDBStatsProvider constructs a stats provider.
func NewDBStatsProvider(pg *pgmonitor.Service) *DBStatsProvider {
return &DBStatsProvider{pg: pg}
}
// Hints returns table-level vacuum/bloat hints.
func (d *DBStatsProvider) Hints(ctx context.Context, tableName string) (TableHints, error) {
out := TableHints{TableName: tableName}
if d == nil || d.pg == nil {
return out, fmt.Errorf("maintenance: postgres monitoring not configured")
}
if err := ValidateTableName(tableName); err != nil {
return out, err
}
tables, err := d.pg.Tables(ctx, 100)
if err != nil {
return out, err
}
for _, t := range tables {
if t.Relname != tableName {
continue
}
out.DeadTuples = t.DeadTuples
out.BloatRatio = t.BloatRatio
if t.LastAutovacuum != nil {
out.LastAutovacuum = t.LastAutovacuum.UTC().Format("2006-01-02T15:04:05Z")
}
if t.BloatRatio > 0.2 && t.DeadTuples > 5000 {
out.RecommendVacuum = true
out.Detail = "Высокая доля n_dead_tup; рекомендуется VACUUM."
out.Refs = []string{t.Relname}
}
return out, nil
}
out.Detail = "Таблица не найдена в pg_stat_user_tables (top by size)."
return out, nil
}
+2
View File
@@ -0,0 +1,2 @@
// Package maintenance implements PostgreSQL maintenance policies loaded from the database.
package maintenance
+195
View File
@@ -0,0 +1,195 @@
package maintenance
import (
"context"
"fmt"
"hash/fnv"
"strings"
"time"
"evobgp/internal/observability"
"evobgp/internal/pgmonitor"
"evobgp/internal/store"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// PolicyExecutor runs maintenance policies against PostgreSQL.
type PolicyExecutor struct {
Store store.Backend
Pool *pgxpool.Pool
}
// Execute runs cleanup and/or vacuum steps for a policy.
func (e *PolicyExecutor) Execute(ctx context.Context, policy *store.MaintenancePolicy, dryRun bool) (map[string]any, error) {
start := time.Now()
if policy == nil {
return nil, store.ErrInvalidInput
}
action := policyAction(policy)
record := func(status string, detail map[string]any) {
observability.RecordMaintenancePolicyRun(policy.ID, action, status, dryRun, time.Since(start), rowsDeletedFromDetail(detail))
}
if e == nil || e.Pool == nil {
record("failed", nil)
return nil, fmt.Errorf("maintenance: postgres not configured")
}
if err := ValidateTableName(policy.TableName); err != nil {
record("failed", nil)
return nil, err
}
if err := ValidateCondition(policy.Condition); err != nil {
record("failed", nil)
return nil, err
}
if !store.ValidVacuumStrategy(policy.VacuumStrategy) {
record("failed", nil)
return nil, store.ErrInvalidInput
}
detail := map[string]any{
"policy_id": policy.ID,
"table": policy.TableName,
"dry_run": dryRun,
}
if policy.RetentionPeriodSec != nil || policy.MaxRows != nil {
cleanupDetail, err := e.runCleanup(ctx, policy, dryRun)
for k, v := range cleanupDetail {
detail[k] = v
}
if err != nil {
record("failed", detail)
return detail, err
}
}
if policy.VacuumStrategy != store.VacuumStrategyNone {
kind := vacuumKind(policy.VacuumStrategy)
vacDetail, err := pgmonitor.ExecMaintenance(ctx, e.Pool, kind, policy.TableName, dryRun)
if vacDetail != nil {
detail["vacuum"] = vacDetail
}
if err != nil {
record("failed", detail)
return detail, err
}
}
if !dryRun && e.Store != nil {
_ = e.Store.TouchMaintenancePolicyRun(policy.ID, "succeeded", "")
}
record("succeeded", detail)
return detail, nil
}
func policyAction(p *store.MaintenancePolicy) string {
if p == nil {
return "run"
}
if p.RetentionPeriodSec != nil || p.MaxRows != nil {
if p.VacuumStrategy != store.VacuumStrategyNone {
return "cleanup_vacuum"
}
return "cleanup"
}
if p.VacuumStrategy != store.VacuumStrategyNone {
return p.VacuumStrategy
}
return "run"
}
func rowsDeletedFromDetail(detail map[string]any) int64 {
if detail == nil {
return 0
}
switch v := detail["deleted"].(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
default:
return 0
}
}
func (e *PolicyExecutor) runCleanup(ctx context.Context, policy *store.MaintenancePolicy, dryRun bool) (map[string]any, error) {
detail := map[string]any{"cleanup": true}
limit := NormalizeBatchLimit(policy.MaxRows)
qualTable := pgx.Identifier{policy.TableName}.Sanitize()
cond := store.NormalizeMaintenancePolicyCondition(policy.Condition)
tx, err := e.Pool.Begin(ctx)
if err != nil {
return detail, fmt.Errorf("maintenance: begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
lockKey := advisoryKey(policy.ID)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, lockKey); err != nil {
return detail, fmt.Errorf("maintenance: advisory lock: %w", err)
}
if _, err := tx.Exec(ctx, fmt.Sprintf(`SET LOCAL statement_timeout = '%ds'`, DefaultStatementTimeoutSec)); err != nil {
return detail, fmt.Errorf("maintenance: statement_timeout: %w", err)
}
var args []any
where := cond
argN := 1
if policy.RetentionPeriodSec != nil && *policy.RetentionPeriodSec > 0 {
cutoff := time.Now().UTC().Add(-time.Duration(*policy.RetentionPeriodSec) * time.Second)
where = fmt.Sprintf("(%s) AND created_at < $%d", cond, argN)
args = append(args, cutoff)
argN++
}
countSQL := fmt.Sprintf(`SELECT count(*) FROM %s WHERE %s`, qualTable, where)
var wouldDelete int64
if err := tx.QueryRow(ctx, countSQL, args...).Scan(&wouldDelete); err != nil {
return detail, fmt.Errorf("maintenance: count: %w", err)
}
detail["would_delete"] = wouldDelete
if dryRun {
return detail, nil
}
deleteSQL := fmt.Sprintf(`
DELETE FROM %s WHERE ctid IN (
SELECT ctid FROM %s WHERE %s LIMIT $%d
)`, qualTable, qualTable, where, argN)
args = append(args, limit)
tag, err := tx.Exec(ctx, deleteSQL, args...)
if err != nil {
return detail, fmt.Errorf("maintenance: delete: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return detail, fmt.Errorf("maintenance: commit: %w", err)
}
detail["deleted"] = tag.RowsAffected()
return detail, nil
}
func vacuumKind(strategy string) string {
switch strings.TrimSpace(strategy) {
case store.VacuumStrategyVacuum:
return "vacuum"
case store.VacuumStrategyAnalyze:
return "analyze"
case store.VacuumStrategyVacuumAnalyze:
return "vacuum_analyze"
case store.VacuumStrategyReindex:
return "reindex"
default:
return "vacuum"
}
}
func advisoryKey(policyID string) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte("maint:" + policyID))
return int64(h.Sum64())
}
@@ -0,0 +1,161 @@
package maintenance
import (
"context"
"errors"
"strings"
"testing"
"evobgp/internal/store"
)
func validPolicy() *store.MaintenancePolicy {
ret := 86400
return &store.MaintenancePolicy{
ID: "11111111-1111-1111-1111-111111111111",
Name: "job audit",
TableName: "job_audit",
Condition: "true",
RetentionPeriodSec: &ret,
VacuumStrategy: store.VacuumStrategyNone,
Schedule: "0 3 * * *",
Enabled: true,
}
}
func TestPolicyExecutorExecuteValidation(t *testing.T) {
ctx := context.Background()
mem := store.NewMemory()
base := validPolicy()
tests := []struct {
name string
exec *PolicyExecutor
policy *store.MaintenancePolicy
wantErr error
contains string
}{
{
name: "nil policy",
exec: &PolicyExecutor{Store: mem},
policy: nil,
wantErr: store.ErrInvalidInput,
},
{
name: "nil pool",
exec: &PolicyExecutor{Store: mem, Pool: nil},
policy: base,
contains: "postgres not configured",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.exec.Execute(ctx, tc.policy, false)
if tc.wantErr != nil {
if !errors.Is(err, tc.wantErr) {
t.Fatalf("Execute() err=%v want %v", err, tc.wantErr)
}
return
}
if err == nil {
t.Fatal("Execute() expected error")
}
if tc.contains != "" && !strings.Contains(err.Error(), tc.contains) {
t.Fatalf("Execute() err=%q want substring %q", err, tc.contains)
}
})
}
}
func TestPolicyExecutorPreExecuteValidation(t *testing.T) {
p := validPolicy()
p.TableName = "tenant"
if err := ValidateTableName(p.TableName); err == nil {
t.Fatal("expected blocked table error")
}
p = validPolicy()
p.Condition = "1=1; DROP TABLE job_audit"
if err := ValidateCondition(p.Condition); err == nil {
t.Fatal("expected unsafe condition error")
}
p = validPolicy()
p.VacuumStrategy = "invalid"
if !store.ValidVacuumStrategy(p.VacuumStrategy) {
return
}
t.Fatal("expected invalid vacuum strategy")
}
func TestPolicyAction(t *testing.T) {
ret := 3600
tests := []struct {
name string
p *store.MaintenancePolicy
want string
}{
{"nil", nil, "run"},
{"cleanup only", &store.MaintenancePolicy{RetentionPeriodSec: &ret, VacuumStrategy: store.VacuumStrategyNone}, "cleanup"},
{"vacuum only", &store.MaintenancePolicy{VacuumStrategy: store.VacuumStrategyVacuum}, "vacuum"},
{"cleanup+vacuum", &store.MaintenancePolicy{MaxRows: &ret, VacuumStrategy: store.VacuumStrategyAnalyze}, "cleanup_vacuum"},
{"noop run", &store.MaintenancePolicy{VacuumStrategy: store.VacuumStrategyNone}, "run"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := policyAction(tc.p); got != tc.want {
t.Fatalf("policyAction()=%q want %q", got, tc.want)
}
})
}
}
func TestVacuumKind(t *testing.T) {
tests := []struct {
strategy string
want string
}{
{store.VacuumStrategyVacuum, "vacuum"},
{store.VacuumStrategyAnalyze, "analyze"},
{store.VacuumStrategyVacuumAnalyze, "vacuum_analyze"},
{store.VacuumStrategyReindex, "reindex"},
{"unknown", "vacuum"},
}
for _, tc := range tests {
if got := vacuumKind(tc.strategy); got != tc.want {
t.Fatalf("vacuumKind(%q)=%q want %q", tc.strategy, got, tc.want)
}
}
}
func TestRowsDeletedFromDetail(t *testing.T) {
tests := []struct {
name string
detail map[string]any
want int64
}{
{"nil", nil, 0},
{"int64", map[string]any{"deleted": int64(42)}, 42},
{"int", map[string]any{"deleted": 7}, 7},
{"float64", map[string]any{"deleted": float64(3)}, 3},
{"missing", map[string]any{"other": 1}, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := rowsDeletedFromDetail(tc.detail); got != tc.want {
t.Fatalf("rowsDeletedFromDetail()=%d want %d", got, tc.want)
}
})
}
}
func TestAdvisoryKeyStable(t *testing.T) {
a := advisoryKey("policy-a")
b := advisoryKey("policy-a")
c := advisoryKey("policy-b")
if a != b {
t.Fatal("advisory key not stable for same id")
}
if a == c {
t.Fatal("advisory key collision for different ids")
}
}
+72
View File
@@ -0,0 +1,72 @@
package maintenance
import (
"fmt"
"regexp"
"strings"
)
const (
DefaultBatchRows = 10000
MaxBatchRows = 100000
DefaultStatementTimeoutSec = 30
)
var (
blockedTableNames = map[string]struct{}{
"schema_migrations": {},
"tenant": {},
"maintenance_policy": {},
"maintenance_policy_config_audit": {},
}
sqlForbidden = regexp.MustCompile(`(?i)(;|--|/\*|\b(drop|truncate|insert|update|alter|create|grant|revoke|copy)\b)`)
)
// ValidateTableName ensures table is a safe identifier and not blocked.
func ValidateTableName(name string) error {
name = strings.TrimSpace(name)
if name == "" || !isSafeIdent(name) {
return fmt.Errorf("maintenance: invalid table name")
}
if _, blocked := blockedTableNames[strings.ToLower(name)]; blocked {
return fmt.Errorf("maintenance: table %q is not allowed", name)
}
return nil
}
// ValidateCondition ensures the WHERE fragment is safe for parameterized cleanup.
func ValidateCondition(condition string) error {
c := strings.TrimSpace(condition)
if c == "" {
return nil
}
if sqlForbidden.MatchString(c) {
return fmt.Errorf("maintenance: unsafe condition")
}
return nil
}
// NormalizeBatchLimit clamps delete batch size.
func NormalizeBatchLimit(maxRows *int) int {
if maxRows == nil || *maxRows <= 0 {
return DefaultBatchRows
}
if *maxRows > MaxBatchRows {
return MaxBatchRows
}
return *maxRows
}
func isSafeIdent(name string) bool {
if name == "" {
return false
}
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
+46
View File
@@ -0,0 +1,46 @@
package maintenance
import "testing"
func TestValidateCondition(t *testing.T) {
tests := []struct {
cond string
ok bool
}{
{"true", true},
{"status IN ('succeeded', 'failed')", true},
{"1=1; DROP TABLE tenant", false},
{"x -- comment", false},
}
for _, tc := range tests {
err := ValidateCondition(tc.cond)
if tc.ok && err != nil {
t.Fatalf("cond %q: want ok, got %v", tc.cond, err)
}
if !tc.ok && err == nil {
t.Fatalf("cond %q: want error", tc.cond)
}
}
}
func TestValidateTableName(t *testing.T) {
if err := ValidateTableName("job_audit"); err != nil {
t.Fatal(err)
}
if err := ValidateTableName("tenant"); err == nil {
t.Fatal("expected blocked table")
}
if err := ValidateTableName("bad-name"); err == nil {
t.Fatal("expected invalid ident")
}
}
func TestNormalizeBatchLimit(t *testing.T) {
if got := NormalizeBatchLimit(nil); got != DefaultBatchRows {
t.Fatalf("default=%d got=%d", DefaultBatchRows, got)
}
max := 200000
if got := NormalizeBatchLimit(&max); got != MaxBatchRows {
t.Fatalf("max=%d got=%d", MaxBatchRows, got)
}
}
+85
View File
@@ -0,0 +1,85 @@
package maintenance
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
// StartScheduler enqueues maintenance_policy_run jobs when cron schedules match.
func StartScheduler(ctx context.Context, provider *ConfigProvider, enqueue func(policyID string, dryRun bool, idempotencyKey string), tick time.Duration) {
if provider == nil || enqueue == nil {
return
}
if tick <= 0 {
tick = 30 * time.Second
}
go func() {
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
var mu sync.Mutex
schedules := map[string]cron.Schedule{}
lastFired := map[string]time.Time{}
rebuild := func() {
mu.Lock()
defer mu.Unlock()
schedules = map[string]cron.Schedule{}
for _, p := range provider.Snapshot() {
if p == nil || !p.Enabled || strings.TrimSpace(p.Schedule) == "" {
continue
}
sched, err := parser.Parse(p.Schedule)
if err != nil {
log.Printf("maintenance: invalid cron for policy %s: %v", p.ID, err)
continue
}
schedules[p.ID] = sched
}
}
rebuild()
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
rebuild()
now := time.Now().UTC()
mu.Lock()
for _, p := range provider.Snapshot() {
if p == nil || !p.Enabled {
continue
}
sched, ok := schedules[p.ID]
if !ok {
continue
}
prev := lastFired[p.ID]
if prev.IsZero() {
prev = now.Add(-time.Minute)
}
next := sched.Next(prev)
if next.After(now) {
continue
}
slot := next.Unix() / 60
if lf, ok := lastFired[p.ID]; ok && lf.Unix()/60 == slot {
continue
}
lastFired[p.ID] = next
idem := fmt.Sprintf("maint-%s-%d", p.ID, slot)
enqueue(p.ID, p.DryRunEnabled, idem)
}
mu.Unlock()
}
}
}()
log.Printf("maintenance: policy scheduler started (tick=%s)", tick)
}
@@ -0,0 +1,72 @@
package observability
import (
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
maintenancePolicyRuns = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "maintenance_policy_runs_total",
Help: "Maintenance policy executions by outcome.",
},
[]string{"policy_id", "action", "status", "dry_run"},
)
maintenancePolicyDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: namespace,
Name: "maintenance_policy_duration_seconds",
Help: "Maintenance policy execution duration.",
Buckets: prometheus.ExponentialBuckets(0.05, 2, 12),
},
[]string{"policy_id", "action"},
)
maintenanceRowsDeleted = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "maintenance_policy_rows_deleted_total",
Help: "Rows deleted by maintenance cleanup policies.",
},
[]string{"policy_id"},
)
maintenanceConfigChanges = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "maintenance_config_changes_total",
Help: "Maintenance policy configuration changes from UI/API.",
},
[]string{"action"},
)
)
// RecordMaintenancePolicyRun updates run counters and histograms.
func RecordMaintenancePolicyRun(policyID, action, status string, dryRun bool, duration time.Duration, rowsDeleted int64) {
if policyID == "" {
policyID = "unknown"
}
if action == "" {
action = "run"
}
dry := strconv.FormatBool(dryRun)
maintenancePolicyRuns.WithLabelValues(policyID, action, status, dry).Inc()
maintenancePolicyDuration.WithLabelValues(policyID, action).Observe(duration.Seconds())
if rowsDeleted > 0 && !dryRun {
maintenanceRowsDeleted.WithLabelValues(policyID).Add(float64(rowsDeleted))
}
}
// IncMaintenanceConfigChange increments config audit metric.
func IncMaintenanceConfigChange(action string) {
if action == "" {
action = "unknown"
}
maintenanceConfigChanges.WithLabelValues(action).Inc()
}
+13 -71
View File
@@ -3,91 +3,33 @@ package pgmonitor
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// CleanupPolicy names safe retention policies.
type CleanupPolicy string
const (
PolicyJobAuditRetention CleanupPolicy = "job_audit_retention"
PolicyASNCacheRetention CleanupPolicy = "asn_cache_retention"
)
// CleanupRequest for POST /postgres/cleanup.
// CleanupRequest for deprecated POST /postgres/cleanup (use /v1/maintenance/run).
type CleanupRequest struct {
Policy string `json:"policy"`
DryRun bool `json:"dry_run"`
Limit int `json:"limit"`
}
// RunCleanup executes a named retention policy.
func RunCleanup(ctx context.Context, pool *pgxpool.Pool, policy string, dryRun bool, limit int) (map[string]any, error) {
if pool == nil {
return nil, fmt.Errorf("pgmonitor: postgres not configured")
}
if limit <= 0 {
limit = 10000
}
if limit > 100000 {
limit = 100000
}
detail := map[string]any{"policy": policy, "dry_run": dryRun, "limit": limit}
switch CleanupPolicy(policy) {
case PolicyJobAuditRetention:
cutoff := time.Now().UTC().Add(-90 * 24 * time.Hour)
if dryRun {
var n int64
err := pool.QueryRow(ctx, `
SELECT count(*) FROM job_audit
WHERE created_at < $1 AND status IN ('succeeded', 'failed', 'cancelled')`, cutoff).Scan(&n)
detail["would_delete"] = n
return detail, err
}
tag, err := pool.Exec(ctx, `
DELETE FROM job_audit
WHERE id IN (
SELECT id FROM job_audit
WHERE created_at < $1 AND status IN ('succeeded', 'failed', 'cancelled')
LIMIT $2
)`, cutoff, limit)
if err != nil {
return detail, err
}
detail["deleted"] = tag.RowsAffected()
return detail, nil
case PolicyASNCacheRetention:
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
if dryRun {
var n int64
err := pool.QueryRow(ctx, `SELECT count(*) FROM asn_prefix_cache WHERE fetched_at < $1`, cutoff).Scan(&n)
detail["would_delete"] = n
return detail, err
}
tag, err := pool.Exec(ctx, `
DELETE FROM asn_prefix_cache WHERE fetched_at < $1`, cutoff)
if err != nil {
return detail, err
}
detail["deleted"] = tag.RowsAffected()
return detail, nil
default:
return nil, fmt.Errorf("pgmonitor: unknown cleanup policy %q", policy)
}
PolicyID string `json:"policy_id"`
Policy string `json:"policy"`
DryRun bool `json:"dry_run"`
Limit int `json:"limit"`
}
// InsertMaintenanceAudit records an audit row at job start.
func InsertMaintenanceAudit(ctx context.Context, pool *pgxpool.Pool, tenantID, actorPrefix, kind, table string, dryRun bool) (string, error) {
return InsertMaintenanceAuditWithPolicy(ctx, pool, tenantID, actorPrefix, kind, table, "", dryRun)
}
// InsertMaintenanceAuditWithPolicy records an audit row linked to maintenance_policy.
func InsertMaintenanceAuditWithPolicy(ctx context.Context, pool *pgxpool.Pool, tenantID, actorPrefix, kind, table, policyID string, dryRun bool) (string, error) {
id := uuid.New().String()
_, err := pool.Exec(ctx, `
INSERT INTO postgres_maintenance_audit
(id, tenant_id, actor_prefix, kind, target_table, dry_run, status, created_at)
VALUES ($1, NULLIF($2,''), NULLIF($3,''), $4, NULLIF($5,''), $6, 'running', now())`,
id, tenantID, actorPrefix, kind, table, dryRun)
(id, tenant_id, actor_prefix, kind, target_table, policy_id, dry_run, status, created_at)
VALUES ($1, NULLIF($2,''), NULLIF($3,''), $4, NULLIF($5,''), NULLIF($6,''), $7, 'running', now())`,
id, tenantID, actorPrefix, kind, table, policyID, dryRun)
return id, err
}
+3 -24
View File
@@ -1,29 +1,8 @@
package repository
import (
"context"
"time"
)
import "context"
const (
jobAuditRetentionDays = 90
asnCacheRetentionDays = 7
)
// RunPeriodicMaintenance prunes stale job_audit and asn_prefix_cache rows (PostgreSQL).
// RunPeriodicMaintenance is a no-op; retention is driven by maintenance_policy rows (UI-configured).
func (p *Postgres) RunPeriodicMaintenance(ctx context.Context) {
if p == nil || p.pool == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
jobCutoff := time.Now().UTC().Add(-time.Duration(jobAuditRetentionDays) * 24 * time.Hour)
_, _ = p.pool.Exec(ctx, `
DELETE FROM job_audit
WHERE created_at < $1
AND status IN ('succeeded', 'failed', 'cancelled')`, jobCutoff)
asnCutoff := time.Now().UTC().Add(-time.Duration(asnCacheRetentionDays) * 24 * time.Hour)
_, _ = p.pool.Exec(ctx, `
DELETE FROM asn_prefix_cache WHERE fetched_at < $1`, asnCutoff)
_ = ctx
}
@@ -0,0 +1,304 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const maintenancePolicySelect = `
SELECT id, name, table_name, condition_sql, retention_period_sec, max_rows,
vacuum_strategy, schedule_cron, enabled, dry_run_enabled,
last_run_at, COALESCE(last_status, ''), COALESCE(last_error, ''),
created_at, updated_at
FROM maintenance_policy`
func scanMaintenancePolicy(row pgx.Row) (*store.MaintenancePolicy, error) {
var p store.MaintenancePolicy
var retention, maxRows *int32
var lastRun *time.Time
err := row.Scan(
&p.ID, &p.Name, &p.TableName, &p.Condition, &retention, &maxRows,
&p.VacuumStrategy, &p.Schedule, &p.Enabled, &p.DryRunEnabled,
&lastRun, &p.LastStatus, &p.LastError, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
if retention != nil {
v := int(*retention)
p.RetentionPeriodSec = &v
}
if maxRows != nil {
v := int(*maxRows)
p.MaxRows = &v
}
if lastRun != nil {
t := lastRun.UTC()
p.LastRunAt = &t
}
return &p, nil
}
func (p *Postgres) ListMaintenancePolicies(cursor string, limit int) ([]*store.MaintenancePolicy, string, bool, error) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, maintenancePolicySelect+`
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2`, limit+1, off)
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var out []*store.MaintenancePolicy
for rows.Next() {
pol, err := scanMaintenancePolicy(rows)
if err != nil {
continue
}
out = append(out, pol)
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = strconv.Itoa(off + limit)
}
return out, next, more, rows.Err()
}
func (p *Postgres) GetMaintenancePolicy(id string) (*store.MaintenancePolicy, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, maintenancePolicySelect+` WHERE id=$1`, id)
pol, err := scanMaintenancePolicy(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return pol, nil
}
func (p *Postgres) CreateMaintenancePolicy(in *store.MaintenancePolicy) (*store.MaintenancePolicy, error) {
if in == nil {
return nil, store.ErrInvalidInput
}
vacuum := in.VacuumStrategy
if vacuum == "" {
vacuum = store.VacuumStrategyNone
}
if err := store.ValidateMaintenancePolicyInput(in.Name, in.TableName, vacuum, in.Schedule); err != nil {
return nil, err
}
ctx := context.Background()
id := uuid.NewString()
now := time.Now().UTC()
condition := store.NormalizeMaintenancePolicyCondition(in.Condition)
var retention, maxRows *int32
if in.RetentionPeriodSec != nil {
v := int32(*in.RetentionPeriodSec)
retention = &v
}
if in.MaxRows != nil {
v := int32(*in.MaxRows)
maxRows = &v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO maintenance_policy (
id, name, table_name, condition_sql, retention_period_sec, max_rows,
vacuum_strategy, schedule_cron, enabled, dry_run_enabled, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)`,
id, strings.TrimSpace(in.Name), strings.TrimSpace(in.TableName), condition,
retention, maxRows, vacuum, strings.TrimSpace(in.Schedule),
in.Enabled, in.DryRunEnabled, now)
if err != nil {
return nil, err
}
return p.GetMaintenancePolicy(id)
}
func (p *Postgres) UpdateMaintenancePolicy(id string, patch *store.MaintenancePolicyPatch) (*store.MaintenancePolicy, error) {
if patch == nil {
return nil, store.ErrInvalidInput
}
cur, err := p.GetMaintenancePolicy(id)
if err != nil {
return nil, err
}
if patch.Name != nil {
cur.Name = strings.TrimSpace(*patch.Name)
}
if patch.TableName != nil {
cur.TableName = strings.TrimSpace(*patch.TableName)
}
if patch.Condition != nil {
cur.Condition = store.NormalizeMaintenancePolicyCondition(*patch.Condition)
}
if patch.RetentionPeriodSec != nil {
cur.RetentionPeriodSec = patch.RetentionPeriodSec
}
if patch.MaxRows != nil {
cur.MaxRows = patch.MaxRows
}
if patch.VacuumStrategy != nil {
if !store.ValidVacuumStrategy(*patch.VacuumStrategy) {
return nil, store.ErrInvalidInput
}
cur.VacuumStrategy = strings.TrimSpace(*patch.VacuumStrategy)
}
if patch.Schedule != nil {
cur.Schedule = strings.TrimSpace(*patch.Schedule)
}
if patch.Enabled != nil {
cur.Enabled = *patch.Enabled
}
if patch.DryRunEnabled != nil {
cur.DryRunEnabled = *patch.DryRunEnabled
}
if err := store.ValidateMaintenancePolicyInput(cur.Name, cur.TableName, cur.VacuumStrategy, cur.Schedule); err != nil {
return nil, err
}
var retention, maxRows *int32
if cur.RetentionPeriodSec != nil {
v := int32(*cur.RetentionPeriodSec)
retention = &v
}
if cur.MaxRows != nil {
v := int32(*cur.MaxRows)
maxRows = &v
}
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE maintenance_policy SET
name=$2, table_name=$3, condition_sql=$4, retention_period_sec=$5, max_rows=$6,
vacuum_strategy=$7, schedule_cron=$8, enabled=$9, dry_run_enabled=$10, updated_at=now()
WHERE id=$1`,
id, cur.Name, cur.TableName, cur.Condition, retention, maxRows,
cur.VacuumStrategy, cur.Schedule, cur.Enabled, cur.DryRunEnabled)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, store.ErrNotFound
}
return p.GetMaintenancePolicy(id)
}
func (p *Postgres) DeleteMaintenancePolicy(id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `DELETE FROM maintenance_policy WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) TouchMaintenancePolicyRun(id, status, errMsg string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE maintenance_policy SET
last_run_at=now(), last_status=$2, last_error=NULLIF($3,''), updated_at=now()
WHERE id=$1`, id, status, errMsg)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error {
ctx := context.Background()
id := uuid.NewString()
var beforeJSON, afterJSON []byte
if before != nil {
beforeJSON, _ = json.Marshal(before)
}
if after != nil {
afterJSON, _ = json.Marshal(after)
}
_, err := p.pool.Exec(ctx, `
INSERT INTO maintenance_policy_config_audit
(id, policy_id, actor_prefix, action, before_json, after_json, created_at)
VALUES ($1, NULLIF($2,''), $3, $4, $5::jsonb, $6::jsonb, now())`,
id, policyID, strings.TrimSpace(actor), action,
nullJSONBytes(beforeJSON), nullJSONBytes(afterJSON))
return err
}
func nullJSONBytes(b []byte) any {
if len(b) == 0 {
return nil
}
return string(b)
}
func (p *Postgres) ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*store.MaintenancePolicyConfigAudit, string, bool, error) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, COALESCE(policy_id,''), actor_prefix, action,
before_json, after_json, created_at
FROM maintenance_policy_config_audit
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2`, limit+1, off)
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var out []*store.MaintenancePolicyConfigAudit
for rows.Next() {
var r store.MaintenancePolicyConfigAudit
var beforeRaw, afterRaw []byte
if err := rows.Scan(&r.ID, &r.PolicyID, &r.ActorPrefix, &r.Action, &beforeRaw, &afterRaw, &r.CreatedAt); err != nil {
continue
}
if len(beforeRaw) > 0 {
_ = json.Unmarshal(beforeRaw, &r.Before)
}
if len(afterRaw) > 0 {
_ = json.Unmarshal(afterRaw, &r.After)
}
out = append(out, &r)
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = strconv.Itoa(off + limit)
}
return out, next, more, rows.Err()
}
+10
View File
@@ -115,6 +115,16 @@ type Backend interface {
// RunPeriodicMaintenance prunes stale DB rows (no-op for in-memory).
RunPeriodicMaintenance(ctx context.Context)
// Maintenance policies (instance-scoped PostgreSQL maintenance configuration).
ListMaintenancePolicies(cursor string, limit int) ([]*MaintenancePolicy, string, bool, error)
GetMaintenancePolicy(id string) (*MaintenancePolicy, error)
CreateMaintenancePolicy(in *MaintenancePolicy) (*MaintenancePolicy, error)
UpdateMaintenancePolicy(id string, patch *MaintenancePolicyPatch) (*MaintenancePolicy, error)
DeleteMaintenancePolicy(id string) error
TouchMaintenancePolicyRun(id, status, errMsg string) error
AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error
ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error)
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+89
View File
@@ -0,0 +1,89 @@
package store
import (
"strings"
"time"
)
// Vacuum strategy values for maintenance_policy.vacuum_strategy.
const (
VacuumStrategyNone = "none"
VacuumStrategyVacuum = "vacuum"
VacuumStrategyAnalyze = "analyze"
VacuumStrategyVacuumAnalyze = "vacuum_analyze"
VacuumStrategyReindex = "reindex"
)
// MaintenancePolicy is an instance-scoped PostgreSQL maintenance policy (control plane DB).
type MaintenancePolicy struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
TableName string `json:"table_name"`
Condition string `json:"condition"`
RetentionPeriodSec *int `json:"retention_period_sec,omitempty"`
MaxRows *int `json:"max_rows,omitempty"`
VacuumStrategy string `json:"vacuum_strategy"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
DryRunEnabled bool `json:"dry_run_enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// MaintenancePolicyPatch is a partial update for maintenance_policy.
type MaintenancePolicyPatch struct {
Name *string `json:"name,omitempty"`
TableName *string `json:"table_name,omitempty"`
Condition *string `json:"condition,omitempty"`
RetentionPeriodSec *int `json:"retention_period_sec,omitempty"`
MaxRows *int `json:"max_rows,omitempty"`
VacuumStrategy *string `json:"vacuum_strategy,omitempty"`
Schedule *string `json:"schedule,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
DryRunEnabled *bool `json:"dry_run_enabled,omitempty"`
}
// MaintenancePolicyConfigAudit is a configuration change log entry.
type MaintenancePolicyConfigAudit struct {
ID string `json:"id"`
PolicyID string `json:"policy_id,omitempty"`
ActorPrefix string `json:"actor_prefix"`
Action string `json:"action"`
Before map[string]any `json:"before,omitempty"`
After map[string]any `json:"after,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ValidVacuumStrategy reports whether s is an allowed vacuum_strategy value.
func ValidVacuumStrategy(s string) bool {
switch strings.TrimSpace(s) {
case VacuumStrategyNone, VacuumStrategyVacuum, VacuumStrategyAnalyze,
VacuumStrategyVacuumAnalyze, VacuumStrategyReindex:
return true
default:
return false
}
}
// NormalizeMaintenancePolicyCondition returns a safe default WHERE fragment.
func NormalizeMaintenancePolicyCondition(condition string) string {
c := strings.TrimSpace(condition)
if c == "" {
return "true"
}
return c
}
// ValidateMaintenancePolicyInput checks required fields for create/update payloads.
func ValidateMaintenancePolicyInput(name, tableName, vacuumStrategy, schedule string) error {
if strings.TrimSpace(name) == "" || strings.TrimSpace(tableName) == "" || strings.TrimSpace(schedule) == "" {
return ErrInvalidInput
}
if !ValidVacuumStrategy(vacuumStrategy) {
return ErrInvalidInput
}
return nil
}
+32 -28
View File
@@ -33,17 +33,19 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -123,23 +125,25 @@ type Speaker struct {
func NewMemory() *Memory {
return &Memory{
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
}
}
+244
View File
@@ -0,0 +1,244 @@
package store
import (
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) ListMaintenancePolicies(cursor string, limit int) ([]*MaintenancePolicy, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
all := make([]*MaintenancePolicy, 0, len(m.maintenancePolicies))
for _, p := range m.maintenancePolicies {
all = append(all, p)
}
sort.Slice(all, func(i, j int) bool {
if all[i].CreatedAt.Equal(all[j].CreatedAt) {
return all[i].ID > all[j].ID
}
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(all) {
end = len(all)
} else if end < len(all) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(all) {
return nil, "", false, nil
}
out := make([]*MaintenancePolicy, end-off)
copy(out, all[off:end])
return out, next, hasMore, nil
}
func (m *Memory) GetMaintenancePolicy(id string) (*MaintenancePolicy, error) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.maintenancePolicies[id]
if !ok {
return nil, ErrNotFound
}
return cloneMaintenancePolicy(p), nil
}
func (m *Memory) CreateMaintenancePolicy(in *MaintenancePolicy) (*MaintenancePolicy, error) {
if in == nil {
return nil, ErrInvalidInput
}
vacuum := in.VacuumStrategy
if vacuum == "" {
vacuum = VacuumStrategyNone
}
if err := ValidateMaintenancePolicyInput(in.Name, in.TableName, vacuum, in.Schedule); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UTC()
id := uuid.NewString()
p := &MaintenancePolicy{
ID: id,
Name: strings.TrimSpace(in.Name),
TableName: strings.TrimSpace(in.TableName),
Condition: NormalizeMaintenancePolicyCondition(in.Condition),
RetentionPeriodSec: in.RetentionPeriodSec,
MaxRows: in.MaxRows,
VacuumStrategy: vacuum,
Schedule: strings.TrimSpace(in.Schedule),
Enabled: in.Enabled,
DryRunEnabled: in.DryRunEnabled,
CreatedAt: now,
UpdatedAt: now,
}
m.maintenancePolicies[id] = p
return cloneMaintenancePolicy(p), nil
}
func (m *Memory) UpdateMaintenancePolicy(id string, patch *MaintenancePolicyPatch) (*MaintenancePolicy, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.maintenancePolicies[id]
if !ok {
return nil, ErrNotFound
}
if patch.Name != nil {
p.Name = strings.TrimSpace(*patch.Name)
}
if patch.TableName != nil {
p.TableName = strings.TrimSpace(*patch.TableName)
}
if patch.Condition != nil {
p.Condition = NormalizeMaintenancePolicyCondition(*patch.Condition)
}
if patch.RetentionPeriodSec != nil {
p.RetentionPeriodSec = patch.RetentionPeriodSec
}
if patch.MaxRows != nil {
p.MaxRows = patch.MaxRows
}
if patch.VacuumStrategy != nil {
if !ValidVacuumStrategy(*patch.VacuumStrategy) {
return nil, ErrInvalidInput
}
p.VacuumStrategy = strings.TrimSpace(*patch.VacuumStrategy)
}
if patch.Schedule != nil {
p.Schedule = strings.TrimSpace(*patch.Schedule)
}
if patch.Enabled != nil {
p.Enabled = *patch.Enabled
}
if patch.DryRunEnabled != nil {
p.DryRunEnabled = *patch.DryRunEnabled
}
if err := ValidateMaintenancePolicyInput(p.Name, p.TableName, p.VacuumStrategy, p.Schedule); err != nil {
return nil, err
}
p.UpdatedAt = time.Now().UTC()
return cloneMaintenancePolicy(p), nil
}
func (m *Memory) DeleteMaintenancePolicy(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.maintenancePolicies[id]; !ok {
return ErrNotFound
}
delete(m.maintenancePolicies, id)
return nil
}
func (m *Memory) TouchMaintenancePolicyRun(id, status, errMsg string) error {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.maintenancePolicies[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
p.LastRunAt = &now
p.LastStatus = status
p.LastError = errMsg
p.UpdatedAt = now
return nil
}
func (m *Memory) AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
row := &MaintenancePolicyConfigAudit{
ID: uuid.NewString(),
PolicyID: policyID,
ActorPrefix: strings.TrimSpace(actor),
Action: action,
Before: before,
After: after,
CreatedAt: time.Now().UTC(),
}
m.maintConfigAudit = append(m.maintConfigAudit, row)
return nil
}
func (m *Memory) ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
all := append([]*MaintenancePolicyConfigAudit(nil), m.maintConfigAudit...)
sort.Slice(all, func(i, j int) bool {
if all[i].CreatedAt.Equal(all[j].CreatedAt) {
return all[i].ID > all[j].ID
}
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(all) {
end = len(all)
} else if end < len(all) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(all) {
return nil, "", false, nil
}
out := make([]*MaintenancePolicyConfigAudit, end-off)
copy(out, all[off:end])
return out, next, hasMore, nil
}
func cloneMaintenancePolicy(p *MaintenancePolicy) *MaintenancePolicy {
if p == nil {
return nil
}
cp := *p
if p.RetentionPeriodSec != nil {
v := *p.RetentionPeriodSec
cp.RetentionPeriodSec = &v
}
if p.MaxRows != nil {
v := *p.MaxRows
cp.MaxRows = &v
}
if p.LastRunAt != nil {
t := *p.LastRunAt
cp.LastRunAt = &t
}
return &cp
}
func parseMaintCursor(cursor string) int {
if cursor == "" {
return 0
}
var off int
for _, r := range cursor {
if r < '0' || r > '9' {
return 0
}
off = off*10 + int(r-'0')
}
return off
}
func formatMaintCursor(off int) string {
return strconv.Itoa(off)
}
@@ -0,0 +1,96 @@
package store
import "testing"
func TestMemoryMaintenancePolicyCRUD(t *testing.T) {
m := NewMemory()
ret := int(86400)
max := 5000
created, err := m.CreateMaintenancePolicy(&MaintenancePolicy{
Name: "audit cleanup",
TableName: "job_audit",
Condition: "status = 'succeeded'",
RetentionPeriodSec: &ret,
MaxRows: &max,
VacuumStrategy: VacuumStrategyVacuumAnalyze,
Schedule: "0 4 * * *",
Enabled: true,
DryRunEnabled: true,
})
if err != nil {
t.Fatal(err)
}
if created.ID == "" {
t.Fatal("missing id")
}
items, _, hasMore, err := m.ListMaintenancePolicies("", 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || hasMore {
t.Fatalf("list: len=%d hasMore=%v", len(items), hasMore)
}
got, err := m.GetMaintenancePolicy(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "audit cleanup" || got.VacuumStrategy != VacuumStrategyVacuumAnalyze {
t.Fatalf("get: %+v", got)
}
newName := "renamed"
disabled := false
updated, err := m.UpdateMaintenancePolicy(created.ID, &MaintenancePolicyPatch{
Name: &newName,
Enabled: &disabled,
})
if err != nil {
t.Fatal(err)
}
if updated.Name != newName || updated.Enabled {
t.Fatalf("update: %+v", updated)
}
if err := m.TouchMaintenancePolicyRun(created.ID, "succeeded", ""); err != nil {
t.Fatal(err)
}
afterTouch, err := m.GetMaintenancePolicy(created.ID)
if err != nil {
t.Fatal(err)
}
if afterTouch.LastStatus != "succeeded" || afterTouch.LastRunAt == nil {
t.Fatalf("touch: %+v", afterTouch)
}
if err := m.AppendMaintenancePolicyConfigAudit("op:test", created.ID, "update", map[string]any{"name": "old"}, map[string]any{"name": newName}); err != nil {
t.Fatal(err)
}
audit, next, hasMore, err := m.ListMaintenancePolicyConfigAudit("", 10)
if err != nil {
t.Fatal(err)
}
if len(audit) != 1 || audit[0].Action != "update" || next != "" || hasMore {
t.Fatalf("audit: %+v next=%q hasMore=%v", audit, next, hasMore)
}
if err := m.DeleteMaintenancePolicy(created.ID); err != nil {
t.Fatal(err)
}
if _, err := m.GetMaintenancePolicy(created.ID); err != ErrNotFound {
t.Fatalf("after delete: %v", err)
}
}
func TestCreateMaintenancePolicyInvalid(t *testing.T) {
m := NewMemory()
_, err := m.CreateMaintenancePolicy(&MaintenancePolicy{Name: "", TableName: "job_audit", Schedule: "0 3 * * *"})
if err != ErrInvalidInput {
t.Fatalf("want ErrInvalidInput got %v", err)
}
_, err = m.CreateMaintenancePolicy(&MaintenancePolicy{Name: "x", TableName: "job_audit", Schedule: "0 3 * * *", VacuumStrategy: "bad"})
if err != ErrInvalidInput {
t.Fatalf("want ErrInvalidInput got %v", err)
}
}
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS idx_postgres_maintenance_audit_policy;
ALTER TABLE postgres_maintenance_audit DROP COLUMN IF EXISTS policy_id;
DROP TABLE IF EXISTS maintenance_policy_config_audit;
DROP TABLE IF EXISTS maintenance_policy;
@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS maintenance_policy (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
table_name TEXT NOT NULL,
condition_sql TEXT NOT NULL DEFAULT 'true',
retention_period_sec INTEGER,
max_rows INTEGER,
vacuum_strategy TEXT NOT NULL DEFAULT 'none',
schedule_cron TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
dry_run_enabled BOOLEAN NOT NULL DEFAULT false,
last_run_at TIMESTAMPTZ,
last_status TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT maintenance_policy_name_chk CHECK (length(trim(name)) > 0),
CONSTRAINT maintenance_policy_table_name_chk CHECK (length(trim(table_name)) > 0),
CONSTRAINT maintenance_policy_vacuum_strategy_chk CHECK (
vacuum_strategy IN ('none', 'vacuum', 'analyze', 'vacuum_analyze', 'reindex')
)
);
CREATE INDEX IF NOT EXISTS idx_maintenance_policy_enabled
ON maintenance_policy (enabled);
CREATE TABLE IF NOT EXISTS maintenance_policy_config_audit (
id TEXT PRIMARY KEY,
policy_id TEXT,
actor_prefix TEXT NOT NULL,
action TEXT NOT NULL,
before_json JSONB,
after_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT maintenance_policy_config_audit_action_chk CHECK (
action IN ('create', 'update', 'delete')
)
);
CREATE INDEX IF NOT EXISTS idx_maintenance_policy_config_audit_created
ON maintenance_policy_config_audit (created_at DESC);
ALTER TABLE postgres_maintenance_audit
ADD COLUMN IF NOT EXISTS policy_id TEXT REFERENCES maintenance_policy (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_postgres_maintenance_audit_policy
ON postgres_maintenance_audit (policy_id);
@@ -0,0 +1,5 @@
ALTER TABLE postgres_maintenance_audit DROP COLUMN policy_id;
DROP TABLE IF EXISTS maintenance_policy_config_audit;
DROP TABLE IF EXISTS maintenance_policy;
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS maintenance_policy (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
table_name TEXT NOT NULL,
condition_sql TEXT NOT NULL DEFAULT 'true',
retention_period_sec INTEGER,
max_rows INTEGER,
vacuum_strategy TEXT NOT NULL DEFAULT 'none',
schedule_cron TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
dry_run_enabled INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT,
last_status TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS maintenance_policy_config_audit (
id TEXT PRIMARY KEY,
policy_id TEXT,
actor_prefix TEXT NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
created_at TEXT NOT NULL
);
ALTER TABLE postgres_maintenance_audit ADD COLUMN policy_id TEXT;
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"codegraph": {
"type": "local",
"command": [
"codegraph",
"serve",
"--mcp"
],
"enabled": true
}
}
}
@@ -0,0 +1,367 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { AuthSession } from '$lib/api/types.js';
import type { PostgresTableRow } from '$lib/monitoring/postgres.js';
import {
createMaintenancePolicy,
deleteMaintenancePolicy,
fetchPolicyHints,
listMaintenancePolicies,
runMaintenancePolicy,
updateMaintenancePolicy,
type MaintenancePolicy,
type MaintenancePolicyHints
} from '$lib/maintenance/policy-api.js';
import {
emptyMaintenancePolicyForm,
formToPayload,
vacuumStrategies,
type MaintenancePolicyForm
} from '$lib/maintenance/policy.schema.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} 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 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';
import type { DataTableColumn } from '$lib/ui/patterns/data-table/types.js';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Play from '@lucide/svelte/icons/play';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import Info from '@lucide/svelte/icons/info';
type Props = {
session: AuthSession | null;
tables: PostgresTableRow[];
onJobQueued?: () => void | Promise<void>;
};
let { session, tables = [], onJobQueued }: Props = $props();
let policies = $state<MaintenancePolicy[]>([]);
let loading = $state(true);
let dialogOpen = $state(false);
let editTarget = $state<MaintenancePolicy | null>(null);
let form = $state<MaintenancePolicyForm>(emptyMaintenancePolicyForm());
let saving = $state(false);
let hints = $state<MaintenancePolicyHints | null>(null);
let hintsLoading = $state(false);
const isOperator = $derived(session?.role === 'operator');
const tableOptions = $derived.by(() => {
const names = new Set(tables.map((t) => t.relname));
if (form.table_name.trim()) names.add(form.table_name.trim());
return [...names].sort();
});
const columns: DataTableColumn<MaintenancePolicy>[] = [
{ id: 'name', label: 'Название', sortable: true, sortValue: (p) => p.name },
{ id: 'table_name', label: 'Таблица', sortable: true, sortValue: (p) => p.table_name },
{ id: 'schedule', label: 'Cron (UTC)' },
{ id: 'status', label: 'Статус' },
{ id: 'actions', label: '', class: 'w-40' }
];
async function loadPolicies() {
loading = true;
try {
policies = await listMaintenancePolicies();
} catch (e) {
notifyApiError(e, 'Не удалось загрузить политики');
} finally {
loading = false;
}
}
function openCreate() {
editTarget = null;
form = emptyMaintenancePolicyForm();
hints = null;
dialogOpen = true;
}
function openEdit(p: MaintenancePolicy) {
editTarget = p;
form = {
name: p.name,
table_name: p.table_name,
condition: p.condition || 'true',
retention_period_sec: p.retention_period_sec ? String(p.retention_period_sec) : '',
max_rows: p.max_rows ? String(p.max_rows) : '',
vacuum_strategy: (vacuumStrategies.includes(
p.vacuum_strategy as (typeof vacuumStrategies)[number]
)
? p.vacuum_strategy
: 'none') as MaintenancePolicyForm['vacuum_strategy'],
schedule: p.schedule,
enabled: p.enabled,
dry_run_enabled: p.dry_run_enabled
};
hints = null;
dialogOpen = true;
void loadHints(p.id);
}
async function loadHints(id: string) {
hintsLoading = true;
try {
hints = await fetchPolicyHints(id);
} catch {
hints = null;
} finally {
hintsLoading = false;
}
}
function requestDelete(p: MaintenancePolicy) {
void confirm({
title: `Удалить политику «${p.name}»?`,
description: 'Расписание и очистка по этой политике прекратятся.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await deleteMaintenancePolicy(p.id);
notify.success('Политика удалена');
await loadPolicies();
}
});
}
async function save() {
if (!form.name.trim() || !form.table_name.trim() || !form.schedule.trim()) {
notify.error('Заполните обязательные поля');
return;
}
saving = true;
try {
const payload = formToPayload(form);
if (editTarget) {
await updateMaintenancePolicy(editTarget.id, payload);
notify.success('Политика обновлена');
} else {
await createMaintenancePolicy(payload);
notify.success('Политика создана');
}
dialogOpen = false;
await loadPolicies();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function queueRun(p: MaintenancePolicy, dryRun: boolean) {
void confirm({
title: dryRun ? `Dry-run: ${p.name}` : `Запуск: ${p.name}`,
description: dryRun
? 'Изменения в БД не применяются — только оценка.'
: 'Задача будет поставлена в очередь jobs.',
confirmLabel: dryRun ? 'Dry-run' : 'Запустить',
destructive: !dryRun,
onConfirm: async () => {
const res = await runMaintenancePolicy(p.id, dryRun);
notify.success(`Задача ${res.job_id}`);
await onJobQueued?.();
}
});
}
function statusBadge(p: MaintenancePolicy) {
if (!p.enabled) return 'выкл';
if (p.dry_run_enabled) return 'dry-run sched';
return p.last_status || '—';
}
onMount(() => {
void loadPolicies();
});
</script>
{#if !isOperator}
<Alert>
<AlertTitle>Только operator</AlertTitle>
<AlertDescription>Политики обслуживания БД настраиваются с ролью operator.</AlertDescription>
</Alert>
{/if}
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<CardTitle>Политики обслуживания</CardTitle>
<CardDescription>
Единственный источник конфигурации retention, vacuum и расписания (UTC cron).
</CardDescription>
</div>
{#if isOperator}
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
{/if}
</CardHeader>
<CardContent class="pt-4">
<AppDataTable
{columns}
rows={policies}
rowKey={(p) => p.id}
{loading}
emptyTitle="Политики не созданы"
emptyDescription="Добавьте первую политику через UI — это единственный способ настройки."
>
{#snippet cell({ row, column })}
{#if column.id === 'status'}
<Badge variant={row.enabled ? 'secondary' : 'outline'}>{statusBadge(row)}</Badge>
{#if row.last_run_at}
<p class="mt-1 text-xs text-muted-foreground">{row.last_run_at}</p>
{/if}
{:else if column.id === 'actions' && isOperator}
<div class="flex flex-wrap gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => openEdit(row)}
aria-label="Изменить"
>
<Pencil class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, true)}
aria-label="Dry-run"
>
<FlaskConical class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, false)}
aria-label="Run"
>
<Play class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => requestDelete(row)}
aria-label="Удалить"
>
<Trash2 class="size-4" />
</Button>
</div>
{:else if column.id === 'name'}
{row.name}
{:else if column.id === 'table_name'}
{row.table_name}
{:else if column.id === 'schedule'}
<span class="font-mono text-xs">{row.schedule}</span>
{:else if column.id !== 'actions'}
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editTarget ? 'Изменить политику' : 'Новая политика'}</DialogTitle>
</DialogHeader>
{#if hints?.recommend_vacuum}
<Alert class="border-warning/30 bg-warning/5">
<Info class="text-warning" />
<AlertTitle>Рекомендация</AlertTitle>
<AlertDescription>{hints.detail ?? 'Рекомендуется VACUUM.'}</AlertDescription>
</Alert>
{:else if hintsLoading}
<p class="text-sm text-muted-foreground">Загрузка подсказок pg_stat…</p>
{/if}
<div class="grid gap-4 py-2">
<FormField label="Название" id="mp-name" required>
<AppInput bind:value={form.name} disabled={!isOperator} />
</FormField>
<FormField label="Таблица" id="mp-table" required>
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.table_name}
disabled={!isOperator}
>
<option value="">— выберите —</option>
{#each tableOptions as name (name)}
<option value={name}>{name}</option>
{/each}
</select>
</FormField>
<FormField label="Condition (SQL WHERE)" id="mp-condition" required>
<textarea
class="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
bind:value={form.condition}
disabled={!isOperator}
></textarea>
</FormField>
<div class="grid gap-4 sm:grid-cols-2">
<FormField label="Retention (сек)" id="mp-retention">
<AppInput bind:value={form.retention_period_sec} type="number" disabled={!isOperator} />
</FormField>
<FormField label="Max rows (batch)" id="mp-max-rows">
<AppInput bind:value={form.max_rows} type="number" disabled={!isOperator} />
</FormField>
</div>
<FormField label="Vacuum strategy" id="mp-vacuum">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.vacuum_strategy}
disabled={!isOperator}
>
{#each vacuumStrategies as s (s)}
<option value={s}>{s}</option>
{/each}
</select>
</FormField>
<FormField label="Schedule (cron, UTC)" id="mp-schedule" required>
<AppInput bind:value={form.schedule} class="font-mono" disabled={!isOperator} />
</FormField>
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-2">
<Switch id="mp-enabled" bind:checked={form.enabled} disabled={!isOperator} />
<Label for="mp-enabled">Включена</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="mp-dry" bind:checked={form.dry_run_enabled} disabled={!isOperator} />
<Label for="mp-dry">Scheduler только dry-run</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
{#if isOperator}
<Button onclick={save} disabled={saving}>{saving ? 'Сохранение…' : 'Сохранить'}</Button>
{/if}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notifyApiError } from '$lib/ui/app/toast.js';
import MaintenancePoliciesTab from '$lib/components/monitoring/MaintenancePoliciesTab.svelte';
import {
POSTGRES_POLL_MS,
POSTGRES_SLOW_POLL_MS,
@@ -117,33 +117,6 @@
clearInterval(slow);
};
});
const isOperator = $derived(session?.role === 'operator');
function runMaint(
title: string,
path: string,
body: Record<string, unknown>,
destructive = true
) {
void confirm({
title,
description: body.dry_run
? 'Dry-run: изменения не применяются, только план.'
: 'Операция выполняется асинхронно через jobs. Убедитесь, что выбрано maintenance-окно.',
confirmLabel: body.dry_run ? 'Dry-run' : 'Выполнить',
destructive,
onConfirm: async () => {
try {
const res = await apiMutate<{ job_id: string; status: string }>(path, 'POST', body);
notify.success(`Задача ${res.job_id} (${res.status})`);
await loadSlow();
} catch (e) {
notifyApiError(e, title);
}
}
});
}
</script>
<div class="flex flex-wrap items-center justify-between gap-3">
@@ -406,50 +379,7 @@
</TabsContent>
<TabsContent value="maintenance" class="mt-4 space-y-4">
{#if !isOperator}
<Alert>
<AlertTitle>Только operator</AlertTitle>
<AlertDescription>Обслуживание БД доступно с ролью operator.</AlertDescription>
</Alert>
{:else}
<Card>
<CardHeader>
<CardTitle>Операции</CardTitle>
<CardDescription>Все операции — async job (202). По умолчанию dry-run.</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button
variant="outline"
onclick={() => runMaint('VACUUM', '/v1/postgres/vacuum', { dry_run: true })}
>
Vacuum (dry-run)
</Button>
<Button
variant="outline"
onclick={() => runMaint('ANALYZE', '/v1/postgres/analyze', { dry_run: true })}
>
Analyze (dry-run)
</Button>
<Button
variant="destructive"
onclick={() => runMaint('VACUUM', '/v1/postgres/vacuum', { dry_run: false }, true)}
>
Vacuum
</Button>
<Button
variant="destructive"
onclick={() =>
runMaint('Cleanup job_audit', '/v1/postgres/cleanup', {
policy: 'job_audit_retention',
dry_run: true,
limit: 10000
})}
>
Cleanup audit (dry-run)
</Button>
</CardContent>
</Card>
{/if}
<MaintenancePoliciesTab {session} {tables} onJobQueued={loadSlow} />
<Card>
<CardHeader>
<CardTitle>Журнал обслуживания</CardTitle>
+68
View File
@@ -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<MaintenancePolicy[]> {
const r = await apiJSON<MaintenancePoliciesResponse>(`/v1/maintenance/policies?limit=${limit}`);
return r.items ?? [];
}
export async function createMaintenancePolicy(
body: Record<string, unknown>
): Promise<MaintenancePolicy> {
return apiMutate<MaintenancePolicy>('/v1/maintenance/policies', 'POST', body);
}
export async function updateMaintenancePolicy(
id: string,
body: Record<string, unknown>
): Promise<MaintenancePolicy> {
return apiMutate<MaintenancePolicy>(`/v1/maintenance/policies/${id}`, 'PATCH', body);
}
export async function deleteMaintenancePolicy(id: string): Promise<void> {
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<MaintenancePolicyHints> {
return apiJSON<MaintenancePolicyHints>(`/v1/maintenance/policies/${id}/hints`);
}
+58
View File
@@ -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<typeof maintenancePolicySchema>;
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
};
}
+2
View File
@@ -23,6 +23,8 @@ export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap<string, s
return 'Откат ревизии';
case 'bird_reload':
return 'Перезагрузка BIRD';
case 'maintenance_policy_run':
return 'Обслуживание PostgreSQL (политика)';
default:
return job.kind;
}