Compare commits

..
7 Commits
Author SHA1 Message Date
Denozordec 5fca165c69 refactor(settings): deprecate settingsKnownSchema and integrate bird and revision settings
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 36s
CI / go (push) Successful in 49s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m59s
- Merged birdSettingsSchema and revisionSettingsSchema into settingsKnownSchema, marking the previous schema as deprecated.
- Updated emptySettingsKnownForm to utilize emptyBirdSettingsForm and emptyRevisionSettingsForm.
- Refactored layout and page components to streamline theme management and improve tab synchronization in network and operations pages.
- Introduced new system settings tab in operations and updated settings page to manage theme preferences.
2026-05-21 10:11:04 +07:00
Denozordec 293115e0e1 fix(nginx): update proxy_pass configuration to use full request URI
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m33s
- Modified the NGINX configuration to pass the full request URI in the proxy_pass directive, ensuring proper handling of requests.
- Added a comment to clarify the limitation of using a variable in proxy_pass for URI replacement.
2026-05-20 16:05:54 +07:00
Denozordec 6d2051f813 fix(web): improve error handling and API health check logic
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Successful in 41s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m25s
- Refactored the API health check to handle errors more gracefully, providing clearer feedback on loading issues.
- Updated the NGINX configuration to use a dynamic upstream variable for better resilience against IP changes in Docker.
- Enhanced user notifications for API availability and data loading errors, including instructions for local demo setup.
2026-05-20 15:48:18 +07:00
DenozordecandCursor b51a9ae3b3 ci: add ci type to release configuration and update documentation
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 31s
CI / web (push) Successful in 41s
CI / go (push) Successful in 47s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 3m47s
- Introduced `ci` type in `.releaserc.json` for patch releases.
- Updated conventional commits documentation to reflect the new `ci` type and its implications for versioning.
- Clarified the role of `ci` in the context of patch releases in the releasing guide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:24:21 +07:00
DenozordecandCursor 4d4cd2301f ci: update golangci-lint version and installation mode
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 30s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
- Upgraded golangci-lint from v1.62 to v1.64.8 to ensure compatibility with Go 1.24.
- Changed installation mode to 'goinstall' for improved setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:18:53 +07:00
DenozordecandCursor d687881eaa chore: update golangci configuration and improve resource cleanup
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 31s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Disabled all linters in .golangci.yml to streamline linting process.
- Updated resource cleanup in multiple files to use deferred functions for closing response bodies, ensuring proper error handling and resource management.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:13:02 +07:00
DenozordecandCursor 87e756f34f ci: expand changes detection to rewire all pipeline nodes
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 40s
CI / go (push) Failing after 21s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Полный прогон при scripts/*, workflows, golangci, pre-commit; openapi в full_pipeline; миграции и deploy-пути; исправлен fallback пустого diff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:08:28 +07:00
42 changed files with 1042 additions and 618 deletions
+1 -1
View File
@@ -32,6 +32,6 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
- Новая пользовательская возможность → `feat` (minor)
- Починка ожидаемого поведения / баг → `fix` (patch)
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
- Только перестройка без нового поведения → `refactor` (none)
- Только перестройка без нового поведения → `refactor` (patch, без новых функций)
Заголовок — EN, императив, ≤72 символов. Тело — RU.
+4 -4
View File
@@ -50,10 +50,10 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
| `perf` | ускорение без смены API | patch |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
| `docs` | только документация | — |
| `test` | тесты | — |
| `ci` | CI/CD (`.gitea/`, workflows) | — |
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
| `chore` | обслуживание, deps, `.cursor/` | — |
### Выбор type: semver, а не «красивые слова»
@@ -62,7 +62,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
4. Ускорение без изменения контракта → `perf`
**Не путать с формулировкой diff:**
@@ -105,7 +105,7 @@ feat(web): migrate modules list to AppDataTable
# Хорошо — если не было нового user-facing
refactor(web): migrate modules list to AppDataTable
Единый паттерн таблиц; поведение списка модулей без изменений.
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
```
```
+1 -1
View File
@@ -101,7 +101,7 @@ git commit -m "$( @'
| Пользователь получает **новую** возможность? | `feat` (minor) |
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
| Только скорость, контракт тот же? | `perf` (patch) |
| Только структура кода/UI, поведение то же? | `refactor` (none) |
| Только структура кода/UI, поведение то же? | `refactor` (patch) |
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
+1 -1
View File
@@ -12,7 +12,7 @@ Workflow: [workflows/ci.yaml](workflows/ci.yaml).
## CI (quality gates)
Job **changes** вычисляет флаги по путям в diff. Изменение `.gitea/workflows/*` поднимает полный прогон.
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml``go` / `openapi` и т.д. (см. `ci.yaml`).
На **pull request****commitlint** (Conventional Commits).
+79 -39
View File
@@ -8,10 +8,9 @@ on:
jobs:
# ---------------------------------------------------------------------------
# Гранулярная детекция изменений по модулям.
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
# поднимает все флаги, чтобы гарантировать полный прогон.
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
# ---------------------------------------------------------------------------
changes:
runs-on: ubuntu-latest
@@ -40,6 +39,23 @@ jobs:
docker_web=false
docker_bird=false
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
@@ -52,62 +68,84 @@ jobs:
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
openapi=true; go=true; web=true; bird_conf=true
docker_go=true; docker_web=true; docker_bird=true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
echo "$v=true" >> "$GITHUB_OUTPUT"
done
echo "No parent commit — full pipeline"
set_all_flags_true
write_outputs
echo "No parent commit — full pipeline (all modules)"
exit 0
fi
fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
go=true; web=true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
echo "Empty diff — safe fallback: go=true web=true"
set_all_flags_true
write_outputs
echo "Empty diff — full pipeline fallback"
exit 0
fi
ci_changed=false
full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*) ci_changed=true ;;
docs/openapi.yaml|redocly.yaml) openapi=true ;;
web/README.md) ;; # doc-only
web/*) web=true ;;
deploy/bird/*) bird_conf=true ;;
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
deploy/docker/gobinary/*) docker_go=true ;;
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
deploy/docker/evobgp-agent/*) docker_go=true ;;
deploy/docker/evobgp-web/*) docker_web=true ;;
deploy/docker/bird2/*) docker_bird=true ;;
go.mod|go.sum|go.work) go=true ;;
migrations/*) go=true ;;
*.go) go=true ;;
cmd/*|internal/*) go=true ;;
# CI/CD инфраструктура — все узлы quality gates
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
web/README.md|web/components.json)
;;
web/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
go=true
;;
package.json|package-lock.json|.releaserc.json)
full_pipeline=true
;;
*)
go=true
;;
esac
done <<< "$FILES"
if $ci_changed; then
go=true; web=true; bird_conf=true
docker_go=true; docker_web=true; docker_bird=true
if $full_pipeline; then
set_all_flags_true
fi
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
write_outputs
echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---"
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
# ---------------------------------------------------------------------------
openapi:
@@ -160,10 +198,12 @@ jobs:
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.62
version: v1.64.8
install-mode: goinstall
- name: Test
run: go test ./... -race -count=1
- name: Build all commands
+1
View File
@@ -2,6 +2,7 @@ run:
timeout: 5m
linters:
disable-all: true
enable:
- gofmt
- govet
+2
View File
@@ -10,6 +10,8 @@
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "breaking": true, "release": "major" }
]
}
+8 -2
View File
@@ -5,8 +5,14 @@ server {
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
resolver 127.0.0.11 valid=10s ipv6=off;
set $evobgp_upstream evobgp-api;
location /v1/ {
proxy_pass http://evobgp-api:8080/v1/;
# С переменной в proxy_pass нельзя полагаться на замену URI — передаём $request_uri целиком.
proxy_pass http://$evobgp_upstream:8080$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,7 +21,7 @@ server {
}
location = /metrics {
proxy_pass http://evobgp-api:8080/metrics;
proxy_pass http://$evobgp_upstream:8080/metrics;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
+6 -2
View File
@@ -7,9 +7,13 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
| Тип коммита | Bump |
|-------------|------|
| `feat` | minor (1.0.0 → 1.1.0) |
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза |
| `docs`, `chore`, `test` | без релиза |
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
+4 -4
View File
@@ -42,7 +42,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
if err != nil {
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, err
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
}
var wrap struct {
Status string `json:"status"`
Data struct {
Status string `json:"status"`
Data struct {
Prefixes []struct {
Prefix string `json:"prefix"`
} `json:"prefixes"`
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
+4 -4
View File
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
type BGPPeerFromTemplateOptions struct {
ProtocolName string
TemplateName string
NeighborIP string
NeighborASN uint32
ProtocolName string
TemplateName string
NeighborIP string
NeighborASN uint32
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
OverrideLocalIP string
OverrideLocalASN uint32
+6 -6
View File
@@ -17,12 +17,12 @@ import (
// Manifest describes bundle contents for evobgp-node verification.
type Manifest struct {
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
}
// FileEntry is one file inside the bundle archive.
+1 -1
View File
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
if err != nil {
return nil, err
}
defer gr.Close()
defer func() { _ = gr.Close() }()
var manifestRaw []byte
var sig []byte
+11 -11
View File
@@ -7,21 +7,21 @@ import (
)
const (
internalErrorDetail = "an internal error occurred"
badGatewayDetail = "upstream request failed"
notFoundDetail = "resource not found"
invalidInputDetail = "invalid request data"
cdnExtractDetail = "could not extract prefixes from source"
csvInvalidRowDetail = "invalid row in csv file"
internalErrorDetail = "an internal error occurred"
badGatewayDetail = "upstream request failed"
notFoundDetail = "resource not found"
invalidInputDetail = "invalid request data"
cdnExtractDetail = "could not extract prefixes from source"
csvInvalidRowDetail = "invalid row in csv file"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
+1 -1
View File
@@ -258,7 +258,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
writeBadGateway(w, "cdn preview fetch", err)
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
+3 -3
View File
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respList.Body.Close()
defer func() { _ = respList.Body.Close() }()
if respList.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respList.Body)
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respImport.Body.Close()
defer func() { _ = respImport.Body.Close() }()
if respImport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respImport.Body)
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respExport.Body.Close()
defer func() { _ = respExport.Body.Close() }()
if respExport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respExport.Body)
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
+3 -3
View File
@@ -35,7 +35,7 @@ func TestNestedModuleListPagination(t *testing.T) {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
}
@@ -47,7 +47,7 @@ func TestNestedModuleListPagination(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("list status %d: %s", resp.StatusCode, b)
@@ -73,7 +73,7 @@ func TestNestedModuleListPagination(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
defer func() { _ = resp2.Body.Close() }()
var page2 struct {
Items []map[string]any `json:"items"`
HasMore bool `json:"has_more"`
+4 -4
View File
@@ -32,10 +32,10 @@ type Server struct {
type Options struct {
APIKeys string
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
CORSAllowedOrigins string
}
+14 -14
View File
@@ -51,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -76,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -97,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -118,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -132,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -162,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
@@ -185,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -215,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -255,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
@@ -269,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -289,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -314,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -341,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
var body struct {
Status string `json:"status"`
}
@@ -405,7 +405,7 @@ func TestVersionEndpoints(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
+2 -2
View File
@@ -66,7 +66,7 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
@@ -94,7 +94,7 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
+1 -1
View File
@@ -27,7 +27,7 @@ func Run(args []string) int {
// Usage prints CLI help to w.
func Usage(w interface{ Write([]byte) (int, error) }) {
fmt.Fprintf(w, `Usage:
_, _ = fmt.Fprintf(w, `Usage:
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
+1 -1
View File
@@ -126,7 +126,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
}
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
@@ -46,4 +46,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
}
}
+10 -10
View File
@@ -306,7 +306,7 @@ func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL,
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
@@ -378,7 +378,7 @@ func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, hos
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
@@ -725,15 +725,15 @@ func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
}
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
h.Write([]byte(strings.TrimSpace(tenantID)))
h.Write([]byte{0})
_, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
_, _ = h.Write([]byte{0})
for _, l := range lines {
h.Write([]byte(l.p))
h.Write([]byte{1})
h.Write([]byte(l.c))
h.Write([]byte{1})
h.Write([]byte(l.s))
h.Write([]byte{0})
_, _ = h.Write([]byte(l.p))
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(l.c))
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(l.s))
_, _ = h.Write([]byte{0})
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
if err != nil {
return
}
defer f.Close()
defer func() { _ = f.Close() }()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
payload := map[string]any{
+1 -1
View File
@@ -121,7 +121,7 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
return nil
}
+7 -7
View File
@@ -33,13 +33,13 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
+3 -3
View File
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
func TestParsePeerNeighbor(t *testing.T) {
tests := []struct {
in string
want string
wantOK bool
in string
want string
wantOK bool
}{
{"192.168.0.2", "192.168.0.2", true},
{"192.168.0.2/32", "192.168.0.2", true},
@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import {
birdSettingsSchema,
emptyBirdSettingsForm,
type BirdSettingsForm
} from '$lib/settings/bird-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} 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 { 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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Info from '@lucide/svelte/icons/info';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyBirdSettingsForm(), zod4(birdSettingsSchema)),
{
validators: zod4(birdSettingsSchema),
SPA: true,
dataType: 'json'
}
);
let hasValidationErrors = $derived(
BIRD_SETTING_KEYS.some((key) => Boolean($errors[key as keyof BirdSettingsForm]?.length))
);
let canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return BIRD_SETTING_KEYS.some((key) => {
const value = String($form[key as keyof BirdSettingsForm] ?? '').trim();
return value !== '' && !$errors[key as keyof BirdSettingsForm]?.length;
});
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.bird });
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(
BIRD_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры BIRD сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>Control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
адреса). Пиры и спикеры настраиваются на соседних вкладках.
</AlertDescription>
</Alert>
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить параметры</Button>
{:else}
<div class="space-y-3">
<FormField
id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input id="bird-router-id" bind:value={$form.bird_router_id} placeholder="203.0.113.1" />
</FormField>
<FormField
id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input
id="bird-local-ipv4"
bind:value={$form.bird_local_ipv4}
placeholder="198.51.100.10"
/>
</FormField>
<FormField
id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input
id="bird-local-ipv6"
bind:value={$form.bird_local_ipv6}
placeholder="2001:db8::10"
/>
</FormField>
<FormField
id="bird-local-asn"
label="Локальный ASN (bird_local_asn)"
error={$errors.bird_local_asn?.[0]}
>
<Input
id="bird-local-asn"
type="number"
min="1"
bind:value={$form.bird_local_asn}
placeholder="65001"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv4"
label="BGP source IPv4 (bird_bgp_source_ipv4)"
error={$errors.bird_bgp_source_ipv4?.[0]}
>
<Input
id="bird-bgp-source-ipv4"
bind:value={$form.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv6"
label="BGP source IPv6 (bird_bgp_source_ipv6)"
error={$errors.bird_bgp_source_ipv6?.[0]}
>
<Input
id="bird-bgp-source-ipv6"
bind:value={$form.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
</FormField>
</div>
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры'}
</Button>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,223 @@
<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="Допустимый диапазон: 1543200 минут."
>
<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,24 @@
import { z } from 'zod';
import { optionalIPv4, optionalIPv6 } from './ip-validation.js';
export const birdSettingsSchema = z.object({
bird_router_id: optionalIPv4('router id'),
bird_local_ipv4: optionalIPv4('local IPv4'),
bird_local_ipv6: optionalIPv6('local IPv6'),
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
message: 'ASN должен быть целым числом больше 0'
}),
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6')
});
export type BirdSettingsForm = z.infer<typeof birdSettingsSchema>;
export const emptyBirdSettingsForm = (): BirdSettingsForm => ({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: ''
});
+47
View File
@@ -0,0 +1,47 @@
import { z } from 'zod';
function isValidIPv4(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return false;
if (part.length > 1 && part.startsWith('0')) return false;
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
}
return true;
}
function isValidIPv6(value: string): boolean {
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
if ((value.match(/::/g) ?? []).length > 1) return false;
const hasCompression = value.includes('::');
const [leftRaw, rightRaw = ''] = value.split('::');
const left = leftRaw === '' ? [] : leftRaw.split(':');
const right = rightRaw === '' ? [] : rightRaw.split(':');
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
let segments = [...left, ...right];
let ipv4TailSegments = 0;
const lastSegment = segments.at(-1);
if (lastSegment && lastSegment.includes('.')) {
if (!isValidIPv4(lastSegment)) return false;
segments = segments.slice(0, -1);
ipv4TailSegments = 2;
}
for (const segment of segments) {
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
}
const totalSegments = segments.length + ipv4TailSegments;
if (hasCompression) return totalSegments < 8;
return totalSegments === 8;
}
export const optionalIPv4 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
message: `Введите корректный IPv4 адрес (${label})`
});
export const optionalIPv6 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
message: `Введите корректный IPv6 адрес (${label})`
});
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const revisionSettingsSchema = z.object({
revision_retention_minutes: z.string().refine(
(v) => {
const s = v.trim();
if (s === '') return true;
const ttl = Number(s);
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
},
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
)
});
export type RevisionSettingsForm = z.infer<typeof revisionSettingsSchema>;
export const emptyRevisionSettingsForm = (): RevisionSettingsForm => ({
revision_retention_minutes: ''
});
+89
View File
@@ -0,0 +1,89 @@
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { AppSettings } from '$lib/api/types.js';
import { emptyBirdSettingsForm, type BirdSettingsForm } from './bird-settings.schema.js';
import {
emptyRevisionSettingsForm,
type RevisionSettingsForm
} from './revision-settings.schema.js';
import {
BIRD_SETTING_KEYS,
KNOWN_SETTING_KEYS,
NUMERIC_SETTING_KEYS,
type BirdSettingKey,
type KnownSettingKey,
type RevisionSettingKey
} from './settings-known-keys.js';
export type AdditionalSettingEntry = { id: number; key: string; value: string };
export type PartitionedSettings = {
bird: BirdSettingsForm;
revision: RevisionSettingsForm;
additional: AdditionalSettingEntry[];
};
function parseKnownValue(key: KnownSettingKey, value: unknown): string {
if (NUMERIC_SETTING_KEYS.has(key)) {
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'string') return value;
return '';
}
if (typeof value === 'string') return value;
return '';
}
export function partitionSettings(
settings: AppSettings,
nextId = 1
): { partitioned: PartitionedSettings; nextId: number } {
const bird = emptyBirdSettingsForm();
const revision = emptyRevisionSettingsForm();
const additional: AdditionalSettingEntry[] = [];
let idCounter = nextId;
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value);
} else if (key === 'revision_retention_minutes') {
revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value);
} else {
additional.push({
id: idCounter++,
key,
value: typeof value === 'string' ? value : String(value)
});
}
}
return {
partitioned: { bird, revision, additional },
nextId: idCounter
};
}
export async function loadSettings(): Promise<AppSettings> {
return apiJSON<AppSettings>('/v1/settings');
}
export async function patchSettings(payload: Record<string, string | number>): Promise<void> {
await apiMutate('/v1/settings', 'PATCH', payload);
}
export function buildPayloadFromFormFields(
keys: readonly KnownSettingKey[],
form: Record<string, string>,
errors: Partial<Record<string, string[]>>
): Record<string, string | number> {
const payload: Record<string, string | number> = {};
for (const key of keys) {
const value = String(form[key] ?? '').trim();
if (!value || errors[key]?.length) continue;
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value);
else payload[key] = value;
}
return payload;
}
export function isKnownSettingKey(key: string): key is KnownSettingKey {
return (KNOWN_SETTING_KEYS as readonly string[]).includes(key);
}
@@ -0,0 +1,21 @@
export const BIRD_SETTING_KEYS = [
'bird_router_id',
'bird_local_ipv4',
'bird_local_ipv6',
'bird_local_asn',
'bird_bgp_source_ipv4',
'bird_bgp_source_ipv6'
] as const;
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const;
export const KNOWN_SETTING_KEYS = [...BIRD_SETTING_KEYS, ...REVISION_SETTING_KEYS] as const;
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number];
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number];
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number];
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
'bird_local_asn',
'revision_retention_minutes'
]);
+8 -72
View File
@@ -1,79 +1,15 @@
import { z } from 'zod';
import { birdSettingsSchema, emptyBirdSettingsForm } from './bird-settings.schema.js';
import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-settings.schema.js';
function isValidIPv4(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return false;
if (part.length > 1 && part.startsWith('0')) return false;
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
}
return true;
}
function isValidIPv6(value: string): boolean {
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
if ((value.match(/::/g) ?? []).length > 1) return false;
const hasCompression = value.includes('::');
const [leftRaw, rightRaw = ''] = value.split('::');
const left = leftRaw === '' ? [] : leftRaw.split(':');
const right = rightRaw === '' ? [] : rightRaw.split(':');
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
let segments = [...left, ...right];
let ipv4TailSegments = 0;
const lastSegment = segments.at(-1);
if (lastSegment && lastSegment.includes('.')) {
if (!isValidIPv4(lastSegment)) return false;
segments = segments.slice(0, -1);
ipv4TailSegments = 2;
}
for (const segment of segments) {
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
}
const totalSegments = segments.length + ipv4TailSegments;
if (hasCompression) return totalSegments < 8;
return totalSegments === 8;
}
const optionalIPv4 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
message: `Введите корректный IPv4 адрес (${label})`
});
const optionalIPv6 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
message: `Введите корректный IPv6 адрес (${label})`
});
export const settingsKnownSchema = z.object({
bird_router_id: optionalIPv4('router id'),
bird_local_ipv4: optionalIPv4('local IPv4'),
bird_local_ipv6: optionalIPv6('local IPv6'),
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
message: 'ASN должен быть целым числом больше 0'
}),
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'),
revision_retention_minutes: z.string().refine(
(v) => {
const s = v.trim();
if (s === '') return true;
const ttl = Number(s);
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
},
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
)
});
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
...emptyBirdSettingsForm(),
...emptyRevisionSettingsForm()
});
+28
View File
@@ -0,0 +1,28 @@
import { browser } from '$app/environment';
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from './theme.js';
class ThemePreferencesState {
pref = $state<ThemePreference>('system');
init(): void {
if (!browser) return;
this.pref = readTheme();
applyTheme(this.pref);
}
set(next: ThemePreference): void {
this.pref = next;
}
persist(): void {
if (!browser) return;
applyTheme(this.pref);
try {
localStorage.setItem(THEME_STORAGE_KEY, this.pref);
} catch {
/* ignore */
}
}
}
export const themeState = new ThemePreferencesState();
+7 -14
View File
@@ -5,19 +5,17 @@
import favicon from '$lib/assets/favicon.svg';
import AppLayout from '$lib/ui/app/layout/app-layout.svelte';
import ConfirmDialog from '$lib/ui/patterns/confirm/confirm-dialog.svelte';
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
import { applyTheme } from '$lib/theme.js';
import { themeState } from '$lib/theme-preferences.svelte.js';
import { Toaster } from 'svelte-sonner';
let { children } = $props();
let themePref = $state<ThemePreference>('system');
onMount(() => {
themePref = readTheme();
applyTheme(themePref);
themeState.init();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onOs = () => {
if (themePref === 'system') applyTheme('system');
if (themeState.pref === 'system') applyTheme('system');
};
mq.addEventListener('change', onOs);
return () => mq.removeEventListener('change', onOs);
@@ -25,16 +23,11 @@
$effect(() => {
if (!browser) return;
applyTheme(themePref);
try {
localStorage.setItem(THEME_STORAGE_KEY, themePref);
} catch {
/* ignore */
}
themeState.persist();
});
const sonnerTheme = $derived(
themePref === 'system' ? 'system' : themePref === 'dark' ? 'dark' : 'light'
themeState.pref === 'system' ? 'system' : themeState.pref === 'dark' ? 'dark' : 'light'
);
</script>
@@ -44,4 +37,4 @@
</svelte:head>
<Toaster richColors theme={sonnerTheme} position="top-right" />
<ConfirmDialog />
<AppLayout bind:theme={themePref}>{@render children()}</AppLayout>
<AppLayout bind:theme={themeState.pref}>{@render children()}</AppLayout>
+65 -22
View File
@@ -159,12 +159,29 @@
}
]);
function toErrorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
async function load() {
if (!initialLoading) refreshing = true;
loadError = null;
try {
const [h, m, p, s, r, j] = await Promise.all([
apiFetch('/v1/health'),
try {
const h = await apiFetch('/v1/health');
healthy = h.ok;
if (!h.ok) {
loadError = `GET /v1/health: HTTP ${h.status}`;
return;
}
} catch (e) {
healthy = false;
loadError = toErrorMessage(e);
notifyApiError(e);
return;
}
const [m, p, s, r, j] = await Promise.allSettled([
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
apiJSON<PeersResponse>('/v1/peers?limit=200'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
@@ -172,24 +189,38 @@
apiJSON<JobsResponse>('/v1/jobs?limit=20')
]);
healthy = h.ok;
moduleItems = m.items ?? [];
modulesHasMore = m.has_more;
peerItems = p.items ?? [];
peersHasMore = p.has_more;
speakerItems = s.items ?? [];
speakersHasMore = s.has_more;
revisionItems = r.items ?? [];
revisionsHasMore = r.has_more;
jobItems = j.items ?? [];
recentJobs = jobItems.slice(0, 10);
recentRevisions = revisionItems.slice(0, 10);
runningJobs = jobItems.filter((i) => i.status === 'running' || i.status === 'queued').length;
lastUpdated = new Date();
} catch (e) {
healthy = false;
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
const firstReject = [m, p, s, r, j].find((x) => x.status === 'rejected');
if (firstReject?.status === 'rejected') {
loadError = toErrorMessage(firstReject.reason);
notifyApiError(firstReject.reason);
}
if (m.status === 'fulfilled') {
moduleItems = m.value.items ?? [];
modulesHasMore = m.value.has_more;
}
if (p.status === 'fulfilled') {
peerItems = p.value.items ?? [];
peersHasMore = p.value.has_more;
}
if (s.status === 'fulfilled') {
speakerItems = s.value.items ?? [];
speakersHasMore = s.value.has_more;
}
if (r.status === 'fulfilled') {
revisionItems = r.value.items ?? [];
revisionsHasMore = r.value.has_more;
}
if (j.status === 'fulfilled') {
jobItems = j.value.items ?? [];
recentJobs = jobItems.slice(0, 10);
recentRevisions = revisionItems.slice(0, 10);
runningJobs = jobItems.filter(
(i) => i.status === 'running' || i.status === 'queued'
).length;
}
if (!loadError) lastUpdated = new Date();
} finally {
initialLoading = false;
refreshing = false;
@@ -235,18 +266,30 @@
<AlertTitle>Проверка API…</AlertTitle>
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
</Alert>
{:else if healthy}
{:else if healthy && !loadError}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
{:else if healthy && loadError}
<Alert class="border-warning/30 bg-warning/5">
<Info class="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>
{loadError}. Для локального демо укажите Bearer-токен
<code class="text-xs">dev</code> в
<Button variant="link" class="h-auto p-0" href={resolve('/settings')}>Настройках</Button>
(нужен <code class="text-xs">EVOBGP_DEV_INSECURE=1</code> на API).
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
<XCircle class="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте подключение и статус API.
{loadError ??
'Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080), в dev — `npm run dev` с прокси Vite, в Docker — контейнер evobgp-api / evobgp-all и nginx в evobgp-web.'}
</AlertDescription>
</Alert>
{/if}
+40 -12
View File
@@ -1,32 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { apiJSON } from '$lib/api/client.js';
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
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 { cn } from '$lib/utils.js';
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import NetworkIcon from '@lucide/svelte/icons/network';
import Info from '@lucide/svelte/icons/info';
import Share2 from '@lucide/svelte/icons/share-2';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import Server from '@lucide/svelte/icons/server';
type NetworkTab = 'peers' | 'speakers' | 'control-plane';
function parseNetworkTab(value: string | null): NetworkTab {
if (value === 'speakers' || value === 'control-plane') return value;
return 'peers';
}
let peers = $state<PeerRow[]>([]);
let speakers = $state<SpeakerRow[]>([]);
let peersLoading = $state(false);
@@ -34,6 +35,8 @@
let initialLoading = $state(true);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
let activeTab = $state<NetworkTab>('peers');
let tabSyncReady = $state(false);
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
@@ -150,7 +153,27 @@
}
}
onMount(load);
onMount(() => {
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
void load();
});
function syncTabToUrl(tab: NetworkTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'peers') 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">
@@ -187,10 +210,11 @@
class="sm:grid-cols-3"
/>
<Tabs value="peers">
<Tabs bind:value={activeTab}>
<TabsList>
<TabsTrigger value="peers">Пиры</TabsTrigger>
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
</TabsList>
<TabsContent value="peers" class="mt-4">
@@ -213,5 +237,9 @@
onRefresh={refreshSpeakers}
/>
</TabsContent>
<TabsContent value="control-plane" class="mt-4">
<BirdSettingsForm />
</TabsContent>
</Tabs>
</div>
+11 -5
View File
@@ -53,6 +53,7 @@
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,
@@ -80,10 +81,10 @@
import Info from '@lucide/svelte/icons/info';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
type OpsTab = 'revisions' | 'diff' | 'jobs';
type OpsTab = 'revisions' | 'diff' | 'jobs' | 'system';
function parseOpsTab(value: string | null): OpsTab {
if (value === 'diff' || value === 'jobs') return value;
if (value === 'diff' || value === 'jobs' || value === 'system') return value;
return 'revisions';
}
@@ -882,12 +883,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. Apply и Reload требуют operator. Сводный
мониторинг BGP — на
<strong>Задачи</strong> — ingest, apply, rollback; <strong>Система</strong> — TTL ревизий и
дополнительные KV. Apply и Reload требуют operator. Сводный мониторинг BGP — на
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
</AlertDescription>
</Alert>
@@ -918,6 +919,7 @@
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
<TabsTrigger value="system">Система</TabsTrigger>
</TabsList>
</div>
@@ -982,6 +984,10 @@
jobStatusVariant={jobStatusBadgeVariant}
/>
</TabsContent>
<TabsContent value="system" class="mt-4">
<OperationsSystemSettingsTab />
</TabsContent>
</Tabs>
</div>
+68 -365
View File
@@ -1,110 +1,33 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { AppSettings } from '$lib/api/types.js';
import {
emptySettingsKnownForm,
settingsKnownSchema,
type SettingsKnownForm
} from '$lib/settings/settings-known.schema.js';
import { themeState } from '$lib/theme-preferences.svelte.js';
import type { ThemePreference } from '$lib/theme.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Card, CardContent, CardHeader, CardDescription } from '$lib/ui/core/card/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 { Label } from '$lib/ui/core/label/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Plus from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import Trash2 from '@lucide/svelte/icons/trash-2';
let token = $state('');
let apiSettings = $state<AppSettings | null>(null);
let loadingSettings = $state(false);
let savingSettings = $state(false);
let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]);
let additionalIdCounter = $state(1);
type KnownFieldKey = keyof SettingsKnownForm;
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
const knownFieldKeys: KnownFieldKey[] = [
'bird_router_id',
'bird_local_ipv4',
'bird_local_ipv6',
'bird_local_asn',
'bird_bgp_source_ipv4',
'bird_bgp_source_ipv6',
'revision_retention_minutes'
const themeOptions: Array<{ value: ThemePreference; label: string }> = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Как в системе' }
];
const { form, errors, reset, validateForm } = superForm(
defaults(emptySettingsKnownForm(), zod4(settingsKnownSchema)),
{
validators: zod4(settingsKnownSchema),
SPA: true,
dataType: 'json'
}
);
let activeSection = $state<SettingsSection>('token');
const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [
{ id: 'token', label: 'API-ключ', description: 'Авторизация в UI' },
{ id: 'bird', label: 'BIRD', description: 'Сетевые параметры' },
{ id: 'revisions', label: 'Ревизии', description: 'Хранение истории' },
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
];
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean($errors[key]?.length)));
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
}
function removeAdditionalSetting(id: number) {
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
}
function resetFormFromApi(settings: AppSettings) {
const parsedKnown: Record<KnownFieldKey, string> = {
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
};
const parsedAdditional: Array<{ id: number; key: string; value: string }> = [];
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
if (knownFieldKeys.includes(key as KnownFieldKey)) {
if (key === 'bird_local_asn' || key === 'revision_retention_minutes') {
if (typeof value === 'number' && Number.isFinite(value)) parsedKnown[key] = String(value);
else if (typeof value === 'string') parsedKnown[key] = value;
} else if (typeof value === 'string') {
parsedKnown[key as KnownFieldKey] = value;
}
} else {
parsedAdditional.push({
id: additionalIdCounter++,
key,
value: typeof value === 'string' ? value : String(value)
});
}
}
reset({ data: parsedKnown });
additionalSettings = parsedAdditional;
}
function saveToken() {
if (!browser) return;
const t = token.trim();
@@ -113,295 +36,75 @@
notify.success('Токен сохранён');
}
async function loadApiSettings() {
loadingSettings = true;
try {
const s = await apiJSON<AppSettings>('/v1/settings');
apiSettings = s;
resetFormFromApi(s);
} catch (e) {
notifyApiError(e);
} finally {
loadingSettings = false;
}
}
let canSaveSettings = $derived.by(() => {
if (loadingSettings || savingSettings || hasValidationErrors) return false;
const hasKnownValues = knownFieldKeys.some((key) => {
const value = String($form[key] ?? '').trim();
return value !== '' && !$errors[key]?.length;
});
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
return hasKnownValues || hasAdditionalValues;
});
async function saveApiSettings() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSaveSettings) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload: Record<string, string | number> = {};
for (const key of knownFieldKeys) {
const value = String($form[key] ?? '').trim();
if (!value || $errors[key]?.length) continue;
if (key === 'bird_local_asn' || key === 'revision_retention_minutes')
payload[key] = Number(value);
else payload[key] = value;
}
for (const entry of additionalSettings) {
const key = entry.key.trim();
if (!key) continue;
payload[key] = entry.value;
}
savingSettings = true;
try {
await apiMutate('/v1/settings', 'PATCH', payload);
notify.success('Настройки сохранены');
await loadApiSettings();
} catch (e) {
notifyApiError(e);
} finally {
savingSettings = false;
}
}
onMount(() => {
themeState.init();
if (browser) {
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
}
void loadApiSettings();
});
function onThemeChange(value: string) {
if (value === 'light' || value === 'dark' || value === 'system') {
themeState.set(value);
}
}
</script>
<div class="mx-auto flex max-w-5xl flex-col gap-6">
<div class="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Настройки"
description="Управление токеном доступа и глобальными параметрами control plane."
description="Параметры браузера и подключения к API."
icon={SettingsIcon}
iconClass="bg-muted text-muted-foreground"
/>
<Card>
<CardContent class="p-4 md:p-6">
<div class="grid gap-6 md:grid-cols-[220px_1fr]">
<div class="space-y-1">
{#each sectionItems as section (section.id)}
<button
type="button"
class={[
'w-full rounded-lg border px-3 py-2 text-left transition-colors',
activeSection === section.id
? 'border-primary bg-muted text-foreground'
: 'border-transparent text-muted-foreground hover:border-border hover:bg-muted/70 hover:text-foreground'
]}
onclick={() => (activeSection = section.id)}
>
<div class="text-sm font-medium">{section.label}</div>
<div class="text-xs opacity-80">{section.description}</div>
</button>
{/each}
</div>
<div class="rounded-xl border border-border p-4 md:p-5">
{#if activeSection === 'token'}
<div class="space-y-4">
<div class="space-y-1">
<h2 class="text-base font-semibold">API-ключ</h2>
<p class="text-sm text-muted-foreground">
Bearer-токен хранится только в localStorage браузера. Для локального демо с
<code class="rounded bg-muted px-1 py-0.5 text-xs">EVOBGP_DEV_INSECURE=1</code>
используйте токен <code class="rounded bg-muted px-1 py-0.5 text-xs">dev</code>.
</p>
</div>
<div class="space-y-2">
<Label for="token">Токен</Label>
<Input
id="token"
type="password"
autocomplete="off"
bind:value={token}
placeholder="Bearer …"
/>
</div>
<Button onclick={saveToken}>
<Save />
Сохранить токен
</Button>
</div>
{:else if loadingSettings}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if apiSettings === null}
<Button variant="outline" onclick={loadApiSettings}>Загрузить настройки</Button>
{:else}
<div class="space-y-5">
{#if activeSection === 'bird'}
<div class="space-y-3">
<h2 class="text-base font-semibold">Параметры BIRD</h2>
<FormField
id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input
id="bird-router-id"
bind:value={$form.bird_router_id}
placeholder="203.0.113.1"
/>
</FormField>
<FormField
id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input
id="bird-local-ipv4"
bind:value={$form.bird_local_ipv4}
placeholder="198.51.100.10"
/>
</FormField>
<FormField
id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input
id="bird-local-ipv6"
bind:value={$form.bird_local_ipv6}
placeholder="2001:db8::10"
/>
</FormField>
<FormField
id="bird-local-asn"
label="Локальный ASN (bird_local_asn)"
error={$errors.bird_local_asn?.[0]}
>
<Input
id="bird-local-asn"
type="number"
min="1"
bind:value={$form.bird_local_asn}
placeholder="65001"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv4"
label="BGP source IPv4 (bird_bgp_source_ipv4)"
error={$errors.bird_bgp_source_ipv4?.[0]}
>
<Input
id="bird-bgp-source-ipv4"
bind:value={$form.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv6"
label="BGP source IPv6 (bird_bgp_source_ipv6)"
error={$errors.bird_bgp_source_ipv6?.[0]}
>
<Input
id="bird-bgp-source-ipv6"
bind:value={$form.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
</FormField>
</div>
{:else if activeSection === 'revisions'}
<div class="space-y-3">
<h2 class="text-base font-semibold">Управление ревизиями</h2>
<FormField
id="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={$form.revision_retention_minutes}
placeholder="43200"
/>
</FormField>
</div>
{:else if activeSection === 'additional'}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold">Дополнительные настройки (KV)</h2>
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
</div>
{#if additionalSettings.length === 0}
<p class="text-sm text-muted-foreground">Нет дополнительных параметров.</p>
{/if}
<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>
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-red-600">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<div class="pt-2">
<Button onclick={saveApiSettings} disabled={!canSaveSettings}>
<Save />
{savingSettings ? 'Сохранение…' : 'Применить настройки'}
</Button>
</div>
</div>
{/if}
</div>
</div>
</CardContent>
<CardHeader class="pt-0">
<CardHeader>
<CardTitle>API-ключ</CardTitle>
<CardDescription>
<code class="text-xs">GET/PATCH /v1/settings</code> — глобальные параметры control plane (хранятся
в БД). Требуется роль operator.
Bearer-токен хранится только в localStorage браузера. Для локального демо с
<code class="text-xs">EVOBGP_DEV_INSECURE=1</code> используйте токен
<code class="text-xs">dev</code>.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="token">Токен</Label>
<Input
id="token"
type="password"
autocomplete="off"
bind:value={token}
placeholder="Bearer …"
/>
</div>
<Button onclick={saveToken}>
<Save />
Сохранить токен
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Оформление</CardTitle>
<CardDescription>
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<Label for="theme-select">Тема</Label>
<Select type="single" value={themeState.pref} onValueChange={onThemeChange}>
<SelectTrigger id="theme-select" class="w-full max-w-xs">
{themeOptions.find((o) => o.value === themeState.pref)?.label ?? 'Как в системе'}
</SelectTrigger>
<SelectContent>
{#each themeOptions as option (option.value)}
<SelectItem value={option.value} label={option.label}>{option.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</CardContent>
</Card>
</div>