feat(runtime-logs): enhance runtime log management and configuration
Добавлены новые возможности для управления файловыми логами в Docker-сервисах: - Обновлены конфигурации для поддержки логов, включая переменные окружения и монтирование директорий. - Документация обновлена для описания новых эндпоинтов и параметров, связанных с логами. - Упрощен доступ к логам через API и интерфейс пользователя. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -17,3 +17,6 @@ Thumbs.db
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.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=[email protected]
|
||||||
|
CF_DNS_API_TOKEN=
|
||||||
@@ -13,6 +13,11 @@ WEBUI_IP_WHITELIST=109.174.26.78/32,87.103.241.8/32
|
|||||||
LETSENCRYPT_EMAIL=[email protected]
|
LETSENCRYPT_EMAIL=[email protected]
|
||||||
CF_DNS_API_TOKEN=cfut_3D1i0MNBVX6MNSsjcFytsfSlyB4B15t7H3DYXuzq22011588
|
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 контейнеров)
|
# Auto-updater (проверка registry образов и точечный restart контейнеров)
|
||||||
# 0 = выключен, 1 = включен
|
# 0 = выключен, 1 = включен
|
||||||
AUTO_UPDATE_ENABLED=0
|
AUTO_UPDATE_ENABLED=0
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ services:
|
|||||||
evobgp-all:
|
evobgp-all:
|
||||||
environment:
|
environment:
|
||||||
EVOBGP_BROKER_URL: nats://nats:4222
|
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:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
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.
|
# проекта, перенесите acme.json в том evobgp_traefik_letsencrypt.
|
||||||
#
|
#
|
||||||
# Долгий сбор логов в файлы на хосте: сервис stack-runtime-logs пишет в каталог
|
# Долгий сбор логов в файлы на хосте: сервис 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.
|
# Автообновление сервисов из registry (без перезапуска bird2): stack-auto-updater.
|
||||||
# Настройки через .env: AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_SEC,
|
# Настройки через .env: AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_SEC,
|
||||||
# AUTO_UPDATE_SERVICES, AUTO_UPDATE_PROTECTED_SERVICES.
|
# AUTO_UPDATE_SERVICES, AUTO_UPDATE_PROTECTED_SERVICES.
|
||||||
@@ -135,11 +136,18 @@ services:
|
|||||||
EVOBGP_BIRDC_INTERVAL: 30s
|
EVOBGP_BIRDC_INTERVAL: 30s
|
||||||
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
|
||||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
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).
|
# DEV ONLY — не для production (см. docs/access.md).
|
||||||
EVOBGP_DEV_INSECURE: "1"
|
EVOBGP_DEV_INSECURE: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- bird_etc:/etc/bird
|
- bird_etc:/etc/bird
|
||||||
- bird_run:/run/bird:ro
|
- 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:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -237,8 +245,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ./runtime-logs
|
source: ${EVOBGP_RUNTIME_LOGS_HOST_DIR:-./runtime-logs}
|
||||||
target: /logs
|
target: /logs
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
|
|||||||
@@ -140,4 +140,5 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
- Доступ/роли: `docs/access.md`
|
- Доступ/роли: `docs/access.md`
|
||||||
- Web запуск: `web/README.md`
|
- Web запуск: `web/README.md`
|
||||||
- Compose: `deploy/compose/docker-compose.yaml`
|
- 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
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)
|
### Auto-updater для standalone stack (без рестарта BIRD2)
|
||||||
|
|
||||||
Для `stack.microvps-full.yaml` можно включить автообновление только выбранных сервисов (например, `evobgp-all,evobgp-web`) по digest образов в registry.
|
Для `stack.microvps-full.yaml` можно включить автообновление только выбранных сервисов (например, `evobgp-all,evobgp-web`) по digest образов в registry.
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
s.registerPostgresMonitoringRoutes(m)
|
s.registerPostgresMonitoringRoutes(m)
|
||||||
s.registerPostgresMaintenanceRoutes(m)
|
s.registerPostgresMaintenanceRoutes(m)
|
||||||
s.registerMaintenanceRoutes(m)
|
s.registerMaintenanceRoutes(m)
|
||||||
|
s.registerRuntimeLogsRoutes(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
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/jobs"
|
||||||
"evobgp/internal/maintenance"
|
"evobgp/internal/maintenance"
|
||||||
"evobgp/internal/pgmonitor"
|
"evobgp/internal/pgmonitor"
|
||||||
|
"evobgp/internal/runtimelogs"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -30,6 +31,7 @@ type Server struct {
|
|||||||
keyResolver *apiKeyResolver
|
keyResolver *apiKeyResolver
|
||||||
corsOrigins []string
|
corsOrigins []string
|
||||||
cdnHTTP *http.Client
|
cdnHTTP *http.Client
|
||||||
|
runtimeLogs *runtimelogs.Service
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
keyResolver: resolver,
|
keyResolver: resolver,
|
||||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||||
cdnHTTP: NewCDNHTTPClient(),
|
cdnHTTP: NewCDNHTTPClient(),
|
||||||
|
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||||
}
|
}
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
|
|||||||
@@ -3,28 +3,21 @@
|
|||||||
## Текущий фокус␍
|
## Текущий фокус␍
|
||||||
␍
|
␍
|
||||||
**Task:** `settings-ui-and-runtime-logs` ␍
|
**Task:** `settings-ui-and-runtime-logs` ␍
|
||||||
**Phase:** **BUILD Phase 2 complete** → **Phase 3** (HTTP handlers)
|
**Phase:** **BUILD Phase 5 complete** → **Phase 6** (Runtime logs Web UI)␍
|
||||||
␍
|
␍
|
||||||
## Phase 2 deliverables
|
## Phase 5 deliverables␍
|
||||||
␍
|
␍
|
||||||
- `internal/runtimelogs/` — Config, Service, safe path, List/Tail/Cleanup
|
- `web/src/routes/tenant-settings/+page.svelte`␍
|
||||||
- `EVOBGP_RUNTIME_LOGS_DIR` в `internal/config/config.go`
|
- `web/src/lib/components/tenant-settings/` — TenantSettingsPage, Bird/Revision/Additional cards␍
|
||||||
- `docs/access.md` — документация env
|
- `web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte`␍
|
||||||
|
- nav «Параметры» → `/tenant-settings`␍
|
||||||
## Ключевые константы
|
- Operations: убран tab `system`, редирект `?tab=system` → tenant-settings␍
|
||||||
|
- Удалены `OperationsSystemSettingsTab`, `BirdSettingsForm`␍
|
||||||
- Guard: `EVOBGP_SERVICE=evobgp-all` + non-empty absolute `EVOBGP_RUNTIME_LOGS_DIR`
|
|
||||||
- Cleanup max: 512 MiB; tail: 200 default, 2000 max, 256 KiB read cap
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
- `go test ./internal/runtimelogs/...` — pass
|
|
||||||
- `scripts/lint-go.ps1` — pass
|
|
||||||
␍
|
␍
|
||||||
## Следующий шаг␍
|
## Следующий шаг␍
|
||||||
␍
|
␍
|
||||||
```␍
|
```␍
|
||||||
/build Phase 3
|
/build Phase 6␍
|
||||||
```␍
|
```␍
|
||||||
␍
|
␍
|
||||||
HTTP handlers + routes для `/v1/runtime-logs/*`.
|
Monitoring tab «Файловые логи» + API client runtime logs.␍
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
| VAN / PLAN / CREATIVE | ✅ |
|
| VAN / PLAN / CREATIVE | ✅ |
|
||||||
| BUILD P1 OpenAPI+store | ✅ |
|
| BUILD P1 OpenAPI+store | ✅ |
|
||||||
| BUILD P2 FS layer | ✅ 2026-06-12 |
|
| BUILD P2 FS layer | ✅ 2026-06-12 |
|
||||||
| BUILD P3 HTTP | ⏳ |
|
| BUILD P3 HTTP | ✅ 2026-06-12 |
|
||||||
| BUILD P4 Deploy | ⏳ |
|
| BUILD P4 Deploy | ✅ 2026-06-12 |
|
||||||
| BUILD P5 Tenant UI | ⏳ |
|
| BUILD P5 Tenant UI | ✅ 2026-06-12 |
|
||||||
| BUILD P6 Runtime logs UI | ⏳ |
|
| BUILD P6 Runtime logs UI | ⏳ |
|
||||||
| BUILD P7 QA | ⏳ |
|
| BUILD P7 QA | ⏳ |
|
||||||
|
|||||||
+20
-12
@@ -8,7 +8,7 @@
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| **Task ID** | `settings-ui-and-runtime-logs` |
|
| **Task ID** | `settings-ui-and-runtime-logs` |
|
||||||
| **Complexity** | **Level 4** |
|
| **Complexity** | **Level 4** |
|
||||||
| **Status** | **BUILD Phase 2 complete** → Phase 3 |
|
| **Status** | **BUILD Phase 5 complete** → Phase 6 |
|
||||||
| **Дата VAN** | 2026-06-12 |
|
| **Дата VAN** | 2026-06-12 |
|
||||||
| **Дата PLAN** | 2026-06-12 |
|
| **Дата PLAN** | 2026-06-12 |
|
||||||
|
|
||||||
@@ -197,8 +197,9 @@ GET /v1/runtime-logs/cleanup-audit # cursor/limit, viewer+
|
|||||||
**Роли:** list/tail/audit — `viewer+`; cleanup — `operator+`.
|
**Роли:** list/tail/audit — `viewer+`; cleanup — `operator+`.
|
||||||
|
|
||||||
**Checklist Phase 3:**
|
**Checklist Phase 3:**
|
||||||
- [ ] `go test ./internal/httpapi/... -race`
|
- [x] `go test ./internal/httpapi/... -run RuntimeLogs` (Windows: без `-race`, CGO disabled)
|
||||||
- [ ] `scripts/lint-httpapi.sh`
|
- [x] `scripts/lint-go.ps1` exit 0
|
||||||
|
- [x] lint-httpapi gates (ERR-01, ARCH-01) — проверено grep
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -220,8 +221,10 @@ environment:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Checklist Phase 4:**
|
**Checklist Phase 4:**
|
||||||
- [ ] dev: `./runtime-logs` рядом с compose
|
- [x] dev: `./runtime-logs` рядом с compose (`EVOBGP_RUNTIME_LOGS_HOST_DIR` default)
|
||||||
- [ ] prod: `/opt/evobgp/runtime-logs` на хосте
|
- [x] prod: `/opt/evobgp/runtime-logs` на хосте (через `EVOBGP_RUNTIME_LOGS_HOST_DIR` в `.env`)
|
||||||
|
- [x] `stack.microvps-full.yaml` + `docker-compose.microvps-full.yaml` — mount + env на `evobgp-all`
|
||||||
|
- [x] `.env.stack.microvps-full.example`, `docs/quickstart.md`, `docs/manual.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -250,8 +253,10 @@ environment:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Checklist Phase 5:**
|
**Checklist Phase 5:**
|
||||||
- [ ] `npm run check && npm run lint`
|
- [x] `npm run check && npm run lint`
|
||||||
- [ ] `/settings` без tenant-форм
|
- [x] `/settings` без tenant-форм
|
||||||
|
- [x] `/tenant-settings` с Tabs BIRD / Ревизии / Дополнительно
|
||||||
|
- [x] nav «Параметры»; Operations без tab system; Network summary + link
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,12 +331,12 @@ graph TD
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `/settings` — только frontend (токен, тема)
|
- [x] `/settings` — только frontend (токен, тема)
|
||||||
- [ ] `/tenant-settings` — все tenant KV (BIRD + revision + custom)
|
- [x] `/tenant-settings` — все tenant KV (BIRD + revision + custom)
|
||||||
- [ ] Operations без tab `system`; Network без полной BIRD-формы (summary + link)
|
- [x] Operations без tab `system`; Network без полной BIRD-формы (summary + link)
|
||||||
- [ ] Runtime logs: list, tail, sync cleanup на evobgp-all
|
- [ ] Runtime logs: list, tail, sync cleanup на evobgp-all
|
||||||
- [ ] Audit cleanup в БД + просмотр в UI
|
- [ ] Audit cleanup в БД + просмотр в UI
|
||||||
- [ ] `EVOBGP_RUNTIME_LOGS_DIR`, volume в compose
|
- [x] `EVOBGP_RUNTIME_LOGS_DIR`, volume в compose
|
||||||
- [ ] redocly lint, go test -race, web check+lint
|
- [ ] redocly lint, go test -race, web check+lint
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -343,7 +348,10 @@ graph TD
|
|||||||
- [x] CREATIVE (4 docs)
|
- [x] CREATIVE (4 docs)
|
||||||
- [x] BUILD Phase 1 (OpenAPI + migration + store)
|
- [x] BUILD Phase 1 (OpenAPI + migration + store)
|
||||||
- [x] BUILD Phase 2 (FS layer + config)
|
- [x] BUILD Phase 2 (FS layer + config)
|
||||||
- [ ] BUILD Phase 3–7
|
- [x] BUILD Phase 3 HTTP handlers
|
||||||
|
- [x] BUILD Phase 4 Deploy (compose)
|
||||||
|
- [x] BUILD Phase 5 Tenant settings UI
|
||||||
|
- [ ] BUILD Phase 6–7
|
||||||
- [ ] REFLECT
|
- [ ] REFLECT
|
||||||
- [ ] ARCHIVE
|
- [ ] ARCHIVE
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Control plane</CardTitle>
|
<CardTitle>BIRD control plane</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
|
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
|
||||||
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
|
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
<Info class="text-info" />
|
<Info class="text-info" />
|
||||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
|
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса). Пиры и
|
||||||
адреса). Пиры и спикеры настраиваются на соседних вкладках.
|
спикеры настраиваются в разделе «Сеть».
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
|
|
||||||
<Button onclick={save} disabled={!canSave}>
|
<Button onclick={save} disabled={!canSave}>
|
||||||
<Save />
|
<Save />
|
||||||
{saving ? 'Сохранение…' : 'Применить параметры'}
|
{saving ? 'Сохранение…' : 'Применить параметры BIRD'}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</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>
|
||||||
@@ -5,7 +5,7 @@ import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-se
|
|||||||
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
|
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
|
||||||
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
|
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
|
||||||
|
|
||||||
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
|
/** @deprecated Используйте TenantBirdSettingsCard и TenantRevisionSettingsCard. */
|
||||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||||
|
|
||||||
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
|
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
|||||||
import Network from '@lucide/svelte/icons/network';
|
import Network from '@lucide/svelte/icons/network';
|
||||||
import Settings from '@lucide/svelte/icons/settings';
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
import Shield from '@lucide/svelte/icons/shield';
|
import Shield from '@lucide/svelte/icons/shield';
|
||||||
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -21,7 +22,8 @@ export const mainNav: NavItem[] = [
|
|||||||
{ href: '/network', label: 'Сеть', icon: Network },
|
{ href: '/network', label: 'Сеть', icon: Network },
|
||||||
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
{ 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[] = [
|
export const bottomNav: NavItem[] = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||||
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.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 NetworkOverviewTab from '$lib/components/network/NetworkOverviewTab.svelte';
|
||||||
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
|
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
|
||||||
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
|
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="control-plane" class="mt-4">
|
<TabsContent value="control-plane" class="mt-4">
|
||||||
<BirdSettingsForm />
|
<NetworkBirdSettingsSummaryCard />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
||||||
import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte';
|
import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte';
|
||||||
import OperationsJobsFilters from '$lib/components/operations/OperationsJobsFilters.svelte';
|
import OperationsJobsFilters from '$lib/components/operations/OperationsJobsFilters.svelte';
|
||||||
import OperationsSystemSettingsTab from '$lib/components/operations/OperationsSystemSettingsTab.svelte';
|
|
||||||
import type {
|
import type {
|
||||||
JobDetailedReport,
|
JobDetailedReport,
|
||||||
JobLogEntry,
|
JobLogEntry,
|
||||||
@@ -82,10 +81,10 @@
|
|||||||
import Info from '@lucide/svelte/icons/info';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
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 {
|
function parseOpsTab(value: string | null): OpsTab {
|
||||||
if (value === 'diff' || value === 'jobs' || value === 'system') return value;
|
if (value === 'diff' || value === 'jobs') return value;
|
||||||
return 'revisions';
|
return 'revisions';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +350,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
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;
|
lastLoadedTab = activeTab;
|
||||||
tabSyncReady = true;
|
tabSyncReady = true;
|
||||||
void refreshActiveTab(true);
|
void refreshActiveTab(true);
|
||||||
@@ -459,9 +463,6 @@
|
|||||||
break;
|
break;
|
||||||
case 'diff':
|
case 'diff':
|
||||||
break;
|
break;
|
||||||
case 'system':
|
|
||||||
await loadBirdStatus();
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
await loadRevisions();
|
await loadRevisions();
|
||||||
}
|
}
|
||||||
@@ -928,12 +929,12 @@
|
|||||||
|
|
||||||
<Alert class="border-info/30 bg-info/5">
|
<Alert class="border-info/30 bg-info/5">
|
||||||
<Info class="text-info" />
|
<Info class="text-info" />
|
||||||
<AlertTitle>Четыре раздела на одной странице</AlertTitle>
|
<AlertTitle>Три раздела на одной странице</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
||||||
префиксов;
|
префиксов; <strong>Задачи</strong> — ingest, apply, rollback. TTL ревизий и tenant KV — в
|
||||||
<strong>Задачи</strong> — ingest, apply, rollback; <strong>Система</strong> — TTL ревизий и
|
<Button variant="link" class="h-auto p-0" href={resolve('/tenant-settings')}>Параметры</Button
|
||||||
дополнительные KV. Apply и Reload требуют operator. Сводный мониторинг BGP — на
|
>. Apply и Reload требуют operator. Сводный мониторинг BGP — на
|
||||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -964,7 +965,6 @@
|
|||||||
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||||
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||||
<TabsTrigger value="system">Система</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1029,10 +1029,6 @@
|
|||||||
jobStatusVariant={jobStatusBadgeVariant}
|
jobStatusVariant={jobStatusBadgeVariant}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="system" class="mt-4">
|
|
||||||
<OperationsSystemSettingsTab />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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