From 0c5502b5bb67aa92d40496cb0bfdefa35e6975e3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 12 Jun 2026 19:18:50 +0700 Subject: [PATCH] feat(runtime-logs): enhance runtime log management and configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены новые возможности для управления файловыми логами в Docker-сервисах: - Обновлены конфигурации для поддержки логов, включая переменные окружения и монтирование директорий. - Документация обновлена для описания новых эндпоинтов и параметров, связанных с логами. - Упрощен доступ к логам через API и интерфейс пользователя. Co-authored-by: Cursor --- .gitignore | 3 + deploy/compose/.env.production.example | 17 ++ .../compose/.env.stack.microvps-full.example | 5 + .../compose/docker-compose.microvps-full.yaml | 8 + .../docker-compose.production.example.yaml | 278 ++++++++++++++++++ deploy/compose/stack.microvps-full.yaml | 14 +- docs/manual.md | 1 + docs/quickstart.md | 8 + internal/httpapi/routes.go | 1 + internal/httpapi/routes_runtime_logs.go | 179 +++++++++++ internal/httpapi/routes_runtime_logs_test.go | 149 ++++++++++ internal/httpapi/server.go | 3 + memory-bank/activeContext.md | 53 ++-- memory-bank/progress.md | 6 +- memory-bank/tasks.md | 32 +- .../NetworkBirdSettingsSummaryCard.svelte | 80 +++++ .../OperationsSystemSettingsTab.svelte | 223 -------------- .../TenantAdditionalSettingsCard.svelte | 149 ++++++++++ .../TenantBirdSettingsCard.svelte} | 8 +- .../TenantRevisionSettingsCard.svelte | 138 +++++++++ .../tenant-settings/TenantSettingsPage.svelte | 84 ++++++ web/src/lib/settings/settings-known.schema.ts | 2 +- web/src/lib/ui/app/layout/nav.ts | 4 +- web/src/routes/network/+page.svelte | 4 +- web/src/routes/operations/+page.svelte | 28 +- web/src/routes/tenant-settings/+page.svelte | 5 + 26 files changed, 1188 insertions(+), 294 deletions(-) create mode 100644 deploy/compose/.env.production.example create mode 100644 deploy/compose/docker-compose.production.example.yaml create mode 100644 internal/httpapi/routes_runtime_logs.go create mode 100644 internal/httpapi/routes_runtime_logs_test.go create mode 100644 web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte delete mode 100644 web/src/lib/components/operations/OperationsSystemSettingsTab.svelte create mode 100644 web/src/lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte rename web/src/lib/components/{network/BirdSettingsForm.svelte => tenant-settings/TenantBirdSettingsCard.svelte} (95%) create mode 100644 web/src/lib/components/tenant-settings/TenantRevisionSettingsCard.svelte create mode 100644 web/src/lib/components/tenant-settings/TenantSettingsPage.svelte create mode 100644 web/src/routes/tenant-settings/+page.svelte diff --git a/.gitignore b/.gitignore index 788dc3e..c393a50 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ Thumbs.db .env.* !.env.example !.env.*.example + +# Compose runtime log sidecar output (deploy/compose/runtime-logs) +deploy/compose/runtime-logs/ diff --git a/deploy/compose/.env.production.example b/deploy/compose/.env.production.example new file mode 100644 index 0000000..814ddd2 --- /dev/null +++ b/deploy/compose/.env.production.example @@ -0,0 +1,17 @@ +# Production /opt/evobgp — скопируйте в .env на сервере +# cp .env.production.example .env + +EVOBGP_REGISTRY=git.shts.su/denozord +EVOBGP_IMAGE_TAG=latest + +# Имя compose-проекта (должно совпадать с name: в docker-compose.yaml) +COMPOSE_PROJECT_NAME=evobgp-microvps-full + +# Каталог *.log на хосте (sidecar + API evobgp-all) +EVOBGP_RUNTIME_LOGS_HOST_DIR=/opt/evobgp/runtime-logs + +# Traefik + Let's Encrypt (Cloudflare DNS challenge) +WEBUI_DOMAIN=bgp.example.com +WEBUI_IP_WHITELIST=203.0.113.10/32 +LETSENCRYPT_EMAIL=admin@example.com +CF_DNS_API_TOKEN= diff --git a/deploy/compose/.env.stack.microvps-full.example b/deploy/compose/.env.stack.microvps-full.example index 3388d4d..a00122a 100644 --- a/deploy/compose/.env.stack.microvps-full.example +++ b/deploy/compose/.env.stack.microvps-full.example @@ -13,6 +13,11 @@ WEBUI_IP_WHITELIST=109.174.26.78/32,87.103.241.8/32 LETSENCRYPT_EMAIL=admin@shz.su CF_DNS_API_TOKEN=cfut_3D1i0MNBVX6MNSsjcFytsfSlyB4B15t7H3DYXuzq22011588 +# Файловые runtime-логи (sidecar stack-runtime-logs + API /v1/runtime-logs/* на evobgp-all). +# Dev (рядом с compose): ./runtime-logs +# Prod на хосте: /opt/evobgp/runtime-logs +# EVOBGP_RUNTIME_LOGS_HOST_DIR=./runtime-logs + # Auto-updater (проверка registry образов и точечный restart контейнеров) # 0 = выключен, 1 = включен AUTO_UPDATE_ENABLED=0 diff --git a/deploy/compose/docker-compose.microvps-full.yaml b/deploy/compose/docker-compose.microvps-full.yaml index a6c32cc..e870ccd 100644 --- a/deploy/compose/docker-compose.microvps-full.yaml +++ b/deploy/compose/docker-compose.microvps-full.yaml @@ -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: diff --git a/deploy/compose/docker-compose.production.example.yaml b/deploy/compose/docker-compose.production.example.yaml new file mode 100644 index 0000000..5a68009 --- /dev/null +++ b/deploy/compose/docker-compose.production.example.yaml @@ -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 diff --git a/deploy/compose/stack.microvps-full.yaml b/deploy/compose/stack.microvps-full.yaml index ae902a7..0ace3cb 100644 --- a/deploy/compose/stack.microvps-full.yaml +++ b/deploy/compose/stack.microvps-full.yaml @@ -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: - | diff --git a/docs/manual.md b/docs/manual.md index f6f28c4..80bc18e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -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) diff --git a/docs/quickstart.md b/docs/quickstart.md index 16bf83c..3476347 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -156,6 +156,14 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u Health API: `http://: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. diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 5e2fb59..8e9da1e 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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) { diff --git a/internal/httpapi/routes_runtime_logs.go b/internal/httpapi/routes_runtime_logs.go new file mode 100644 index 0000000..fba444e --- /dev/null +++ b/internal/httpapi/routes_runtime_logs.go @@ -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 +} diff --git a/internal/httpapi/routes_runtime_logs_test.go b/internal/httpapi/routes_runtime_logs_test.go new file mode 100644 index 0000000..4b276e5 --- /dev/null +++ b/internal/httpapi/routes_runtime_logs_test.go @@ -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()) + } + }) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index ac03853..d0359ed 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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() diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 0628ff4..73c14bd 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 7ce933e..a6246f5 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 | ⏳ | diff --git a/memory-bank/tasks.md b/memory-bank/tasks.md index 44a7000..5d3f365 100644 --- a/memory-bank/tasks.md +++ b/memory-bank/tasks.md @@ -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 diff --git a/web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte b/web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte new file mode 100644 index 0000000..3c81b97 --- /dev/null +++ b/web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte @@ -0,0 +1,80 @@ + + + + + BIRD (кратко) + + Глобальные параметры BIRD из tenant settings. Полная форма — в разделе «Параметры». + + + + {#if loading} +

