Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db75126bea |
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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 для веб-интерфейса
|
||||
|
||||
|
||||
+4
-2
@@ -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`).
|
||||
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+34
-23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -66,6 +66,7 @@
|
||||
let files = $state<RuntimeLogFile[]>([]);
|
||||
|
||||
let auditLoading = $state(false);
|
||||
let auditError = $state<string | null>(null);
|
||||
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
|
||||
let auditCursor = $state<string | undefined>(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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -359,33 +365,47 @@
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={auditColumns}
|
||||
rows={auditItems}
|
||||
rowKey={(r) => r.id}
|
||||
loading={auditLoading && auditItems.length === 0}
|
||||
emptyTitle="Записей пока нет"
|
||||
emptyDescription="Очистка log-файлов появится здесь после operator DELETE."
|
||||
>
|
||||
{#snippet cell({ row, column })}
|
||||
{#if column.id === 'created'}
|
||||
<span class="text-sm">{formatDateTime(row.created_at)}</span>
|
||||
{:else if column.id === 'actor'}
|
||||
<span class="font-mono text-xs">{row.actor_prefix}</span>
|
||||
{:else if column.id === 'filename'}
|
||||
<span class="font-mono text-xs">{row.filename}</span>
|
||||
{:else if column.id === 'action'}
|
||||
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
|
||||
{:else if column.id === 'sizes'}
|
||||
<span class="text-xs tabular-nums">
|
||||
{formatBytes(row.size_before)}
|
||||
{#if row.size_after != null}
|
||||
→ {formatBytes(row.size_after)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
{#if auditError && auditItems.length === 0}
|
||||
<EmptyState icon={FileText} title="Не удалось загрузить audit" description={auditError}>
|
||||
{#snippet action()}
|
||||
<Button variant="outline" size="sm" onclick={() => loadAudit(true)}>
|
||||
Повторить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<AppDataTable
|
||||
columns={auditColumns}
|
||||
rows={auditItems}
|
||||
rowKey={(r) => r.id}
|
||||
loading={auditLoading && auditItems.length === 0}
|
||||
emptyTitle="Записей пока нет"
|
||||
emptyDescription="Очистка появится после ручного DELETE или автоочистки. Настройки — Параметры → Файловые логи."
|
||||
>
|
||||
{#snippet cell({ row, column })}
|
||||
{#if column.id === 'created'}
|
||||
<span class="text-sm">{formatDateTime(row.created_at)}</span>
|
||||
{:else if column.id === 'source'}
|
||||
<Badge variant={row.actor_prefix.startsWith('auto:') ? 'secondary' : 'outline'}>
|
||||
{auditSourceLabel(row.actor_prefix)}
|
||||
</Badge>
|
||||
{:else if column.id === 'actor'}
|
||||
<span class="font-mono text-xs">{row.actor_prefix}</span>
|
||||
{:else if column.id === 'filename'}
|
||||
<span class="font-mono text-xs">{row.filename}</span>
|
||||
{:else if column.id === 'action'}
|
||||
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
|
||||
{:else if column.id === 'sizes'}
|
||||
<span class="text-xs tabular-nums">
|
||||
{formatBytes(row.size_before)}
|
||||
{#if row.size_after != null}
|
||||
→ {formatBytes(row.size_after)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
{/if}
|
||||
{#if auditHasMore}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import type { AuthSession } from '$lib/api/types.js';
|
||||
import { apiJSON } from '$lib/api/client.js';
|
||||
import {
|
||||
emptyRuntimeLogsSettingsForm,
|
||||
runtimeLogsSettingsSchema
|
||||
} from '$lib/settings/runtime-logs-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { RUNTIME_LOGS_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import {
|
||||
fetchRuntimeLogAutoEstimate,
|
||||
runRuntimeLogAutoCleanup,
|
||||
type RuntimeLogAutoEstimate
|
||||
} from '$lib/runtime-logs/runtime-logs-auto-api.js';
|
||||
import { isRuntimeLogsUnavailable } from '$lib/runtime-logs/runtime-logs-api.js';
|
||||
import { formatBytes } from '$lib/monitoring/postgres.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let running = $state(false);
|
||||
let loaded = $state(false);
|
||||
let session = $state<AuthSession | null>(null);
|
||||
let estimateLoading = $state(false);
|
||||
let estimate = $state<RuntimeLogAutoEstimate | null>(null);
|
||||
let fsUnavailable = $state(false);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyRuntimeLogsSettingsForm(), zod4(runtimeLogsSettingsSchema)),
|
||||
{
|
||||
validators: zod4(runtimeLogsSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
const isOperator = $derived(session?.role === 'operator');
|
||||
const autoEnabled = $derived($form.runtime_logs_auto_enabled === 'true');
|
||||
const hasValidationErrors = $derived(
|
||||
Boolean(
|
||||
$errors.runtime_logs_max_file_mb?.length ||
|
||||
$errors.runtime_logs_auto_schedule?.length ||
|
||||
$errors.runtime_logs_auto_mode?.length
|
||||
)
|
||||
);
|
||||
|
||||
const parsedMaxMb = $derived.by(() => {
|
||||
const s = String($form.runtime_logs_max_file_mb ?? '').trim();
|
||||
if (s === '' || !/^\d+$/.test(s)) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 512) return null;
|
||||
return n;
|
||||
});
|
||||
|
||||
const canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded || !isOperator) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const canRunNow = $derived.by(() => {
|
||||
if (!isOperator || !loaded || running || saving || parsedMaxMb === null || fsUnavailable)
|
||||
return false;
|
||||
return (estimate?.would_count ?? 0) > 0;
|
||||
});
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
session = await apiJSON<AuthSession>('/v1/auth/session');
|
||||
} catch {
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEstimate() {
|
||||
if (fsUnavailable || parsedMaxMb === null) {
|
||||
estimate = null;
|
||||
return;
|
||||
}
|
||||
estimateLoading = true;
|
||||
try {
|
||||
estimate = await fetchRuntimeLogAutoEstimate();
|
||||
fsUnavailable = false;
|
||||
} catch (e) {
|
||||
estimate = null;
|
||||
if (isRuntimeLogsUnavailable(e)) {
|
||||
fsUnavailable = true;
|
||||
return;
|
||||
}
|
||||
notifyApiError(e, 'Оценка автоочистки');
|
||||
} finally {
|
||||
estimateLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!loaded || parsedMaxMb === null) {
|
||||
estimate = null;
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
void refreshEstimate();
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
reset({ data: partitioned.runtimeLogs });
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
const payload = buildPayloadFromFormFields(
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
if ($form.runtime_logs_auto_mode === 'truncate' || $form.runtime_logs_auto_mode === 'delete') {
|
||||
payload.runtime_logs_auto_mode = $form.runtime_logs_auto_mode;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Параметры автоочистки логов сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRunNow() {
|
||||
if (parsedMaxMb === null) return;
|
||||
let est: RuntimeLogAutoEstimate;
|
||||
try {
|
||||
est = await fetchRuntimeLogAutoEstimate();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
return;
|
||||
}
|
||||
estimate = est;
|
||||
const count = est.would_count ?? 0;
|
||||
if (count === 0) {
|
||||
notify.info('Нет файлов выше порога для очистки');
|
||||
return;
|
||||
}
|
||||
const mode = $form.runtime_logs_auto_mode === 'delete' ? 'удаление' : 'truncate';
|
||||
void confirm({
|
||||
title: 'Запустить автоочистку сейчас?',
|
||||
description: `Будет затронуто файлов: ${count}. Режим: ${mode}. Записи появятся в Monitoring → Файловые логи → Audit.`,
|
||||
confirmLabel: 'Запустить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
running = true;
|
||||
try {
|
||||
const res = await runRuntimeLogAutoCleanup(false);
|
||||
notify.success(`Очищено файлов: ${res.cleaned_count ?? 0}`);
|
||||
await refreshEstimate();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
throw e;
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadSession();
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Файловые логи (runtime-logs)</CardTitle>
|
||||
<CardDescription>
|
||||
Автоочистка <code class="text-xs">*.log</code> на диске evobgp-all (sidecar
|
||||
stack-runtime-logs). Требуется volume <code class="text-xs">EVOBGP_RUNTIME_LOGS_DIR</code>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
|
||||
{:else}
|
||||
<FormField
|
||||
id="runtime-logs-auto-enabled"
|
||||
label="Автоочистка по расписанию"
|
||||
description="Scheduler в evobgp-all (UTC cron ниже)."
|
||||
>
|
||||
<Switch
|
||||
id="runtime-logs-auto-enabled"
|
||||
checked={autoEnabled}
|
||||
disabled={!isOperator}
|
||||
onCheckedChange={(v) => {
|
||||
$form.runtime_logs_auto_enabled = v ? 'true' : 'false';
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="runtime-logs-max-mb"
|
||||
label="Порог размера файла, MiB"
|
||||
error={$errors.runtime_logs_max_file_mb?.[0]}
|
||||
description="Очищать файлы строго больше порога (1–512 MiB)."
|
||||
>
|
||||
<Input
|
||||
id="runtime-logs-max-mb"
|
||||
type="number"
|
||||
min="1"
|
||||
max="512"
|
||||
bind:value={$form.runtime_logs_max_file_mb}
|
||||
disabled={!isOperator}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="runtime-logs-schedule"
|
||||
label="Расписание (UTC cron)"
|
||||
error={$errors.runtime_logs_auto_schedule?.[0]}
|
||||
description="5 полей: минута час день месяц день_недели. По умолчанию каждые 6 часов."
|
||||
>
|
||||
<Input
|
||||
id="runtime-logs-schedule"
|
||||
bind:value={$form.runtime_logs_auto_schedule}
|
||||
placeholder="0 */6 * * *"
|
||||
disabled={!isOperator}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="runtime-logs-mode" label="Режим очистки">
|
||||
<Select
|
||||
type="single"
|
||||
value={$form.runtime_logs_auto_mode || 'truncate'}
|
||||
disabled={!isOperator}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'truncate' || v === 'delete') $form.runtime_logs_auto_mode = v;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="runtime-logs-mode" class="w-full max-w-xs">
|
||||
{$form.runtime_logs_auto_mode === 'delete'
|
||||
? 'delete — удалить файл'
|
||||
: 'truncate — обнулить'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
||||
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{#if fsUnavailable}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
FS API недоступен (не evobgp-all или нет volume). Оценка и «Запустить сейчас» недоступны;
|
||||
настройки сохраняются для будущего прогона scheduler.
|
||||
</p>
|
||||
{:else if parsedMaxMb !== null}
|
||||
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<p class="font-medium">Оценка по текущему порогу</p>
|
||||
{#if estimateLoading}
|
||||
<p class="text-muted-foreground">Расчёт…</p>
|
||||
{:else if estimate}
|
||||
<p>
|
||||
Файлов к очистке: <strong>{estimate.would_count ?? 0}</strong>
|
||||
</p>
|
||||
{#if estimate.items?.length}
|
||||
<ul class="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
{#each estimate.items.filter((i) => i.would_cleanup) as item (item.filename)}
|
||||
<li class="font-mono">
|
||||
{item.filename} · {formatBytes(item.size_bytes)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-muted-foreground">Оценка недоступна</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">Исправьте ошибки в полях перед сохранением.</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if isOperator}
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={!canRunNow} onclick={requestRunNow}>
|
||||
<Play />
|
||||
{running ? 'Запуск…' : 'Запустить сейчас'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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 @@
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -77,6 +79,10 @@
|
||||
<TenantRevisionSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" class="mt-4">
|
||||
<TenantRuntimeLogsSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" class="mt-4">
|
||||
<TenantAdditionalSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<RuntimeLogAutoEstimate> {
|
||||
return apiJSON<RuntimeLogAutoEstimate>('/v1/runtime-logs/auto-estimate');
|
||||
}
|
||||
|
||||
export async function runRuntimeLogAutoCleanup(dryRun = false): Promise<RuntimeLogAutoRunResult> {
|
||||
const q = dryRun ? '?dry_run=true' : '';
|
||||
return apiMutate<RuntimeLogAutoRunResult>(`/v1/runtime-logs/auto-run${q}`, 'POST');
|
||||
}
|
||||
@@ -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<typeof runtimeLogsSettingsSchema>;
|
||||
|
||||
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'
|
||||
});
|
||||
@@ -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<AppSettings> {
|
||||
return apiJSON<AppSettings>('/v1/settings');
|
||||
}
|
||||
|
||||
export async function patchSettings(payload: Record<string, string | number>): Promise<void> {
|
||||
export async function patchSettings(
|
||||
payload: Record<string, string | number | boolean>
|
||||
): Promise<void> {
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
}
|
||||
|
||||
@@ -73,11 +96,16 @@ export function buildPayloadFromFormFields(
|
||||
keys: readonly KnownSettingKey[],
|
||||
form: Record<string, string>,
|
||||
errors: Partial<Record<string, string[]>>
|
||||
): Record<string, string | number> {
|
||||
const payload: Record<string, string | number> = {};
|
||||
): Record<string, string | number | boolean> {
|
||||
const payload: Record<string, string | number | boolean> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<KnownSettingKey>([
|
||||
'bird_local_asn',
|
||||
'revision_retention_minutes'
|
||||
'revision_retention_minutes',
|
||||
'runtime_logs_max_file_mb'
|
||||
]);
|
||||
|
||||
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled']);
|
||||
|
||||
Reference in New Issue
Block a user