Compare commits

...
10 Commits
Author SHA1 Message Date
Denozordec db75126bea 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
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.
2026-06-12 22:44:39 +07:00
Denozordec f39df7c4bf feat(revisions): add pruning estimate and cleanup endpoints
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 32s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m18s
Implemented new endpoints for estimating and pruning revisions, including detailed schemas for requests and responses. The `RevisionPruneEstimate` and `RevisionPruneResult` components were added to the OpenAPI documentation, enhancing the API's functionality for managing revision retention. Updated the backend to support these operations and integrated them into the tenant settings UI for improved user interaction.
2026-06-12 21:56:36 +07:00
DenozordecandCursor 5dbdac3d2c feat(memory-bank): update active context and progress documentation
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 30s
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 3m43s
Обновлены разделы активного контекста и прогресса для задачи `settings-ui-and-runtime-logs`. Упрощено отображение статуса завершённых фаз и добавлены ссылки на архив. Уточнены следующие шаги и активные задачи, улучшая ясность и доступность информации.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 21:11:15 +07:00
DenozordecandCursor a0f78a3d21 feat(runtime-logs): update documentation and UI for runtime log management
Обновлены разделы документации для управления файловыми логами, включая новые эндпоинты и параметры. Добавлены описания для вкладки «Файловые логи» в интерфейсе мониторинга и обновлены настройки tenant. Улучшен доступ к логам через API и интерфейс пользователя.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 21:04:29 +07:00
DenozordecandCursor 0c5502b5bb feat(runtime-logs): enhance runtime log management and configuration
Добавлены новые возможности для управления файловыми логами в Docker-сервисах:
- Обновлены конфигурации для поддержки логов, включая переменные окружения и монтирование директорий.
- Документация обновлена для описания новых эндпоинтов и параметров, связанных с логами.
- Упрощен доступ к логам через API и интерфейс пользователя.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 19:18:50 +07:00
DenozordecandCursor 3f0dd6c234 docs(runtime-logs): implement runtime log management features
Добавлены новые возможности для работы с файловыми логами Docker-сервисов:
- Эндпоинты для получения списка логов и хвоста лог-файла.
- Очистка лог-файлов с возможностью выбора режима (truncate или delete) и запись в аудит очистки.
- Обновлена документация и конфигурация для поддержки новых функций.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 19:04:53 +07:00
DenozordecandCursor 1c39c65fc5 docs(memory-bank): add creative phase CP-4 path safety
Решение: allowlist *.log, EvalSymlinks, проверка префикса root; creative phase завершён.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:58:16 +07:00
DenozordecandCursor 493575aca4 docs(memory-bank): add creative phase CP-3 runtime logs cleanup
Решение: truncate по умолчанию, delete опционально, лимиты tail и max 512 MiB на sync cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:58:06 +07:00
DenozordecandCursor e27936c072 docs(memory-bank): add creative phase CP-2 runtime logs UI
Решение: вкладка «Файловые логи» в Monitoring с подвкладками files и audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:58:01 +07:00
DenozordecandCursor ceb6f2f34f docs(memory-bank): add creative phase CP-1 tenant settings UI
Решение: /tenant-settings с вкладками BIRD/Ревизии/Дополнительно, пункт mainNav «Параметры», /settings остаётся frontend-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:57:17 +07:00
84 changed files with 6166 additions and 507 deletions
+3
View File
@@ -17,3 +17,6 @@ Thumbs.db
.env.*
!.env.example
!.env.*.example
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
deploy/compose/runtime-logs/
+7 -6
View File
@@ -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 {
+17
View File
@@ -0,0 +1,17 @@
# Production /opt/evobgp — скопируйте в .env на сервере
# cp .env.production.example .env
EVOBGP_REGISTRY=git.shts.su/denozord
EVOBGP_IMAGE_TAG=latest
# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml)
COMPOSE_PROJECT_NAME=evobgp-microvps-full
# Каталог *.log на хосте (sidecar + API evobgp-all)
EVOBGP_RUNTIME_LOGS_HOST_DIR=/opt/evobgp/runtime-logs
# Traefik + Let's Encrypt (Cloudflare DNS challenge)
WEBUI_DOMAIN=bgp.example.com
WEBUI_IP_WHITELIST=203.0.113.10/32
LETSENCRYPT_EMAIL=admin@example.com
CF_DNS_API_TOKEN=
@@ -13,6 +13,11 @@ WEBUI_IP_WHITELIST=109.174.26.78/32,87.103.241.8/32
LETSENCRYPT_EMAIL=admin@shz.su
CF_DNS_API_TOKEN=cfut_3D1i0MNBVX6MNSsjcFytsfSlyB4B15t7H3DYXuzq22011588
# Файловые runtime-логи (sidecar stack-runtime-logs + API /v1/runtime-logs/* на evobgp-all).
# Dev (рядом с compose): ./runtime-logs
# Prod на хосте: /opt/evobgp/runtime-logs
# EVOBGP_RUNTIME_LOGS_HOST_DIR=./runtime-logs
# Auto-updater (проверка registry образов и точечный restart контейнеров)
# 0 = выключен, 1 = включен
AUTO_UPDATE_ENABLED=0
@@ -13,6 +13,14 @@ services:
evobgp-all:
environment:
EVOBGP_BROKER_URL: nats://nats:4222
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
volumes:
- type: bind
source: ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-./runtime-logs}
target: /opt/evobgp/runtime-logs
bind:
create_host_path: true
deploy:
resources:
limits:
@@ -0,0 +1,278 @@
# Production stack для /opt/evobgp/docker-compose.yaml
# Скопируйте на сервер:
# scp deploy/compose/docker-compose.production.example.yaml root@host:/opt/evobgp/docker-compose.yaml
# scp deploy/compose/.env.production.example root@host:/opt/evobgp/.env
#
# Запуск:
# cd /opt/evobgp
# mkdir -p runtime-logs
# docker login git.shts.su
# docker compose pull
# docker compose up -d
#
# Runtime logs API (/v1/runtime-logs/*): evobgp-all + stack-runtime-logs делят каталог
# EVOBGP_RUNTIME_LOGS_HOST_DIR на хосте (default /opt/evobgp/runtime-logs).
#
# Postgres: shm_size + start_period — иначе часто «dependency postgres failed» на слабом хосте.
# Web UI: WEBUI_DOMAIN, WEBUI_IP_WHITELIST, LETSENCRYPT_EMAIL, CF_DNS_API_TOKEN в .env
# Cloudflare: DNS only (серый облачок) для WEBUI_DOMAIN.
#
# ACME: том traefik_letsencrypt.name зафиксирован — не `docker compose down -v` без бэкапа.
# COMPOSE_PROJECT_NAME должен совпадать с label com.docker.compose.project стека.
name: evobgp-microvps-full
configs:
prometheus_yml:
content: |
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
- job_name: evobgp-all
metrics_path: /metrics
static_configs:
- targets: ["evobgp-all:8080"]
prometheus_alerts_yml:
content: |
groups: []
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
shm_size: "256mb"
environment:
POSTGRES_USER: evobgp
POSTGRES_PASSWORD: evobgp
POSTGRES_DB: evobgp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U evobgp -d evobgp"]
interval: 10s
timeout: 5s
retries: 12
start_period: 45s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
nats:
image: nats:2.10-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
ports:
- "4222:4222"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
bird2:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv4.ip_forward: "1"
net.ipv6.conf.all.forwarding: "1"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
ports:
- "179:179/tcp"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
evobgp-agent:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-agent:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
bird2:
condition: service_started
cap_add:
- NET_ADMIN
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/usr/local/bin/evobgp-agent"]
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
evobgp-all:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
bird2:
condition: service_started
ports:
- "8080:8080"
environment:
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
# EVOBGP_BROKER_URL: nats://nats:4222
EVOBGP_NODE_DISPATCH_ENABLED: "1"
EVOBGP_BUNDLE_SEED_HEX: "bd8fbcd31545aacfdd228203beca8e945ab9a752f2ce5624cf42d9f316389a9d"
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird:ro
- type: bind
source: ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-/opt/evobgp/runtime-logs}
target: /opt/evobgp/runtime-logs
bind:
create_host_path: true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
evobgp-web:
image: ${EVOBGP_REGISTRY:-git.shts.su/denozord}/evobgp-web-all:${EVOBGP_IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
evobgp-all:
condition: service_started
labels:
- traefik.enable=true
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
evobgp-edge:
image: traefik:latest
restart: unless-stopped
depends_on:
evobgp-web:
condition: service_started
ports:
- "80:80"
- "443:443"
environment:
DOCKER_API_VERSION: "1.44"
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN}
command:
- --log.level=INFO
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
prometheus:
image: prom/prometheus:v2.54.1
restart: unless-stopped
depends_on:
evobgp-all:
condition: service_started
ports:
- "9090:9090"
configs:
- source: prometheus_yml
target: /etc/prometheus/prometheus.yml
- source: prometheus_alerts_yml
target: /etc/prometheus/alerts.yml
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --web.enable-lifecycle
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
stack-runtime-logs:
image: docker:27-cli
restart: unless-stopped
depends_on:
evobgp-all:
condition: service_started
environment:
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-evobgp-microvps-full}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- type: bind
source: ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-/opt/evobgp/runtime-logs}
target: /logs
bind:
create_host_path: true
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
mkdir -p /logs
PROJECT=$$COMPOSE_PROJECT_NAME
SERVICES="postgres nats bird2 evobgp-agent evobgp-all evobgp-web evobgp-edge prometheus"
log_one() {
svc=$$1
f="/logs/$$svc.log"
while true; do
cid=$$(docker ps -q \
-f "label=com.docker.compose.service=$$svc" \
-f "label=com.docker.compose.project=$$PROJECT" | head -n1)
if [ -n "$$cid" ]; then
echo "---- $$(date -u +"%Y-%m-%dT%H:%M:%SZ") attach $$svc $$cid ----" >> "$$f"
docker logs -f --timestamps "$$cid" >> "$$f" 2>&1 || true
fi
sleep 3
done
}
for s in $$SERVICES; do log_one "$$s" & done
wait
volumes:
pgdata:
bird_etc:
bird_run:
traefik_letsencrypt:
name: evobgp_traefik_letsencrypt
+12 -2
View File
@@ -15,7 +15,8 @@
# проекта, перенесите acme.json в том evobgp_traefik_letsencrypt.
#
# Долгий сбор логов в файлы на хосте: сервис stack-runtime-logs пишет в каталог
# ./runtime-logs/ (рядом с этим compose-файлом) по одному файлу на сервис.
# ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-./runtime-logs} по одному файлу на сервис.
# evobgp-all монтирует тот же каталог в /opt/evobgp/runtime-logs для API /v1/runtime-logs/*.
# Автообновление сервисов из registry (без перезапуска bird2): stack-auto-updater.
# Настройки через .env: AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_SEC,
# AUTO_UPDATE_SERVICES, AUTO_UPDATE_PROTECTED_SERVICES.
@@ -135,11 +136,18 @@ services:
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird:ro
- type: bind
source: ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-./runtime-logs}
target: /opt/evobgp/runtime-logs
bind:
create_host_path: true
logging:
driver: json-file
options:
@@ -237,8 +245,10 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- type: bind
source: ./runtime-logs
source: ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-./runtime-logs}
target: /logs
bind:
create_host_path: true
entrypoint: ["/bin/sh", "-c"]
command:
- |
+15
View File
@@ -101,6 +101,21 @@ EVOBGP_BUNDLE_SEED_HEX=<32 bytes hex, стабильный>
После `deploy_apply` CP шлёт `POST https://AGENT_DOMAIN/v1/agent/sync` с `Authorization: Bearer <agent_secret>`. На реплике — `EVOBGP_AGENT_SECRET`, Traefik `PANEL_IP_WHITELIST`. Подробнее: [remote-speakers.md](remote-speakers.md).
## Runtime log-файлы (`EVOBGP_RUNTIME_LOGS_DIR`)
Файловые логи Docker-сервисов (sidecar `stack-runtime-logs` в compose) читаются API **только** в процессе **`evobgp-all`**, когда заданы обе переменные:
```text
EVOBGP_SERVICE=evobgp-all
EVOBGP_RUNTIME_LOGS_DIR=/opt/evobgp/runtime-logs
```
В 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 для веб-интерфейса
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
+16 -1
View File
@@ -90,7 +90,22 @@
### Settings
- `GET /v1/settings`, `PATCH /v1/settings`
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, `runtime_logs_*` (автоочистка FS), произвольные ключи. Чтение — viewer+; `PATCH` — operator+.
### RuntimeLogs
Файловые логи Docker-сервисов (sidecar `stack-runtime-logs`). FS API **только** в процессе **`evobgp-all`** при `EVOBGP_SERVICE=evobgp-all` и `EVOBGP_RUNTIME_LOGS_DIR` (см. [access.md](access.md)). Иначе `GET`/`DELETE` по файлам → **503** (`runtime_logs_unavailable`).
| Метод | Путь | Роль | Назначение |
|-------|------|------|------------|
| `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`); **без 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`).
## Соглашения из OpenAPI
+20 -1
View File
@@ -85,7 +85,8 @@ EvoBGP управляет генерацией и применением BGP-к
- `AS Entries`, `CDN Sources`, `Domain Entries`, `IP Range Entries`: источники префиксов.
- `Peers`, `Speakers`: сетевая топология применения.
- `Revisions`, `Deploy`, `Jobs`: жизненный цикл ревизий и фоновых задач.
- `Settings`: глобальные KV-настройки.
- `Settings`: глобальные KV-настройки tenant.
- `RuntimeLogs`: файловые логи compose (только `evobgp-all` + volume).
- `Node`: edge-флоу бандлов/enrollment.
### Типовой сценарий оператора
@@ -106,6 +107,23 @@ EvoBGP управляет генерацией и применением BGP-к
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
### Web UI: настройки tenant и интерфейса
| Маршрут | Назначение |
|---------|------------|
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
| `/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 очистки** (история из БД, в т.ч. `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
### Что проверять при инцидентах
@@ -140,4 +158,5 @@ EvoBGP управляет генерацией и применением BGP-к
- Доступ/роли: `docs/access.md`
- Web запуск: `web/README.md`
- Compose: `deploy/compose/docker-compose.yaml`
- Runtime log-файлы (sidecar + API): `EVOBGP_RUNTIME_LOGS_HOST_DIR` на хосте, mount в `evobgp-all``/opt/evobgp/runtime-logs`; см. [access.md](access.md) и [quickstart.md](quickstart.md#файловые-runtime-логи-api-v1runtime-logs)
+496
View File
@@ -53,6 +53,11 @@ tags:
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
- name: Maintenance
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
- name: RuntimeLogs
description: |
Файловые runtime-логи Docker-сервисов (каталог EVOBGP_RUNTIME_LOGS_DIR).
Доступно только в процессе evobgp-all с примонтированным volume; иначе 503.
Просмотр — viewer+; очистка — operator+ (синхронно, с audit).
security:
- bearerAuth: []
@@ -1060,6 +1065,157 @@ components:
has_more:
type: boolean
RuntimeLogCleanupMode:
type: string
enum: [truncate, delete]
description: |
truncate — обнулить файл (по умолчанию); delete — удалить файл с диска.
RuntimeLogFile:
type: object
required: [name, size_bytes, modified_at]
properties:
name:
type: string
description: Basename файла (*.log) в каталоге runtime-логов.
pattern: '^[a-z0-9][a-z0-9_.-]*\.log$'
size_bytes:
type: integer
format: int64
minimum: 0
modified_at:
type: string
format: date-time
RuntimeLogFileList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/RuntimeLogFile"
RuntimeLogTail:
type: object
required: [filename, content, truncated, lines_returned]
properties:
filename:
type: string
content:
type: string
description: UTF-8 текст хвоста файла.
truncated:
type: boolean
description: true если применён лимит bytes/lines.
lines_returned:
type: integer
minimum: 0
RuntimeLogCleanupResult:
type: object
required: [audit_id, filename, action, size_before]
properties:
audit_id:
$ref: "#/components/schemas/ResourceId"
filename:
type: string
action:
$ref: "#/components/schemas/RuntimeLogCleanupMode"
size_before:
type: integer
format: int64
size_after:
type: ["integer", "null"]
format: int64
RuntimeLogCleanupAudit:
type: object
required: [id, tenant_id, actor_prefix, filename, action, size_before, created_at]
properties:
id:
$ref: "#/components/schemas/ResourceId"
tenant_id:
$ref: "#/components/schemas/ResourceId"
actor_prefix:
type: string
filename:
type: string
action:
$ref: "#/components/schemas/RuntimeLogCleanupMode"
size_before:
type: integer
format: int64
size_after:
type: ["integer", "null"]
format: int64
detail:
type: object
additionalProperties: true
created_at:
type: string
format: date-time
RuntimeLogCleanupAuditList:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/RuntimeLogCleanupAudit"
next_cursor:
type: string
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).
@@ -1104,6 +1260,70 @@ components:
format: date-time
additionalProperties: true
RevisionPruneEstimate:
type: object
required:
- retention_minutes
- cutoff_at
- revision_count
- prefix_row_count
- orphan_snapshot_count
- bytes_estimate
properties:
retention_minutes:
type: integer
minimum: 15
maximum: 43200
cutoff_at:
type: string
format: date-time
revision_count:
type: integer
minimum: 0
prefix_row_count:
type: integer
minimum: 0
description: Строки prefix_snapshot_row в освобождаемых снимках.
orphan_snapshot_count:
type: integer
minimum: 0
bytes_estimate:
type: integer
format: int64
minimum: 0
description: Ориентировочный логический объём данных (байты).
RevisionPruneResult:
type: object
required:
- deleted_revisions
- deleted_prefix_snapshots
- deleted_prefix_rows
- bytes_estimate
properties:
deleted_revisions:
type: integer
minimum: 0
deleted_prefix_snapshots:
type: integer
minimum: 0
deleted_prefix_rows:
type: integer
minimum: 0
bytes_estimate:
type: integer
format: int64
minimum: 0
RevisionPruneRequest:
type: object
properties:
retention_minutes:
type: integer
minimum: 15
maximum: 43200
description: TTL в минутах; если не задан — из revision_retention_minutes tenant settings.
PrefixSnapshotItem:
type: object
description: >
@@ -1216,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:
@@ -2715,6 +2952,66 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/revisions/prune-estimate:
get:
tags: [Revisions]
summary: Оценка очистки ревизий по retention
description: >
Считает ревизии и ориентировочный объём данных, которые будут удалены при prune
(те же правила, что applyRevisionRetention: последняя ревизия tenant и раскатанные на спикерах сохраняются).
operationId: getRevisionPruneEstimate
parameters:
- $ref: "#/components/parameters/TenantId"
- name: retention_minutes
in: query
required: false
schema:
type: integer
minimum: 15
maximum: 43200
description: TTL в минутах; если не задан — из tenant settings (default 30d).
responses:
"200":
description: Оценка.
content:
application/json:
schema:
$ref: "#/components/schemas/RevisionPruneEstimate"
"422":
$ref: "#/components/responses/UnprocessableEntity"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/revisions/prune:
post:
tags: [Revisions]
summary: Очистить старые ревизии (синхронно)
description: >
Удаляет ревизии старше cutoff по retention и GC неиспользуемых prefix_snapshot.
Operator-only.
operationId: pruneRevisions
parameters:
- $ref: "#/components/parameters/TenantId"
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/RevisionPruneRequest"
responses:
"200":
description: Результат очистки.
content:
application/json:
schema:
$ref: "#/components/schemas/RevisionPruneResult"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/UnprocessableEntity"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/revisions/{revision_id}:
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3761,6 +4058,205 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/runtime-logs/files:
get:
tags: [RuntimeLogs]
summary: Список runtime log-файлов
description: |
Список *.log в EVOBGP_RUNTIME_LOGS_DIR (размер и mtime).
Требуется evobgp-all с примонтированным volume.
operationId: listRuntimeLogFiles
parameters:
- $ref: "#/components/parameters/TenantId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/RuntimeLogFileList"
"503":
description: Runtime logs недоступны (не evobgp-all или каталог не настроен).
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/runtime-logs/files/{filename}:
get:
tags: [RuntimeLogs]
summary: Хвост runtime log-файла
operationId: getRuntimeLogTail
parameters:
- $ref: "#/components/parameters/TenantId"
- name: filename
in: path
required: true
schema:
type: string
pattern: '^[a-z0-9][a-z0-9_.-]*\.log$'
description: Basename файла (без пути).
- name: lines
in: query
schema:
type: integer
minimum: 1
maximum: 2000
default: 200
- name: bytes
in: query
schema:
type: integer
minimum: 1
maximum: 262144
description: Альтернатива lines; при указании обоих — более строгий лимит.
- name: grep
in: query
schema:
type: string
maxLength: 128
description: Опциональный подстрочный фильтр (после чтения хвоста).
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/RuntimeLogTail"
"404":
$ref: "#/components/responses/NotFound"
"503":
description: Runtime logs недоступны.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
default:
$ref: "#/components/responses/DefaultProblem"
delete:
tags: [RuntimeLogs]
summary: Очистить runtime log-файл
description: |
Синхронная очистка (truncate по умолчанию или delete). Запись в cleanup audit.
Максимальный размер файла для очистки — 512 MiB. Только operator+.
operationId: deleteRuntimeLogFile
parameters:
- $ref: "#/components/parameters/TenantId"
- name: filename
in: path
required: true
schema:
type: string
pattern: '^[a-z0-9][a-z0-9_.-]*\.log$'
- name: mode
in: query
schema:
$ref: "#/components/schemas/RuntimeLogCleanupMode"
description: По умолчанию truncate.
responses:
"200":
description: Файл очищен.
content:
application/json:
schema:
$ref: "#/components/schemas/RuntimeLogCleanupResult"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"413":
description: Файл превышает лимит 512 MiB.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"503":
description: Runtime logs недоступны.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
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]
summary: Audit очистки runtime log-файлов
operationId: listRuntimeLogCleanupAudit
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/RuntimeLogCleanupAuditList"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+8
View File
@@ -156,6 +156,14 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
Health API: `http://<IP>:8080/v1/health`.
### Файловые runtime-логи (API `/v1/runtime-logs/*`)
В профиле **microvps-full** и в standalone `stack.microvps-full.yaml` sidecar **`stack-runtime-logs`** пишет `docker logs` каждого сервиса в `*.log` на хосте. Каталог по умолчанию — **`./runtime-logs`** рядом с compose-файлами; на production-хосте задайте **`EVOBGP_RUNTIME_LOGS_HOST_DIR=/opt/evobgp/runtime-logs`** (см. `deploy/compose/.env.stack.microvps-full.example`).
Контейнер **`evobgp-all`** монтирует тот же каталог в **`/opt/evobgp/runtime-logs`** и включает FS API при `EVOBGP_SERVICE=evobgp-all` и `EVOBGP_RUNTIME_LOGS_DIR=/opt/evobgp/runtime-logs` (уже в compose). Просмотр и очистка — в Web UI (Monitoring → «Файловые логи») или через REST; детали — [docs/access.md](access.md).
На **`evobgp-api`** (профиль reference) volume не монтируется — эндпоинты отвечают **503** (`runtime_logs_unavailable`).
### Auto-updater для standalone stack (без рестарта BIRD2)
Для `stack.microvps-full.yaml` можно включить автообновление только выбранных сервисов (например, `evobgp-all,evobgp-web`) по digest образов в registry.
+13
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
)
@@ -15,6 +16,10 @@ type Env struct {
DatabaseURL string
// BrokerURL is NATS/Redis when the reference profile uses a message broker (empty in microVPS).
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.
@@ -31,5 +36,13 @@ 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
} else {
e.RuntimeLogsDir = v
}
}
return e
}
+3
View File
@@ -59,6 +59,8 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions)
m.HandleFunc("GET /revisions/prune-estimate", s.handleRevisionPruneEstimate)
m.HandleFunc("POST /revisions/prune", s.handleRevisionPrune)
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
m.HandleFunc("GET /revisions/{revision_id}/diagnostic-log", s.handleRevisionDiagnosticLog)
@@ -79,6 +81,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
s.registerPostgresMonitoringRoutes(m)
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
s.registerRuntimeLogsRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+5
View File
@@ -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
+101
View File
@@ -0,0 +1,101 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"time"
"evobgp/internal/pipeline"
)
func (s *Server) resolveRevisionRetentionMinutesQuery(r *http.Request, tenantID string) (minutes int, ok bool) {
if raw := r.URL.Query().Get("retention_minutes"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
if n < pipeline.RevisionMinTTLMin || n > pipeline.RevisionMaxTTLMin {
return 0, false
}
return n, true
}
return s.defaultRevisionRetentionMinutes(tenantID)
}
func (s *Server) defaultRevisionRetentionMinutes(tenantID string) (int, bool) {
settings, err := s.store.ListGlobalSettings(tenantID)
if err != nil {
return pipeline.ClampRevisionRetentionMinutes(0), true
}
return pipeline.ClampRevisionRetentionMinutes(pipeline.RevisionRetentionMinutesFromSettings(settings)), true
}
func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID string) (minutes int, ok bool) {
var body struct {
RetentionMinutes *int `json:"retention_minutes"`
}
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return 0, false
}
if body.RetentionMinutes != nil {
m := *body.RetentionMinutes
if m < pipeline.RevisionMinTTLMin || m > pipeline.RevisionMaxTTLMin {
return 0, false
}
return m, true
}
}
return s.defaultRevisionRetentionMinutes(tenantID)
}
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
if !valid {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "retention_minutes must be an integer in range 15..43200")
return
}
cutoff := pipeline.RevisionCutoffFromMinutes(minutes)
est, err := s.store.EstimateRevisionPrune(a.TenantID, cutoff, minutes)
if err != nil {
writeInternalError(w, "revision prune estimate", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"retention_minutes": est.RetentionMinutes,
"cutoff_at": est.CutoffAt.UTC().Format(time.RFC3339Nano),
"revision_count": est.RevisionCount,
"prefix_row_count": est.PrefixRowCount,
"orphan_snapshot_count": est.OrphanSnapshotCount,
"bytes_estimate": est.BytesEstimate,
})
}
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
if !valid {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "retention_minutes must be an integer in range 15..43200")
return
}
cutoff := pipeline.RevisionCutoffFromMinutes(minutes)
res, err := s.store.PruneRevisionsWithStats(a.TenantID, cutoff)
if err != nil {
writeInternalError(w, "revision prune", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deleted_revisions": res.DeletedRevisions,
"deleted_prefix_snapshots": res.DeletedPrefixSnapshots,
"deleted_prefix_rows": res.DeletedPrefixRows,
"bytes_estimate": res.BytesEstimate,
})
}
@@ -0,0 +1,94 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRevisionPruneEstimateViewerOK(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/revisions/prune-estimate?retention_minutes=15", nil)
req.Header.Set("Authorization", "Bearer vwkey")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if _, ok := body["revision_count"]; !ok {
t.Fatalf("missing revision_count: %v", body)
}
}
func TestRevisionPruneViewerForbidden(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/revisions/prune", strings.NewReader(`{"retention_minutes":43200}`))
req.Header.Set("Authorization", "Bearer vwkey")
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
}
func TestRevisionPruneOperatorOK(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/revisions/prune", strings.NewReader(`{"retention_minutes":15}`))
req.Header.Set("Authorization", "Bearer opkey")
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
}
+254
View File
@@ -0,0 +1,254 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
)
func (s *Server) registerRuntimeLogsRoutes(m *http.ServeMux) {
m.HandleFunc("GET /runtime-logs/files", s.handleListRuntimeLogFiles)
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 {
if s.runtimeLogs != nil && s.runtimeLogs.Available() {
return true
}
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "runtime_logs_unavailable")
return false
}
func writeRuntimeLogsErr(w http.ResponseWriter, operation string, err error) {
switch {
case errors.Is(err, runtimelogs.ErrUnavailable):
writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "runtime_logs_unavailable")
case errors.Is(err, runtimelogs.ErrNotFound):
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
case errors.Is(err, runtimelogs.ErrFileTooLarge):
writeProblem(w, http.StatusRequestEntityTooLarge, "Payload Too Large", "file exceeds maximum size for cleanup")
case errors.Is(err, runtimelogs.ErrInvalidFilename), errors.Is(err, runtimelogs.ErrNotAFile):
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
default:
writeInternalError(w, operation, err)
}
}
func runtimeLogFileJSON(f store.RuntimeLogFile) map[string]any {
return map[string]any{
"name": f.Name,
"size_bytes": f.SizeBytes,
"modified_at": f.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
}
func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]any {
out := map[string]any{
"id": row.ID,
"tenant_id": row.TenantID,
"actor_prefix": row.ActorPrefix,
"filename": row.Filename,
"action": row.Action,
"size_before": row.SizeBefore,
"created_at": row.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if row.SizeAfter != nil {
out["size_after"] = *row.SizeAfter
}
if row.Detail != nil {
out["detail"] = row.Detail
}
return out
}
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
return
}
items, err := s.runtimeLogs.ListFiles()
if err != nil {
writeRuntimeLogsErr(w, "runtime_logs_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, f := range items {
out = append(out, runtimeLogFileJSON(f))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
opts := runtimelogs.TailOptions{
Lines: parsePositiveIntQuery(r, "lines", runtimelogs.DefaultTailLines, runtimelogs.MaxTailLines),
Bytes: parsePositiveIntQuery(r, "bytes", 0, runtimelogs.MaxTailBytes),
Grep: r.URL.Query().Get("grep"),
}
tail, err := s.runtimeLogs.Tail(filename, opts)
if err != nil {
writeRuntimeLogsErr(w, "runtime_logs_tail", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"filename": tail.Filename,
"content": tail.Content,
"truncated": tail.Truncated,
"lines_returned": tail.LinesReturned,
})
}
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
mode := r.URL.Query().Get("mode")
if mode == "" {
mode = store.RuntimeLogCleanupTruncate
}
if !store.ValidRuntimeLogCleanupAction(mode) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
sizeBefore, sizeAfter, err := s.runtimeLogs.Cleanup(filename, mode)
if err != nil {
writeRuntimeLogsErr(w, "runtime_logs_cleanup", err)
return
}
auditID, err := s.store.AppendRuntimeLogCleanupAudit(
a.TenantID, actorPrefix(a), filename, mode, sizeBefore, sizeAfter, nil)
if err != nil {
writeInternalError(w, "runtime_logs_cleanup_audit", err)
return
}
out := map[string]any{
"audit_id": auditID,
"filename": filename,
"action": mode,
"size_before": sizeBefore,
}
if sizeAfter != nil {
out["size_after"] = *sizeAfter
}
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") {
return
}
cursor := r.URL.Query().Get("cursor")
limit := parseLimitQuery(r, 20, 100)
items, next, hasMore, err := s.store.ListRuntimeLogCleanupAudit(a.TenantID, cursor, limit)
if err != nil {
writeInternalError(w, "runtime_logs_cleanup_audit_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, row := range items {
out = append(out, runtimeLogCleanupAuditJSON(row))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
}
func parsePositiveIntQuery(r *http.Request, key string, def, max int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return def
}
if max > 0 && n > max {
return max
}
return n
}
@@ -0,0 +1,191 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"evobgp/internal/runtimelogs"
)
func TestRuntimeLogsFSUnavailable503(t *testing.T) {
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
handler := srv.Handler()
tests := []struct {
method string
path string
}{
{http.MethodGet, "/v1/runtime-logs/files"},
{http.MethodGet, "/v1/runtime-logs/files/evobgp-all.log"},
{http.MethodDelete, "/v1/runtime-logs/files/evobgp-all.log"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "runtime_logs_unavailable") {
t.Fatalf("expected runtime_logs_unavailable detail, body=%s", rec.Body.String())
}
})
}
}
func TestRuntimeLogsCleanupAuditWithoutFS(t *testing.T) {
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
handler := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/cleanup-audit", nil)
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestRuntimeLogsHappyPath(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "evobgp-all.log")
if err := os.WriteFile(logPath, []byte("line1\nline2\nline3\n"), 0o644); err != nil {
t.Fatal(err)
}
srv, err := New(Options{SeedDemo: true, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
srv.runtimeLogs = runtimelogs.NewService(runtimelogs.Config{
RootDir: dir,
ServiceName: runtimelogs.ServiceNameAll,
})
handler := srv.Handler()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer,opkey|"+tenant+"|operator")
t.Run("list", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/files", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "evobgp-all.log") {
t.Fatalf("expected file in list, body=%s", rec.Body.String())
}
})
t.Run("tail", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/files/evobgp-all.log?lines=2", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "line2") || !strings.Contains(rec.Body.String(), "line3") {
t.Fatalf("unexpected tail body=%s", rec.Body.String())
}
})
t.Run("viewer cannot cleanup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/v1/runtime-logs/files/evobgp-all.log", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("cleanup truncate and audit", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/v1/runtime-logs/files/evobgp-all.log?mode=truncate", nil)
req.Header.Set("Authorization", "Bearer opkey")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"action":"truncate"`) {
t.Fatalf("unexpected cleanup body=%s", rec.Body.String())
}
st, err := os.Stat(logPath)
if err != nil {
t.Fatal(err)
}
if st.Size() != 0 {
t.Fatalf("expected truncated file, size=%d", st.Size())
}
auditReq := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/cleanup-audit", nil)
auditReq.Header.Set("Authorization", "Bearer vwkey")
auditRec := httptest.NewRecorder()
handler.ServeHTTP(auditRec, auditReq)
if auditRec.Code != http.StatusOK {
t.Fatalf("audit status=%d body=%s", auditRec.Code, auditRec.Body.String())
}
if !strings.Contains(auditRec.Body.String(), "evobgp-all.log") {
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())
}
})
}
+35 -21
View File
@@ -13,6 +13,7 @@ import (
"evobgp/internal/jobs"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
@@ -20,17 +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
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.
@@ -42,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.
@@ -79,16 +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(),
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()
@@ -123,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)
}
}
+2 -15
View File
@@ -31,10 +31,7 @@ const (
birdFilterNameV4 = "evobgp_export_v4"
birdFilterNameV6 = "evobgp_export_v6"
auxBirdFullExpanded = "_bird_full_expanded.conf"
revisionTTLKey = "revision_retention_minutes"
revisionMinTTLMin = 15
revisionMaxTTLMin = 30 * 24 * 60
revisionDefaultTTL = 30 * 24 * time.Hour
revisionTTLKey = RevisionRetentionKey
)
// AuxBirdFullExpandedKey returns the preview map key for the expanded BIRD config (generated on demand).
@@ -969,17 +966,7 @@ func applyRevisionRetention(st store.Backend, tenantID string) {
if err != nil {
return
}
ttl := revisionDefaultTTL
if minutes := intFromSettingsMap(settings, revisionTTLKey); minutes > 0 {
if minutes < revisionMinTTLMin {
minutes = revisionMinTTLMin
}
if minutes > revisionMaxTTLMin {
minutes = revisionMaxTTLMin
}
ttl = time.Duration(minutes) * time.Minute
}
cutoff := time.Now().UTC().Add(-ttl)
cutoff := RevisionRetentionCutoff(settings)
_, _ = st.PruneRevisionsBefore(tenantID, cutoff)
}
+75
View File
@@ -0,0 +1,75 @@
package pipeline
import (
"strconv"
"strings"
"time"
)
const (
// RevisionRetentionKey is the global_settings KV for revision TTL (minutes).
RevisionRetentionKey = "revision_retention_minutes"
RevisionMinTTLMin = 15
RevisionMaxTTLMin = 30 * 24 * 60
RevisionDefaultTTL = 30 * 24 * time.Hour
)
// RevisionCutoffFromMinutes returns created_at cutoff for revision prune (UTC now minus clamped TTL).
func RevisionCutoffFromMinutes(minutes int) time.Time {
ttl := RevisionDefaultTTL
if minutes > 0 {
m := minutes
if m < RevisionMinTTLMin {
m = RevisionMinTTLMin
}
if m > RevisionMaxTTLMin {
m = RevisionMaxTTLMin
}
ttl = time.Duration(m) * time.Minute
}
return time.Now().UTC().Add(-ttl)
}
// RevisionRetentionMinutesFromSettings reads revision_retention_minutes from tenant KV (0 if unset).
func RevisionRetentionMinutesFromSettings(settings map[string]any) int {
if settings == nil {
return 0
}
v, ok := settings[RevisionRetentionKey]
if !ok || v == nil {
return 0
}
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
}
// RevisionRetentionCutoff resolves tenant revision_retention_minutes from settings (default 30d).
func RevisionRetentionCutoff(settings map[string]any) time.Time {
return RevisionCutoffFromMinutes(RevisionRetentionMinutesFromSettings(settings))
}
// ClampRevisionRetentionMinutes normalizes user input to the allowed range.
func ClampRevisionRetentionMinutes(minutes int) int {
if minutes <= 0 {
return int(RevisionDefaultTTL / time.Minute)
}
if minutes < RevisionMinTTLMin {
return RevisionMinTTLMin
}
if minutes > RevisionMaxTTLMin {
return RevisionMaxTTLMin
}
return minutes
}
@@ -0,0 +1,30 @@
package pipeline
import (
"testing"
"time"
)
func TestClampRevisionRetentionMinutes(t *testing.T) {
if got := ClampRevisionRetentionMinutes(0); got != int(RevisionDefaultTTL/time.Minute) {
t.Fatalf("default: got %d", got)
}
if got := ClampRevisionRetentionMinutes(5); got != RevisionMinTTLMin {
t.Fatalf("min clamp: got %d", got)
}
if got := ClampRevisionRetentionMinutes(999999); got != RevisionMaxTTLMin {
t.Fatalf("max clamp: got %d", got)
}
if got := ClampRevisionRetentionMinutes(120); got != 120 {
t.Fatalf("unchanged: got %d", got)
}
}
func TestRevisionCutoffFromMinutes(t *testing.T) {
before := time.Now().UTC()
cutoff := RevisionCutoffFromMinutes(60)
after := time.Now().UTC().Add(-59 * time.Minute)
if cutoff.After(before.Add(-59*time.Minute)) || cutoff.Before(after.Add(-2*time.Minute)) {
t.Fatalf("cutoff out of range: %v", cutoff)
}
}
-37
View File
@@ -970,43 +970,6 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
}, nil
}
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
ctx := context.Background()
total := 0
const batchSize = 50
for {
cmd, err := p.pool.Exec(ctx, `
DELETE FROM config_revision AS cr
WHERE cr.id IN (
SELECT id FROM config_revision
WHERE tenant_id = $1
AND created_at < $2
AND id <> (
SELECT id FROM config_revision
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1 FROM bgp_speaker AS sp
WHERE sp.tenant_id = $1
AND (sp.last_applied_revision_id = config_revision.id OR sp.published_revision_id = config_revision.id)
)
ORDER BY created_at ASC
LIMIT $3
)`, tenantID, cutoff.UTC(), batchSize)
if err != nil {
return total, err
}
n := int(cmd.RowsAffected())
total += n
if n < batchSize {
break
}
}
return total, nil
}
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
+23 -15
View File
@@ -53,21 +53,8 @@ func (p *Postgres) ensurePrefixSnapshot(ctx context.Context, db execQuerier, con
return "", nil
}
hash := normalizeSnapshotHash(contentHash)
var existing string
err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&existing)
if err == nil && existing != "" {
return existing, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
snapID := uuid.NewString()
if _, err := db.Exec(ctx, `
INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)
ON CONFLICT (content_hash) DO NOTHING`, snapID, hash); err != nil {
return "", err
}
if err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&snapID); err != nil {
snapID, err := p.resolvePrefixSnapshotID(ctx, db, hash)
if err != nil {
return "", err
}
var rowCount int
@@ -94,6 +81,27 @@ func (p *Postgres) ensurePrefixSnapshot(ctx context.Context, db execQuerier, con
return snapID, nil
}
func (p *Postgres) resolvePrefixSnapshotID(ctx context.Context, db execQuerier, hash string) (string, error) {
var existing string
err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&existing)
if err == nil && existing != "" {
return existing, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
snapID := uuid.NewString()
if _, err := db.Exec(ctx, `
INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)
ON CONFLICT (content_hash) DO NOTHING`, snapID, hash); err != nil {
return "", err
}
if err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&snapID); err != nil {
return "", err
}
return snapID, nil
}
func (p *Postgres) listSnapshotPrefixes(ctx context.Context, snapshotID, cursor string, limit int) ([]store.PrefixRow, string, bool) {
afterOrd, off, useOffset := store.ParsePrefixPageCursor(cursor)
var rows pgx.Rows
@@ -0,0 +1,119 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/db"
"evobgp/internal/store"
"github.com/google/uuid"
)
func TestEnsurePrefixSnapshotFillsEmptyExistingIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
hash := normalizeSnapshotHash("sha256:empty-fill-" + uuid.NewString())
snapID := uuid.NewString()
if _, err := pool.Exec(ctx, `INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)`, snapID, hash); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM prefix_snapshot WHERE id = $1::uuid`, snapID)
})
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
got, err := pg.ensurePrefixSnapshot(ctx, tx, "sha256:"+hash, []store.PrefixRow{
{Prefix: "203.0.113.1/32", Source: "test"},
})
if err != nil {
t.Fatal(err)
}
if got != snapID {
t.Fatalf("snap id: got %q want %q", got, snapID)
}
var rowCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM prefix_snapshot_row WHERE snapshot_id = $1::uuid`, snapID).Scan(&rowCount); err != nil {
t.Fatal(err)
}
if rowCount != 1 {
t.Fatalf("row count: got %d", rowCount)
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
}
func TestEnsurePrefixSnapshotIdempotentIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
contentHash := "sha256:idempotent-" + uuid.NewString()
prefixes := []store.PrefixRow{{Prefix: "198.51.100.0/24", Source: "test"}}
tx1, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
id1, err := pg.ensurePrefixSnapshot(ctx, tx1, contentHash, prefixes)
if err != nil {
t.Fatal(err)
}
if err := tx1.Commit(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM prefix_snapshot WHERE id = $1::uuid`, id1)
})
tx2, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx2.Rollback(ctx) }()
id2, err := pg.ensurePrefixSnapshot(ctx, tx2, contentHash, prefixes)
if err != nil {
t.Fatal(err)
}
if id1 != id2 {
t.Fatalf("ids differ: %q vs %q", id1, id2)
}
var rowCount int
if err := tx2.QueryRow(ctx, `SELECT COUNT(*)::int FROM prefix_snapshot_row WHERE snapshot_id = $1::uuid`, id1).Scan(&rowCount); err != nil {
t.Fatal(err)
}
if rowCount != 1 {
t.Fatalf("expected 1 row, got %d", rowCount)
}
}
@@ -0,0 +1,176 @@
package repository
import (
"context"
"time"
"evobgp/internal/store"
)
const revisionPruneBatchSize = 50
// sqlPrunableRevisionsWhere appends prunable revision predicates (tenant + cutoff params).
func sqlPrunableRevisionsWhere(tenantParam, cutoffParam string) string {
return `tenant_id = ` + tenantParam + `
AND created_at < ` + cutoffParam + `
AND id <> (
SELECT id FROM config_revision
WHERE tenant_id = ` + tenantParam + `
ORDER BY created_at DESC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1 FROM bgp_speaker AS sp
WHERE sp.tenant_id = ` + tenantParam + `
AND (sp.last_applied_revision_id = config_revision.id OR sp.published_revision_id = config_revision.id)
)`
}
func (p *Postgres) EstimateRevisionPrune(tenantID string, cutoff time.Time, retentionMinutes int) (store.RevisionPruneEstimate, error) {
ctx := context.Background()
out := store.RevisionPruneEstimate{
RetentionMinutes: retentionMinutes,
CutoffAt: cutoff.UTC(),
}
where := sqlPrunableRevisionsWhere("$1", "$2")
var revBytes, previewBytes, rmpBytes, snapRowBytes int64
err := p.pool.QueryRow(ctx, `
WITH prunable AS (
SELECT id, prefix_snapshot_id, content_hash, meta_json
FROM config_revision
WHERE `+where+`
),
freed_snaps AS (
SELECT DISTINCT pr.prefix_snapshot_id AS snap_id
FROM prunable pr
WHERE pr.prefix_snapshot_id IS NOT NULL
EXCEPT
SELECT DISTINCT cr.prefix_snapshot_id
FROM config_revision cr
WHERE cr.prefix_snapshot_id IS NOT NULL
AND cr.id NOT IN (SELECT id FROM prunable)
)
SELECT
(SELECT COUNT(*)::int FROM prunable),
COALESCE((SELECT SUM(octet_length(content_hash) + octet_length(meta_json::text))::bigint FROM prunable), 0),
COALESCE((
SELECT SUM(octet_length(value))::bigint
FROM config_revision_preview p
JOIN prunable pr ON pr.id = p.revision_id
CROSS JOIN LATERAL jsonb_each_text(COALESCE(p.fragments, '{}'::jsonb))
), 0),
COALESCE((
SELECT SUM(octet_length(rmp.prefix) + octet_length(COALESCE(rmp.source, '')))::bigint
FROM revision_materialized_prefix rmp
WHERE rmp.revision_id IN (SELECT id FROM prunable)
), 0),
(SELECT COUNT(*)::int FROM freed_snaps),
COALESCE((SELECT COUNT(*)::int FROM prefix_snapshot_row psr WHERE psr.snapshot_id IN (SELECT snap_id FROM freed_snaps)), 0),
COALESCE((
SELECT SUM(octet_length(psr.prefix) + octet_length(COALESCE(psr.source, '')))::bigint
FROM prefix_snapshot_row psr
WHERE psr.snapshot_id IN (SELECT snap_id FROM freed_snaps)
), 0)`,
tenantID, cutoff.UTC()).Scan(
&out.RevisionCount,
&revBytes,
&previewBytes,
&rmpBytes,
&out.OrphanSnapshotCount,
&out.PrefixRowCount,
&snapRowBytes,
)
if err != nil {
return out, err
}
out.BytesEstimate = revBytes + previewBytes + rmpBytes + snapRowBytes
return out, nil
}
func (p *Postgres) PruneRevisionsWithStats(tenantID string, cutoff time.Time) (store.RevisionPruneResult, error) {
ctx := context.Background()
est, err := p.EstimateRevisionPrune(tenantID, cutoff, 0)
if err != nil {
return store.RevisionPruneResult{}, err
}
out := store.RevisionPruneResult{BytesEstimate: est.BytesEstimate}
where := sqlPrunableRevisionsWhere("$1", "$2")
for {
cmd, err := p.pool.Exec(ctx, `
DELETE FROM config_revision AS cr
WHERE cr.id IN (
SELECT id FROM config_revision
WHERE `+where+`
ORDER BY created_at ASC
LIMIT $3
)`, tenantID, cutoff.UTC(), revisionPruneBatchSize)
if err != nil {
return out, err
}
n := int(cmd.RowsAffected())
out.DeletedRevisions += n
if n < revisionPruneBatchSize {
break
}
}
for {
snaps, rows, err := p.pruneUnreferencedPrefixSnapshots(ctx, revisionPruneBatchSize)
if err != nil {
return out, err
}
out.DeletedPrefixSnapshots += snaps
out.DeletedPrefixRows += rows
if snaps < revisionPruneBatchSize {
break
}
}
return out, nil
}
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
res, err := p.PruneRevisionsWithStats(tenantID, cutoff)
if err != nil {
return 0, err
}
return res.DeletedRevisions, nil
}
func (p *Postgres) pruneUnreferencedPrefixSnapshots(ctx context.Context, batchSize int) (deletedSnapshots int, deletedRows int, err error) {
if !prefixSnapshotTableExists(ctx, p.pool) {
return 0, 0, nil
}
var snapCount, rowCount int
err = p.pool.QueryRow(ctx, `
WITH doomed AS (
SELECT ps.id FROM prefix_snapshot ps
WHERE NOT EXISTS (
SELECT 1 FROM config_revision cr WHERE cr.prefix_snapshot_id = ps.id
)
LIMIT $1
)
SELECT
(SELECT COUNT(*)::int FROM doomed),
(SELECT COUNT(*)::int FROM prefix_snapshot_row psr WHERE psr.snapshot_id IN (SELECT id FROM doomed))`,
batchSize).Scan(&snapCount, &rowCount)
if err != nil {
return 0, 0, err
}
if snapCount == 0 {
return 0, 0, nil
}
_, err = p.pool.Exec(ctx, `
DELETE FROM prefix_snapshot
WHERE id IN (
SELECT ps.id FROM prefix_snapshot ps
WHERE NOT EXISTS (
SELECT 1 FROM config_revision cr WHERE cr.prefix_snapshot_id = ps.id
)
LIMIT $1
)`, batchSize)
if err != nil {
return 0, 0, err
}
return snapCount, rowCount, nil
}
@@ -0,0 +1,83 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/db"
"evobgp/internal/pipeline"
"github.com/google/uuid"
)
func TestPostgresPruneUnreferencedPrefixSnapshotsIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
snapID := uuid.NewString()
hash := normalizeSnapshotHash("sha256:test-orphan-" + snapID)
if _, err := pool.Exec(ctx, `INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)`, snapID, hash); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO prefix_snapshot_row (snapshot_id, ord, prefix, source)
VALUES ($1::uuid, 0, '203.0.113.0/24', 'test')`, snapID); err != nil {
t.Fatal(err)
}
snaps, rows, err := pg.pruneUnreferencedPrefixSnapshots(ctx, 50)
if err != nil {
t.Fatal(err)
}
if snaps < 1 || rows < 1 {
t.Fatalf("expected orphan cleanup, got snaps=%d rows=%d", snaps, rows)
}
var n int
if err := pool.QueryRow(ctx, `SELECT COUNT(*)::int FROM prefix_snapshot WHERE id = $1::uuid`, snapID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("snapshot still exists")
}
}
func TestPostgresEstimateRevisionPruneIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
tenant := uuid.NewString()
cutoff := pipeline.RevisionCutoffFromMinutes(15)
est, err := pg.EstimateRevisionPrune(tenant, cutoff, 15)
if err != nil {
t.Fatal(err)
}
if est.RevisionCount != 0 {
t.Fatalf("expected 0 revisions for empty tenant, got %d", est.RevisionCount)
}
}
@@ -0,0 +1,87 @@
package repository
import (
"context"
"encoding/json"
"strconv"
"strings"
"github.com/google/uuid"
"evobgp/internal/store"
)
// AppendRuntimeLogCleanupAudit records a runtime log cleanup operation.
func (p *Postgres) AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error) {
if strings.TrimSpace(tenantID) == "" || !store.ValidRuntimeLogCleanupAction(action) || strings.TrimSpace(filename) == "" {
return "", store.ErrInvalidInput
}
ctx := context.Background()
id := uuid.NewString()
var detailJSON []byte
if detail != nil {
detailJSON, _ = json.Marshal(detail)
}
var sizeAfterVal any
if sizeAfter != nil {
sizeAfterVal = *sizeAfter
}
_, err := p.pool.Exec(ctx, `
INSERT INTO runtime_log_cleanup_audit
(id, tenant_id, actor_prefix, filename, action, size_before, size_after, detail_json, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now())`,
id, tenantID, strings.TrimSpace(actor), strings.TrimSpace(filename), action,
sizeBefore, sizeAfterVal, nullJSONBytes(detailJSON))
if err != nil {
return "", err
}
return id, nil
}
// ListRuntimeLogCleanupAudit returns paginated cleanup audit rows for a tenant.
func (p *Postgres) ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*store.RuntimeLogCleanupAudit, string, bool, error) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, tenant_id, actor_prefix, filename, action, size_before, size_after, detail_json, created_at
FROM runtime_log_cleanup_audit
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var out []*store.RuntimeLogCleanupAudit
for rows.Next() {
var r store.RuntimeLogCleanupAudit
var detailRaw []byte
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 {
return nil, "", false, err
}
r.SizeAfter = sizeAfter
if len(detailRaw) > 0 {
_ = json.Unmarshal(detailRaw, &r.Detail)
}
out = append(out, &r)
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = strconv.Itoa(off + limit)
}
return out, next, more, rows.Err()
}
+147
View File
@@ -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,
}
}
+81
View File
@@ -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)
}
}
+51
View File
@@ -0,0 +1,51 @@
package runtimelogs
import (
"os"
"evobgp/internal/store"
)
// Cleanup truncates or deletes a runtime log file synchronously.
func (s *Service) Cleanup(filename, mode string) (sizeBefore int64, sizeAfter *int64, err error) {
if !s.Available() {
return 0, nil, ErrUnavailable
}
if !store.ValidRuntimeLogCleanupAction(mode) {
return 0, nil, ErrInvalidFilename
}
path, err := ResolveLogPath(s.cfg.RootDir, filename)
if err != nil {
return 0, nil, err
}
st, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil, ErrNotFound
}
return 0, nil, err
}
if st.IsDir() {
return 0, nil, ErrNotAFile
}
sizeBefore = st.Size()
if sizeBefore > MaxCleanupBytes {
return 0, nil, ErrFileTooLarge
}
switch mode {
case store.RuntimeLogCleanupTruncate:
if err := os.Truncate(path, 0); err != nil {
return 0, nil, err
}
zero := int64(0)
return sizeBefore, &zero, nil
case store.RuntimeLogCleanupDelete:
if err := os.Remove(path); err != nil {
return 0, nil, err
}
return sizeBefore, nil, nil
default:
return 0, nil, ErrInvalidFilename
}
}
+36
View File
@@ -0,0 +1,36 @@
package runtimelogs
import (
"os"
"path/filepath"
"strings"
)
// Config holds runtime log filesystem settings.
type Config struct {
// RootDir is the absolute path to runtime log files (empty disables FS API).
RootDir string
// ServiceName is EVOBGP_SERVICE (must be evobgp-all when set).
ServiceName string
}
// ConfigFromEnv builds Config from EVOBGP_RUNTIME_LOGS_DIR and EVOBGP_SERVICE.
func ConfigFromEnv() Config {
root := strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_DIR"))
if root != "" {
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
}
svc := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE"))
return Config{RootDir: root, ServiceName: svc}
}
// Enabled reports whether runtime log FS operations are allowed in this process.
func (c Config) Enabled() bool {
if c.RootDir == "" || c.ServiceName != ServiceNameAll {
return false
}
st, err := os.Stat(c.RootDir)
return err == nil && st.IsDir()
}
+16
View File
@@ -0,0 +1,16 @@
package runtimelogs
const (
// MaxCleanupBytes is the maximum file size eligible for sync cleanup.
MaxCleanupBytes = 512 * 1024 * 1024
// DefaultTailLines is the default number of lines returned from Tail.
DefaultTailLines = 200
// MaxTailLines caps the lines query parameter.
MaxTailLines = 2000
// MaxTailBytes caps tail read size.
MaxTailBytes = 256 * 1024
// MaxGrepLen caps optional grep filter length.
MaxGrepLen = 128
// ServiceNameAll is the only process role that may access runtime logs FS.
ServiceNameAll = "evobgp-all"
)
+12
View File
@@ -0,0 +1,12 @@
package runtimelogs
import "errors"
// Sentinel errors for runtime log filesystem operations.
var (
ErrUnavailable = errors.New("runtimelogs: unavailable")
ErrInvalidFilename = errors.New("runtimelogs: invalid filename")
ErrNotFound = errors.New("runtimelogs: not found")
ErrFileTooLarge = errors.New("runtimelogs: file too large")
ErrNotAFile = errors.New("runtimelogs: not a file")
)
+225
View File
@@ -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))
}
}
+61
View File
@@ -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")
}
}
+69
View File
@@ -0,0 +1,69 @@
package runtimelogs
import (
"os"
"path/filepath"
"regexp"
"strings"
)
var filenamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*\.log$`)
// ValidateFilename checks basename allowlist for runtime log files.
func ValidateFilename(filename string) error {
name := strings.TrimSpace(filename)
if name == "" || len(name) > 128 {
return ErrInvalidFilename
}
if name != filepath.Base(name) {
return ErrInvalidFilename
}
if strings.Contains(name, "..") {
return ErrInvalidFilename
}
if !filenamePattern.MatchString(name) {
return ErrInvalidFilename
}
return nil
}
// ResolveLogPath maps a validated basename to an absolute path under root.
func ResolveLogPath(root, filename string) (string, error) {
if err := ValidateFilename(filename); err != nil {
return "", err
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
rootAbs = filepath.Clean(rootAbs)
candidate := filepath.Join(rootAbs, filename)
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
if os.IsNotExist(err) {
resolved = filepath.Clean(candidate)
} else {
return "", err
}
}
resolved = filepath.Clean(resolved)
if !pathUnderRoot(resolved, rootAbs) {
return "", ErrInvalidFilename
}
if st, err := os.Lstat(resolved); err == nil {
if st.IsDir() {
return "", ErrNotAFile
}
}
return resolved, nil
}
func pathUnderRoot(path, root string) bool {
path = filepath.Clean(path)
root = filepath.Clean(root)
if path == root {
return false
}
sep := string(os.PathSeparator)
return strings.HasPrefix(path+sep, root+sep)
}
+59
View File
@@ -0,0 +1,59 @@
package runtimelogs
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestValidateFilename(t *testing.T) {
valid := []string{"evobgp-all.log", "postgres.log", "bird2.log", "a.log"}
for _, name := range valid {
if err := ValidateFilename(name); err != nil {
t.Fatalf("%q: %v", name, err)
}
}
invalid := []string{"", ".log", "SECRET.log", "../x.log", "x/../y.log", "foo.txt", "a"}
for _, name := range invalid {
if err := ValidateFilename(name); err == nil {
t.Fatalf("expected invalid: %q", name)
}
}
}
func TestResolveLogPathTraversal(t *testing.T) {
root := t.TempDir()
if err := ValidateFilename("ok.log"); err != nil {
t.Fatal(err)
}
okPath := filepath.Join(root, "ok.log")
if err := os.WriteFile(okPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := ResolveLogPath(root, "ok.log"); err != nil {
t.Fatalf("ok.log: %v", err)
}
if _, err := ResolveLogPath(root, "../etc/passwd"); err == nil {
t.Fatal("expected traversal reject")
}
}
func TestResolveLogPathSymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink root escape test skipped on windows")
}
root := t.TempDir()
outside := t.TempDir()
secret := filepath.Join(outside, "secret.log")
if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "evil.log")
if err := os.Symlink(secret, link); err != nil {
t.Skip(err)
}
if _, err := ResolveLogPath(root, "evil.log"); err == nil {
t.Fatal("expected symlink escape to be rejected")
}
}
+126
View File
@@ -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)
}
+65
View File
@@ -0,0 +1,65 @@
package runtimelogs
import (
"os"
"strings"
"evobgp/internal/store"
)
// Service performs filesystem operations on runtime log files.
type Service struct {
cfg Config
}
// NewService returns a runtime logs FS service.
func NewService(cfg Config) *Service {
return &Service{cfg: cfg}
}
// Available reports whether the service can access runtime logs.
func (s *Service) Available() bool {
return s.cfg.Enabled()
}
// Config returns a copy of the service configuration.
func (s *Service) Config() Config {
return s.cfg
}
// ListFiles returns metadata for *.log files in the configured root.
func (s *Service) ListFiles() ([]store.RuntimeLogFile, error) {
if !s.Available() {
return nil, ErrUnavailable
}
entries, err := os.ReadDir(s.cfg.RootDir)
if err != nil {
return nil, err
}
out := make([]store.RuntimeLogFile, 0, len(entries))
for _, ent := range entries {
if ent.IsDir() {
continue
}
name := ent.Name()
if strings.HasPrefix(name, ".") {
continue
}
if err := ValidateFilename(name); err != nil {
continue
}
info, err := ent.Info()
if err != nil {
continue
}
if info.IsDir() {
continue
}
out = append(out, store.RuntimeLogFile{
Name: name,
SizeBytes: info.Size(),
ModifiedAt: info.ModTime().UTC(),
})
}
return out, nil
}
+138
View File
@@ -0,0 +1,138 @@
package runtimelogs
import (
"os"
"path/filepath"
"strings"
"testing"
"evobgp/internal/store"
)
func testService(t *testing.T) (*Service, string) {
t.Helper()
dir := t.TempDir()
svc := NewService(Config{RootDir: dir, ServiceName: ServiceNameAll})
if !svc.Available() {
t.Fatal("expected available")
}
return svc, dir
}
func TestConfigEnabled(t *testing.T) {
dir := t.TempDir()
if (Config{}).Enabled() {
t.Fatal("empty config")
}
if (Config{RootDir: dir, ServiceName: "evobgp-api"}).Enabled() {
t.Fatal("wrong service")
}
if !(Config{RootDir: dir, ServiceName: ServiceNameAll}).Enabled() {
t.Fatal("expected enabled")
}
}
func TestListFiles(t *testing.T) {
svc, dir := testService(t)
if err := os.WriteFile(filepath.Join(dir, "evobgp-all.log"), []byte("line\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".hidden.log"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "subdir.log"), 0o755); err != nil {
t.Fatal(err)
}
items, err := svc.ListFiles()
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Name != "evobgp-all.log" {
t.Fatalf("list: %+v", items)
}
}
func TestTailAndCleanup(t *testing.T) {
svc, dir := testService(t)
path := filepath.Join(dir, "postgres.log")
var b strings.Builder
for i := 0; i < 50; i++ {
b.WriteString("line\n")
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
tail, err := svc.Tail("postgres.log", TailOptions{Lines: 3})
if err != nil {
t.Fatal(err)
}
if tail.LinesReturned != 3 || !strings.Contains(tail.Content, "line") {
t.Fatalf("tail: %+v", tail)
}
tailGrep, err := svc.Tail("postgres.log", TailOptions{Lines: 100, Grep: "nomatch"})
if err != nil {
t.Fatal(err)
}
if tailGrep.LinesReturned != 0 {
t.Fatalf("grep filter: %+v", tailGrep)
}
before, after, err := svc.Cleanup("postgres.log", store.RuntimeLogCleanupTruncate)
if err != nil {
t.Fatal(err)
}
if before <= 0 || after == nil || *after != 0 {
t.Fatalf("truncate: before=%d after=%v", before, after)
}
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Size() != 0 {
t.Fatalf("expected empty file, size=%d", st.Size())
}
if err := os.WriteFile(path, []byte("again\n"), 0o644); err != nil {
t.Fatal(err)
}
_, _, err = svc.Cleanup("postgres.log", store.RuntimeLogCleanupDelete)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected deleted, stat err=%v", err)
}
}
func TestCleanupFileTooLarge(t *testing.T) {
svc, dir := testService(t)
path := filepath.Join(dir, "big.log")
if err := os.WriteFile(path, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
// Patch check by using a tiny max - we test the constant path via stat size.
// Use a file just over limit only in integration; here verify small file works.
_, _, err := svc.Cleanup("big.log", store.RuntimeLogCleanupTruncate)
if err != nil {
t.Fatal(err)
}
}
func TestUnavailableWhenDisabled(t *testing.T) {
svc := NewService(Config{RootDir: "", ServiceName: ServiceNameAll})
if _, err := svc.ListFiles(); err != ErrUnavailable {
t.Fatalf("list: %v", err)
}
if _, err := svc.Tail("a.log", TailOptions{}); err != ErrUnavailable {
t.Fatalf("tail: %v", err)
}
if _, _, err := svc.Cleanup("a.log", store.RuntimeLogCleanupTruncate); err != ErrUnavailable {
t.Fatalf("cleanup: %v", err)
}
}
+136
View File
@@ -0,0 +1,136 @@
package runtimelogs
import (
"bufio"
"bytes"
"io"
"os"
"strings"
"evobgp/internal/store"
)
// TailOptions controls tail/preview reads.
type TailOptions struct {
Lines int
Bytes int
Grep string
}
// Tail reads the end of a runtime log file.
func (s *Service) Tail(filename string, opts TailOptions) (*store.RuntimeLogTail, error) {
if !s.Available() {
return nil, ErrUnavailable
}
path, err := ResolveLogPath(s.cfg.RootDir, filename)
if err != nil {
return nil, err
}
st, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, err
}
if st.IsDir() {
return nil, ErrNotAFile
}
lines := opts.Lines
if lines <= 0 {
lines = DefaultTailLines
}
if lines > MaxTailLines {
lines = MaxTailLines
}
maxRead := MaxTailBytes
if opts.Bytes > 0 && opts.Bytes < maxRead {
maxRead = opts.Bytes
}
raw, truncated, err := readTailBytes(path, int64(maxRead))
if err != nil {
return nil, err
}
grep := strings.TrimSpace(opts.Grep)
if len(grep) > MaxGrepLen {
grep = grep[:MaxGrepLen]
}
contentLines := splitLines(raw)
if grep != "" {
filtered := contentLines[:0]
for _, line := range contentLines {
if strings.Contains(line, grep) {
filtered = append(filtered, line)
}
}
contentLines = filtered
}
if len(contentLines) > lines {
contentLines = contentLines[len(contentLines)-lines:]
truncated = true
}
return &store.RuntimeLogTail{
Filename: filename,
Content: strings.Join(contentLines, "\n"),
Truncated: truncated,
LinesReturned: len(contentLines),
}, nil
}
func readTailBytes(path string, maxRead int64) ([]byte, bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, false, err
}
defer func() { _ = f.Close() }()
st, err := f.Stat()
if err != nil {
return nil, false, err
}
size := st.Size()
truncated := size > maxRead
start := int64(0)
if size > maxRead {
start = size - maxRead
}
if _, err := f.Seek(start, io.SeekStart); err != nil {
return nil, false, err
}
buf := make([]byte, size-start)
n, err := io.ReadFull(f, buf)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, false, err
}
buf = buf[:n]
if start > 0 {
// Drop partial first line when reading from middle of file.
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 && idx+1 < len(buf) {
buf = buf[idx+1:]
truncated = true
} else if start > 0 {
truncated = true
}
}
return buf, truncated, nil
}
func splitLines(b []byte) []string {
if len(b) == 0 {
return nil
}
sc := bufio.NewScanner(bytes.NewReader(b))
var lines []string
for sc.Scan() {
lines = append(lines, sc.Text())
}
if len(lines) == 0 {
return []string{string(b)}
}
return lines
}
+6
View File
@@ -84,6 +84,8 @@ type Backend interface {
// CreateRenderRevision inserts a new config_revision (revID must be unique) with materialized prefixes and preview fragments.
CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error
RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
EstimateRevisionPrune(tenantID string, cutoff time.Time, retentionMinutes int) (RevisionPruneEstimate, error)
PruneRevisionsWithStats(tenantID string, cutoff time.Time) (RevisionPruneResult, error)
PruneRevisionsBefore(tenantID string, cutoff time.Time) (deleted int, err error)
SetLastAppliedRevision(tenantID, speakerID, revisionID string) error
PublishRevisionForSpeaker(speakerID, revisionID string) error
@@ -125,6 +127,10 @@ type Backend interface {
TouchMaintenancePolicyRun(id, status, errMsg string) error
AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error
ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error)
// Runtime log cleanup audit (filesystem ops logged per tenant).
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+34 -88
View File
@@ -33,19 +33,20 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -125,25 +126,26 @@ type Speaker struct {
func NewMemory() *Memory {
return &Memory{
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
runtimeLogCleanupAudit: nil,
}
}
@@ -614,62 +616,6 @@ func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
}, nil
}
func (m *Memory) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
var newest *Revision
for _, rev := range m.revisions {
if rev.TenantID != tenantID {
continue
}
if newest == nil || rev.CreatedAt.After(newest.CreatedAt) {
newest = rev
}
}
if newest == nil {
return 0, nil
}
protected := map[string]struct{}{
newest.ID: {},
}
for _, sp := range m.speakers {
if sp == nil || sp.TenantID != tenantID {
continue
}
if sp.LastAppliedRevisionID != nil && strings.TrimSpace(*sp.LastAppliedRevisionID) != "" {
protected[strings.TrimSpace(*sp.LastAppliedRevisionID)] = struct{}{}
}
}
for speakerID, info := range m.publishedRevision {
sp, ok := m.speakers[speakerID]
if !ok || sp == nil || sp.TenantID != tenantID {
continue
}
if strings.TrimSpace(info.RevisionID) != "" {
protected[strings.TrimSpace(info.RevisionID)] = struct{}{}
}
}
deleted := 0
for id, rev := range m.revisions {
if rev == nil || rev.TenantID != tenantID {
continue
}
if !rev.CreatedAt.Before(cutoff) {
continue
}
if _, keep := protected[id]; keep {
continue
}
delete(m.revisions, id)
delete(m.revPrefixes, id)
deleted++
}
return deleted, nil
}
func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) {
rev, ok := m.revisions[revisionID]
if !ok {
+118
View File
@@ -0,0 +1,118 @@
package store
import (
"strings"
"time"
)
func (m *Memory) prunableRevisionIDsLocked(tenantID string, cutoff time.Time) []string {
var newest *Revision
for _, rev := range m.revisions {
if rev == nil || rev.TenantID != tenantID {
continue
}
if newest == nil || rev.CreatedAt.After(newest.CreatedAt) {
newest = rev
}
}
if newest == nil {
return nil
}
protected := map[string]struct{}{newest.ID: {}}
for _, sp := range m.speakers {
if sp == nil || sp.TenantID != tenantID {
continue
}
if sp.LastAppliedRevisionID != nil && strings.TrimSpace(*sp.LastAppliedRevisionID) != "" {
protected[strings.TrimSpace(*sp.LastAppliedRevisionID)] = struct{}{}
}
}
for speakerID, info := range m.publishedRevision {
sp, ok := m.speakers[speakerID]
if !ok || sp == nil || sp.TenantID != tenantID {
continue
}
if strings.TrimSpace(info.RevisionID) != "" {
protected[strings.TrimSpace(info.RevisionID)] = struct{}{}
}
}
var ids []string
for id, rev := range m.revisions {
if rev == nil || rev.TenantID != tenantID {
continue
}
if !rev.CreatedAt.Before(cutoff) {
continue
}
if _, keep := protected[id]; keep {
continue
}
ids = append(ids, id)
}
return ids
}
func revisionBytesEstimate(rev *Revision, prefixes []PrefixRow) int64 {
if rev == nil {
return 0
}
var n int64
n += int64(len(rev.ContentHash))
for _, v := range rev.PreviewFragments {
n += int64(len(v))
}
for _, pr := range prefixes {
n += int64(len(pr.Prefix) + len(pr.Source))
}
return n
}
func (m *Memory) EstimateRevisionPrune(tenantID string, cutoff time.Time, retentionMinutes int) (RevisionPruneEstimate, error) {
m.mu.Lock()
defer m.mu.Unlock()
ids := m.prunableRevisionIDsLocked(tenantID, cutoff)
var bytes int64
for _, id := range ids {
bytes += revisionBytesEstimate(m.revisions[id], m.revPrefixes[id])
}
prefixRows := 0
for _, id := range ids {
prefixRows += len(m.revPrefixes[id])
}
return RevisionPruneEstimate{
RetentionMinutes: retentionMinutes,
CutoffAt: cutoff.UTC(),
RevisionCount: len(ids),
PrefixRowCount: prefixRows,
OrphanSnapshotCount: 0,
BytesEstimate: bytes,
}, nil
}
func (m *Memory) PruneRevisionsWithStats(tenantID string, cutoff time.Time) (RevisionPruneResult, error) {
est, err := m.EstimateRevisionPrune(tenantID, cutoff, 0)
if err != nil {
return RevisionPruneResult{}, err
}
m.mu.Lock()
defer m.mu.Unlock()
ids := m.prunableRevisionIDsLocked(tenantID, cutoff)
for _, id := range ids {
delete(m.revisions, id)
delete(m.revPrefixes, id)
}
return RevisionPruneResult{
DeletedRevisions: len(ids),
DeletedPrefixSnapshots: 0,
DeletedPrefixRows: est.PrefixRowCount,
BytesEstimate: est.BytesEstimate,
}, nil
}
func (m *Memory) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
res, err := m.PruneRevisionsWithStats(tenantID, cutoff)
if err != nil {
return 0, err
}
return res.DeletedRevisions, nil
}
+74
View File
@@ -0,0 +1,74 @@
package store
import (
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error) {
if strings.TrimSpace(tenantID) == "" {
return "", ErrNotFound
}
if !ValidRuntimeLogCleanupAction(action) {
return "", ErrInvalidInput
}
name := strings.TrimSpace(filename)
if name == "" {
return "", ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
id := uuid.NewString()
row := &RuntimeLogCleanupAudit{
ID: id,
TenantID: tenantID,
ActorPrefix: strings.TrimSpace(actor),
Filename: name,
Action: action,
SizeBefore: sizeBefore,
SizeAfter: sizeAfter,
Detail: detail,
CreatedAt: time.Now().UTC(),
}
m.runtimeLogCleanupAudit = append(m.runtimeLogCleanupAudit, row)
return id, nil
}
func (m *Memory) ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
var filtered []*RuntimeLogCleanupAudit
for _, row := range m.runtimeLogCleanupAudit {
if row.TenantID == tenantID {
filtered = append(filtered, row)
}
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
return filtered[i].ID > filtered[j].ID
}
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(filtered) {
end = len(filtered)
} else if end < len(filtered) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(filtered) {
return nil, "", false, nil
}
out := make([]*RuntimeLogCleanupAudit, end-off)
copy(out, filtered[off:end])
return out, next, hasMore, nil
}
@@ -0,0 +1,68 @@
package store
import "testing"
func TestMemoryRuntimeLogCleanupAudit(t *testing.T) {
m := NewMemory()
tenantA := "tenant-a"
tenantB := "tenant-b"
after := int64(0)
id, err := m.AppendRuntimeLogCleanupAudit(tenantA, "op:alice", "evobgp-all.log", RuntimeLogCleanupTruncate, 1024, &after, nil)
if err != nil {
t.Fatal(err)
}
if id == "" {
t.Fatal("expected audit id")
}
if _, err := m.AppendRuntimeLogCleanupAudit(tenantA, "op:alice", "postgres.log", RuntimeLogCleanupDelete, 512, nil, map[string]any{"note": "removed"}); err != nil {
t.Fatal(err)
}
if _, err := m.AppendRuntimeLogCleanupAudit(tenantB, "op:bob", "bird2.log", RuntimeLogCleanupTruncate, 256, &after, nil); err != nil {
t.Fatal(err)
}
items, next, hasMore, err := m.ListRuntimeLogCleanupAudit(tenantA, "", 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || hasMore || next != "" {
t.Fatalf("tenantA list: len=%d hasMore=%v next=%q", len(items), hasMore, next)
}
if items[0].Filename == items[1].Filename {
t.Fatalf("expected desc order by created_at: %+v", items)
}
page, next, hasMore, err := m.ListRuntimeLogCleanupAudit(tenantA, "", 1)
if err != nil {
t.Fatal(err)
}
if len(page) != 1 || !hasMore || next == "" {
t.Fatalf("page1: len=%d hasMore=%v next=%q", len(page), hasMore, next)
}
page2, next2, hasMore2, err := m.ListRuntimeLogCleanupAudit(tenantA, next, 1)
if err != nil {
t.Fatal(err)
}
if len(page2) != 1 || hasMore2 || next2 != "" {
t.Fatalf("page2: len=%d hasMore=%v next=%q", len(page2), hasMore2, next2)
}
if page[0].ID == page2[0].ID {
t.Fatal("expected different audit rows across pages")
}
if _, err := m.AppendRuntimeLogCleanupAudit(tenantA, "op:x", "bad.log", "wipe", 1, nil, nil); err != ErrInvalidInput {
t.Fatalf("invalid action: %v", err)
}
}
func TestValidRuntimeLogCleanupAction(t *testing.T) {
if !ValidRuntimeLogCleanupAction(RuntimeLogCleanupTruncate) {
t.Fatal("truncate")
}
if ValidRuntimeLogCleanupAction("rotate") {
t.Fatal("unexpected valid")
}
}
+21
View File
@@ -0,0 +1,21 @@
package store
import "time"
// RevisionPruneEstimate describes revisions and storage that would be removed by prune.
type RevisionPruneEstimate struct {
RetentionMinutes int `json:"retention_minutes"`
CutoffAt time.Time `json:"cutoff_at"`
RevisionCount int `json:"revision_count"`
PrefixRowCount int `json:"prefix_row_count"`
OrphanSnapshotCount int `json:"orphan_snapshot_count"`
BytesEstimate int64 `json:"bytes_estimate"`
}
// RevisionPruneResult is the outcome of a revision prune run.
type RevisionPruneResult struct {
DeletedRevisions int `json:"deleted_revisions"`
DeletedPrefixSnapshots int `json:"deleted_prefix_snapshots"`
DeletedPrefixRows int `json:"deleted_prefix_rows"`
BytesEstimate int64 `json:"bytes_estimate"`
}
+50
View File
@@ -0,0 +1,50 @@
package store
import (
"strings"
"time"
)
// Runtime log cleanup actions (runtime_log_cleanup_audit.action).
const (
RuntimeLogCleanupTruncate = "truncate"
RuntimeLogCleanupDelete = "delete"
)
// RuntimeLogFile describes a file in EVOBGP_RUNTIME_LOGS_DIR (API DTO).
type RuntimeLogFile struct {
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
ModifiedAt time.Time `json:"modified_at"`
}
// RuntimeLogTail is a tail/preview fragment of a runtime log file.
type RuntimeLogTail struct {
Filename string `json:"filename"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
LinesReturned int `json:"lines_returned"`
}
// RuntimeLogCleanupAudit is a persisted cleanup operation log entry.
type RuntimeLogCleanupAudit struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
ActorPrefix string `json:"actor_prefix"`
Filename string `json:"filename"`
Action string `json:"action"`
SizeBefore int64 `json:"size_before"`
SizeAfter *int64 `json:"size_after,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ValidRuntimeLogCleanupAction reports whether action is truncate or delete.
func ValidRuntimeLogCleanupAction(action string) bool {
switch strings.TrimSpace(action) {
case RuntimeLogCleanupTruncate, RuntimeLogCleanupDelete:
return true
default:
return false
}
}
+19 -26
View File
@@ -1,26 +1,19 @@
# Memory Bank: Active Context
## Текущий фокус
**VAN-инициализация** — Memory Bank создан, задача не определена.
## Статус
- Memory Bank: создан и заполнен базовым контекстом проекта
- Активная задача: отсутствует (ожидается описание от пользователя)
## Наблюдаемый контекст (git)
Незакоммиченные файлы, вероятно из предыдущей сессии:
- `web/src/lib/components/monitoring/MaintenancePoliciesTab.svelte`
- `web/src/lib/maintenance/policy-schedule.ts`
Область: UI политик обслуживания PostgreSQL (расписание cron, пресеты, CRUD).
## Следующий шаг
Определить задачу и уровень сложности (1–4), затем маршрутизация:
- Level 1 → `/build`
- Level 24 → `/plan`
# Memory Bank: Active Context
## Текущий фокус
_Нет активной задачи._
## Последняя завершённая
**`settings-ui-and-runtime-logs`** — tenant settings UI + runtime logs API/UI/deploy.
Архив: `memory-bank/archive/archive-settings-ui-and-runtime-logs.md`
## Следующий шаг
```
/van
```
Для новой задачи. На production — E2E checklist из архива (compose + pull `evobgp-all`).
@@ -0,0 +1,124 @@
# TASK ARCHIVE: settings-ui-and-runtime-logs
## METADATA
| Поле | Значение |
|------|----------|
| **Task ID** | `settings-ui-and-runtime-logs` |
| **Complexity** | Level 4 |
| **VAN** | 2026-06-12 |
| **PLAN** | 2026-06-12 |
| **BUILD complete** | 2026-06-12 |
| **REFLECT** | 2026-06-12 |
| **ARCHIVE** | 2026-06-12 |
---
## SUMMARY
Два связанных улучшения control plane:
1. **Tenant settings UI** — единый модуль `/tenant-settings` (BIRD, ревизии, custom KV); `/settings` только для браузера.
2. **Runtime logs** — API и UI для файлов `*.log` от sidecar `stack-runtime-logs`: list, tail, sync cleanup, audit в БД; FS только на `evobgp-all` с volume.
---
## REQUIREMENTS (resolved)
| # | Решение |
|---|---------|
| `/settings` vs tenant | Раздельные модули |
| Cleanup | Синхронный HTTP, не jobs |
| FS API | Только `evobgp-all` + mount |
| Audit | `runtime_log_cleanup_audit` в postgres/sqlite |
---
## IMPLEMENTATION
### Backend
| Компонент | Путь |
|-----------|------|
| OpenAPI tag `RuntimeLogs` | `docs/openapi.yaml` |
| Миграция 000026 | `migrations/postgres/`, `migrations/sqlite/` |
| Store | `internal/store/runtime_logs.go`, `memory_runtime_logs.go`, `postgres_runtime_logs.go` |
| FS layer | `internal/runtimelogs/` (config, safe, list, tail, cleanup) |
| HTTP | `internal/httpapi/routes_runtime_logs.go`, `server.go` wiring |
| Config/docs | `internal/config/config.go`, `docs/access.md` |
**API paths:** `GET/DELETE /v1/runtime-logs/files`, `GET .../files/{filename}`, `GET /v1/runtime-logs/cleanup-audit`
### Deploy
| Файл | Изменение |
|------|-----------|
| `deploy/compose/stack.microvps-full.yaml` | env + mount на `evobgp-all`, `EVOBGP_RUNTIME_LOGS_HOST_DIR` |
| `deploy/compose/docker-compose.microvps-full.yaml` | overlay mount/env |
| `deploy/compose/docker-compose.production.example.yaml` | полный prod example для `/opt/evobgp` |
| `deploy/compose/.env.production.example` | host dir для runtime-logs |
| `docs/quickstart.md` | секция runtime logs |
| `.gitignore` | `deploy/compose/runtime-logs/` |
### Web UI
| Область | Путь |
|---------|------|
| Tenant settings | `web/src/routes/tenant-settings/`, `web/src/lib/components/tenant-settings/*` |
| Nav «Параметры» | `web/src/lib/ui/app/layout/nav.ts` |
| Operations | убран tab `system`, редирект `?tab=system` |
| Network | `NetworkBirdSettingsSummaryCard.svelte` |
| Runtime logs UI | `web/src/lib/runtime-logs/runtime-logs-api.ts`, `RuntimeLogsTab.svelte` |
| Monitoring | `?tab=runtime-logs`, URL sync |
**Удалено:** `OperationsSystemSettingsTab.svelte`, `BirdSettingsForm.svelte`
### Docs (Phase 7)
- `docs/api.md` — RuntimeLogs
- `docs/manual.md` — Web UI tenant-settings + monitoring runtime logs
---
## TESTING
| Gate | Результат |
|------|-----------|
| `go test ./... -count=1` | pass |
| `scripts/lint-go.ps1` | pass |
| `go test ./internal/httpapi/... -run RuntimeLogs` | pass |
| `go test ./internal/runtimelogs/...` | pass |
| `npx @redocly/cli lint docs/openapi.yaml` | pass |
| `npm run check && npm run lint` | pass |
| E2E manual prod | **не выполнен** (чеклист в tasks Phase 7) |
---
## LESSONS LEARNED
См. [reflection-settings-ui-and-runtime-logs.md](../reflection/reflection-settings-ui-and-runtime-logs.md).
Кратко: deploy mount обязателен для FS API; audit не зависит от FS; production compose example критичен; phased BUILD + creative снижают риск откатов.
---
## POST-DEPLOY (оператор)
1. Обновить `/opt/evobgp/docker-compose.yaml` (или скопировать `docker-compose.production.example.yaml`).
2. `mkdir -p /opt/evobgp/runtime-logs`
3. `docker compose pull evobgp-all evobgp-web && docker compose up -d evobgp-all`
4. E2E: list → tail → truncate → audit row; `/tenant-settings` PATCH.
---
## REFERENCES
| Документ | Путь |
|----------|------|
| Reflection | `memory-bank/reflection/reflection-settings-ui-and-runtime-logs.md` |
| CP-1 Tenant UI | `memory-bank/creative/creative-tenant-settings-ui.md` |
| CP-2 Runtime logs UI | `memory-bank/creative/creative-runtime-logs-ui.md` |
| CP-3 Cleanup | `memory-bank/creative/creative-runtime-logs-cleanup.md` |
| CP-4 Path safety | `memory-bank/creative/creative-runtime-logs-path-safety.md` |
| HTTP contract | `docs/openapi.yaml` (tag RuntimeLogs) |
| Access / env | `docs/access.md` |
@@ -0,0 +1,84 @@
# Creative: Runtime Logs Cleanup & Tail (CP-3)
📌 **CREATIVE PHASE START: Cleanup Semantics & Tail Limits**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## 1️⃣ PROBLEM
**Description:** Sync cleanup больших `.log` файлов на HTTP worker; tail/preview без OOM. Нужны чёткие лимиты и режимы очистки, совместимые с sidecar `docker logs -f >> file` (файл пересоздаётся при рестарте sidecar).
**Requirements:**
- Cleanup — **синхронный** HTTP (решение заказчика)
- Audit: `size_before`, `size_after`, `action`
- Sidecar продолжает писать в тот же путь после truncate
- Защита от чтения гигабайтных файлов в tail
**Constraints:**
- Без `jobs.Registry` для cleanup
- Request timeout: разумный предел на handler (context с deadline 60s для cleanup)
- Файлы: только allowlisted basenames (CP-4)
## 2️⃣ OPTIONS — Cleanup mode
| Option | Описание |
|--------|----------|
| **A** | **Truncate** по умолчанию (`os.Truncate(0)` или `O_TRUNC`) — файл остаётся, inode может сохраниться |
| **B** | **Delete** по умолчанию — `os.Remove`, sidecar создаст при следующей записи |
| **C** | Rotate: rename to `.old` + create new |
| **D** | Truncate только &lt; N MB, иначе reject |
## 3️⃣ ANALYSIS — Cleanup
| Criterion | A Truncate | B Delete | C Rotate | D Size gate |
|-----------|------------|----------|----------|-------------|
| Sidecar совместимость | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| Predictable filename | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| Sync latency | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| Audit clarity | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
**Key insight:** sidecar открывает файл через shell redirect `>>`; **truncate** обнуляет содержимое без смены имени в list — оператор видит тот же `evobgp-all.log`. Delete допустим как явная опция (файл исчезнет из list до следующей строки sidecar).
## 4️⃣ DECISION — Cleanup
**Default:** `mode=truncate` (query param, default when omitted).
**Optional:** `mode=delete` — только operator, UI second action «Удалить файл полностью».
**Max file size for cleanup:** **512 MiB** — выше reject `413` / problem `file_too_large` (защита sync worker). Документировать в OpenAPI.
**Handler timeout:** `60s` context на cleanup; для типичных log &lt; 512 MiB truncate/delete — миллисекунды.
**Audit:** всегда запись после успешной операции; при ошибке — no audit, `500`.
## 2️⃣ OPTIONS — Tail / preview
| Option | Lines default | Bytes cap |
|--------|---------------|-----------|
| **T1** | 200 lines | 256 KiB |
| **T2** | 500 lines | 1 MiB |
| **T3** | 1000 lines | 512 KiB |
## 4️⃣ DECISION — Tail
**Query params** `GET /v1/runtime-logs/files/{filename}`:
| Param | Default | Max | Note |
|-------|---------|-----|------|
| `lines` | 200 | 2000 | Читать с конца файла |
| `bytes` | — | 262144 (256 KiB) | Альтернатива lines; если оба — **min** лимит |
| `grep` | — | max 128 chars | Опционально; фильтр после чтения tail chunk |
**Implementation:** read last N bytes (cap 256 KiB), split lines, take last `lines` (cap 2000). Не mmap всего файла.
**grep:** простой `strings.Contains` post-filter (не regex) — снижает ReDoS risk.
## 5️⃣ IMPLEMENTATION NOTES
- `internal/runtimelogs/tail.go``TailFile(path, opts) ([]byte, truncated bool, err)`
- `internal/runtimelogs/cleanup.go``Cleanup(path, mode) (sizeBefore, sizeAfter int64, err)`
- OpenAPI enum `RuntimeLogCleanupMode: truncate | delete`
- Response cleanup: `{ "audit_id", "filename", "action", "size_before", "size_after" }`
- UI: primary button «Очистить (обнулить)»; secondary «Удалить файл»
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📌 **CREATIVE PHASE END: Cleanup & Tail**
@@ -0,0 +1,93 @@
# Creative: Runtime Logs Path Safety (CP-4)
📌 **CREATIVE PHASE START: Filesystem Path Hardening**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## 1️⃣ PROBLEM
**Description:** HTTP API принимает `{filename}` и читает/удаляет файлы под `EVOBGP_RUNTIME_LOGS_DIR`. Без жёсткой политики возможны path traversal, чтение произвольных файлов при symlink attack, доступ к не-log артефактам.
**Requirements:**
- Только файлы внутри configured root directory
- Только basenames из list API (no subdirs)
- Имена как у sidecar: `postgres.log`, `evobgp-all.log`, …
- Fail closed при любой аномалии
**Constraints:**
- Root: `/opt/evobgp/runtime-logs` (prod) или `./runtime-logs` (dev)
- API disabled если root unset или not `evobgp-all`
## 2️⃣ OPTIONS
| Option | Описание |
|--------|----------|
| **A** | Strict basename allowlist regex + `filepath.Join(root, base)` + prefix check |
| **B** | Только list-then-operate: handler хранит cache allowed names из ListDir |
| **C** | Regex only, без EvalSymlinks |
| **D** | Regex + EvalSymlinks + `os.SameFile` root check |
## 3️⃣ ANALYSIS
| Criterion | A | B | C | D |
|-----------|---|---|---|---|
| Traversal resistance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Symlink safety | ⭐⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
| Simplicity | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| No TOCTOU list cache | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
## 4️⃣ DECISION
**Selected: Option D** — regex + resolved path under root.
### Filename allowlist
```regexp
^[a-z0-9][a-z0-9_.-]*\.log$
```
- Длина: 3128
- Запрещено: `..`, `/`, `\`, null
- Примеры valid: `evobgp-all.log`, `postgres.log`, `bird2.log`
- URL path param: только unescaped basename; handler rejects `%2e%2e`
### Resolution algorithm (`SafePath(root, filename)`)
1. Reject if `filename != filepath.Base(filename)` or fails regex
2. `candidate := filepath.Join(root, filename)`
3. `resolved, err := filepath.EvalSymlinks(candidate)` — if not exist for new file, use `filepath.Clean(candidate)` for delete target that exists
4. `rootAbs := filepath.Clean(root)` (must be absolute after config load)
5. Require `strings.HasPrefix(resolved+string(os.PathSeparator), rootAbs+string(os.PathSeparator))` OR `resolved == rootAbs` (reject)
6. Reject if `resolved` is directory
### ListDir
- `os.ReadDir(root)` only — **no recursion**
- Skip subdirectories, non-matching names, hidden files (prefix `.`)
- Return only entries passing regex
### Guard (`Enabled()`)
```text
EVOBGP_RUNTIME_LOGS_DIR != ""
AND filepath.IsAbs(dir) OR dir cleaned to absolute at startup
AND EVOBGP_SERVICE == "evobgp-all"
AND os.Stat(root) is directory
```
Otherwise handlers return `503` type `runtime_logs_unavailable`.
### Config load
- `EVOBGP_RUNTIME_LOGS_DIR` trimmed; default empty (disabled)
- At bootstrap: `filepath.Abs(dir)`; log warning if not exists (list returns empty, not error)
## 5️⃣ IMPLEMENTATION NOTES
- Package: `internal/runtimelogs/safe.go``ValidateFilename`, `ResolveLogPath`
- Tests: `../../../etc/passwd`, `foo/../bar.log`, symlink escape (skip on Windows if needed), valid names
- OpenAPI `filename` path param pattern + description
- **Never** accept absolute paths or globs from client
- List response `name` field = basename only; UI passes same string back
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📌 **CREATIVE PHASE END: Path Safety**
@@ -0,0 +1,84 @@
# Creative: Runtime Logs UI (CP-2)
📌 **CREATIVE PHASE START: Runtime Logs Web UI**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## 1️⃣ PROBLEM
**Description:** Оператору нужен просмотр файлов runtime-логов (`*.log` из sidecar `stack-runtime-logs`), tail/preview и sync-очистка с audit. API будет доступен только на `evobgp-all` с volume; UI должен корректно показывать `503` и направлять к документации.
**Requirements:**
- Список файлов: имя, размер, mtime
- Preview (tail) в dialog или panel
- Cleanup с ConfirmDialog (operator)
- Просмотр cleanup audit (viewer+)
- Empty/unavailable state при `503`
**Constraints:**
- Не смешивать с PostgreSQL maintenance logs (уже tab `postgres` в Monitoring)
- `AppDataTable`, `ScrollPreBlock`, `ConfirmDialog`
- Sync DELETE — UI ждёт ответ, показывает spinner на кнопке
## 2️⃣ OPTIONS
| Option | Описание |
|--------|----------|
| **A** | Новая вкладка **«Файловые логи»** в `/monitoring` (`?tab=runtime-logs`) |
| **B** | Отдельный route `/runtime-logs` в mainNav |
| **C** | Секция в Operations (рядом с jobs) |
| **D** | Подвкладка внутри Monitoring → PostgreSQL |
## 3️⃣ ANALYSIS
| Criterion | A Monitoring tab | B Own route | C Operations | D Under Postgres |
|-----------|------------------|-------------|--------------|------------------|
| Логическая группировка | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐ |
| Переиспользование Monitoring layout | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| Не перегружать nav | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Рядом с infra observability | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| Реализация (diff) | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
**Key insights:**
- Monitoring уже = health + Postgres; файловые логи — ещё один «операционный» источник диагностики.
- Отдельный route раздувает nav (8+ пунктов mainNav).
- Operations семантически про ревизии/jobs, не raw FS.
## 4️⃣ DECISION
**Selected: Option A** — вкладка в Monitoring.
**Structure:**
```
/monitoring
├─ system (существует)
├─ postgres (существует)
└─ runtime-logs (новая)
```
**Внутри вкладки `runtime-logs` — nested Tabs:**
| Sub-tab | Содержание |
|---------|------------|
| `files` | AppDataTable файлов + actions: Просмотр / Очистить |
| `audit` | Таблица cleanup audit (`GET /v1/runtime-logs/cleanup-audit`) |
**Unavailable (`503`):** `EmptyState` с текстом: «Файловые логи доступны только на evobgp-all с примонтированным каталогом runtime-logs» + ссылка на docs.
**Preview:** `Dialog` + `ScrollPreBlock`, загрузка `GET .../files/{name}?lines=200` (лимиты из CP-3).
**Cleanup:** `ConfirmDialog` — текст с именем файла и размером; `DELETE ?mode=truncate` по умолчанию; опция «Удалить файл» в dropdown для operator.
**Rationale:** минимум nav-churn, консистентность с postgres monitoring, один URL `/monitoring?tab=runtime-logs`.
## 5️⃣ IMPLEMENTATION NOTES
- `web/src/lib/components/monitoring/RuntimeLogsTab.svelte`
- `web/src/lib/runtime-logs/runtime-logs-api.ts`
- `monitoring/+page.svelte`: `TabsTrigger value="runtime-logs"` label «Файловые логи»
- Parse `?tab=` включить `runtime-logs`
- Иконка вкладки: `FileText` или `HardDrive` (уже импортирован в monitoring)
- KPI сверху (опционально): суммарный размер, число файлов — из list response
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📌 **CREATIVE PHASE END: Runtime Logs UI**
@@ -0,0 +1,77 @@
# Creative: Tenant Settings UI (CP-1)
📌 **CREATIVE PHASE START: Tenant Settings Module**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## 1️⃣ PROBLEM
**Description:** Tenant-настройки (`global_settings`, `/v1/settings`) разбросаны: вкладка «Система» в Operations и форма BIRD на `/network`. `/settings` зарезервирован под frontend (токен, тема). Нужен отдельный модуль без смешения с browser settings.
**Requirements:**
- Один route для всех tenant KV (BIRD, revision retention, custom)
- Роли: viewer — read; operator — PATCH
- Переиспользовать существующие схемы (`bird-settings.schema`, `revision-settings.schema`, `settings-api.ts`)
- Убрать дубли из Operations и Network
**Constraints:**
- Контракт API не менять на UI-фазе
- shadcn-svelte, Svelte 5 runes, superforms
- WEB-16: ConfirmDialog для удаления custom keys
## 2️⃣ OPTIONS
| Option | Описание |
|--------|----------|
| **A** | Одна страница `/tenant-settings` с Tabs: BIRD / Ревизии / Дополнительно |
| **B** | Три отдельных route: `/tenant-settings/bird`, `/revision`, `/extra` |
| **C** | Accordion на одной длинной странице без tabs |
| **D** | Оставить BIRD на Network, перенести только revision+KV |
## 3️⃣ ANALYSIS
| Criterion | A Tabs | B Multi-route | C Accordion | D Partial |
|-----------|--------|---------------|-------------|-----------|
| Discoverability | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| Соответствие плану | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐ |
| Меньше дублирования | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
| Сложность реализации | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Консистентность с Monitoring/Ops tabs | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐ |
**Key insights:**
- Monitoring уже использует `Tabs` (`system` / `postgres`) — паттерн знаком оператору.
- BIRD и revision логически связаны с pipeline/bundles — держать вместе усиливает «единый центр tenant config».
- Отдельные routes (B) дают deep links, но избыточны для ~10 полей.
## 4️⃣ DECISION
**Selected: Option A** — `/tenant-settings` с тремя вкладками.
**Navigation:**
- **mainNav** (не bottom): новый пункт **`Параметры`** → `/tenant-settings`, icon `SlidersHorizontal`
- **bottomNav** `/settings` — без изменений семантики («Настройки интерфейса»)
- **Network:** Card «BIRD (кратко)» + кнопка «Изменить параметры» → `/tenant-settings?tab=bird`
- **Operations:** удалить tab `system`; в quick actions / empty state — ссылка на `/tenant-settings`
**Tab structure:**
| Tab value | Label | Компонент | Сохранение |
|-----------|-------|-----------|------------|
| `bird` | BIRD | `TenantBirdSettingsCard` (из `BirdSettingsForm`) | PATCH known bird_* |
| `revision` | Ревизии | `TenantRevisionSettingsCard` | PATCH `revision_retention_minutes` |
| `additional` | Дополнительно | `TenantAdditionalSettingsCard` | PATCH custom KV, operator |
**URL:** `?tab=bird|revision|additional` (как Operations `?tab=jobs`).
**Rationale:** минимальный diff, один mental model «параметры tenant», переиспользование tabs-паттерна Monitoring.
## 5️⃣ IMPLEMENTATION NOTES
- `web/src/routes/tenant-settings/+page.svelte` — PageHeader + Tabs
- Вынести карточки в `web/src/lib/components/tenant-settings/`
- Общий `loadSettings()` / `patchSettings()` из `settings-api.ts`
- PageHeader description: «Параметры control plane для текущего tenant (API /v1/settings)»
- Не показывать на этой странице token/theme
- После миграции: удалить `OperationsSystemSettingsTab`, упростить `network/+page.svelte`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📌 **CREATIVE PHASE END: Tenant Settings UI**
+22 -16
View File
@@ -1,16 +1,22 @@
# Memory Bank: Progress
## VAN Initialization — 2026-06-12
| Шаг | Статус |
|-----|--------|
| Platform detection | ✅ Windows / PowerShell |
| Memory Bank structure | ✅ Создан |
| Core files populated | ✅ Базовый контекст EvoBGP |
| Task analysis | ⏳ Ожидает описание задачи |
| Complexity determination | ⏳ — |
| Route to workflow | ⏳ — |
## Реализация
_Нет активной реализации._
# Memory Bank: Progress
## Completed
### settings-ui-and-runtime-logs (2026-06-12)
| Фаза | Статус |
|------|--------|
| VAN / PLAN / CREATIVE | ✅ |
| BUILD P1P7 | ✅ |
| REFLECT | ✅ |
| ARCHIVE | ✅ |
**Archive:** [archive-settings-ui-and-runtime-logs.md](archive/archive-settings-ui-and-runtime-logs.md)
**Открыто:** E2E manual на production после deploy compose + pull образа.
---
## Active
_Нет._
@@ -0,0 +1,98 @@
# Reflection: settings-ui-and-runtime-logs
**Task ID:** `settings-ui-and-runtime-logs`
**Complexity:** Level 4
**Дата reflection:** 2026-06-12
**Статус BUILD:** фазы 1–7 завершены
---
## Summary
Задача объединила два независимых направления:
1. **Tenant settings UI** — вынос BIRD / revision / custom KV из Operations и Network в `/tenant-settings`; `/settings` остаётся только для браузера (токен, тема).
2. **Runtime logs** — контракт OpenAPI, audit в БД, безопасный FS-слой, HTTP API только на `evobgp-all`, compose volume, Web UI во вкладке Monitoring.
Реализация шла по 7 фазам (0–7 с creative): контракт → persistence → FS → HTTP → deploy → два UI-модуля → docs/QA. Все acceptance criteria закрыты автоматическими проверками; E2E на production-хосте оставлен ручным чеклистом.
---
## What Went Well
| Область | Наблюдение |
|---------|------------|
| **Creative до BUILD** | Четыре CP-документа зафиксировали спорные точки (tabs vs routes, sync cleanup, path safety, Monitoring tab). В BUILD не было откатов по UX. |
| **Contract-first (P1)** | OpenAPI + миграция `000026` + `store.Backend` до FS/HTTP упростили параллельную работу и review. |
| **Паттерны репозитория** | Maintenance audit (`actor_prefix`, cursor list) и settings-api переиспользованы без новых абстракций. |
| **Guard FS** | `EVOBGP_SERVICE=evobgp-all` + `EVOBGP_RUNTIME_LOGS_DIR` + path regex/EvalSymlinks — единая точка в `internal/runtimelogs`. |
| **Поэтапный UI** | P5 (tenant) не зависел от runtime logs backend — можно было бы параллелить с P2–P4. |
| **503 + audit** | Разделение `filesUnavailable` и audit-only в UI: оператор видит историю очистки даже без volume. |
| **Production example** | `docker-compose.production.example.yaml` закрыл разрыв между repo `stack.microvps-full.yaml` и кастомным compose на сервере пользователя. |
---
## Challenges
| Challenge | Как решали |
|-----------|------------|
| **Prod compose без Phase 4 env/mount** | Пользовательский `/opt/evobgp/docker-compose.yaml` отставал от репозитория; подготовлен полный example с сохранением кастомных env (`BUNDLE_SEED_HEX`, `NODE_DISPATCH`). |
| **Windows dev** | `go test -race` и `bash scripts/lint-httpapi.sh` недоступны; gates выполнялись альтернативами (test без race, grep ERR-01/ARCH-01). |
| **Sidecar уже был, API — нет** | `stack-runtime-logs` писал в `./runtime-logs`, но `evobgp-all` не монтировал каталог — типичная «половинная» интеграция; Phase 4 явно связал оба mount через `EVOBGP_RUNTIME_LOGS_HOST_DIR`. |
| **Старые закладки Operations** | `?tab=system` → редирект на `/tenant-settings?tab=revision`. |
| **E2E не автоматизирован** | Нет compose в CI с реальным volume и sidecar; manual checklist в `tasks.md`. |
---
## Lessons Learned
1. **Deploy — часть фичи.** FS API без compose mount на целевом процессе даёт 503 и ощущение «баг в коде»; example для production обязателен при stack, который копируют на сервер вручную.
2. **Audit endpoint ≠ FS endpoint.** Cleanup audit в БД не должен зависеть от `requireRuntimeLogs` — иначе теряется ценность на `evobgp-api`/без volume.
3. **Разделение `/settings` и tenant** снижает путаницу ролей: browser config vs control plane KV — разные mental models и nav-пункты.
4. **Синхронный DELETE** при лимите 512 MiB и truncate-by-default — приемлемый trade-off для операторского UI без jobs; важно документировать в OpenAPI и ConfirmDialog.
5. **Memory Bank phased BUILD** хорошо масштабируется на Level 4: 7 фаз с чеклистами удерживают контекст между сессиями агента.
---
## Process Improvements
| Рекомендация | Действие |
|--------------|----------|
| После изменения compose в repo — **синхронизировать production.example** в той же фазе | Уже сделано для этой задачи; закрепить как правило в Phase 4 checklist |
| **E2E smoke** в `scripts/` или compose profile `test-runtime-logs` (temp dir + evobgp-all env) | Backlog: снизить зависимость от ручного prod |
| В PR template: «обновлены `docs/api.md` + `manual.md`?» для API/UI фич | Phase 7 не забывать при мелких задачах |
| Creative commit local без push — ок для итерации; перед prod нужен **CI image** с новым API | Напоминание в runbook E2E |
---
## Technical Improvements (backlog)
- **Operator role в UI:** сейчас `session?.role === 'operator'` — если появятся расширенные роли, вынести `canMutateSettings` / `canCleanupLogs` в один helper.
- **Monitoring URL tabs:** добавлен sync для `runtime-logs`; при новых вкладках — единый helper как в Operations/tenant-settings.
- **Удаление custom KV:** PATCH только перечисленных ключей; полное удаление ключа из tenant может требовать явного API (сейчас — операторская семантика через форму).
- **Метрики:** опционально `evobgp_runtime_logs_cleanup_total` в observability (PERF-03).
---
## Comparison to Plan
| План | Факт |
|------|------|
| 7 BUILD фаз | Выполнено |
| CP-1…CP-4 | Соблюдены |
| `/tenant-settings` Tabs | Да |
| Monitoring `?tab=runtime-logs` | Да + nested files/audit |
| Только evobgp-all FS | Да |
| Sync cleanup + audit | Да |
| `redocly`, go test, web check+lint | Pass локально |
**Отклонения:** нет существенных. E2E manual на prod — единственный незакрытый автоматический gate.
---
## Next Steps
1. **`/archive`** — архив задачи в `memory-bank/archive/`.
2. **На сервере:** применить `docker-compose.production.example.yaml` (или патч Phase 4), `pull evobgp-all`, пройти E2E checklist из `tasks.md` Phase 7.
3. **Коммит/PR:** сгруппировать изменения (backend, web, deploy, docs) или один feature PR — по предпочтению команды.
4. **Опционально:** smoke-скрипт для runtime logs API в dev compose.
+14 -22
View File
@@ -2,31 +2,23 @@
## Current Task
**[Не определена]** — пользователь вызвал `/van` без описания задачи.
_Нет активной задачи. Запустите `/van` для новой._
## Status
---
- [x] VAN: platform detection
- [x] VAN: Memory Bank verification & creation
- [x] VAN: baseline project context
- [ ] Task definition (от пользователя)
- [ ] Complexity determination (Level 14)
- [ ] Implementation plan
- [ ] Execution
- [ ] Documentation / reflect
## Last Completed
## Candidate Context (из git, не подтверждено)
| Task ID | Archive | Дата |
|---------|---------|------|
| `settings-ui-and-runtime-logs` | [archive-settings-ui-and-runtime-logs.md](archive/archive-settings-ui-and-runtime-logs.md) | 2026-06-12 |
Возможное продолжение работы над **Maintenance Policies UI**:
---
- `MaintenancePoliciesTab.svelte` — вкладка политик в мониторинге
- `policy-schedule.ts` — редактор cron-расписания (5-field, UTC)
## Status Template (для следующей задачи)
## Requirements
_Ожидается формулировка задачи от пользователя._
Пример:
```
/van Доработать MaintenancePoliciesTab: валидация cron и тесты
```
- [ ] VAN
- [ ] PLAN
- [ ] CREATIVE (если Level 34)
- [ ] BUILD
- [ ] REFLECT
- [ ] ARCHIVE
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_runtime_log_cleanup_audit_tenant_created;
DROP TABLE IF EXISTS runtime_log_cleanup_audit;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS runtime_log_cleanup_audit (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
actor_prefix TEXT NOT NULL,
filename TEXT NOT NULL,
action TEXT NOT NULL,
size_before BIGINT NOT NULL,
size_after BIGINT,
detail_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT runtime_log_cleanup_audit_action_chk CHECK (
action IN ('truncate', 'delete')
),
CONSTRAINT runtime_log_cleanup_audit_filename_chk CHECK (length(trim(filename)) > 0)
);
CREATE INDEX IF NOT EXISTS idx_runtime_log_cleanup_audit_tenant_created
ON runtime_log_cleanup_audit (tenant_id, created_at DESC);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_runtime_log_cleanup_audit_tenant_created;
DROP TABLE IF EXISTS runtime_log_cleanup_audit;
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS runtime_log_cleanup_audit (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
actor_prefix TEXT NOT NULL,
filename TEXT NOT NULL,
action TEXT NOT NULL,
size_before INTEGER NOT NULL,
size_after INTEGER,
detail_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_runtime_log_cleanup_audit_tenant_created
ON runtime_log_cleanup_audit (tenant_id, created_at DESC);
@@ -0,0 +1,451 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import {
cleanupRuntimeLogFile,
getRuntimeLogTail,
isRuntimeLogsUnavailable,
listRuntimeLogCleanupAudit,
listRuntimeLogFiles,
type RuntimeLogCleanupAudit,
type RuntimeLogCleanupMode,
type RuntimeLogFile
} from '$lib/runtime-logs/runtime-logs-api.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '$lib/ui/core/dialog/index.js';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '$lib/ui/core/dropdown-menu/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import type { DataTableColumn } from '$lib/ui/patterns/data-table/types.js';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import {
dialogBodyDocument,
dialogContentDocument,
dialogHeaderDocument
} from '$lib/dialog-layout.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import Eraser from '@lucide/svelte/icons/eraser';
import Trash2 from '@lucide/svelte/icons/trash-2';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import FileText from '@lucide/svelte/icons/file-text';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
type SubTab = 'files' | 'audit';
let subTab = $state<SubTab>('files');
let session = $state<AuthSession | null>(null);
let filesUnavailable = $state(false);
let filesLoading = $state(true);
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);
let previewOpen = $state(false);
let previewFilename = $state('');
let previewLoading = $state(false);
let previewContent = $state('');
let previewTruncated = $state(false);
let previewLines = $state(0);
let cleaningFilename = $state<string | null>(null);
const isOperator = $derived(session?.role === 'operator');
const totalBytes = $derived(files.reduce((sum, f) => sum + (f.size_bytes ?? 0), 0));
const fileColumns: DataTableColumn<RuntimeLogFile>[] = [
{ id: 'name', label: 'Файл', sortable: true, sortValue: (f) => f.name },
{
id: 'size',
label: 'Размер',
sortable: true,
sortValue: (f) => f.size_bytes
},
{
id: 'modified',
label: 'Изменён',
sortable: true,
sortValue: (f) => f.modified_at
},
{ id: 'actions', label: '', class: 'w-36' }
];
const auditColumns: DataTableColumn<RuntimeLogCleanupAudit>[] = [
{
id: 'created',
label: 'Время',
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: 'Действие' },
{ id: 'sizes', label: 'Размер' }
];
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function loadFiles() {
filesLoading = true;
try {
files = await listRuntimeLogFiles();
filesUnavailable = false;
} catch (e) {
if (isRuntimeLogsUnavailable(e)) {
filesUnavailable = true;
files = [];
return;
}
notifyApiError(e, 'Файловые логи');
} finally {
filesLoading = false;
}
}
async function loadAudit(reset = true) {
auditLoading = true;
if (reset) auditError = null;
try {
const page = await listRuntimeLogCleanupAudit({
limit: 20,
cursor: reset ? undefined : auditCursor
});
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)]);
}
async function openPreview(file: RuntimeLogFile) {
previewFilename = file.name;
previewOpen = true;
previewLoading = true;
previewContent = '';
try {
const tail = await getRuntimeLogTail(file.name, { lines: 200 });
previewContent = tail.content;
previewTruncated = tail.truncated;
previewLines = tail.lines_returned;
} catch (e) {
notifyApiError(e);
previewOpen = false;
} finally {
previewLoading = false;
}
}
function requestCleanup(file: RuntimeLogFile, mode: RuntimeLogCleanupMode) {
const actionLabel = mode === 'delete' ? 'удалить файл' : 'обнулить (truncate)';
void confirm({
title: mode === 'delete' ? 'Удалить log-файл?' : 'Очистить log-файл?',
description: `${file.name} · ${formatBytes(file.size_bytes)}. Действие: ${actionLabel}. Операция синхронная.`,
confirmLabel: mode === 'delete' ? 'Удалить' : 'Очистить',
destructive: true,
onConfirm: async () => {
cleaningFilename = file.name;
try {
await cleanupRuntimeLogFile(file.name, mode);
notify.success(mode === 'delete' ? 'Файл удалён' : 'Файл обнулён');
await refreshAll();
} catch (e) {
notifyApiError(e);
} finally {
cleaningFilename = null;
}
}
});
}
function actionBadgeVariant(action: RuntimeLogCleanupMode): 'secondary' | 'destructive' {
return action === 'delete' ? 'destructive' : 'secondary';
}
onMount(() => {
void (async () => {
await loadSession();
await loadFiles();
await loadAudit(true);
})();
});
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive class="size-4" />
<span>Файловые логи Docker-сервисов (sidecar stack-runtime-logs)</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => refreshAll()}
disabled={filesLoading || auditLoading}
>
<RefreshCw class={filesLoading || auditLoading ? 'animate-spin' : ''} />
Обновить
</Button>
</div>
{#if filesUnavailable}
<EmptyState
icon={HardDrive}
title="Файловые логи недоступны"
description="Список и очистка *.log доступны только на evobgp-all с примонтированным каталогом runtime-logs (EVOBGP_RUNTIME_LOGS_DIR и EVOBGP_SERVICE=evobgp-all). См. docs/access.md в репозитории. Вкладка audit очистки ниже работает без volume."
/>
{/if}
{#if !filesUnavailable}
<div class="grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader class="pb-2">
<CardDescription>Файлов</CardDescription>
<CardTitle class="text-2xl tabular-nums">{files.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Суммарный размер</CardDescription>
<CardTitle class="text-2xl tabular-nums">{formatBytes(totalBytes)}</CardTitle>
</CardHeader>
</Card>
</div>
{/if}
<Tabs bind:value={subTab}>
<TabsList>
<TabsTrigger value="files">Файлы</TabsTrigger>
<TabsTrigger value="audit">Audit очистки</TabsTrigger>
</TabsList>
<TabsContent value="files" class="mt-4">
{#if filesUnavailable}
<EmptyState
icon={FileText}
title="FS API отключён"
description="Примонтируйте runtime-logs к evobgp-all и задайте EVOBGP_RUNTIME_LOGS_DIR."
/>
{:else}
<Card>
<CardHeader>
<CardTitle class="text-base">*.log на хосте</CardTitle>
<CardDescription>
Просмотр хвоста и синхронная очистка (truncate по умолчанию). Очистка — роль operator.
</CardDescription>
</CardHeader>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={fileColumns}
rows={files}
rowKey={(f) => f.name}
loading={filesLoading}
emptyTitle="Нет log-файлов"
emptyDescription="Sidecar ещё не создал файлы или каталог пуст."
>
{#snippet cell({ row, column })}
{#if column.id === 'name'}
<span class="font-mono text-xs">{row.name}</span>
{:else if column.id === 'size'}
{formatBytes(row.size_bytes)}
{:else if column.id === 'modified'}
<span class="text-sm">{formatDateTime(row.modified_at)}</span>
{:else if column.id === 'actions'}
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Просмотр"
onclick={() => openPreview(row)}
>
<Eye class="size-3.5" />
</Button>
{#if isOperator}
<Button
variant="ghost"
size="icon-sm"
aria-label="Очистить (truncate)"
disabled={cleaningFilename === row.name}
onclick={() => requestCleanup(row, 'truncate')}
>
{#if cleaningFilename === row.name}
<LoaderCircle class="size-3.5 animate-spin" />
{:else}
<Eraser class="size-3.5" />
{/if}
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-sm" aria-label="Ещё">
<MoreHorizontal class="size-3.5" />
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
disabled={cleaningFilename === row.name}
onclick={() => requestCleanup(row, 'delete')}
>
<Trash2 class="size-4" />
Удалить файл
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/if}
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
{/if}
</TabsContent>
<TabsContent value="audit" class="mt-4">
<Card>
<CardHeader>
<CardTitle class="text-base">История очистки</CardTitle>
<CardDescription>
Записи из <code class="text-xs">runtime_log_cleanup_audit</code> (viewer+).
</CardDescription>
</CardHeader>
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
{#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"
size="sm"
disabled={auditLoading}
onclick={() => loadAudit(false)}
>
{#if auditLoading}
<LoaderCircle class="size-4 animate-spin" />
{/if}
Загрузить ещё
</Button>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<Dialog bind:open={previewOpen}>
<DialogContent class={dialogContentDocument}>
<DialogHeader class={dialogHeaderDocument}>
<DialogTitle class="flex items-center gap-2">
<FileText class="size-4" />
{previewFilename}
</DialogTitle>
<DialogDescription>
{#if previewTruncated}
Показан усечённый хвост ({previewLines} строк).
{:else}
Хвост файла ({previewLines} строк).
{/if}
</DialogDescription>
</DialogHeader>
<div class={dialogBodyDocument}>
{#if previewLoading}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else}
<ScrollPreBlock variant="preserve" text={previewContent || '—'} class="max-h-[70vh]" />
{/if}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { loadSettings, partitionSettings } from '$lib/settings/settings-api.js';
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import { notifyApiError } from '$lib/ui/app/toast.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
const labels: Record<string, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Local IPv4',
bird_local_ipv6: 'Local IPv6',
bird_local_asn: 'Local ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6'
};
let loading = $state(true);
let values = $state<Record<string, string>>({});
onMount(() => {
void (async () => {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
const out: Record<string, string> = {};
for (const key of BIRD_SETTING_KEYS) {
const v = String(partitioned.bird[key] ?? '').trim();
if (v) out[key] = v;
}
values = out;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
})();
});
</script>
<Card>
<CardHeader>
<CardTitle>BIRD (кратко)</CardTitle>
<CardDescription>
Глобальные параметры BIRD из tenant settings. Полная форма — в разделе «Параметры».
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if loading}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if Object.keys(values).length === 0}
<p class="text-sm text-muted-foreground">Параметры BIRD ещё не заданы.</p>
{:else}
<dl class="grid gap-2 text-sm sm:grid-cols-2">
{#each Object.entries(values) as [key, value] (key)}
<div class="rounded-md border bg-muted/30 px-3 py-2">
<dt class="text-muted-foreground">{labels[key] ?? key}</dt>
<dd class="font-mono text-xs break-all">{value}</dd>
</div>
{/each}
</dl>
{/if}
<Button variant="outline" href={resolve('/tenant-settings?tab=bird')}>
<SlidersHorizontal class="size-4" />
Изменить параметры
<ArrowRight class="size-4" />
</Button>
</CardContent>
</Card>
@@ -1,223 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import {
emptyRevisionSettingsForm,
revisionSettingsSchema
} from '$lib/settings/revision-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings,
type AdditionalSettingEntry
} from '$lib/settings/settings-api.js';
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.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 { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Info from '@lucide/svelte/icons/info';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
let additionalIdCounter = $state(1);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
{
validators: zod4(revisionSettingsSchema),
SPA: true,
dataType: 'json'
}
);
let hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
}
function removeAdditionalSetting(id: number) {
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
}
let canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
const hasRetention = String($form.revision_retention_minutes ?? '').trim() !== '';
const hasAdditional = additionalSettings.some((entry) => entry.key.trim() !== '');
return hasRetention || hasAdditional;
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
reset({ data: partitioned.revision });
additionalSettings = partitioned.additional;
additionalIdCounter = nextId;
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSave) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload = buildPayloadFromFormFields(
REVISION_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
for (const entry of additionalSettings) {
const key = entry.key.trim();
if (!key) continue;
payload[key] = entry.value;
}
saving = true;
try {
await patchSettings(payload);
notify.success('Системные настройки сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<div class="flex flex-col gap-6">
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение параметров через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Хранение ревизий</CardTitle>
<CardDescription>
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
</CardDescription>
</CardHeader>
<CardContent>
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
{:else}
<FormField
id="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Допустимый диапазон: 15–43200 минут."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={$form.revision_retention_minutes}
placeholder="43200"
/>
</FormField>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>Произвольные KV-пары в global_settings.</CardDescription>
</div>
{#if loaded}
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
{/if}
</div>
</CardHeader>
<CardContent>
{#if !loaded}
<p class="text-sm text-muted-foreground">Загрузите настройки выше.</p>
{:else if additionalSettings.length === 0}
<EmptyState
title="Нет дополнительных параметров"
description="Добавьте KV-пару при необходимости."
/>
{:else}
<div class="space-y-2">
{#each additionalSettings as entry (entry.id)}
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
<Input bind:value={entry.value} placeholder="Значение (строка)" />
<Button
variant="ghost"
size="icon"
aria-label="Удалить строку"
onclick={() => removeAdditionalSetting(entry.id)}
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
{#if loaded}
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить настройки'}
</Button>
{/if}
</div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadSettings,
partitionSettings,
patchSettings,
type AdditionalSettingEntry
} from '$lib/settings/settings-api.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 EmptyState from '$lib/ui/patterns/empty-state/empty-state.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 Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
let additionalIdCounter = $state(1);
let canSave = $derived.by(() => {
if (loading || saving || !loaded) return false;
return additionalSettings.some((entry) => entry.key.trim() !== '');
});
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
}
function requestRemoveAdditionalSetting(entry: AdditionalSettingEntry) {
const key = entry.key.trim();
void confirm({
title: key ? `Удалить параметр «${key}»?` : 'Удалить строку?',
description: key
? 'Строка исчезнет из формы. Чтобы удалить ключ из tenant, сохраните без него или очистите значение и примените PATCH.'
: 'Несохранённая пустая строка будет удалена из формы.',
confirmLabel: 'Удалить',
destructive: Boolean(key),
onConfirm: async () => {
additionalSettings = additionalSettings.filter((row) => row.id !== entry.id);
}
});
}
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
additionalSettings = partitioned.additional;
additionalIdCounter = nextId;
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
if (!canSave) {
notify.error('Нечего сохранять');
return;
}
const payload: Record<string, string> = {};
for (const entry of additionalSettings) {
const key = entry.key.trim();
if (!key) continue;
payload[key] = entry.value;
}
saving = true;
try {
await patchSettings(payload);
notify.success('Дополнительные параметры сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<Card>
<CardHeader>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>Произвольные KV-пары в global_settings (operator).</CardDescription>
</div>
{#if loaded}
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
{/if}
</div>
</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 if additionalSettings.length === 0}
<EmptyState
title="Нет дополнительных параметров"
description="Добавьте KV-пару при необходимости."
/>
{:else}
<div class="space-y-2">
{#each additionalSettings as entry (entry.id)}
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
<Input bind:value={entry.value} placeholder="Значение (строка)" />
<Button
variant="ghost"
size="icon"
aria-label="Удалить строку"
onclick={() => requestRemoveAdditionalSetting(entry)}
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить дополнительные параметры'}
</Button>
{/if}
</CardContent>
</Card>
@@ -104,7 +104,7 @@
<Card>
<CardHeader>
<CardTitle>Control plane</CardTitle>
<CardTitle>BIRD control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
@@ -115,8 +115,8 @@
<Info class="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
адреса). Пиры и спикеры настраиваются на соседних вкладках.
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса). Пиры и
спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
@@ -205,7 +205,7 @@
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры'}
{saving ? 'Сохранение…' : 'Применить параметры BIRD'}
</Button>
{/if}
</CardContent>
@@ -0,0 +1,277 @@
<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 {
emptyRevisionSettingsForm,
revisionSettingsSchema
} from '$lib/settings/revision-settings.schema.js';
import {
fetchRevisionPruneEstimate,
pruneRevisionsNow,
type RevisionPruneEstimate
} from '$lib/settings/revision-prune-api.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.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 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 Trash2 from '@lucide/svelte/icons/trash-2';
let loading = $state(false);
let saving = $state(false);
let pruning = $state(false);
let loaded = $state(false);
let session = $state<AuthSession | null>(null);
let estimateLoading = $state(false);
let estimate = $state<RevisionPruneEstimate | null>(null);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
{
validators: zod4(revisionSettingsSchema),
SPA: true,
dataType: 'json'
}
);
const isOperator = $derived(session?.role === 'operator');
const hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
const parsedRetentionMinutes = $derived.by(() => {
const s = String($form.revision_retention_minutes ?? '').trim();
if (s === '' || !/^\d+$/.test(s)) return null;
const n = Number(s);
if (!Number.isInteger(n) || n < 15 || n > 43200) return null;
return n;
});
const canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return String($form.revision_retention_minutes ?? '').trim() !== '';
});
const canPruneNow = $derived.by(() => {
if (!isOperator || !loaded || pruning || saving || parsedRetentionMinutes === null)
return false;
return (estimate?.revision_count ?? 0) > 0;
});
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function refreshEstimate(minutes: number) {
estimateLoading = true;
try {
estimate = await fetchRevisionPruneEstimate(minutes);
} catch (e) {
estimate = null;
notifyApiError(e, 'Не удалось рассчитать оценку очистки');
} finally {
estimateLoading = false;
}
}
$effect(() => {
const minutes = parsedRetentionMinutes;
if (!loaded || minutes === null) {
estimate = null;
return;
}
const handle = setTimeout(() => {
void refreshEstimate(minutes);
}, 400);
return () => clearTimeout(handle);
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.revision });
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSave) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload = buildPayloadFromFormFields(
REVISION_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры хранения ревизий сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
async function requestPruneNow() {
const minutes = parsedRetentionMinutes;
if (minutes === null) return;
let est: RevisionPruneEstimate;
try {
est = await fetchRevisionPruneEstimate(minutes);
} catch (e) {
notifyApiError(e);
return;
}
estimate = est;
if (est.revision_count === 0) {
notify.info('Нет ревизий для удаления по выбранному retention');
return;
}
void confirm({
title: 'Очистить старые ревизии?',
description: `Будет удалено ${est.revision_count} ревизий. Ориентировочно освободится ~${formatBytes(est.bytes_estimate)}. Действие необратимо.`,
confirmLabel: 'Очистить',
destructive: true,
onConfirm: async () => {
pruning = true;
try {
const res = await pruneRevisionsNow(minutes);
notify.success(
`Удалено ревизий: ${res.deleted_revisions}, освобождено ~${formatBytes(res.bytes_estimate)}`
);
await refreshEstimate(minutes);
} catch (e) {
notifyApiError(e);
throw e;
} finally {
pruning = false;
}
}
});
}
onMount(() => {
void loadSession();
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>Хранение ревизий</CardTitle>
<CardDescription>
Автоматическая очистка старых ревизий. Последняя ревизия и раскатанные на спикерах не
удаляются.
</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="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Допустимый диапазон: 15–43200 минут."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={$form.revision_retention_minutes}
placeholder="43200"
/>
</FormField>
{#if parsedRetentionMinutes !== null}
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
<p class="font-medium">Оценка очистки по введённому retention</p>
{#if estimateLoading}
<p class="text-muted-foreground">Расчёт…</p>
{:else if estimate}
<p>
Будет удалено ревизий: <strong>{estimate.revision_count}</strong>
</p>
<p>
Освободится ориентировочно: <strong>~{formatBytes(estimate.bytes_estimate)}</strong>
</p>
{#if estimate.prefix_row_count > 0}
<p class="text-xs text-muted-foreground">
Строк префиксов в снимках: {estimate.prefix_row_count}
{#if estimate.orphan_snapshot_count > 0}
· снимков: {estimate.orphan_snapshot_count}
{/if}
</p>
{/if}
{:else}
<p class="text-muted-foreground">Оценка недоступна</p>
{/if}
<p class="pt-1 text-xs text-muted-foreground">
Учитываются те же правила, что при автоочистке: последняя ревизия и раскатанные на
спикерах не удаляются.
</p>
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<div class="flex flex-wrap gap-2">
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
</Button>
{#if isOperator}
<Button variant="destructive" disabled={!canPruneNow} onclick={requestPruneNow}>
<Trash2 />
{pruning ? 'Очистка…' : 'Очистить сейчас'}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
@@ -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>
@@ -0,0 +1,90 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
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' | 'runtime-logs' | 'additional';
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
if (value === 'revision' || value === 'runtime-logs' || value === 'additional') return value;
return 'bird';
}
let activeTab = $state<TenantSettingsTab>('bird');
let tabSyncReady = $state(false);
onMount(() => {
activeTab = parseTenantSettingsTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
});
function syncTabToUrl(tab: TenantSettingsTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'bird') url.searchParams.delete('tab');
else url.searchParams.set('tab', tab);
const next = `${url.pathname}${url.search}${url.hash}`;
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
}
}
$effect(() => {
if (!tabSyncReady) return;
syncTabToUrl(activeTab);
});
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Параметры tenant"
description="Параметры control plane для текущего tenant (API /v1/settings). Токен и тема интерфейса — в разделе «Настройки»."
icon={SlidersHorizontal}
iconClass="bg-chart-5/15 text-chart-5"
/>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение значений через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<Tabs bind:value={activeTab}>
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
<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>
<TabsContent value="bird" class="mt-4">
<TenantBirdSettingsCard />
</TabsContent>
<TabsContent value="revision" class="mt-4">
<TenantRevisionSettingsCard />
</TabsContent>
<TabsContent value="runtime-logs" class="mt-4">
<TenantRuntimeLogsSettingsCard />
</TabsContent>
<TabsContent value="additional" class="mt-4">
<TenantAdditionalSettingsCard />
</TabsContent>
</Tabs>
</div>
+18
View File
@@ -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,90 @@
import { apiJSON, apiMutate, ApiError } from '$lib/api/client.js';
export type RuntimeLogFile = {
name: string;
size_bytes: number;
modified_at: string;
};
export type RuntimeLogTail = {
filename: string;
content: string;
truncated: boolean;
lines_returned: number;
};
export type RuntimeLogCleanupMode = 'truncate' | 'delete';
export type RuntimeLogCleanupResult = {
audit_id: string;
filename: string;
action: RuntimeLogCleanupMode;
size_before: number;
size_after?: number | null;
};
export type RuntimeLogCleanupAudit = {
id: string;
tenant_id: string;
actor_prefix: string;
filename: string;
action: RuntimeLogCleanupMode;
size_before: number;
size_after?: number | null;
detail?: Record<string, unknown>;
created_at: string;
};
export type RuntimeLogCleanupAuditList = {
items: RuntimeLogCleanupAudit[];
next_cursor?: string;
has_more?: boolean;
};
/** True when FS API is disabled (not evobgp-all or no volume). */
export function isRuntimeLogsUnavailable(err: unknown): boolean {
if (!(err instanceof ApiError) || err.status !== 503) return false;
const detail = err.problem?.detail ?? err.message;
return detail === 'runtime_logs_unavailable' || detail.includes('runtime_logs_unavailable');
}
export async function listRuntimeLogFiles(): Promise<RuntimeLogFile[]> {
const r = await apiJSON<{ items: RuntimeLogFile[] }>('/v1/runtime-logs/files');
return r.items ?? [];
}
export async function getRuntimeLogTail(
filename: string,
opts?: { lines?: number; grep?: string }
): Promise<RuntimeLogTail> {
const q = new URLSearchParams();
q.set('lines', String(opts?.lines ?? 200));
if (opts?.grep?.trim()) q.set('grep', opts.grep.trim());
return apiJSON<RuntimeLogTail>(
`/v1/runtime-logs/files/${encodeURIComponent(filename)}?${q.toString()}`
);
}
export async function cleanupRuntimeLogFile(
filename: string,
mode: RuntimeLogCleanupMode = 'truncate'
): Promise<RuntimeLogCleanupResult> {
const q = new URLSearchParams({ mode });
return apiMutate<RuntimeLogCleanupResult>(
`/v1/runtime-logs/files/${encodeURIComponent(filename)}?${q.toString()}`,
'DELETE',
undefined,
{ idempotent: false }
);
}
export async function listRuntimeLogCleanupAudit(opts?: {
cursor?: string;
limit?: number;
}): Promise<RuntimeLogCleanupAuditList> {
const q = new URLSearchParams();
if (opts?.limit != null) q.set('limit', String(opts.limit));
if (opts?.cursor) q.set('cursor', opts.cursor);
const suffix = q.toString() ? `?${q.toString()}` : '';
return apiJSON<RuntimeLogCleanupAuditList>(`/v1/runtime-logs/cleanup-audit${suffix}`);
}
@@ -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,30 @@
import { apiJSON, apiMutate } from '$lib/api/client.js';
export type RevisionPruneEstimate = {
retention_minutes: number;
cutoff_at: string;
revision_count: number;
prefix_row_count: number;
orphan_snapshot_count: number;
bytes_estimate: number;
};
export type RevisionPruneResult = {
deleted_revisions: number;
deleted_prefix_snapshots: number;
deleted_prefix_rows: number;
bytes_estimate: number;
};
export async function fetchRevisionPruneEstimate(
retentionMinutes: number
): Promise<RevisionPruneEstimate> {
const q = new URLSearchParams({ retention_minutes: String(retentionMinutes) });
return apiJSON<RevisionPruneEstimate>(`/v1/revisions/prune-estimate?${q}`);
}
export async function pruneRevisionsNow(retentionMinutes: number): Promise<RevisionPruneResult> {
return apiMutate<RevisionPruneResult>('/v1/revisions/prune', 'POST', {
retention_minutes: retentionMinutes
});
}
@@ -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'
});
+34 -6
View File
@@ -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;
}
+17 -2
View File
@@ -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']);
@@ -5,7 +5,7 @@ import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-se
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
/** @deprecated Используйте TenantBirdSettingsCard и TenantRevisionSettingsCard. */
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
+3 -1
View File
@@ -8,6 +8,7 @@ import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Shield from '@lucide/svelte/icons/shield';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
export type NavItem = {
href: string;
label: string;
@@ -21,7 +22,8 @@ export const mainNav: NavItem[] = [
{ href: '/network', label: 'Сеть', icon: Network },
{ href: '/operations', label: 'Ревизии', icon: Activity },
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
{ href: '/tenant-settings', label: 'Параметры', icon: SlidersHorizontal }
];
export const bottomNav: NavItem[] = [
+38 -2
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { apiJSON, apiFetch } from '$lib/api/client.js';
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
@@ -50,6 +52,7 @@
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import MonitoringPostgresTab from '$lib/components/monitoring/MonitoringPostgresTab.svelte';
import RuntimeLogsTab from '$lib/components/monitoring/RuntimeLogsTab.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
@@ -82,7 +85,15 @@
let lastUpdated = $state<Date | null>(null);
let initialLoading = $state(true);
let refreshing = $state(false);
let mainTab = $state('system');
type MainTab = 'system' | 'postgres' | 'runtime-logs';
function parseMainTab(value: string | null): MainTab {
if (value === 'postgres' || value === 'runtime-logs') return value;
return 'system';
}
let mainTab = $state<MainTab>('system');
let tabSyncReady = $state(false);
const statAccents = [
{
@@ -305,7 +316,27 @@
return error.length > max ? `${error.slice(0, max)}…` : error;
}
onMount(load);
function syncMainTabToUrl(tab: MainTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'system') url.searchParams.delete('tab');
else url.searchParams.set('tab', tab);
const next = `${url.pathname}${url.search}${url.hash}`;
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
}
}
onMount(() => {
mainTab = parseMainTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
void load();
});
$effect(() => {
if (!tabSyncReady) return;
syncMainTabToUrl(mainTab);
});
</script>
<div class="flex flex-col gap-6">
@@ -329,6 +360,7 @@
<TabsList>
<TabsTrigger value="system">Система</TabsTrigger>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
</TabsList>
<TabsContent value="system" class="mt-4 flex flex-col gap-6">
@@ -682,5 +714,9 @@
<TabsContent value="postgres" class="mt-4">
<MonitoringPostgresTab />
</TabsContent>
<TabsContent value="runtime-logs" class="mt-4">
<RuntimeLogsTab />
</TabsContent>
</Tabs>
</div>
+2 -2
View File
@@ -19,7 +19,7 @@
import { notifyApiError } from '$lib/ui/app/toast.js';
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
import NetworkBirdSettingsSummaryCard from '$lib/components/network/NetworkBirdSettingsSummaryCard.svelte';
import NetworkOverviewTab from '$lib/components/network/NetworkOverviewTab.svelte';
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
@@ -238,7 +238,7 @@
</TabsContent>
<TabsContent value="control-plane" class="mt-4">
<BirdSettingsForm />
<NetworkBirdSettingsSummaryCard />
</TabsContent>
</Tabs>
</div>
+12 -16
View File
@@ -53,7 +53,6 @@
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte';
import OperationsJobsFilters from '$lib/components/operations/OperationsJobsFilters.svelte';
import OperationsSystemSettingsTab from '$lib/components/operations/OperationsSystemSettingsTab.svelte';
import type {
JobDetailedReport,
JobLogEntry,
@@ -82,10 +81,10 @@
import Info from '@lucide/svelte/icons/info';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
type OpsTab = 'revisions' | 'diff' | 'jobs' | 'system';
type OpsTab = 'revisions' | 'diff' | 'jobs';
function parseOpsTab(value: string | null): OpsTab {
if (value === 'diff' || value === 'jobs' || value === 'system') return value;
if (value === 'diff' || value === 'jobs') return value;
return 'revisions';
}
@@ -351,7 +350,12 @@
}
onMount(() => {
activeTab = parseOpsTab(page.url.searchParams.get('tab'));
const tabParam = page.url.searchParams.get('tab');
if (tabParam === 'system') {
void goto(resolve('/tenant-settings?tab=revision'), { replaceState: true });
return;
}
activeTab = parseOpsTab(tabParam);
lastLoadedTab = activeTab;
tabSyncReady = true;
void refreshActiveTab(true);
@@ -459,9 +463,6 @@
break;
case 'diff':
break;
case 'system':
await loadBirdStatus();
break;
default:
await loadRevisions();
}
@@ -928,12 +929,12 @@
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Четыре раздела на одной странице</AlertTitle>
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
префиксов;
<strong>Задачи</strong> — ingest, apply, rollback; <strong>Система</strong> — TTL ревизий и
дополнительные KV. Apply и Reload требуют operator. Сводный мониторинг BGP — на
префиксов; <strong>Задачи</strong> — ingest, apply, rollback. TTL ревизий и tenant KV — в
<Button variant="link" class="h-auto p-0" href={resolve('/tenant-settings')}>Параметры</Button
>. Apply и Reload требуют operator. Сводный мониторинг BGP — на
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
</AlertDescription>
</Alert>
@@ -964,7 +965,6 @@
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
<TabsTrigger value="system">Система</TabsTrigger>
</TabsList>
</div>
@@ -1029,10 +1029,6 @@
jobStatusVariant={jobStatusBadgeVariant}
/>
</TabsContent>
<TabsContent value="system" class="mt-4">
<OperationsSystemSettingsTab />
</TabsContent>
</Tabs>
</div>
@@ -0,0 +1,5 @@
<script lang="ts">
import TenantSettingsPage from '$lib/components/tenant-settings/TenantSettingsPage.svelte';
</script>
<TenantSettingsPage />