From db75126beab14c10b42db8be680e8cca00390f9c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 12 Jun 2026 22:44:39 +0700 Subject: [PATCH] feat(runtime-logs): enhance auto-cleanup features and documentation Added new endpoints for estimating and executing runtime log auto-cleanup based on tenant settings. Introduced configuration options for auto-cleanup policies, including scheduling and file size limits. Updated the API documentation and UI components to reflect these changes, improving user interaction with runtime log management. Enhanced error handling and added new UI elements for better visibility of audit logs and cleanup actions. --- cmd/evobgp-all/main.go | 13 +- docs/access.md | 6 +- docs/api.md | 6 +- docs/manual.md | 7 +- docs/openapi.yaml | 122 +++++++ internal/config/config.go | 3 + internal/httpapi/routes_crud.go | 5 + internal/httpapi/routes_runtime_logs.go | 75 ++++ internal/httpapi/routes_runtime_logs_test.go | 44 ++- internal/httpapi/server.go | 57 +-- internal/repository/postgres_runtime_logs.go | 2 +- internal/runtimelogs/autocleanup.go | 147 ++++++++ internal/runtimelogs/autocleanup_test.go | 81 +++++ internal/runtimelogs/policy.go | 225 ++++++++++++ internal/runtimelogs/policy_test.go | 61 ++++ internal/runtimelogs/scheduler.go | 126 +++++++ .../monitoring/RuntimeLogsTab.svelte | 86 +++-- .../TenantRuntimeLogsSettingsCard.svelte | 340 ++++++++++++++++++ .../tenant-settings/TenantSettingsPage.svelte | 10 +- web/src/lib/maintenance/policy-presets.ts | 18 + .../lib/runtime-logs/runtime-logs-auto-api.ts | 37 ++ .../settings/runtime-logs-settings.schema.ts | 66 ++++ web/src/lib/settings/settings-api.ts | 40 ++- web/src/lib/settings/settings-known-keys.ts | 19 +- 24 files changed, 1516 insertions(+), 80 deletions(-) create mode 100644 internal/runtimelogs/autocleanup.go create mode 100644 internal/runtimelogs/autocleanup_test.go create mode 100644 internal/runtimelogs/policy.go create mode 100644 internal/runtimelogs/policy_test.go create mode 100644 internal/runtimelogs/scheduler.go create mode 100644 web/src/lib/components/tenant-settings/TenantRuntimeLogsSettingsCard.svelte create mode 100644 web/src/lib/runtime-logs/runtime-logs-auto-api.ts create mode 100644 web/src/lib/settings/runtime-logs-settings.schema.ts diff --git a/cmd/evobgp-all/main.go b/cmd/evobgp-all/main.go index b8bcd11..457afdf 100644 --- a/cmd/evobgp-all/main.go +++ b/cmd/evobgp-all/main.go @@ -30,12 +30,13 @@ func main() { } cfg := config.Load() opts := httpapi.Options{ - APIKeys: os.Getenv("EVOBGP_API_KEYS"), - DatabaseURL: cfg.DatabaseURL, - InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1", - SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0", - BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")), - CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")), + APIKeys: os.Getenv("EVOBGP_API_KEYS"), + DatabaseURL: cfg.DatabaseURL, + InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1", + SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0", + BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")), + CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")), + RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant, } srv, err := httpapi.New(opts) if err != nil { diff --git a/docs/access.md b/docs/access.md index 2cfcf14..3292788 100644 --- a/docs/access.md +++ b/docs/access.md @@ -110,7 +110,11 @@ EVOBGP_SERVICE=evobgp-all EVOBGP_RUNTIME_LOGS_DIR=/opt/evobgp/runtime-logs ``` -В dev-профиле compose каталог на хосте обычно `./runtime-logs`, в контейнере — mount на `/opt/evobgp/runtime-logs`. Если каталог не задан или роль процесса не `evobgp-all`, эндпоинты `/v1/runtime-logs/*` отвечают **503** (`runtime_logs_unavailable`). Очистка файлов — роль **operator+**; операции пишутся в таблицу `runtime_log_cleanup_audit`. +В dev-профиле compose каталог на хосте обычно `./runtime-logs`, в контейнере — mount на `/opt/evobgp/runtime-logs`. Если каталог не задан или роль процесса не `evobgp-all`, FS-эндпоинты (`/files`, `/auto-*`) отвечают **503** (`runtime_logs_unavailable`). `GET /v1/runtime-logs/cleanup-audit` доступен без volume. + +Очистка файлов — роль **operator+**; операции пишутся в `runtime_log_cleanup_audit`. Автоочистка настраивается в tenant settings (`runtime_logs_auto_enabled`, `runtime_logs_max_file_mb`, `runtime_logs_auto_schedule`, `runtime_logs_auto_mode`); scheduler — только в `evobgp-all`. Опционально: `EVOBGP_RUNTIME_LOGS_POLICY_TENANT` — tenant, чьи settings читает scheduler (иначе первый tenant с включённой автоочисткой). + +Retention строк audit: пресет maintenance policy `runtime_log_cleanup_audit` (90d) в Monitoring → PostgreSQL → Политики. ## CORS для веб-интерфейса diff --git a/docs/api.md b/docs/api.md index ebe0157..716f971 100644 --- a/docs/api.md +++ b/docs/api.md @@ -90,7 +90,7 @@ ### Settings -- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, произвольные ключи. Чтение — viewer+; `PATCH` — operator+. +- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, `runtime_logs_*` (автоочистка FS), произвольные ключи. Чтение — viewer+; `PATCH` — operator+. ### RuntimeLogs @@ -101,7 +101,9 @@ | `GET` | `/v1/runtime-logs/files` | viewer+ | Список `*.log` (имя, размер, mtime) | | `GET` | `/v1/runtime-logs/files/{filename}` | viewer+ | Хвост файла (`?lines=`, `?bytes=`, `?grep=`) | | `DELETE` | `/v1/runtime-logs/files/{filename}` | operator+ | Синхронная очистка (`?mode=truncate\|delete`, default truncate); max 512 MiB | -| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`) | +| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`); **без FS volume** | +| `GET` | `/v1/runtime-logs/auto-estimate` | operator+ | Файлы выше порога из tenant settings | +| `POST` | `/v1/runtime-logs/auto-run` | operator+ | Немедленный прогон (`?dry_run=true`); audit с `actor_prefix=auto:scheduler` | `{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`). diff --git a/docs/manual.md b/docs/manual.md index 5588071..14e39db 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -112,15 +112,16 @@ EvoBGP управляет генерацией и применением BGP-к | Маршрут | Назначение | |---------|------------| | `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. | -| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии** (`revision_retention_minutes`), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. | +| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. | | `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. | | `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. | ### Web UI: файловые runtime-логи - **Мониторинг** → вкладка **«Файловые логи»** (`/monitoring?tab=runtime-logs`). -- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки**. -- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); audit из БД может отображаться отдельно. +- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки** (история из БД, в т.ч. `auto:scheduler`). +- Автоочистка: **Параметры** → **Файловые логи** — порог MiB, UTC cron, режим truncate/delete; scheduler в `evobgp-all`. +- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); `GET /v1/runtime-logs/cleanup-audit` работает без volume. - Deploy: `EVOBGP_RUNTIME_LOGS_DIR`, bind-mount на `evobgp-all`, sidecar `stack-runtime-logs` — [quickstart.md](quickstart.md#файловые-runtime-логи-api-v1runtime-logs), [access.md](access.md). ## 7. Эксплуатация и runbook diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 50f3e58..6e1a3a3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1168,6 +1168,54 @@ components: has_more: type: boolean + RuntimeLogAutoPolicy: + type: object + properties: + enabled: + type: boolean + max_file_bytes: + type: integer + format: int64 + schedule: + type: string + description: UTC cron (minute hour dom month dow). + mode: + $ref: "#/components/schemas/RuntimeLogCleanupMode" + + RuntimeLogAutoEstimateItem: + type: object + required: [filename, size_bytes, would_cleanup] + properties: + filename: + type: string + size_bytes: + type: integer + format: int64 + would_cleanup: + type: boolean + skip_reason: + type: string + description: under_threshold, too_large, или текст ошибки. + + RuntimeLogAutoEstimate: + type: object + properties: + policy: + $ref: "#/components/schemas/RuntimeLogAutoPolicy" + items: + type: array + items: + $ref: "#/components/schemas/RuntimeLogAutoEstimateItem" + would_count: + type: integer + minimum: 0 + + RuntimeLogAutoRunResult: + type: object + additionalProperties: true + description: | + dry_run, trigger, policy, cleaned[], skipped[], cleaned_count, skipped_count. + BirdLocalStatus: type: object description: Статус локального BIRD на хосте API (GET /v1/bird/status). @@ -1388,6 +1436,23 @@ components: bird_bgp_source_ipv6: type: string description: Зарезервировано; в текущей генерации BGP не используется. + revision_retention_minutes: + type: integer + minimum: 15 + maximum: 43200 + runtime_logs_auto_enabled: + type: boolean + description: Автоочистка *.log на evobgp-all по расписанию (только при FS volume). + runtime_logs_max_file_mb: + type: integer + minimum: 1 + maximum: 512 + description: Truncate/delete файлов строго больше порога (MiB). + runtime_logs_auto_schedule: + type: string + description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`). + runtime_logs_auto_mode: + $ref: "#/components/schemas/RuntimeLogCleanupMode" additionalProperties: true RevisionDiff: @@ -4116,6 +4181,63 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/runtime-logs/auto-estimate: + get: + tags: [RuntimeLogs] + summary: Оценка автоочистки runtime log-файлов + description: | + Список файлов, которые будут затронуты текущей политикой tenant settings. + Требует evobgp-all с примонтированным каталогом runtime-logs. + operationId: estimateRuntimeLogAutoCleanup + parameters: + - $ref: "#/components/parameters/TenantId" + responses: + "200": + description: Успешно. + content: + application/json: + schema: + $ref: "#/components/schemas/RuntimeLogAutoEstimate" + "503": + description: FS API недоступен (не evobgp-all или нет volume). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + default: + $ref: "#/components/responses/DefaultProblem" + + /v1/runtime-logs/auto-run: + post: + tags: [RuntimeLogs] + summary: Запустить автоочистку runtime log-файлов + description: | + Немедленный прогон политики из tenant settings. `dry_run=true` — только оценка без FS-изменений. + Записи audit с `actor_prefix=auto:scheduler`. + operationId: runRuntimeLogAutoCleanup + parameters: + - $ref: "#/components/parameters/TenantId" + - name: dry_run + in: query + schema: + type: boolean + default: false + responses: + "200": + description: Успешно. + content: + application/json: + schema: + $ref: "#/components/schemas/RuntimeLogAutoRunResult" + "503": + description: FS API недоступен. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/runtime-logs/cleanup-audit: get: tags: [RuntimeLogs] diff --git a/internal/config/config.go b/internal/config/config.go index bec098b..0ff9da1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,8 @@ type Env struct { BrokerURL string // RuntimeLogsDir is the absolute host path to Docker runtime log files (evobgp-all only). RuntimeLogsDir string + // RuntimeLogsPolicyTenant overrides which tenant global_settings drive auto-cleanup (optional). + RuntimeLogsPolicyTenant string } // Load reads EVOBGP_* environment variables with safe defaults. @@ -34,6 +36,7 @@ func Load() Env { e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")) e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL")) e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL")) + e.RuntimeLogsPolicyTenant = strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_POLICY_TENANT")) if v := strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_DIR")); v != "" { if abs, err := filepath.Abs(v); err == nil { e.RuntimeLogsDir = abs diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index a578921..e928963 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -14,6 +14,7 @@ import ( "evobgp/internal/importer" "evobgp/internal/pipeline" + "evobgp/internal/runtimelogs" "evobgp/internal/store" ) @@ -1117,6 +1118,10 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) { } body["revision_retention_minutes"] = v } + if !runtimelogs.ValidateRuntimeLogsSettingsPatch(body) { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid runtime_logs_* settings") + return + } if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil { writeStoreErr(w, err) return diff --git a/internal/httpapi/routes_runtime_logs.go b/internal/httpapi/routes_runtime_logs.go index fba444e..7aeccc8 100644 --- a/internal/httpapi/routes_runtime_logs.go +++ b/internal/httpapi/routes_runtime_logs.go @@ -14,6 +14,8 @@ func (s *Server) registerRuntimeLogsRoutes(m *http.ServeMux) { m.HandleFunc("GET /runtime-logs/files/{filename}", s.handleGetRuntimeLogTail) m.HandleFunc("DELETE /runtime-logs/files/{filename}", s.handleDeleteRuntimeLogFile) m.HandleFunc("GET /runtime-logs/cleanup-audit", s.handleListRuntimeLogCleanupAudit) + m.HandleFunc("GET /runtime-logs/auto-estimate", s.handleRuntimeLogAutoEstimate) + m.HandleFunc("POST /runtime-logs/auto-run", s.handleRuntimeLogAutoRun) } func (s *Server) requireRuntimeLogs(w http.ResponseWriter) bool { @@ -144,6 +146,79 @@ func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, out) } +func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, tenantID string) (runtimelogs.AutoPolicy, bool) { + settings, err := s.store.ListGlobalSettings(tenantID) + if err != nil { + writeInternalError(w, "runtime_logs_policy", err) + return runtimelogs.AutoPolicy{}, false + } + return runtimelogs.PolicyFromSettings(settings), true +} + +func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) { + return + } + policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID) + if !ok { + return + } + items, err := runtimelogs.EstimateAutoCleanup(s.runtimeLogs, policy) + if err != nil { + writeRuntimeLogsErr(w, "runtime_logs_auto_estimate", err) + return + } + out := make([]map[string]any, 0, len(items)) + var wouldCount int + for _, it := range items { + if it.WouldCleanup { + wouldCount++ + } + row := map[string]any{ + "filename": it.Filename, + "size_bytes": it.SizeBytes, + "would_cleanup": it.WouldCleanup, + } + if it.SkipReason != "" { + row["skip_reason"] = it.SkipReason + } + out = append(out, row) + } + writeJSON(w, http.StatusOK, map[string]any{ + "policy": map[string]any{ + "enabled": policy.Enabled, + "max_file_bytes": policy.MaxFileBytes, + "schedule": policy.Schedule, + "mode": policy.Mode, + }, + "items": out, + "would_count": wouldCount, + }) +} + +func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) { + return + } + policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID) + if !ok { + return + } + dryRun := r.URL.Query().Get("dry_run") == "true" + result, err := runtimelogs.RunAutoCleanup(r.Context(), runtimelogs.AutoCleanupDeps{ + Service: s.runtimeLogs, + Store: s.store, + TenantID: a.TenantID, + }, policy, dryRun, "manual") + if err != nil { + writeRuntimeLogsErr(w, "runtime_logs_auto_run", err) + return + } + writeJSON(w, http.StatusOK, result) +} + func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requireAtLeast(w, a, "viewer") { diff --git a/internal/httpapi/routes_runtime_logs_test.go b/internal/httpapi/routes_runtime_logs_test.go index 4b276e5..5fe7154 100644 --- a/internal/httpapi/routes_runtime_logs_test.go +++ b/internal/httpapi/routes_runtime_logs_test.go @@ -77,7 +77,7 @@ func TestRuntimeLogsHappyPath(t *testing.T) { ServiceName: runtimelogs.ServiceNameAll, }) handler := srv.Handler() - tenant := "00000000-0000-0000-0000-000000000001" + tenant, _, _, _, _ := srv.Store().DemoIDs() mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer,opkey|"+tenant+"|operator") t.Run("list", func(t *testing.T) { @@ -146,4 +146,46 @@ func TestRuntimeLogsHappyPath(t *testing.T) { t.Fatalf("expected audit entry, body=%s", auditRec.Body.String()) } }) + + t.Run("auto estimate and run", func(t *testing.T) { + bigPath := filepath.Join(dir, "big.log") + if err := os.WriteFile(bigPath, make([]byte, 2*1024*1024), 0o644); err != nil { + t.Fatal(err) + } + if err := srv.store.PatchGlobalSettings(tenant, map[string]any{ + runtimelogs.KeyMaxFileMB: 1, + runtimelogs.KeyAutoMode: "truncate", + }); err != nil { + t.Fatal(err) + } + + estReq := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/auto-estimate", nil) + estReq.Header.Set("Authorization", "Bearer opkey") + estRec := httptest.NewRecorder() + handler.ServeHTTP(estRec, estReq) + if estRec.Code != http.StatusOK { + t.Fatalf("estimate status=%d body=%s", estRec.Code, estRec.Body.String()) + } + if !strings.Contains(estRec.Body.String(), `"would_count":1`) { + t.Fatalf("expected would_count=1, body=%s", estRec.Body.String()) + } + + runReq := httptest.NewRequest(http.MethodPost, "/v1/runtime-logs/auto-run", nil) + runReq.Header.Set("Authorization", "Bearer opkey") + runRec := httptest.NewRecorder() + handler.ServeHTTP(runRec, runReq) + if runRec.Code != http.StatusOK { + t.Fatalf("auto-run status=%d body=%s", runRec.Code, runRec.Body.String()) + } + if !strings.Contains(runRec.Body.String(), "auto:scheduler") && !strings.Contains(runRec.Body.String(), `"cleaned_count":1`) { + t.Fatalf("unexpected auto-run body=%s", runRec.Body.String()) + } + st, err := os.Stat(bigPath) + if err != nil { + t.Fatal(err) + } + if st.Size() != 0 { + t.Fatalf("expected truncated big.log, size=%d", st.Size()) + } + }) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index d0359ed..01c093a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -21,18 +21,19 @@ import ( // Server implements EvoBGP control-plane HTTP API. 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 - corsOrigins []string - cdnHTTP *http.Client - runtimeLogs *runtimelogs.Service - mux *http.ServeMux + store store.Backend + pgPool *pgxpool.Pool + pgMonitor *pgmonitor.Service + maintConfig *maintenance.ConfigProvider + maintStats *maintenance.DBStatsProvider + jobs *jobs.Registry + bundlePriv ed25519.PrivateKey + keyResolver *apiKeyResolver + corsOrigins []string + cdnHTTP *http.Client + runtimeLogs *runtimelogs.Service + runtimeLogsPolicyTenant string + mux *http.ServeMux } // Options configures the API server. @@ -44,6 +45,8 @@ type Options struct { SeedDemo bool BundleSeedHex string CORSAllowedOrigins string + // RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional). + RuntimeLogsPolicyTenant string } // New constructs Server and wiring for async jobs. @@ -81,17 +84,18 @@ func New(opts Options) (*Server, error) { maintStats = maintenance.NewDBStatsProvider(pgMon) } s := &Server{ - store: backend, - pgPool: pool, - pgMonitor: pgMon, - maintConfig: maintCfg, - maintStats: maintStats, - jobs: reg, - bundlePriv: priv, - keyResolver: resolver, - corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins), - cdnHTTP: NewCDNHTTPClient(), - runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()), + store: backend, + pgPool: pool, + pgMonitor: pgMon, + maintConfig: maintCfg, + maintStats: maintStats, + jobs: reg, + bundlePriv: priv, + keyResolver: resolver, + corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins), + cdnHTTP: NewCDNHTTPClient(), + runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()), + runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant), } s.mux = http.NewServeMux() s.registerRoutes() @@ -126,4 +130,11 @@ func (s *Server) StartBackground(ctx context.Context) { }) }, 30*time.Second) } + if s != nil && s.runtimeLogs != nil && s.store != nil { + runtimelogs.StartAutoCleanupScheduler(ctx, runtimelogs.SchedulerDeps{ + Service: s.runtimeLogs, + Store: s.store, + PolicyTenant: s.runtimeLogsPolicyTenant, + }, 30*time.Second) + } } diff --git a/internal/repository/postgres_runtime_logs.go b/internal/repository/postgres_runtime_logs.go index 7e2fe12..b1e2734 100644 --- a/internal/repository/postgres_runtime_logs.go +++ b/internal/repository/postgres_runtime_logs.go @@ -67,7 +67,7 @@ func (p *Postgres) ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int var sizeAfter *int64 if err := rows.Scan(&r.ID, &r.TenantID, &r.ActorPrefix, &r.Filename, &r.Action, &r.SizeBefore, &sizeAfter, &detailRaw, &r.CreatedAt); err != nil { - continue + return nil, "", false, err } r.SizeAfter = sizeAfter if len(detailRaw) > 0 { diff --git a/internal/runtimelogs/autocleanup.go b/internal/runtimelogs/autocleanup.go new file mode 100644 index 0000000..eb98756 --- /dev/null +++ b/internal/runtimelogs/autocleanup.go @@ -0,0 +1,147 @@ +package runtimelogs + +import ( + "context" + "fmt" + + "evobgp/internal/store" +) + +const autoSchedulerActor = "auto:scheduler" + +// FileEstimate describes whether a log file would be cleaned by auto policy. +type FileEstimate struct { + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + WouldCleanup bool `json:"would_cleanup"` + SkipReason string `json:"skip_reason,omitempty"` +} + +// AutoCleanupDeps wires FS cleanup with audit persistence. +type AutoCleanupDeps struct { + Service *Service + Store store.Backend + TenantID string +} + +// EstimateAutoCleanup lists files that exceed the configured size threshold. +func EstimateAutoCleanup(svc *Service, policy AutoPolicy) ([]FileEstimate, error) { + if svc == nil || !svc.Available() { + return nil, ErrUnavailable + } + files, err := svc.ListFiles() + if err != nil { + return nil, err + } + out := make([]FileEstimate, 0, len(files)) + for _, f := range files { + est := FileEstimate{ + Filename: f.Name, + SizeBytes: f.SizeBytes, + } + if f.SizeBytes <= policy.MaxFileBytes { + est.SkipReason = "under_threshold" + } else if f.SizeBytes > MaxCleanupBytes { + est.SkipReason = "too_large" + } else { + est.WouldCleanup = true + } + out = append(out, est) + } + return out, nil +} + +// RunAutoCleanup applies auto policy to eligible files and writes audit rows. +func RunAutoCleanup(ctx context.Context, deps AutoCleanupDeps, policy AutoPolicy, dryRun bool, trigger string) (map[string]any, error) { + if deps.Service == nil || !deps.Service.Available() { + return nil, ErrUnavailable + } + if deps.Store == nil || deps.TenantID == "" { + return nil, fmt.Errorf("runtimelogs: store tenant required for auto cleanup") + } + _ = ctx + + estimates, err := EstimateAutoCleanup(deps.Service, policy) + if err != nil { + return nil, err + } + + detailBase := map[string]any{ + "trigger": trigger, + "dry_run": dryRun, + "max_file_bytes": policy.MaxFileBytes, + "mode": policy.Mode, + "files_processed": 0, + } + cleaned := make([]map[string]any, 0) + skipped := make([]map[string]any, 0) + + for _, est := range estimates { + if !est.WouldCleanup { + if est.SkipReason != "" { + skipped = append(skipped, map[string]any{ + "filename": est.Filename, + "reason": est.SkipReason, + "size": est.SizeBytes, + }) + } + continue + } + if dryRun { + cleaned = append(cleaned, map[string]any{ + "filename": est.Filename, + "size": est.SizeBytes, + "dry_run": true, + }) + continue + } + sizeBefore, sizeAfter, err := deps.Service.Cleanup(est.Filename, policy.Mode) + if err != nil { + skipped = append(skipped, map[string]any{ + "filename": est.Filename, + "reason": err.Error(), + "size": est.SizeBytes, + }) + continue + } + auditDetail := map[string]any{ + "trigger": trigger, + "mode": policy.Mode, + } + auditID, err := deps.Store.AppendRuntimeLogCleanupAudit( + deps.TenantID, autoSchedulerActor, est.Filename, policy.Mode, sizeBefore, sizeAfter, auditDetail) + if err != nil { + return nil, fmt.Errorf("runtimelogs: audit: %w", err) + } + entry := map[string]any{ + "filename": est.Filename, + "audit_id": auditID, + "size_before": sizeBefore, + "action": policy.Mode, + } + if sizeAfter != nil { + entry["size_after"] = *sizeAfter + } + cleaned = append(cleaned, entry) + } + + detailBase["files_processed"] = len(cleaned) + return map[string]any{ + "dry_run": dryRun, + "trigger": trigger, + "policy": policySnapshot(policy), + "cleaned": cleaned, + "skipped": skipped, + "cleaned_count": len(cleaned), + "skipped_count": len(skipped), + }, nil +} + +func policySnapshot(p AutoPolicy) map[string]any { + return map[string]any{ + "enabled": p.Enabled, + "max_file_bytes": p.MaxFileBytes, + "schedule": p.Schedule, + "mode": p.Mode, + } +} diff --git a/internal/runtimelogs/autocleanup_test.go b/internal/runtimelogs/autocleanup_test.go new file mode 100644 index 0000000..087a60e --- /dev/null +++ b/internal/runtimelogs/autocleanup_test.go @@ -0,0 +1,81 @@ +package runtimelogs + +import ( + "context" + "os" + "path/filepath" + "testing" + + "evobgp/internal/store" +) + +func TestEstimateAndRunAutoCleanup(t *testing.T) { + dir := t.TempDir() + small := filepath.Join(dir, "small.log") + large := filepath.Join(dir, "large.log") + if err := os.WriteFile(small, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + payload := make([]byte, 2*1024*1024) + if err := os.WriteFile(large, payload, 0o644); err != nil { + t.Fatal(err) + } + + svc := NewService(Config{RootDir: dir, ServiceName: ServiceNameAll}) + policy := AutoPolicy{ + Enabled: true, + MaxFileBytes: 1024 * 1024, + Mode: store.RuntimeLogCleanupTruncate, + } + + est, err := EstimateAutoCleanup(svc, policy) + if err != nil { + t.Fatal(err) + } + if len(est) != 2 { + t.Fatalf("estimates=%d", len(est)) + } + var would int + for _, e := range est { + if e.WouldCleanup { + would++ + if e.Filename != "large.log" { + t.Fatalf("unexpected cleanup target %q", e.Filename) + } + } + } + if would != 1 { + t.Fatalf("would=%d", would) + } + + mem := store.NewMemory() + tenant := "tenant-a" + result, err := RunAutoCleanup(context.Background(), AutoCleanupDeps{ + Service: svc, + Store: mem, + TenantID: tenant, + }, policy, false, "test") + if err != nil { + t.Fatal(err) + } + if result["cleaned_count"] != 1 { + t.Fatalf("cleaned=%v", result["cleaned_count"]) + } + st, err := os.Stat(large) + if err != nil { + t.Fatal(err) + } + if st.Size() != 0 { + t.Fatalf("expected truncated large.log, size=%d", st.Size()) + } + items, _, _, err := mem.ListRuntimeLogCleanupAudit(tenant, "", 10) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("audit rows=%d", len(items)) + } + if items[0].ActorPrefix != autoSchedulerActor { + t.Fatalf("actor=%q", items[0].ActorPrefix) + } +} diff --git a/internal/runtimelogs/policy.go b/internal/runtimelogs/policy.go new file mode 100644 index 0000000..2c52421 --- /dev/null +++ b/internal/runtimelogs/policy.go @@ -0,0 +1,225 @@ +package runtimelogs + +import ( + "fmt" + "strconv" + "strings" + + "evobgp/internal/store" + + "github.com/robfig/cron/v3" +) + +// global_settings keys for runtime log auto-cleanup. +const ( + KeyAutoEnabled = "runtime_logs_auto_enabled" + KeyMaxFileMB = "runtime_logs_max_file_mb" + KeyAutoSchedule = "runtime_logs_auto_schedule" + KeyAutoMode = "runtime_logs_auto_mode" + DefaultMaxFileMB = 128 + DefaultSchedule = "0 */6 * * *" +) + +// AutoPolicy is tenant KV configuration for scheduled FS log cleanup. +type AutoPolicy struct { + Enabled bool + MaxFileBytes int64 + Schedule string + Mode string +} + +// DefaultAutoPolicy returns policy defaults when settings are unset. +func DefaultAutoPolicy() AutoPolicy { + return AutoPolicy{ + Enabled: false, + MaxFileBytes: int64(DefaultMaxFileMB) * 1024 * 1024, + Schedule: DefaultSchedule, + Mode: store.RuntimeLogCleanupTruncate, + } +} + +// PolicyFromSettings reads auto-cleanup policy from tenant global_settings KV. +func PolicyFromSettings(settings map[string]any) AutoPolicy { + p := DefaultAutoPolicy() + if settings == nil { + return p + } + if v, ok := settings[KeyAutoEnabled]; ok { + p.Enabled = parseBoolSetting(v) + } + if mb := parseIntSetting(settings[KeyMaxFileMB]); mb > 0 { + p.MaxFileBytes = int64(ClampMaxFileMB(mb)) * 1024 * 1024 + } + if s := parseStringSetting(settings[KeyAutoSchedule]); s != "" { + p.Schedule = s + } + if m := parseStringSetting(settings[KeyAutoMode]); store.ValidRuntimeLogCleanupAction(m) { + p.Mode = m + } + return p +} + +// ClampMaxFileMB normalizes max file size to 1..512 MiB. +func ClampMaxFileMB(mb int) int { + if mb <= 0 { + return DefaultMaxFileMB + } + if mb < 1 { + return 1 + } + if mb > 512 { + return 512 + } + return mb +} + +// ValidAutoSchedule reports whether schedule is a valid 5-field UTC cron expression. +func ValidAutoSchedule(schedule string) bool { + schedule = strings.TrimSpace(schedule) + if schedule == "" { + return false + } + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + _, err := parser.Parse(schedule) + return err == nil +} + +// ValidateRuntimeLogsSettingsPatch normalizes and validates runtime log settings in a PATCH body. +// Returns false if any present key is invalid. +func ValidateRuntimeLogsSettingsPatch(body map[string]any) bool { + if raw, ok := body[KeyAutoEnabled]; ok && raw != nil { + v, ok := normalizeBoolSetting(raw) + if !ok { + return false + } + body[KeyAutoEnabled] = v + } + if raw, ok := body[KeyMaxFileMB]; ok && raw != nil { + mb, ok := parsePatchInt(raw) + if !ok { + return false + } + mb = ClampMaxFileMB(mb) + body[KeyMaxFileMB] = mb + } + if raw, ok := body[KeyAutoSchedule]; ok && raw != nil { + s := parseStringSetting(raw) + if !ValidAutoSchedule(s) { + return false + } + body[KeyAutoSchedule] = s + } + if raw, ok := body[KeyAutoMode]; ok && raw != nil { + m := parseStringSetting(raw) + if !store.ValidRuntimeLogCleanupAction(m) { + return false + } + body[KeyAutoMode] = m + } + return true +} + +func parseBoolSetting(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + s := strings.TrimSpace(strings.ToLower(x)) + return s == "1" || s == "true" || s == "yes" + case float64: + return x != 0 + case int: + return x != 0 + case int64: + return x != 0 + default: + return false + } +} + +func normalizeBoolSetting(v any) (bool, bool) { + switch x := v.(type) { + case bool: + return x, true + case string: + s := strings.TrimSpace(strings.ToLower(x)) + switch s { + case "1", "true", "yes": + return true, true + case "0", "false", "no": + return false, true + default: + return false, false + } + case float64: + if x != 0 && x != 1 { + return false, false + } + return x != 0, true + case int: + if x != 0 && x != 1 { + return false, false + } + return x != 0, true + case int64: + if x != 0 && x != 1 { + return false, false + } + return x != 0, true + default: + return false, false + } +} + +func parseIntSetting(v any) int { + switch x := v.(type) { + case float64: + return int(x) + case int: + return x + case int64: + return int(x) + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + if err == nil { + return n + } + } + return 0 +} + +func parsePatchInt(v any) (int, bool) { + switch x := v.(type) { + case float64: + if x != float64(int(x)) { + return 0, false + } + n := int(x) + return n, n >= 1 && n <= 512 + case int: + return x, x >= 1 && x <= 512 + case int64: + n := int(x) + return n, n >= 1 && n <= 512 + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + if err != nil { + return 0, false + } + return n, n >= 1 && n <= 512 + default: + return 0, false + } +} + +func parseStringSetting(v any) string { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + default: + if v == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(v)) + } +} diff --git a/internal/runtimelogs/policy_test.go b/internal/runtimelogs/policy_test.go new file mode 100644 index 0000000..df5737e --- /dev/null +++ b/internal/runtimelogs/policy_test.go @@ -0,0 +1,61 @@ +package runtimelogs + +import ( + "testing" + + "evobgp/internal/store" +) + +func TestPolicyFromSettingsDefaults(t *testing.T) { + p := PolicyFromSettings(nil) + if p.Enabled { + t.Fatal("expected disabled by default") + } + if p.MaxFileBytes != int64(DefaultMaxFileMB)*1024*1024 { + t.Fatalf("max bytes=%d", p.MaxFileBytes) + } + if p.Schedule != DefaultSchedule { + t.Fatalf("schedule=%q", p.Schedule) + } + if p.Mode != store.RuntimeLogCleanupTruncate { + t.Fatalf("mode=%q", p.Mode) + } +} + +func TestClampMaxFileMB(t *testing.T) { + if ClampMaxFileMB(0) != DefaultMaxFileMB { + t.Fatal("zero should default") + } + if ClampMaxFileMB(999) != 512 { + t.Fatal("cap 512") + } +} + +func TestValidateRuntimeLogsSettingsPatch(t *testing.T) { + body := map[string]any{ + KeyAutoEnabled: true, + KeyMaxFileMB: 64, + KeyAutoSchedule: "0 3 * * *", + KeyAutoMode: "truncate", + } + if !ValidateRuntimeLogsSettingsPatch(body) { + t.Fatal("expected valid patch") + } + if body[KeyMaxFileMB] != 64 { + t.Fatalf("max mb=%v", body[KeyMaxFileMB]) + } + + bad := map[string]any{KeyAutoSchedule: "not a cron"} + if ValidateRuntimeLogsSettingsPatch(bad) { + t.Fatal("expected invalid cron") + } +} + +func TestValidAutoSchedule(t *testing.T) { + if !ValidAutoSchedule("0 */6 * * *") { + t.Fatal("expected valid schedule") + } + if ValidAutoSchedule("invalid") { + t.Fatal("expected invalid schedule") + } +} diff --git a/internal/runtimelogs/scheduler.go b/internal/runtimelogs/scheduler.go new file mode 100644 index 0000000..21b85df --- /dev/null +++ b/internal/runtimelogs/scheduler.go @@ -0,0 +1,126 @@ +package runtimelogs + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "evobgp/internal/store" + + "github.com/robfig/cron/v3" +) + +// SchedulerDeps configures the runtime log auto-cleanup background scheduler. +type SchedulerDeps struct { + Service *Service + Store store.Backend + PolicyTenant string +} + +// ResolvePolicyTenant picks the tenant whose settings drive auto-cleanup. +func ResolvePolicyTenant(st store.Backend, explicit string) (string, error) { + explicit = strings.TrimSpace(explicit) + if explicit != "" { + return explicit, nil + } + if st == nil { + return "", fmt.Errorf("runtimelogs: store required") + } + ids, err := st.ListTenantIDs() + if err != nil { + return "", err + } + for _, id := range ids { + settings, err := st.ListGlobalSettings(id) + if err != nil { + continue + } + if PolicyFromSettings(settings).Enabled { + return id, nil + } + } + if len(ids) > 0 { + return ids[0], nil + } + return "", fmt.Errorf("runtimelogs: no tenants configured") +} + +// StartAutoCleanupScheduler runs periodic auto-cleanup on evobgp-all when FS is available. +func StartAutoCleanupScheduler(ctx context.Context, deps SchedulerDeps, tick time.Duration) { + if deps.Service == nil || !deps.Service.Available() { + log.Printf("runtimelogs: auto-cleanup scheduler disabled (FS unavailable)") + return + } + if deps.Store == nil { + log.Printf("runtimelogs: auto-cleanup scheduler disabled (no store)") + 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 + lastFired := map[string]time.Time{} + + t := time.NewTicker(tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + tenantID, err := ResolvePolicyTenant(deps.Store, deps.PolicyTenant) + if err != nil { + continue + } + settings, err := deps.Store.ListGlobalSettings(tenantID) + if err != nil { + continue + } + policy := PolicyFromSettings(settings) + if !policy.Enabled { + continue + } + sched, err := parser.Parse(policy.Schedule) + if err != nil { + log.Printf("runtimelogs: invalid cron %q: %v", policy.Schedule, err) + continue + } + + now := time.Now().UTC() + mu.Lock() + prev := lastFired[policy.Schedule] + if prev.IsZero() { + prev = now.Add(-time.Minute) + } + next := sched.Next(prev) + if next.After(now) { + mu.Unlock() + continue + } + slot := next.Unix() / 60 + if lf, ok := lastFired[policy.Schedule]; ok && lf.Unix()/60 == slot { + mu.Unlock() + continue + } + lastFired[policy.Schedule] = next + mu.Unlock() + + _, err = RunAutoCleanup(ctx, AutoCleanupDeps{ + Service: deps.Service, + Store: deps.Store, + TenantID: tenantID, + }, policy, false, "scheduler") + if err != nil { + log.Printf("runtimelogs: auto-cleanup run failed: %v", err) + } + } + } + }() + log.Printf("runtimelogs: auto-cleanup scheduler started (tick=%s)", tick) +} diff --git a/web/src/lib/components/monitoring/RuntimeLogsTab.svelte b/web/src/lib/components/monitoring/RuntimeLogsTab.svelte index b6c5ece..d343375 100644 --- a/web/src/lib/components/monitoring/RuntimeLogsTab.svelte +++ b/web/src/lib/components/monitoring/RuntimeLogsTab.svelte @@ -66,6 +66,7 @@ let files = $state([]); let auditLoading = $state(false); + let auditError = $state(null); let auditItems = $state([]); let auditCursor = $state(undefined); let auditHasMore = $state(false); @@ -107,6 +108,7 @@ sortable: true, sortValue: (r) => r.created_at }, + { id: 'source', label: 'Источник' }, { id: 'actor', label: 'Actor' }, { id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename }, { id: 'action', label: 'Действие' }, @@ -140,6 +142,7 @@ async function loadAudit(reset = true) { auditLoading = true; + if (reset) auditError = null; try { const page = await listRuntimeLogCleanupAudit({ limit: 20, @@ -148,13 +151,22 @@ auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])]; auditCursor = page.next_cursor; auditHasMore = Boolean(page.has_more && page.next_cursor); + auditError = null; } catch (e) { + auditError = e instanceof Error ? e.message : 'Не удалось загрузить audit'; + if (reset) auditItems = []; notifyApiError(e, 'Audit очистки логов'); } finally { auditLoading = false; } } + function auditSourceLabel(actor: string): string { + if (actor.startsWith('auto:')) return 'Авто'; + if (actor.startsWith('op:')) return 'Оператор'; + return '—'; + } + async function refreshAll() { await Promise.all([loadFiles(), loadAudit(true)]); } @@ -210,12 +222,6 @@ await loadAudit(true); })(); }); - - $effect(() => { - if (subTab === 'audit' && auditItems.length === 0 && !auditLoading) { - void loadAudit(true); - } - });
@@ -359,33 +365,47 @@ - r.id} - loading={auditLoading && auditItems.length === 0} - emptyTitle="Записей пока нет" - emptyDescription="Очистка log-файлов появится здесь после operator DELETE." - > - {#snippet cell({ row, column })} - {#if column.id === 'created'} - {formatDateTime(row.created_at)} - {:else if column.id === 'actor'} - {row.actor_prefix} - {:else if column.id === 'filename'} - {row.filename} - {:else if column.id === 'action'} - {row.action} - {:else if column.id === 'sizes'} - - {formatBytes(row.size_before)} - {#if row.size_after != null} - → {formatBytes(row.size_after)} - {/if} - - {/if} - {/snippet} - + {#if auditError && auditItems.length === 0} + + {#snippet action()} + + {/snippet} + + {:else} + r.id} + loading={auditLoading && auditItems.length === 0} + emptyTitle="Записей пока нет" + emptyDescription="Очистка появится после ручного DELETE или автоочистки. Настройки — Параметры → Файловые логи." + > + {#snippet cell({ row, column })} + {#if column.id === 'created'} + {formatDateTime(row.created_at)} + {:else if column.id === 'source'} + + {auditSourceLabel(row.actor_prefix)} + + {:else if column.id === 'actor'} + {row.actor_prefix} + {:else if column.id === 'filename'} + {row.filename} + {:else if column.id === 'action'} + {row.action} + {:else if column.id === 'sizes'} + + {formatBytes(row.size_before)} + {#if row.size_after != null} + → {formatBytes(row.size_after)} + {/if} + + {/if} + {/snippet} + + {/if} {#if auditHasMore} + {:else} + + { + $form.runtime_logs_auto_enabled = v ? 'true' : 'false'; + }} + /> + + + + + + + + + + + + + + + {#if fsUnavailable} +

+ FS API недоступен (не evobgp-all или нет volume). Оценка и «Запустить сейчас» недоступны; + настройки сохраняются для будущего прогона scheduler. +

+ {:else if parsedMaxMb !== null} +
+

Оценка по текущему порогу

+ {#if estimateLoading} +

Расчёт…

+ {:else if estimate} +

+ Файлов к очистке: {estimate.would_count ?? 0} +

+ {#if estimate.items?.length} +
    + {#each estimate.items.filter((i) => i.would_cleanup) as item (item.filename)} +
  • + {item.filename} · {formatBytes(item.size_bytes)} +
  • + {/each} +
+ {/if} + {:else} +

Оценка недоступна

+ {/if} +
+ {/if} + + {#if hasValidationErrors} +

Исправьте ошибки в полях перед сохранением.

+ {/if} + +
+ {#if isOperator} + + + {/if} +
+ {/if} +
+ diff --git a/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte b/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte index 2dda04f..be0290f 100644 --- a/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte +++ b/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte @@ -8,13 +8,14 @@ import TenantBirdSettingsCard from '$lib/components/tenant-settings/TenantBirdSettingsCard.svelte'; import TenantRevisionSettingsCard from '$lib/components/tenant-settings/TenantRevisionSettingsCard.svelte'; import TenantAdditionalSettingsCard from '$lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte'; + import TenantRuntimeLogsSettingsCard from '$lib/components/tenant-settings/TenantRuntimeLogsSettingsCard.svelte'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import Info from '@lucide/svelte/icons/info'; - type TenantSettingsTab = 'bird' | 'revision' | 'additional'; + type TenantSettingsTab = 'bird' | 'revision' | 'runtime-logs' | 'additional'; function parseTenantSettingsTab(value: string | null): TenantSettingsTab { - if (value === 'revision' || value === 'additional') return value; + if (value === 'revision' || value === 'runtime-logs' || value === 'additional') return value; return 'bird'; } @@ -65,6 +66,7 @@ BIRD Ревизии + Файловые логи Дополнительно
@@ -77,6 +79,10 @@ + + + + diff --git a/web/src/lib/maintenance/policy-presets.ts b/web/src/lib/maintenance/policy-presets.ts index 1c4de6d..e073bee 100644 --- a/web/src/lib/maintenance/policy-presets.ts +++ b/web/src/lib/maintenance/policy-presets.ts @@ -33,6 +33,24 @@ export const maintenancePolicyPresets: MaintenancePolicyPreset[] = [ dry_run_enabled: true } }, + { + id: 'runtime_log_cleanup_audit_retention', + label: 'Runtime log cleanup audit — 90d', + description: + 'Удаляет записи runtime_log_cleanup_audit старше 90 дней (ручная и автоочистка FS).', + tableHint: 'runtime_log_cleanup_audit', + form: { + name: 'Runtime log cleanup audit (90d)', + table_name: 'runtime_log_cleanup_audit', + condition: 'true', + retention_period_sec: String(90 * DAY_SEC), + max_rows: '5000', + vacuum_strategy: 'none', + schedule: '0 4 * * *', + enabled: true, + dry_run_enabled: true + } + }, { id: 'postgres_maintenance_audit_retention', label: 'Maintenance audit — 30d', diff --git a/web/src/lib/runtime-logs/runtime-logs-auto-api.ts b/web/src/lib/runtime-logs/runtime-logs-auto-api.ts new file mode 100644 index 0000000..d96aa44 --- /dev/null +++ b/web/src/lib/runtime-logs/runtime-logs-auto-api.ts @@ -0,0 +1,37 @@ +import { apiJSON, apiMutate } from '$lib/api/client.js'; + +export type RuntimeLogAutoEstimateItem = { + filename: string; + size_bytes: number; + would_cleanup: boolean; + skip_reason?: string; +}; + +export type RuntimeLogAutoEstimate = { + policy?: { + enabled?: boolean; + max_file_bytes?: number; + schedule?: string; + mode?: 'truncate' | 'delete'; + }; + items?: RuntimeLogAutoEstimateItem[]; + would_count?: number; +}; + +export type RuntimeLogAutoRunResult = { + dry_run?: boolean; + trigger?: string; + cleaned_count?: number; + skipped_count?: number; + cleaned?: unknown[]; + skipped?: unknown[]; +}; + +export async function fetchRuntimeLogAutoEstimate(): Promise { + return apiJSON('/v1/runtime-logs/auto-estimate'); +} + +export async function runRuntimeLogAutoCleanup(dryRun = false): Promise { + const q = dryRun ? '?dry_run=true' : ''; + return apiMutate(`/v1/runtime-logs/auto-run${q}`, 'POST'); +} diff --git a/web/src/lib/settings/runtime-logs-settings.schema.ts b/web/src/lib/settings/runtime-logs-settings.schema.ts new file mode 100644 index 0000000..2c4c6ac --- /dev/null +++ b/web/src/lib/settings/runtime-logs-settings.schema.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +function boolInput(val: unknown): string { + if (val === undefined || val === null) return 'false'; + if (typeof val === 'boolean') return val ? 'true' : 'false'; + if (typeof val === 'number') return val !== 0 ? 'true' : 'false'; + return String(val).trim().toLowerCase() === 'true' || String(val).trim() === '1' + ? 'true' + : 'false'; +} + +function stringInput(val: unknown): string { + if (val === undefined || val === null) return ''; + return String(val); +} + +const runtimeLogsAutoEnabled = z.preprocess(boolInput, z.enum(['true', 'false'])); + +const runtimeLogsMaxFileMb = z.preprocess( + stringInput, + z.string().refine( + (v) => { + const s = v.trim(); + if (s === '') return true; + const n = Number(s); + return /^\d+$/.test(s) && Number.isInteger(n) && n >= 1 && n <= 512; + }, + { message: 'Порог должен быть целым числом от 1 до 512 MiB' } + ) +); + +const runtimeLogsAutoSchedule = z.preprocess( + stringInput, + z.string().refine( + (v) => { + const s = v.trim(); + if (s === '') return true; + const parts = s.split(/\s+/); + return parts.length === 5; + }, + { message: 'Cron: 5 полей (минута час день месяц день_недели), UTC' } + ) +); + +const runtimeLogsAutoMode = z.preprocess( + stringInput, + z.enum(['truncate', 'delete', '']).refine((v) => v === '' || v === 'truncate' || v === 'delete', { + message: 'Режим: truncate или delete' + }) +); + +export const runtimeLogsSettingsSchema = z.object({ + runtime_logs_auto_enabled: runtimeLogsAutoEnabled, + runtime_logs_max_file_mb: runtimeLogsMaxFileMb, + runtime_logs_auto_schedule: runtimeLogsAutoSchedule, + runtime_logs_auto_mode: runtimeLogsAutoMode +}); + +export type RuntimeLogsSettingsForm = z.infer; + +export const emptyRuntimeLogsSettingsForm = (): RuntimeLogsSettingsForm => ({ + runtime_logs_auto_enabled: 'false', + runtime_logs_max_file_mb: '128', + runtime_logs_auto_schedule: '0 */6 * * *', + runtime_logs_auto_mode: 'truncate' +}); diff --git a/web/src/lib/settings/settings-api.ts b/web/src/lib/settings/settings-api.ts index 5d9188e..234d274 100644 --- a/web/src/lib/settings/settings-api.ts +++ b/web/src/lib/settings/settings-api.ts @@ -5,13 +5,20 @@ import { emptyRevisionSettingsForm, type RevisionSettingsForm } from './revision-settings.schema.js'; +import { + emptyRuntimeLogsSettingsForm, + type RuntimeLogsSettingsForm +} from './runtime-logs-settings.schema.js'; import { BIRD_SETTING_KEYS, + BOOLEAN_SETTING_KEYS, KNOWN_SETTING_KEYS, NUMERIC_SETTING_KEYS, + RUNTIME_LOGS_SETTING_KEYS, type BirdSettingKey, type KnownSettingKey, - type RevisionSettingKey + type RevisionSettingKey, + type RuntimeLogsSettingKey } from './settings-known-keys.js'; export type AdditionalSettingEntry = { id: number; key: string; value: string }; @@ -19,6 +26,7 @@ export type AdditionalSettingEntry = { id: number; key: string; value: string }; export type PartitionedSettings = { bird: BirdSettingsForm; revision: RevisionSettingsForm; + runtimeLogs: RuntimeLogsSettingsForm; additional: AdditionalSettingEntry[]; }; @@ -38,6 +46,7 @@ export function partitionSettings( ): { partitioned: PartitionedSettings; nextId: number } { const bird = emptyBirdSettingsForm(); const revision = emptyRevisionSettingsForm(); + const runtimeLogs = emptyRuntimeLogsSettingsForm(); const additional: AdditionalSettingEntry[] = []; let idCounter = nextId; @@ -46,6 +55,18 @@ export function partitionSettings( bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value); } else if (key === 'revision_retention_minutes') { revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value); + } else if ((RUNTIME_LOGS_SETTING_KEYS as readonly string[]).includes(key)) { + const rk = key as RuntimeLogsSettingKey; + if (rk === 'runtime_logs_auto_enabled') { + runtimeLogs.runtime_logs_auto_enabled = + value === true || value === 1 || value === 'true' || value === '1' ? 'true' : 'false'; + } else if (rk === 'runtime_logs_auto_mode') { + const m = String(value ?? '').trim(); + runtimeLogs.runtime_logs_auto_mode = + m === 'delete' ? 'delete' : m === 'truncate' ? 'truncate' : ''; + } else { + runtimeLogs[rk] = parseKnownValue(key as KnownSettingKey, value); + } } else { additional.push({ id: idCounter++, @@ -56,7 +77,7 @@ export function partitionSettings( } return { - partitioned: { bird, revision, additional }, + partitioned: { bird, revision, runtimeLogs, additional }, nextId: idCounter }; } @@ -65,7 +86,9 @@ export async function loadSettings(): Promise { return apiJSON('/v1/settings'); } -export async function patchSettings(payload: Record): Promise { +export async function patchSettings( + payload: Record +): Promise { await apiMutate('/v1/settings', 'PATCH', payload); } @@ -73,11 +96,16 @@ export function buildPayloadFromFormFields( keys: readonly KnownSettingKey[], form: Record, errors: Partial> -): Record { - const payload: Record = {}; +): Record { + const payload: Record = {}; for (const key of keys) { const value = String(form[key] ?? '').trim(); - if (!value || errors[key]?.length) continue; + if (errors[key]?.length) continue; + if (BOOLEAN_SETTING_KEYS.has(key)) { + payload[key] = value === 'true'; + continue; + } + if (!value) continue; if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value); else payload[key] = value; } diff --git a/web/src/lib/settings/settings-known-keys.ts b/web/src/lib/settings/settings-known-keys.ts index 63caccd..27075d6 100644 --- a/web/src/lib/settings/settings-known-keys.ts +++ b/web/src/lib/settings/settings-known-keys.ts @@ -9,13 +9,28 @@ export const BIRD_SETTING_KEYS = [ export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const; -export const KNOWN_SETTING_KEYS = [...BIRD_SETTING_KEYS, ...REVISION_SETTING_KEYS] as const; +export const RUNTIME_LOGS_SETTING_KEYS = [ + 'runtime_logs_auto_enabled', + 'runtime_logs_max_file_mb', + 'runtime_logs_auto_schedule', + 'runtime_logs_auto_mode' +] as const; + +export const KNOWN_SETTING_KEYS = [ + ...BIRD_SETTING_KEYS, + ...REVISION_SETTING_KEYS, + ...RUNTIME_LOGS_SETTING_KEYS +] as const; export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number]; export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number]; +export type RuntimeLogsSettingKey = (typeof RUNTIME_LOGS_SETTING_KEYS)[number]; export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number]; export const NUMERIC_SETTING_KEYS = new Set([ 'bird_local_asn', - 'revision_retention_minutes' + 'revision_retention_minutes', + 'runtime_logs_max_file_mb' ]); + +export const BOOLEAN_SETTING_KEYS = new Set(['runtime_logs_auto_enabled']);