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']);