Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dbdac3d2c | ||
|
|
a0f78a3d21 | ||
|
|
0c5502b5bb | ||
|
|
3f0dd6c234 | ||
|
|
1c39c65fc5 | ||
|
|
493575aca4 | ||
|
|
e27936c072 | ||
|
|
ceb6f2f34f |
@@ -17,3 +17,6 @@ Thumbs.db
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||
deploy/compose/runtime-logs/
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
- |
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 2–4 → `/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 только < 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 < 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$
|
||||
```
|
||||
|
||||
- Длина: 3–128
|
||||
- Запрещено: `..`, `/`, `\`, 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
@@ -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 P1–P7 | ✅ |
|
||||
| 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
@@ -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 1–4)
|
||||
- [ ] 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 3–4)
|
||||
- [ ] 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>
|
||||
+4
-4
@@ -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. */
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
Reference in New Issue
Block a user