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.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]
|
||||
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:
|
||||
- |
|
||||
|
||||
@@ -140,4 +140,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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
# Memory Bank: Active Context
|
||||
|
||||
## Текущий фокус
|
||||
|
||||
**Task:** `settings-ui-and-runtime-logs`
|
||||
**Phase:** **BUILD Phase 2 complete** → **Phase 3** (HTTP handlers)
|
||||
|
||||
## Phase 2 deliverables
|
||||
|
||||
- `internal/runtimelogs/` — Config, Service, safe path, List/Tail/Cleanup
|
||||
- `EVOBGP_RUNTIME_LOGS_DIR` в `internal/config/config.go`
|
||||
- `docs/access.md` — документация env
|
||||
|
||||
## Ключевые константы
|
||||
|
||||
- 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
|
||||
```
|
||||
|
||||
HTTP handlers + routes для `/v1/runtime-logs/*`.
|
||||
# Memory Bank: Active Context␍
|
||||
␍
|
||||
## Текущий фокус␍
|
||||
␍
|
||||
**Task:** `settings-ui-and-runtime-logs` ␍
|
||||
**Phase:** **BUILD Phase 5 complete** → **Phase 6** (Runtime logs Web UI)␍
|
||||
␍
|
||||
## Phase 5 deliverables␍
|
||||
␍
|
||||
- `web/src/routes/tenant-settings/+page.svelte`␍
|
||||
- `web/src/lib/components/tenant-settings/` — TenantSettingsPage, Bird/Revision/Additional cards␍
|
||||
- `web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte`␍
|
||||
- nav «Параметры» → `/tenant-settings`␍
|
||||
- Operations: убран tab `system`, редирект `?tab=system` → tenant-settings␍
|
||||
- Удалены `OperationsSystemSettingsTab`, `BirdSettingsForm`␍
|
||||
␍
|
||||
## Следующий шаг␍
|
||||
␍
|
||||
```␍
|
||||
/build Phase 6␍
|
||||
```␍
|
||||
␍
|
||||
Monitoring tab «Файловые логи» + API client runtime logs.␍
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
| VAN / PLAN / CREATIVE | ✅ |
|
||||
| BUILD P1 OpenAPI+store | ✅ |
|
||||
| BUILD P2 FS layer | ✅ 2026-06-12 |
|
||||
| BUILD P3 HTTP | ⏳ |
|
||||
| BUILD P4 Deploy | ⏳ |
|
||||
| BUILD P5 Tenant UI | ⏳ |
|
||||
| BUILD P3 HTTP | ✅ 2026-06-12 |
|
||||
| BUILD P4 Deploy | ✅ 2026-06-12 |
|
||||
| BUILD P5 Tenant UI | ✅ 2026-06-12 |
|
||||
| BUILD P6 Runtime logs UI | ⏳ |
|
||||
| BUILD P7 QA | ⏳ |
|
||||
|
||||
+20
-12
@@ -8,7 +8,7 @@
|
||||
|------|----------|
|
||||
| **Task ID** | `settings-ui-and-runtime-logs` |
|
||||
| **Complexity** | **Level 4** |
|
||||
| **Status** | **BUILD Phase 2 complete** → Phase 3 |
|
||||
| **Status** | **BUILD Phase 5 complete** → Phase 6 |
|
||||
| **Дата VAN** | 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+`.
|
||||
|
||||
**Checklist Phase 3:**
|
||||
- [ ] `go test ./internal/httpapi/... -race`
|
||||
- [ ] `scripts/lint-httpapi.sh`
|
||||
- [x] `go test ./internal/httpapi/... -run RuntimeLogs` (Windows: без `-race`, CGO disabled)
|
||||
- [x] `scripts/lint-go.ps1` exit 0
|
||||
- [x] lint-httpapi gates (ERR-01, ARCH-01) — проверено grep
|
||||
|
||||
---
|
||||
|
||||
@@ -220,8 +221,10 @@ environment:
|
||||
```
|
||||
|
||||
**Checklist Phase 4:**
|
||||
- [ ] dev: `./runtime-logs` рядом с compose
|
||||
- [ ] prod: `/opt/evobgp/runtime-logs` на хосте
|
||||
- [x] dev: `./runtime-logs` рядом с compose (`EVOBGP_RUNTIME_LOGS_HOST_DIR` default)
|
||||
- [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:**
|
||||
- [ ] `npm run check && npm run lint`
|
||||
- [ ] `/settings` без tenant-форм
|
||||
- [x] `npm run check && npm run lint`
|
||||
- [x] `/settings` без tenant-форм
|
||||
- [x] `/tenant-settings` с Tabs BIRD / Ревизии / Дополнительно
|
||||
- [x] nav «Параметры»; Operations без tab system; Network summary + link
|
||||
|
||||
---
|
||||
|
||||
@@ -326,12 +331,12 @@ graph TD
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `/settings` — только frontend (токен, тема)
|
||||
- [ ] `/tenant-settings` — все tenant KV (BIRD + revision + custom)
|
||||
- [ ] Operations без tab `system`; Network без полной BIRD-формы (summary + link)
|
||||
- [x] `/settings` — только frontend (токен, тема)
|
||||
- [x] `/tenant-settings` — все tenant KV (BIRD + revision + custom)
|
||||
- [x] Operations без tab `system`; Network без полной BIRD-формы (summary + link)
|
||||
- [ ] Runtime logs: list, tail, sync cleanup на evobgp-all
|
||||
- [ ] Audit cleanup в БД + просмотр в UI
|
||||
- [ ] `EVOBGP_RUNTIME_LOGS_DIR`, volume в compose
|
||||
- [x] `EVOBGP_RUNTIME_LOGS_DIR`, volume в compose
|
||||
- [ ] redocly lint, go test -race, web check+lint
|
||||
|
||||
---
|
||||
@@ -343,7 +348,10 @@ graph TD
|
||||
- [x] CREATIVE (4 docs)
|
||||
- [x] BUILD Phase 1 (OpenAPI + migration + store)
|
||||
- [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
|
||||
- [ ] 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>
|
||||
<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>
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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