Загрузка…

+ {:else if Object.keys(values).length === 0} +

Параметры BIRD ещё не заданы.

+ {:else} +
+ {#each Object.entries(values) as [key, value] (key)} +
+
{labels[key] ?? key}
+
{value}
+
+ {/each} +
+ {/if} + + +
+
diff --git a/web/src/lib/components/operations/OperationsSystemSettingsTab.svelte b/web/src/lib/components/operations/OperationsSystemSettingsTab.svelte deleted file mode 100644 index dc75b97..0000000 --- a/web/src/lib/components/operations/OperationsSystemSettingsTab.svelte +++ /dev/null @@ -1,223 +0,0 @@ - - -
- - - Operator-only - - Изменение параметров через PATCH /v1/settings требует роли operator. - При отсутствии прав API вернёт 403. - - - - - - Хранение ревизий - - Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется. - - - - {#if loading && !loaded} -

Загрузка…

- {:else if !loaded} - - {:else} - - - - {/if} -
-
- - - -
-
- Дополнительные параметры - Произвольные KV-пары в global_settings. -
- {#if loaded} - - {/if} -
-
- - {#if !loaded} -

Загрузите настройки выше.

- {:else if additionalSettings.length === 0} - - {:else} -
- {#each additionalSettings as entry (entry.id)} -
- - - -
- {/each} -
- {/if} -
-
- - {#if loaded} - {#if hasValidationErrors} -

- Есть ошибки в полях. Исправьте их, чтобы сохранить изменения. -

- {/if} - - - {/if} -
diff --git a/web/src/lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte b/web/src/lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte new file mode 100644 index 0000000..c9487b7 --- /dev/null +++ b/web/src/lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte @@ -0,0 +1,149 @@ + + + + +
+
+ Дополнительные параметры + Произвольные KV-пары в global_settings (operator). +
+ {#if loaded} + + {/if} +
+
+ + {#if loading && !loaded} +

Загрузка…

+ {:else if !loaded} + + {:else if additionalSettings.length === 0} + + {:else} +
+ {#each additionalSettings as entry (entry.id)} +
+ + + +
+ {/each} +
+ + + {/if} +
+
diff --git a/web/src/lib/components/network/BirdSettingsForm.svelte b/web/src/lib/components/tenant-settings/TenantBirdSettingsCard.svelte similarity index 95% rename from web/src/lib/components/network/BirdSettingsForm.svelte rename to web/src/lib/components/tenant-settings/TenantBirdSettingsCard.svelte index a3e09c4..f622908 100644 --- a/web/src/lib/components/network/BirdSettingsForm.svelte +++ b/web/src/lib/components/tenant-settings/TenantBirdSettingsCard.svelte @@ -104,7 +104,7 @@ - Control plane + BIRD control plane Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через PATCH /v1/settings (роль operator). @@ -115,8 +115,8 @@ Подстановка в конфиг - Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS, - адреса). Пиры и спикеры настраиваются на соседних вкладках. + Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса). Пиры и + спикеры настраиваются в разделе «Сеть». @@ -205,7 +205,7 @@ {/if} diff --git a/web/src/lib/components/tenant-settings/TenantRevisionSettingsCard.svelte b/web/src/lib/components/tenant-settings/TenantRevisionSettingsCard.svelte new file mode 100644 index 0000000..43ec69a --- /dev/null +++ b/web/src/lib/components/tenant-settings/TenantRevisionSettingsCard.svelte @@ -0,0 +1,138 @@ + + + + + Хранение ревизий + + Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется. + + + + {#if loading && !loaded} +

Загрузка…

+ {:else if !loaded} + + {:else} + + + + + {#if hasValidationErrors} +

+ Есть ошибки в полях. Исправьте их, чтобы сохранить изменения. +

+ {/if} + + + {/if} +
+
diff --git a/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte b/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte new file mode 100644 index 0000000..2dda04f --- /dev/null +++ b/web/src/lib/components/tenant-settings/TenantSettingsPage.svelte @@ -0,0 +1,84 @@ + + +
+ + + + + Operator-only + + Изменение значений через PATCH /v1/settings требует роли operator. + При отсутствии прав API вернёт 403. + + + + +
+ + BIRD + Ревизии + Дополнительно + +
+ + + + + + + + + + + + +
+
diff --git a/web/src/lib/settings/settings-known.schema.ts b/web/src/lib/settings/settings-known.schema.ts index 1d6aba1..677eeac 100644 --- a/web/src/lib/settings/settings-known.schema.ts +++ b/web/src/lib/settings/settings-known.schema.ts @@ -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; /** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */ diff --git a/web/src/lib/ui/app/layout/nav.ts b/web/src/lib/ui/app/layout/nav.ts index d45a314..73d4ab0 100644 --- a/web/src/lib/ui/app/layout/nav.ts +++ b/web/src/lib/ui/app/layout/nav.ts @@ -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[] = [ diff --git a/web/src/routes/network/+page.svelte b/web/src/routes/network/+page.svelte index 2f103eb..6ac0a99 100644 --- a/web/src/routes/network/+page.svelte +++ b/web/src/routes/network/+page.svelte @@ -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 @@ - + diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 899bfca..c52f890 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -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 @@ - Четыре раздела на одной странице + Три раздела на одной странице Ревизии — история конфигов и откат; Сравнение — diff - префиксов; - Задачи — ingest, apply, rollback; Система — TTL ревизий и - дополнительные KV. Apply и Reload требуют operator. Сводный мониторинг BGP — на + префиксов; Задачи — ingest, apply, rollback. TTL ревизий и tenant KV — в + . Apply и Reload требуют operator. Сводный мониторинг BGP — на . @@ -964,7 +965,6 @@ Ревизии ({revisions.length}) Сравнение Задачи ({jobs.length}) - Система @@ -1029,10 +1029,6 @@ jobStatusVariant={jobStatusBadgeVariant} /> - - - - diff --git a/web/src/routes/tenant-settings/+page.svelte b/web/src/routes/tenant-settings/+page.svelte new file mode 100644 index 0000000..d8f099f --- /dev/null +++ b/web/src/routes/tenant-settings/+page.svelte @@ -0,0 +1,5 @@ + + +