Compare commits

...
8 Commits
Author SHA1 Message Date
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
58 changed files with 3556 additions and 349 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/
+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:
- |
+11
View File
@@ -101,6 +101,17 @@ 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`, эндпоинты `/v1/runtime-logs/*` отвечают **503** (`runtime_logs_unavailable`). Очистка файлов — роль **operator+**; операции пишутся в таблицу `runtime_log_cleanup_audit`.
## CORS для веб-интерфейса
Браузерные запросы с другого origin требуют заголовков CORS на API. Задайте список разрешённых origin через **`EVOBGP_CORS_ORIGINS`** (через запятую), например:
+14 -1
View File
@@ -90,7 +90,20 @@
### Settings
- `GET /v1/settings`, `PATCH /v1/settings`
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, произвольные ключи. Чтение — 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`) |
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
## Соглашения из OpenAPI
+19 -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,22 @@ 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**, **Ревизии** (`revision_retention_minutes`), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
### Web UI: файловые runtime-логи
- **Мониторинг** → вкладка **«Файловые логи»** (`/monitoring?tab=runtime-logs`).
- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки**.
- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); audit из БД может отображаться отдельно.
- 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 +157,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)
+250
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,109 @@ 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
BirdLocalStatus:
type: object
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
@@ -3761,6 +3869,148 @@ 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/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.
+10
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
)
@@ -15,6 +16,8 @@ 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
}
// Load reads EVOBGP_* environment variables with safe defaults.
@@ -31,5 +34,12 @@ 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"))
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
}
+1
View File
@@ -79,6 +79,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) {
+179
View File
@@ -0,0 +1,179 @@
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)
}
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) 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,149 @@
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 := "00000000-0000-0000-0000-000000000001"
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())
}
})
}
+3
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"
@@ -30,6 +31,7 @@ type Server struct {
keyResolver *apiKeyResolver
corsOrigins []string
cdnHTTP *http.Client
runtimeLogs *runtimelogs.Service
mux *http.ServeMux
}
@@ -89,6 +91,7 @@ func New(opts Options) (*Server, error) {
keyResolver: resolver,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
}
s.mux = http.NewServeMux()
s.registerRoutes()
@@ -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 {
continue
}
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()
}
+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")
)
+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")
}
}
+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
}
+4
View File
@@ -125,6 +125,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 -32
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,
}
}
+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")
}
}
+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,431 @@
<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 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: '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;
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);
} catch (e) {
notifyApiError(e, 'Audit очистки логов');
} finally {
auditLoading = false;
}
}
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);
})();
});
$effect(() => {
if (subTab === 'audit' && auditItems.length === 0 && !auditLoading) {
void 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">
<AppDataTable
columns={auditColumns}
rows={auditItems}
rowKey={(r) => r.id}
loading={auditLoading && auditItems.length === 0}
emptyTitle="Записей пока нет"
emptyDescription="Очистка log-файлов появится здесь после operator DELETE."
>
{#snippet cell({ row, column })}
{#if column.id === 'created'}
<span class="text-sm">{formatDateTime(row.created_at)}</span>
{:else if column.id === 'actor'}
<span class="font-mono text-xs">{row.actor_prefix}</span>
{:else if column.id === 'filename'}
<span class="font-mono text-xs">{row.filename}</span>
{:else if column.id === 'action'}
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
{:else if column.id === 'sizes'}
<span class="text-xs tabular-nums">
{formatBytes(row.size_before)}
{#if row.size_after != null}
{formatBytes(row.size_after)}
{/if}
</span>
{/if}
{/snippet}
</AppDataTable>
{#if 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,138 @@
<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
} 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 FormField from '$lib/ui/patterns/form/form-field.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
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));
let canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return String($form.revision_retention_minutes ?? '').trim() !== '';
});
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;
}
}
onMount(() => {
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 hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
</Button>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,84 @@
<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 SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import Info from '@lucide/svelte/icons/info';
type TenantSettingsTab = 'bird' | 'revision' | 'additional';
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
if (value === 'revision' || 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="additional">Дополнительно</TabsTrigger>
</TabsList>
</div>
<TabsContent value="bird" class="mt-4">
<TenantBirdSettingsCard />
</TabsContent>
<TabsContent value="revision" class="mt-4">
<TenantRevisionSettingsCard />
</TabsContent>
<TabsContent value="additional" class="mt-4">
<TenantAdditionalSettingsCard />
</TabsContent>
</Tabs>
</div>
@@ -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}`);
}
@@ -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 />