Compare commits

...
7 Commits
Author SHA1 Message Date
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
DenozordecandCursor 27864fad58 fix(ci): make check-migrations-pair.sh POSIX sh compatible
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) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been cancelled
Убрана bash process substitution; скрипт работает под sh в Gitea runner (DEP-03).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:57:18 +07:00
DenozordecandCursor dc5e777d07 style(web): format settings-known schema for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / release (push) Has been cancelled
CI / web (push) Has been cancelled
Исправление падения CI job web: prettier --check на settings-known.schema.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:56:20 +07:00
29 changed files with 278 additions and 156 deletions

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — | | `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
| `docs` | только документация | — | | `docs` | только документация | — |
| `test` | тесты | — | | `test` | тесты | — |
| `ci` | CI/CD (`.gitea/`, workflows) | — | | `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
| `chore` | обслуживание, deps, `.cursor/` | — | | `chore` | обслуживание, deps, `.cursor/` | — |
### Выбор type: semver, а не «красивые слова» ### Выбор type: semver, а не «красивые слова»
+1 -1
View File
@@ -12,7 +12,7 @@ Workflow: [workflows/ci.yaml](workflows/ci.yaml).
## CI (quality gates) ## 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). На **pull request** — **commitlint** (Conventional Commits).
+79 -39
View File
@@ -8,10 +8,9 @@ on:
jobs: jobs:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Гранулярная детекция изменений по модулям. # Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются # Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*) # .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
# поднимает все флаги, чтобы гарантировать полный прогон.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
changes: changes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -40,6 +39,23 @@ jobs:
docker_web=false docker_web=false
docker_bird=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 if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}" base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.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 elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)" FILES="$(git diff --name-only HEAD~1 HEAD)"
else else
openapi=true; go=true; web=true; bird_conf=true set_all_flags_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 echo "No parent commit — full pipeline (all modules)"
echo "$v=true" >> "$GITHUB_OUTPUT"
done
echo "No parent commit — full pipeline"
exit 0 exit 0
fi fi
fi fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
go=true; web=true set_all_flags_true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do write_outputs
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT" echo "Empty diff — full pipeline fallback"
done
echo "Empty diff — safe fallback: go=true web=true"
exit 0 exit 0
fi fi
ci_changed=false full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue [ -z "${f:-}" ] && continue
case "$f" in case "$f" in
.gitea/workflows/*) ci_changed=true ;; # CI/CD инфраструктура — все узлы quality gates
docs/openapi.yaml|redocly.yaml) openapi=true ;; .gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
web/README.md) ;; # doc-only full_pipeline=true
web/*) web=true ;; ;;
deploy/bird/*) bird_conf=true ;; docs/openapi.yaml|redocly.yaml)
deploy/docker/bird/*) docker_bird=true; docker_go=true ;; openapi=true
deploy/docker/gobinary/*) docker_go=true ;; ;;
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;; docs/api.md|docs/access.md)
deploy/docker/evobgp-agent/*) docker_go=true ;; openapi=true
deploy/docker/evobgp-web/*) docker_web=true ;; go=true
deploy/docker/bird2/*) docker_bird=true ;; ;;
go.mod|go.sum|go.work) go=true ;; web/README.md|web/components.json)
migrations/*) go=true ;; ;;
*.go) go=true ;; web/*)
cmd/*|internal/*) go=true ;; 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 esac
done <<< "$FILES" done <<< "$FILES"
if $ci_changed; then if $full_pipeline; then
go=true; web=true; bird_conf=true set_all_flags_true
docker_go=true; docker_web=true; docker_bird=true
fi fi
for v in openapi go web bird_conf docker_go docker_web docker_bird; do write_outputs
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
echo "Changed files (first 30):" echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30 printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---" echo "--- flags ---"
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf" 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: openapi:
@@ -160,10 +198,12 @@ jobs:
run: sh scripts/lint-httpapi.sh run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03) - name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh run: sh scripts/check-migrations-pair.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
with: with:
version: v1.62 version: v1.64.8
install-mode: goinstall
- name: Test - name: Test
run: go test ./... -race -count=1 run: go test ./... -race -count=1
- name: Build all commands - name: Build all commands
+1
View File
@@ -2,6 +2,7 @@ run:
timeout: 5m timeout: 5m
linters: linters:
disable-all: true
enable: enable:
- gofmt - gofmt
- govet - govet
+1
View File
@@ -10,6 +10,7 @@
{ "type": "feat", "release": "minor" }, { "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" }, { "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" }, { "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "breaking": true, "release": "major" } { "breaking": true, "release": "major" }
] ]
} }
+7 -2
View File
@@ -5,8 +5,13 @@ server {
gzip on; gzip on;
gzip_types text/css application/javascript application/json image/svg+xml; 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/ { location /v1/ {
proxy_pass http://evobgp-api:8080/v1/; proxy_pass http://$evobgp_upstream:8080/v1/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -15,7 +20,7 @@ server {
} }
location = /metrics { location = /metrics {
proxy_pass http://evobgp-api:8080/metrics; proxy_pass http://$evobgp_upstream:8080/metrics;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
+4 -2
View File
@@ -7,9 +7,11 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
| Тип коммита | Bump | | Тип коммита | Bump |
|-------------|------| |-------------|------|
| `feat` | minor (1.0.0 → 1.1.0) | | `feat` | minor (1.0.0 → 1.1.0) |
| `fix`, `perf` | patch (1.0.0 → 1.0.1) | | `fix`, `perf`, `ci` | patch (1.5.1 → 1.5.2) |
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) | | `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза | | `docs`, `chore`, `test`, `refactor` | без релиза |
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты. Первый релиз при отсутствии 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 { if err != nil {
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err) 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)) body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
} }
var wrap struct { var wrap struct {
Status string `json:"status"` Status string `json:"status"`
Data struct { Data struct {
Prefixes []struct { Prefixes []struct {
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
} `json:"prefixes"` } `json:"prefixes"`
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
if err != nil { if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err) 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)) body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil { if err != nil {
return "", err return "", err
+4 -4
View File
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }. // BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
type BGPPeerFromTemplateOptions struct { type BGPPeerFromTemplateOptions struct {
ProtocolName string ProtocolName string
TemplateName string TemplateName string
NeighborIP string NeighborIP string
NeighborASN uint32 NeighborASN uint32
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer). // If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
OverrideLocalIP string OverrideLocalIP string
OverrideLocalASN uint32 OverrideLocalASN uint32
+6 -6
View File
@@ -17,12 +17,12 @@ import (
// Manifest describes bundle contents for evobgp-node verification. // Manifest describes bundle contents for evobgp-node verification.
type Manifest struct { type Manifest struct {
RevisionID string `json:"revision_id"` RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"` SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"` Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"` Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"` PublicKeyB64 string `json:"public_key_base64"`
} }
// FileEntry is one file inside the bundle archive. // 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 { if err != nil {
return nil, err return nil, err
} }
defer gr.Close() defer func() { _ = gr.Close() }()
var manifestRaw []byte var manifestRaw []byte
var sig []byte var sig []byte
+11 -11
View File
@@ -7,21 +7,21 @@ import (
) )
const ( const (
internalErrorDetail = "an internal error occurred" internalErrorDetail = "an internal error occurred"
badGatewayDetail = "upstream request failed" badGatewayDetail = "upstream request failed"
notFoundDetail = "resource not found" notFoundDetail = "resource not found"
invalidInputDetail = "invalid request data" invalidInputDetail = "invalid request data"
cdnExtractDetail = "could not extract prefixes from source" cdnExtractDetail = "could not extract prefixes from source"
csvInvalidRowDetail = "invalid row in csv file" csvInvalidRowDetail = "invalid row in csv file"
) )
// Problem is RFC 9457 application/problem+json. // Problem is RFC 9457 application/problem+json.
type Problem struct { type Problem struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Status int `json:"status"` Status int `json:"status"`
Detail string `json:"detail,omitempty"` Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"` Instance string `json:"instance,omitempty"`
} }
func writeProblem(w http.ResponseWriter, status int, title, detail string) { 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) writeBadGateway(w, "cdn preview fetch", err)
return return
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status)) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer respList.Body.Close() defer func() { _ = respList.Body.Close() }()
if respList.StatusCode != http.StatusOK { if respList.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respList.Body) b, _ := io.ReadAll(respList.Body)
t.Fatalf("communities status %d: %s", respList.StatusCode, b) t.Fatalf("communities status %d: %s", respList.StatusCode, b)
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer respImport.Body.Close() defer func() { _ = respImport.Body.Close() }()
if respImport.StatusCode != http.StatusOK { if respImport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respImport.Body) b, _ := io.ReadAll(respImport.Body)
t.Fatalf("import status %d: %s", respImport.StatusCode, b) t.Fatalf("import status %d: %s", respImport.StatusCode, b)
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer respExport.Body.Close() defer func() { _ = respExport.Body.Close() }()
if respExport.StatusCode != http.StatusOK { if respExport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respExport.Body) b, _ := io.ReadAll(respExport.Body)
t.Fatalf("export status %d: %s", respExport.StatusCode, b) 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) t.Fatal(err)
} }
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusCreated {
t.Fatalf("create entry %d: status %d", i, resp.StatusCode) t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
} }
@@ -47,7 +47,7 @@ func TestNestedModuleListPagination(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("list status %d: %s", resp.StatusCode, b) t.Fatalf("list status %d: %s", resp.StatusCode, b)
@@ -73,7 +73,7 @@ func TestNestedModuleListPagination(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp2.Body.Close() defer func() { _ = resp2.Body.Close() }()
var page2 struct { var page2 struct {
Items []map[string]any `json:"items"` Items []map[string]any `json:"items"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
+4 -4
View File
@@ -32,10 +32,10 @@ type Server struct {
type Options struct { type Options struct {
APIKeys string APIKeys string
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect). // DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
DatabaseURL string DatabaseURL string
InsecureDev bool InsecureDev bool
SeedDemo bool SeedDemo bool
BundleSeedHex string BundleSeedHex string
CORSAllowedOrigins string CORSAllowedOrigins string
} }
+14 -14
View File
@@ -51,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -76,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted { if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -97,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted { if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -118,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -132,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -162,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b) t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
} }
@@ -185,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -215,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -255,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden { if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d want 403: %s", resp.StatusCode, b) t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
@@ -269,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted { if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -289,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted { if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -314,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) 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) t.Fatal(err)
} }
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
var body struct { var body struct {
Status string `json:"status"` Status string `json:"status"`
} }
@@ -405,7 +405,7 @@ func TestVersionEndpoints(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b) 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 { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b))) 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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b))) 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. // Usage prints CLI help to w.
func Usage(w interface{ Write([]byte) (int, error) }) { 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 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 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) 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) 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 { if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body) _, _ = 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) 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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body))) 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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body))) 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) { func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
h.Write([]byte(strings.TrimSpace(tenantID))) _, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
h.Write([]byte{0}) _, _ = h.Write([]byte{0})
for _, l := range lines { for _, l := range lines {
h.Write([]byte(l.p)) _, _ = h.Write([]byte(l.p))
h.Write([]byte{1}) _, _ = h.Write([]byte{1})
h.Write([]byte(l.c)) _, _ = h.Write([]byte(l.c))
h.Write([]byte{1}) _, _ = h.Write([]byte{1})
h.Write([]byte(l.s)) _, _ = h.Write([]byte(l.s))
h.Write([]byte{0}) _, _ = h.Write([]byte{0})
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
if err != nil { if err != nil {
return return
} }
defer f.Close() defer func() { _ = f.Close() }()
var ms runtime.MemStats var ms runtime.MemStats
runtime.ReadMemStats(&ms) runtime.ReadMemStats(&ms)
payload := map[string]any{ 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 { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted { if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
return nil return nil
} }
+7 -7
View File
@@ -33,13 +33,13 @@ type Memory struct {
peers map[string]*BGPPeer peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile dohProfiles map[string]*DohProfile
communities map[string]*Community communities map[string]*Community
cdnSources map[string]*CDNSource cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry asnPrefixCache map[int64]*ASNPrefixCacheEntry
+3 -3
View File
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
func TestParsePeerNeighbor(t *testing.T) { func TestParsePeerNeighbor(t *testing.T) {
tests := []struct { tests := []struct {
in string in string
want string want string
wantOK bool wantOK bool
}{ }{
{"192.168.0.2", "192.168.0.2", true}, {"192.168.0.2", "192.168.0.2", true},
{"192.168.0.2/32", "192.168.0.2", true}, {"192.168.0.2/32", "192.168.0.2", true},
+39 -6
View File
@@ -11,21 +11,54 @@ list_nums() {
ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u
} }
migration_only_in() {
# Prints numbers present in $1 but not in $2 (space-separated lists).
haystack="$2 "
for n in $1; do
case "$haystack" in
*" $n "*) ;;
*) echo "$n" ;;
esac
done
}
pg_nums="$(list_nums "$PG")" pg_nums="$(list_nums "$PG")"
sql_nums="$(list_nums "$SQL")" sql_nums="$(list_nums "$SQL")"
if [ "$pg_nums" != "$sql_nums" ]; then if [ "$pg_nums" != "$sql_nums" ]; then
echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2 echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2
echo "postgres only:" >&2 only_pg="$(migration_only_in "$pg_nums" "$sql_nums")"
comm -23 <(printf '%s\n' "$pg_nums") <(printf '%s\n' "$sql_nums") >&2 || true only_sql="$(migration_only_in "$sql_nums" "$pg_nums")"
echo "sqlite only:" >&2 if [ -n "$only_pg" ]; then
comm -13 <(printf '%s\n' "$pg_nums") <(printf '%s\n' "$sql_nums") >&2 || true echo "postgres only:" >&2
for n in $only_pg; do
echo " $n" >&2
done
fi
if [ -n "$only_sql" ]; then
echo "sqlite only:" >&2
for n in $only_sql; do
echo " $n" >&2
done
fi
exit 1 exit 1
fi fi
for n in $pg_nums; do for n in $pg_nums; do
pg_up="$(ls "$PG"/${n}_*.up.sql 2>/dev/null | head -1)" pg_up=""
sql_up="$(ls "$SQL"/${n}_*.up.sql 2>/dev/null | head -1)" sql_up=""
for f in "$PG"/${n}_*.up.sql; do
if [ -f "$f" ]; then
pg_up="$f"
break
fi
done
for f in "$SQL"/${n}_*.up.sql; do
if [ -f "$f" ]; then
sql_up="$f"
break
fi
done
if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then
echo "check-migrations-pair: missing .up.sql for $n" >&2 echo "check-migrations-pair: missing .up.sql for $n" >&2
exit 1 exit 1
@@ -50,11 +50,9 @@ export const settingsKnownSchema = z.object({
bird_router_id: optionalIPv4('router id'), bird_router_id: optionalIPv4('router id'),
bird_local_ipv4: optionalIPv4('local IPv4'), bird_local_ipv4: optionalIPv4('local IPv4'),
bird_local_ipv6: optionalIPv6('local IPv6'), bird_local_ipv6: optionalIPv6('local IPv6'),
bird_local_asn: z bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
.string() message: 'ASN должен быть целым числом больше 0'
.refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), { }),
message: 'ASN должен быть целым числом больше 0'
}),
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'), bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'), bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'),
revision_retention_minutes: z.string().refine( revision_retention_minutes: z.string().refine(
+65 -22
View File
@@ -159,12 +159,29 @@
} }
]); ]);
function toErrorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
async function load() { async function load() {
if (!initialLoading) refreshing = true; if (!initialLoading) refreshing = true;
loadError = null; loadError = null;
try { try {
const [h, m, p, s, r, j] = await Promise.all([ try {
apiFetch('/v1/health'), 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<ModulesResponse>('/v1/modules?limit=200'),
apiJSON<PeersResponse>('/v1/peers?limit=200'), apiJSON<PeersResponse>('/v1/peers?limit=200'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'), apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
@@ -172,24 +189,38 @@
apiJSON<JobsResponse>('/v1/jobs?limit=20') apiJSON<JobsResponse>('/v1/jobs?limit=20')
]); ]);
healthy = h.ok; const firstReject = [m, p, s, r, j].find((x) => x.status === 'rejected');
moduleItems = m.items ?? []; if (firstReject?.status === 'rejected') {
modulesHasMore = m.has_more; loadError = toErrorMessage(firstReject.reason);
peerItems = p.items ?? []; notifyApiError(firstReject.reason);
peersHasMore = p.has_more; }
speakerItems = s.items ?? [];
speakersHasMore = s.has_more; if (m.status === 'fulfilled') {
revisionItems = r.items ?? []; moduleItems = m.value.items ?? [];
revisionsHasMore = r.has_more; modulesHasMore = m.value.has_more;
jobItems = j.items ?? []; }
recentJobs = jobItems.slice(0, 10); if (p.status === 'fulfilled') {
recentRevisions = revisionItems.slice(0, 10); peerItems = p.value.items ?? [];
runningJobs = jobItems.filter((i) => i.status === 'running' || i.status === 'queued').length; peersHasMore = p.value.has_more;
lastUpdated = new Date(); }
} catch (e) { if (s.status === 'fulfilled') {
healthy = false; speakerItems = s.value.items ?? [];
loadError = e instanceof Error ? e.message : String(e); speakersHasMore = s.value.has_more;
notifyApiError(e); }
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 { } finally {
initialLoading = false; initialLoading = false;
refreshing = false; refreshing = false;
@@ -235,18 +266,30 @@
<AlertTitle>Проверка API…</AlertTitle> <AlertTitle>Проверка API…</AlertTitle>
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription> <AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
</Alert> </Alert>
{:else if healthy} {:else if healthy && !loadError}
<Alert class="border-success/30 bg-success/5"> <Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" /> <CheckCircle class="text-success" />
<AlertTitle>API работает</AlertTitle> <AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription> <AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert> </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} {:else}
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5"> <Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
<XCircle class="text-destructive" /> <XCircle class="text-destructive" />
<AlertTitle>API недоступен</AlertTitle> <AlertTitle>API недоступен</AlertTitle>
<AlertDescription> <AlertDescription>
Не удалось получить ответ от сервера. Проверьте подключение и статус API. {loadError ??
'Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080), в dev — `npm run dev` с прокси Vite, в Docker — контейнер evobgp-api / evobgp-all и nginx в evobgp-web.'}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/if} {/if}