feat(runtime-logs): enhance auto-cleanup features and documentation
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 33s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 20s
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 33s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 20s
Added new endpoints for estimating and executing runtime log auto-cleanup based on tenant settings. Introduced configuration options for auto-cleanup policies, including scheduling and file size limits. Updated the API documentation and UI components to reflect these changes, improving user interaction with runtime log management. Enhanced error handling and added new UI elements for better visibility of audit logs and cleanup actions.
This commit is contained in:
@@ -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