Compare commits

..
14 Commits
Author SHA1 Message Date
DenozordecandCursor cbb4b467ad fix(agentserver): hide upstream errors from clients
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 58s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 3m23s
Generic 502 detail как в httpapi; добавлен docs/production-checklist.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:34:03 +07:00
DenozordecandCursor e65cf0d958 feat(jobs): persist job lifecycle to PostgreSQL job_audit
UpsertQueued/Running/MarkTerminal через SetPersistHooks; исправлен deadlock
fireEnqueued под Registry mutex.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:33:57 +07:00
DenozordecandCursor 4a57c91e29 perf(pipeline): parallel prefetch and lean revision GET
GetRevisionSummary без preview_fragments; boundedRepoCtx для GetRevision;
parallel CDN prefetch с ctx из ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:33:53 +07:00
DenozordecandCursor 2289107911 feat(httpclient): add circuit breaker for CDN and RIPEstat
Per-host circuit breaker с retry для CDN fetch и RIPEstat; порог 5 ошибок,
cooldown 30s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:15:32 +07:00
DenozordecandCursor 782097420d fix(httpclient): replace DefaultClient with timed clients and retry
Пакет httpclient: timeout 45s, idle pool, DoWithRetry. Scheduler и nodecli
используют retry; pipeline/asnresolve/jobs — httpclient.New вместо DefaultClient.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:14:53 +07:00
DenozordecandCursor 82382d90f2 fix(pipeline): validate CDN URLs against SSRF
HTTPS-only CDN URLs; блокировка private/loopback/metadata IP и DNS-resolve
на fetch; проверка в httpapi при create/preview/patch CDN sources.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:14:10 +07:00
DenozordecandCursor 5a16a45922 fix(httpapi): redact agent_secret from speaker list responses
Секрет agent больше не отдаётся viewer/editor в GET/list speakers;
остаётся только при создании спикера.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:13:28 +07:00
DenozordecandCursor 6a6f6cedbc feat(pipeline): use stale snapshot when upstream fetch fails
При ошибке CDN/ASN/DoH ingest использует последний снимок префиксов
(или просроченный ASN-кэш), если EVOBGP_STALE_ON_UPSTREAM_ERROR не равен 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:13:07 +07:00
Denozordec 9639a03bfe feat(store): add test for clearing dispatch error on successful merge
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 39s
CI / go (push) Successful in 58s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m18s
- Introduced a new test case in speaker_meta_test.go to verify that the LastDispatchError is cleared when a successful dispatch status is merged.
- Updated MergeSpeakerMetaJSON function in speaker_meta.go to clear LastDispatchError if LastDispatchStatus is "ok".
- Enhanced speakerDispatchError function in network-metrics.ts to handle stale dispatch errors after successful agent sync.
2026-05-21 18:10:51 +07:00
DenozordecandCursor 48c10b7436 refactor(web): trigger release for network dashboard layout fixes
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 40s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m37s
Follow-up для semantic-release: правки overview/sheet и dispatch-ошибок уже в fb108ec, заголовок с запятой в scope не парсился. Уточнена формулировка verify в releasing.md.

Semver: patch.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 17:56:03 +07:00
Denozordec 4db6438245 remove commitlint configuration and update release documentation to enforce single scope in commit messages
CI / openapi (push) Has been cancelled
CI / web (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / commitlint (push) Has been cancelled
CI / release (push) Has been cancelled
2026-05-21 17:54:41 +07:00
Denozordec fb108ec5ab refactor(NetworkOverviewTab, NetworkSpeakerDetailSheet, NetworkSpeakerStatusCard): improve layout and error handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 39s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 19s
- Refactored the layout of NetworkOverviewTab and NetworkSpeakerDetailSheet for better structure and readability.
- Enhanced error handling in NetworkSpeakerDetailSheet by introducing new error types for dispatch and agent errors.
- Updated NetworkSpeakerStatusCard to improve the display of speaker information and status.
- Adjusted styles in various components to ensure consistent spacing and alignment.
- Modified network-metrics.ts to include new functions for formatting speaker errors, improving user feedback on dispatch issues.
2026-05-21 17:50:27 +07:00
Denozordec a1ada06a76 feat(api): add live status tracking for speakers and BGP sessions
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 24s
CI / web (push) Successful in 29s
CI / go (push) Successful in 43s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m29s
- Introduced new schemas for `SpeakerLiveStatus`, `BgpSessionLive`, and `LiveSpeakerPoll` in OpenAPI documentation to support live status queries.
- Enhanced the `/v1/speakers` endpoint to include a `live` query parameter, allowing retrieval of real-time speaker and BGP status.
- Updated the HTTP API to collect and return live status data for speakers, improving monitoring capabilities.
- Modified frontend components to display live status information, enhancing user visibility into speaker health and BGP session states.
- Added a new endpoint `/v1/bird/status` for retrieving the local BIRD status, further enriching the network monitoring features.
2026-05-21 17:36:50 +07:00
Denozordec b5ed47902c feat(api): enhance peer session tracking and error handling
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 35s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m58s
- Added `PollError` field to `peerSessionOnSpeaker` and `liveSpeakerPoll` types to capture polling errors for speakers.
- Updated `matchPeerOnSpeakers` function to handle polling errors and adjust session state reporting.
- Modified frontend components to display polling error messages alongside session states, improving user visibility into peer connection statuses.
- Enhanced API response structure to include live speaker polling information, facilitating better monitoring of speaker health.
2026-05-21 15:40:44 +07:00
62 changed files with 2897 additions and 322 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["@commitlint/config-conventional"]
}
+2
View File
@@ -142,6 +142,8 @@ feat(web): add module create dialog on /modules
| `.cursor/` | `chore` |
| прочее в корне | `chore` |
**Запрещено:** несколько scope через запятую (`refactor(web, httpapi): …`) — semantic-release не распознает `type`, релиз не будет (см. [docs/releasing.md](../../docs/releasing.md)).
`type` определять по **содержимому diff**, не только по пути.
## Multi-change
+2
View File
@@ -303,6 +303,8 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Verify releasable commit messages
run: node scripts/commit/verify-release-commits.mjs
- name: Semantic release
run: npx semantic-release
env:
+22
View File
@@ -0,0 +1,22 @@
/** @type {import('@commitlint/types').UserConfig} */
module.exports = {
extends: ['@commitlint/config-conventional'],
plugins: [
{
rules: {
'scope-no-commas': ({ scope }) => {
if (scope && scope.includes(',')) {
return [
false,
'scope must not contain commas (semantic-release will not parse the commit type)'
];
}
return [true];
}
}
}
],
rules: {
'scope-no-commas': [2, 'always']
}
};
+117
View File
@@ -845,6 +845,89 @@ components:
description: >
Расширяемый объект. Ключи agent_domain, agent_secret (только при создании),
agent_port, node_ipv4, bird_bgp_source_ipv4, bird_bgp_source_ipv6.
live:
$ref: "#/components/schemas/SpeakerLiveStatus"
description: >
При GET /v1/speakers?live=1 — runtime-статус agent и BGP-опроса на ноде.
additionalProperties: true
SpeakerLiveStatus:
type: object
description: Live runtime snapshot for one speaker (GET /v1/speakers?live=1).
properties:
label:
type: string
description: Человекочитаемая метка ноды (agent domain или CP master).
agent_ok:
type: boolean
description: true если agent /v1/agent/health успешен (master — local birdc poll).
agent_error:
type: string
agent_last_sync_at:
type: string
format: date-time
agent_last_applied_revision_id:
type: string
bgp_poll_ok:
type: boolean
description: true если birdc (CP) или GET /v1/agent/bird/protocols (replica) успешен.
bgp_poll_error:
type: string
bgp_sessions_total:
type: integer
bgp_established:
type: integer
sessions:
type: array
items:
$ref: "#/components/schemas/BgpSessionLive"
additionalProperties: true
BgpSessionLive:
type: object
properties:
name:
type: string
neighbor:
type: string
state:
type: string
additionalProperties: true
LiveSpeakerPoll:
type: object
description: Метаданные опроса одной ноды в GET /v1/peers?live=1.
properties:
speaker_id:
type: string
label:
type: string
ok:
type: boolean
session_count:
type: integer
poll_error:
type: string
additionalProperties: true
BirdLocalStatus:
type: object
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
properties:
birdc_configured:
type: boolean
message:
type: string
error:
type: string
protocols_excerpt:
type: string
bgp_sessions_total:
type: integer
bgp_established:
type: integer
healthy:
type: ["boolean", "null"]
additionalProperties: true
BundleSigningPublicKey:
@@ -2216,6 +2299,12 @@ paths:
type: ["string", "null"]
has_more:
type: boolean
live_speaker_poll:
type: array
description: >
При live=1 — результат опроса каждой ноды (CP birdc + agent protocols).
items:
$ref: "#/components/schemas/LiveSpeakerPoll"
default:
$ref: "#/components/responses/DefaultProblem"
post:
@@ -2336,6 +2425,14 @@ paths:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
- name: live
in: query
schema:
type: string
enum: ["1"]
description: >
Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc.
Обогащает каждый item полем `live`.
responses:
"200":
description: Успешно.
@@ -2670,6 +2767,26 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/bird/status:
get:
tags: [Deploy]
summary: Статус локального BIRD на хосте API
description: >
Опрос birdc через EVOBGP_BIRDC_SOCKET на процессе API (обычно CP master).
На репликах без birdc на CP — birdc_configured=false.
operationId: getBirdStatus
parameters:
- $ref: "#/components/parameters/TenantId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/BirdLocalStatus"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/bird/reload:
post:
tags: [Deploy]
+26
View File
@@ -0,0 +1,26 @@
# Production checklist
Краткий чеклист перед выводом EvoBGP в production (10+ клиентов, нестабильная сеть).
## Обязательно
- `EVOBGP_SEED_DEMO=0` — отключить demo-tenant и токен `Bearer dev`.
- `EVOBGP_DEV_INSECURE` не задавать или `0` — не использовать lab-флаги в prod.
- `EVOBGP_BUNDLE_SEED_HEX` — задать стабильный hex-ключ подписи бандлов; сохранить pubkey для нод.
- PostgreSQL с TLS (`sslmode` не `disable`) при доступе вне private network.
- `EVOBGP_CORS_ORIGINS` — явный whitelist origin веб-панели.
- `EVOBGP_STALE_ON_UPSTREAM_ERROR=1` (по умолчанию) — stale snapshot при сбоях CDN/ASN/DoH.
## Рекомендуется
- `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants.
- `EVOBGP_NODE_DISPATCH_INSECURE_TLS=0` — только валидный TLS к agent.
- Ограничить `/metrics` сетевой политикой или reverse proxy.
- Профиль `evobgp-all` или HA API + персистентная `job_audit` (PostgreSQL).
- Мониторинг drift: `evobgp-deploy`, `last_applied_revision_id` vs published.
## Не использовать в prod
- `EVOBGP_CDN_ALLOW_PRIVATE=1` — только тесты/lab.
- Plaintext `EVOBGP_API_KEYS` без ротации (break-glass — временно).
- Ручное редактирование `evobgp_*.conf` на нодах без ревизии.
+17
View File
@@ -11,6 +11,8 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `test` | без релиза |
**Scope:** один идентификатор **без запятых** (`web`, `httpapi`, `api`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит). Подробнее — раздел «Scope и semantic-release» ниже.
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
@@ -62,6 +64,21 @@ API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
## Scope и semantic-release
Парсер [conventional-commits-parser](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser) (его использует semantic-release) **не понимает запятые в scope**:
| Заголовок | Парсится | Релиз |
|-----------|----------|-------|
| `refactor(web): fix layout` | да, `refactor` | patch |
| `refactor(NetworkOverviewTab, NetworkSpeakersCard): fix layout` | **нет**, `type: null` | **нет** |
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
## CHANGELOG
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
+4 -2
View File
@@ -14,6 +14,8 @@ import (
"evobgp/internal/nodecli"
)
const upstreamErrorDetail = "upstream request failed"
// Config holds evobgp-agent serve settings.
type Config struct {
Listen string
@@ -85,7 +87,7 @@ func (s *Server) handleBirdProtocols(w http.ResponseWriter, r *http.Request) {
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(s.cfg.BirdcBin))
if err != nil {
log.Printf("agentserver: bird protocols: %v", err)
writeProblem(w, http.StatusBadGateway, err.Error())
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
return
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -126,7 +128,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
log.Printf("agentserver: sync: %v", err)
writeProblem(w, http.StatusBadGateway, err.Error())
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
return
}
if s.cfg.OnSyncSuccess != nil {
+6 -4
View File
@@ -13,6 +13,8 @@ import (
"strconv"
"strings"
"time"
"evobgp/internal/httpclient"
)
// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key).
@@ -24,7 +26,7 @@ const DefaultASOverviewURL = "https://stat.ripe.net/data/as-overview/data.json"
// AnnouncedPrefixes returns currently announced IPv4/IPv6 prefixes for the ASN (best-effort via RIPEstat).
func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) {
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL"))
if base == "" {
@@ -38,7 +40,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
resp, err := hc.Do(req)
resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3)
if err != nil {
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
}
@@ -86,7 +88,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
// ASHolderName returns the holder / organization label for the ASN from RIPEstat as-overview (best-effort).
func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, error) {
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL"))
if base == "" {
@@ -100,7 +102,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
resp, err := hc.Do(req)
resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3)
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
}
+17 -11
View File
@@ -19,29 +19,35 @@ func ParseBGPSessions(output string) []BGPSession {
line := strings.TrimRight(raw, "\r")
trim := strings.TrimSpace(line)
if trim == "" {
cur = nil
continue
}
low := strings.ToLower(trim)
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
continue
}
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && isBGPProtocolSummaryRow(trim) {
fields := strings.Fields(trim)
state := extractBGPSessionStateLine(trim)
if state == "" && len(fields) >= 4 {
state = fields[3]
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
if isBGPProtocolSummaryRow(trim) {
fields := strings.Fields(trim)
state := extractBGPSessionStateLine(trim)
if state == "" && len(fields) >= 4 {
state = fields[3]
}
out = append(out, BGPSession{Name: fields[0], State: state})
cur = &out[len(out)-1]
} else {
cur = nil
}
cur = &BGPSession{Name: fields[0], State: state}
out = append(out, *cur)
cur = &out[len(out)-1]
continue
}
if cur == nil {
continue
}
const neighborPrefix = "Neighbor address:"
if idx := strings.Index(trim, neighborPrefix); idx >= 0 {
cur.Neighbor = strings.TrimSpace(trim[idx+len(neighborPrefix):])
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
if idx := strings.Index(trim, prefix); idx >= 0 {
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
break
}
}
}
return out
+33 -10
View File
@@ -7,6 +7,7 @@ import (
"time"
"evobgp/internal/db"
"evobgp/internal/httpclient"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/repository"
@@ -17,7 +18,7 @@ import (
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
func NewCDNHTTPClient() *http.Client {
return &http.Client{Timeout: 45 * time.Second}
return httpclient.New(httpclient.DefaultTimeout)
}
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
@@ -55,18 +56,40 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
wk.Registry = reg
if pool != nil {
audit := repository.NewJobAuditWriter(pool)
reg.SetTerminalHook(func(j *jobs.Job) {
jobMeta := func(j *jobs.Job) map[string]any {
if j == nil {
return
return nil
}
st := j.Snapshot()
status, _ := st["status"].(string)
var errMsg *string
if e, ok := st["error"].(string); ok && e != "" {
errMsg = &e
}
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
})
meta, _ := st["meta"].(map[string]any)
return meta
}
reg.SetPersistHooks(
func(j *jobs.Job) {
if j == nil {
return
}
audit.UpsertQueued(context.Background(), j.TenantID, j.ID, j.Kind, j.IdempotencyKey, j.ModuleID, jobMeta(j))
},
func(j *jobs.Job) {
if j == nil {
return
}
audit.UpsertRunning(context.Background(), j.TenantID, j.ID, j.Kind, j.IdempotencyKey, jobMeta(j))
},
func(j *jobs.Job) {
if j == nil {
return
}
st := j.Snapshot()
status, _ := st["status"].(string)
var errMsg *string
if e, ok := st["error"].(string); ok && e != "" {
errMsg = &e
}
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
},
)
}
observability.RegisterStoreBackend(backend)
return backend, reg, pool, nil
+71 -39
View File
@@ -33,6 +33,15 @@ type peerSessionOnSpeaker struct {
SpeakerID string `json:"speaker_id"`
Label string `json:"label"`
State string `json:"state"`
PollError string `json:"poll_error,omitempty"`
}
type liveSpeakerPoll struct {
SpeakerID string `json:"speaker_id"`
Label string `json:"label"`
OK bool `json:"ok"`
SessionCount int `json:"session_count"`
PollError string `json:"poll_error,omitempty"`
}
func speakerDisplayLabel(sp *store.Speaker) string {
@@ -136,6 +145,29 @@ func (s *Server) collectSpeakerBGPLive(ctx context.Context, tenantID string, fre
return views
}
func liveSpeakerPollJSON(views []speakerBGPLive) []liveSpeakerPoll {
out := make([]liveSpeakerPoll, 0, len(views))
for _, v := range views {
out = append(out, liveSpeakerPoll{
SpeakerID: v.SpeakerID,
Label: v.Label,
OK: v.Error == "",
SessionCount: len(v.Sessions),
PollError: v.Error,
})
}
return out
}
func findPeerSession(sessions []birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) *birdfmt.BGPSession {
for i := range sessions {
if peerSessionMatches(sessions[i], protoName, neighbor, hasNeighbor) {
return &sessions[i]
}
}
return nil
}
func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) (
bestState string,
connectedID string,
@@ -152,24 +184,33 @@ func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) (
for _, v := range views {
if v.Error != "" && len(v.Sessions) == 0 {
continue
}
for _, sess := range v.Sessions {
if !peerSessionMatches(sess, protoName, neighbor, hasNeighbor) {
continue
}
hit := peerSessionOnSpeaker{
on = append(on, peerSessionOnSpeaker{
SpeakerID: v.SpeakerID,
Label: v.Label,
State: sess.State,
}
on = append(on, hit)
if strings.EqualFold(strings.TrimSpace(sess.State), "Established") {
establishedOn = append(establishedOn, hit)
}
if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) {
bestState = sess.State
}
PollError: v.Error,
})
continue
}
sess := findPeerSession(v.Sessions, protoName, neighbor, hasNeighbor)
if sess == nil {
on = append(on, peerSessionOnSpeaker{
SpeakerID: v.SpeakerID,
Label: v.Label,
State: "absent",
})
continue
}
hit := peerSessionOnSpeaker{
SpeakerID: v.SpeakerID,
Label: v.Label,
State: sess.State,
}
on = append(on, hit)
if strings.EqualFold(strings.TrimSpace(sess.State), "Established") {
establishedOn = append(establishedOn, hit)
}
if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) {
bestState = sess.State
}
}
@@ -183,7 +224,7 @@ func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) (
if len(establishedOn) == 1 {
connectedID = establishedOn[0].SpeakerID
}
} else if len(on) == 1 {
} else if len(on) == 1 && on[0].PollError == "" && on[0].State != "" {
connectedID = on[0].SpeakerID
connectedLabel = on[0].Label
}
@@ -209,11 +250,11 @@ func peerSessionMatches(sess birdfmt.BGPSession, protoName string, neighbor neti
if !hasNeighbor || strings.TrimSpace(sess.Neighbor) == "" {
return false
}
addr, err := netip.ParseAddr(strings.TrimSpace(sess.Neighbor))
if err != nil {
peerAddr, ok := store.ParsePeerNeighbor(sess.Neighbor)
if !ok {
return false
}
return addr == neighbor
return peerAddr == neighbor
}
func sessionStateRank(state string) int {
@@ -233,26 +274,17 @@ func sessionStateRank(state string) int {
func applyPeerLiveFields(row map[string]any, peer *store.BGPPeer, views []speakerBGPLive) {
state, connID, connLabel, establishedOn, on, mismatch := matchPeerOnSpeakers(peer, views)
if len(on) > 0 {
if state != "" {
row["session_state"] = state
}
row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID)
row["connected_speaker_label"] = connLabel
row["session_on_speakers"] = on
if len(establishedOn) > 0 {
row["established_on_speakers"] = establishedOn
} else {
row["established_on_speakers"] = []peerSessionOnSpeaker{}
}
row["session_conflict"] = false
row["session_mismatch"] = mismatch
return
}
row["session_on_speakers"] = []peerSessionOnSpeaker{}
row["established_on_speakers"] = []peerSessionOnSpeaker{}
row["session_on_speakers"] = on
row["established_on_speakers"] = establishedOn
row["session_conflict"] = false
row["session_mismatch"] = false
row["session_mismatch"] = mismatch
if state != "" {
row["session_state"] = state
}
if connLabel != "" {
row["connected_speaker_label"] = connLabel
}
row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID)
}
func peerLiveSpeakerIDOrNull(id string) any {
+19 -7
View File
@@ -17,7 +17,7 @@ func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) {
SpeakerID: "master-id",
Label: "CP · bgp.shz.su",
Sessions: []birdfmt.BGPSession{
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Idle"},
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"},
},
},
{
@@ -29,23 +29,23 @@ func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) {
},
}
state, connID, connLabel, established, on, mismatch := matchPeerOnSpeakers(peer, views)
if state != "Established" || connID != "replica-id" || connLabel != "bgp2.shz.su" {
if state != "Established" || connID != "" || connLabel != "CP · bgp.shz.su, bgp2.shz.su" {
t.Fatalf("got state=%q conn=%q label=%q", state, connID, connLabel)
}
if mismatch || len(on) != 2 || len(established) != 1 {
if mismatch || len(on) != 2 || len(established) != 2 {
t.Fatalf("on=%+v established=%+v mismatch=%v", on, established, mismatch)
}
}
func TestMatchPeerOnSpeakers_multipleEstablished(t *testing.T) {
peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"}
peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2/32"}
views := []speakerBGPLive{
{SpeakerID: "a", Label: "n1", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
{SpeakerID: "b", Label: "n2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
}
_, connID, label, established, _, mismatch := matchPeerOnSpeakers(peer, views)
if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 {
t.Fatalf("connID=%q label=%q established=%+v mismatch=%v", connID, label, established, mismatch)
_, connID, label, established, on, mismatch := matchPeerOnSpeakers(peer, views)
if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 || len(on) != 2 {
t.Fatalf("connID=%q label=%q established=%+v on=%+v mismatch=%v", connID, label, established, on, mismatch)
}
}
@@ -65,3 +65,15 @@ func TestMatchPeerOnSpeakers_mismatchConfiguredSpeaker(t *testing.T) {
t.Fatal("expected mismatch when configured replica has no Established")
}
}
func TestMatchPeerOnSpeakers_pollError(t *testing.T) {
peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"}
views := []speakerBGPLive{
{Label: "CP (local BIRD)", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
{SpeakerID: "replica-id", Label: "bgp2.shz.su", Error: "HTTP 404: Not Found"},
}
_, _, _, established, on, _ := matchPeerOnSpeakers(peer, views)
if len(established) != 1 || len(on) != 2 || on[1].PollError == "" {
t.Fatalf("on=%+v established=%+v", on, established)
}
}
+19 -3
View File
@@ -294,7 +294,10 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
items = append(items, row)
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
"items": items,
"next_cursor": strPtrOrNull(next),
"has_more": more,
"live_speaker_poll": liveSpeakerPollJSON(liveViews),
})
}
@@ -382,9 +385,22 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
var liveByID map[string]map[string]any
if fresh {
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
liveByID = s.collectSpeakerLiveStatus(ctx, a.TenantID, true, speakers)
}
items := make([]map[string]any, 0, len(speakers))
for _, sp := range speakers {
items = append(items, speakerJSONFromStore(s.store, sp))
row := speakerJSONFromStore(s.store, sp)
if liveByID != nil {
if live, ok := liveByID[sp.ID]; ok {
row["live"] = live
}
}
items = append(items, row)
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
@@ -563,7 +579,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
+28
View File
@@ -242,6 +242,14 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
return
}
if _, err := pipeline.ValidateCDNURL(u); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
if err := pipeline.ResolveCDNURLHost(r.Context(), u); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
@@ -304,6 +312,16 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
if body.URL != "" {
if _, err := pipeline.ValidateCDNURL(body.URL); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
if err := pipeline.ResolveCDNURLHost(r.Context(), body.URL); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
}
mid := r.PathValue("module_id")
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
if err != nil {
@@ -324,6 +342,16 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
if body.URL != nil && strings.TrimSpace(*body.URL) != "" {
if _, err := pipeline.ValidateCDNURL(*body.URL); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
if err := pipeline.ResolveCDNURLHost(r.Context(), *body.URL); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
}
mid := r.PathValue("module_id")
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
if err != nil {
+4 -1
View File
@@ -39,7 +39,10 @@ func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
if strings.TrimSpace(sp.MetaJSON) != "" && sp.MetaJSON != "{}" {
var raw map[string]any
if json.Unmarshal([]byte(sp.MetaJSON), &raw) == nil {
m["meta_json"] = raw
delete(raw, "agent_secret")
if len(raw) > 0 {
m["meta_json"] = raw
}
}
}
if meta.AgentDomain != "" {
+129
View File
@@ -0,0 +1,129 @@
package httpapi
import (
"context"
"strings"
"sync"
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/nodedispatch"
"evobgp/internal/store"
)
func countBGPSessions(sessions []birdfmt.BGPSession) (total, established int) {
total = len(sessions)
for _, s := range sessions {
if strings.EqualFold(strings.TrimSpace(s.State), "Established") {
established++
}
}
return total, established
}
func speakerLiveStatusJSON(sp *store.Speaker, view speakerBGPLive, health *nodedispatch.AgentHealthResult) map[string]any {
total, established := countBGPSessions(view.Sessions)
m := map[string]any{
"label": view.Label,
"bgp_poll_ok": view.Error == "",
"bgp_sessions_total": total,
"bgp_established": established,
}
if view.Error != "" {
m["bgp_poll_error"] = view.Error
}
if health != nil {
m["agent_ok"] = health.OK
if health.Error != "" {
m["agent_error"] = health.Error
}
if health.LastSyncAt != "" {
m["agent_last_sync_at"] = health.LastSyncAt
}
if health.LastAppliedRevisionID != "" {
m["agent_last_applied_revision_id"] = health.LastAppliedRevisionID
}
} else if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
m["agent_ok"] = view.Error == ""
if view.Error != "" {
m["agent_error"] = view.Error
}
} else if sp != nil && store.SpeakerNeedsRemoteDispatch(sp.Role, store.ParseSpeakerMeta(sp.MetaJSON)) {
m["agent_ok"] = false
m["agent_error"] = "agent health not polled"
}
if len(view.Sessions) > 0 {
sess := make([]map[string]any, 0, len(view.Sessions))
for _, s := range view.Sessions {
row := map[string]any{
"name": s.Name,
"state": s.State,
}
if strings.TrimSpace(s.Neighbor) != "" {
row["neighbor"] = s.Neighbor
}
sess = append(sess, row)
}
m["sessions"] = sess
}
return m
}
func (s *Server) collectSpeakerLiveStatus(ctx context.Context, tenantID string, fresh bool, speakers []*store.Speaker) map[string]map[string]any {
views := s.collectSpeakerBGPLive(ctx, tenantID, fresh)
viewByID := make(map[string]speakerBGPLive, len(views))
for _, v := range views {
if v.SpeakerID != "" {
viewByID[v.SpeakerID] = v
}
}
opts := nodedispatch.Options{Timeout: 8 * time.Second}
type healthWrap struct {
id string
h nodedispatch.AgentHealthResult
}
healthCh := make(chan healthWrap, len(speakers))
var wg sync.WaitGroup
for _, sp := range speakers {
if sp == nil {
continue
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) {
continue
}
wg.Add(1)
go func(speaker *store.Speaker) {
defer wg.Done()
healthCh <- healthWrap{
id: speaker.ID,
h: nodedispatch.FetchAgentHealth(ctx, speaker, opts),
}
}(sp)
}
wg.Wait()
close(healthCh)
healthByID := make(map[string]nodedispatch.AgentHealthResult, len(speakers))
for hw := range healthCh {
healthByID[hw.id] = hw.h
}
out := make(map[string]map[string]any, len(speakers))
for _, sp := range speakers {
if sp == nil {
continue
}
view, ok := viewByID[sp.ID]
if !ok {
view = speakerBGPLive{SpeakerID: sp.ID, Label: speakerDisplayLabel(sp)}
}
var hp *nodedispatch.AgentHealthResult
if h, ok := healthByID[sp.ID]; ok {
hCopy := h
hp = &hCopy
}
out[sp.ID] = speakerLiveStatusJSON(sp, view, hp)
}
return out
}
+74
View File
@@ -0,0 +1,74 @@
package httpapi
import (
"testing"
"evobgp/internal/birdfmt"
"evobgp/internal/nodedispatch"
"evobgp/internal/store"
)
func TestCountBGPSessions(t *testing.T) {
total, est := countBGPSessions([]birdfmt.BGPSession{
{Name: "p1", State: "Established"},
{Name: "p2", State: "Idle"},
{Name: "p3", State: "established"},
})
if total != 3 || est != 2 {
t.Fatalf("total=%d established=%d", total, est)
}
}
func TestSpeakerLiveStatusJSON_masterUsesBirdPoll(t *testing.T) {
sp := &store.Speaker{ID: "m1", Role: "master", Endpoint: "https://cp.example"}
view := speakerBGPLive{
SpeakerID: "m1",
Label: "CP · cp.example",
Sessions: []birdfmt.BGPSession{
{Name: "evobgp_peer_x", State: "Established"},
},
}
m := speakerLiveStatusJSON(sp, view, nil)
if m["agent_ok"] != true || m["bgp_established"] != 1 || m["bgp_sessions_total"] != 1 {
t.Fatalf("got %#v", m)
}
}
func TestSpeakerLiveStatusJSON_replicaWithHealth(t *testing.T) {
sp := &store.Speaker{
ID: "r1",
Role: "replica",
Endpoint: "https://node.example",
MetaJSON: `{"agent_domain":"node.example","agent_secret":"s"}`,
}
view := speakerBGPLive{
SpeakerID: "r1",
Label: "node.example",
Sessions: []birdfmt.BGPSession{{Name: "p", State: "Idle"}},
}
health := &nodedispatch.AgentHealthResult{
OK: true,
LastSyncAt: "2026-05-21T12:00:00Z",
LastAppliedRevisionID: "rev-1",
}
m := speakerLiveStatusJSON(sp, view, health)
if m["agent_ok"] != true || m["agent_last_sync_at"] != "2026-05-21T12:00:00Z" {
t.Fatalf("got %#v", m)
}
if m["bgp_established"] != 0 || m["bgp_poll_ok"] != true {
t.Fatalf("bgp fields: %#v", m)
}
}
func TestSpeakerLiveStatusJSON_pollError(t *testing.T) {
sp := &store.Speaker{ID: "r1", Role: "replica", MetaJSON: `{"agent_domain":"x.example"}`}
view := speakerBGPLive{SpeakerID: "r1", Label: "x.example", Error: "HTTP 503"}
health := &nodedispatch.AgentHealthResult{OK: false, Error: "timeout"}
m := speakerLiveStatusJSON(sp, view, health)
if m["bgp_poll_ok"] != false || m["bgp_poll_error"] != "HTTP 503" {
t.Fatalf("got %#v", m)
}
if m["agent_ok"] != false {
t.Fatalf("agent_ok: %#v", m)
}
}
+62
View File
@@ -0,0 +1,62 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetSpeaker_redactsAgentSecret(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, demoSpk := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
req := httptest.NewRequest(http.MethodGet, "/v1/speakers/"+demoSpk, nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out["agent_secret"] != nil {
t.Fatalf("agent_secret must not appear at top level: %#v", out["agent_secret"])
}
meta, _ := out["meta_json"].(map[string]any)
if meta != nil {
if v, ok := meta["agent_secret"]; ok && v != nil && v != "" {
t.Fatalf("agent_secret must be redacted from meta_json: %#v", v)
}
}
}
func TestListSpeakers_redactsAgentSecret(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
req := httptest.NewRequest(http.MethodGet, "/v1/speakers", nil)
req.Header.Set("Authorization", "Bearer vwkey")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "agent_secret") {
t.Fatalf("list response must not contain agent_secret: %s", body)
}
}
+55
View File
@@ -0,0 +1,55 @@
package httpclient
import (
"sync"
"time"
)
const (
defaultBreakerThreshold = 5
defaultBreakerCooldown = 30 * time.Second
)
type hostBreaker struct {
mu sync.Mutex
failures int
openUntil time.Time
}
var hostBreakers sync.Map // string -> *hostBreaker
func breakerForHost(host string) *hostBreaker {
if host == "" {
host = "_"
}
v, _ := hostBreakers.LoadOrStore(host, &hostBreaker{})
return v.(*hostBreaker)
}
func (b *hostBreaker) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
return time.Now().After(b.openUntil)
}
func (b *hostBreaker) recordSuccess() {
b.mu.Lock()
defer b.mu.Unlock()
b.failures = 0
b.openUntil = time.Time{}
}
func (b *hostBreaker) recordFailure() {
b.mu.Lock()
defer b.mu.Unlock()
b.failures++
if b.failures >= defaultBreakerThreshold {
b.openUntil = time.Now().Add(defaultBreakerCooldown)
b.failures = 0
}
}
// ResetHostBreakers clears all circuit breakers (tests only).
func ResetHostBreakers() {
hostBreakers = sync.Map{}
}
+34
View File
@@ -0,0 +1,34 @@
package httpclient
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestDoWithBreaker_opensAfterFailures(t *testing.T) {
ResetHostBreakers()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
http.Error(w, "fail", http.StatusBadGateway)
}))
defer srv.Close()
hc := New(5 * time.Second)
for i := 0; i < defaultBreakerThreshold*3; i++ {
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, _ = DoWithBreaker(context.Background(), hc, req, 1)
}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := DoWithBreaker(context.Background(), hc, req, 1)
if err == nil || err.Error() == "" {
t.Fatal("expected circuit open error")
}
if got := calls.Load(); got == 0 {
t.Fatal("expected at least one upstream call")
}
}
+87
View File
@@ -0,0 +1,87 @@
// Package httpclient provides shared HTTP clients and retry helpers for outbound calls.
package httpclient
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
const DefaultTimeout = 45 * time.Second
// New returns an HTTP client with timeout and tuned idle connection pooling.
func New(timeout time.Duration) *http.Client {
if timeout <= 0 {
timeout = DefaultTimeout
}
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.MaxIdleConns = 100
tr.MaxIdleConnsPerHost = 10
return &http.Client{Timeout: timeout, Transport: tr}
}
// DoWithRetry executes hc.Do(req) up to maxAttempts times with linear backoff.
func DoWithRetry(ctx context.Context, hc *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
if maxAttempts <= 0 {
maxAttempts = 3
}
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
wait := time.Duration(attempt) * 2 * time.Second
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
if req.GetBody != nil {
body, err := req.GetBody()
if err != nil {
return nil, err
}
req.Body = body
}
}
reqClone := req.Clone(ctx)
resp, err := hc.Do(reqClone)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
lastErr = fmt.Errorf("httpclient: upstream %s", resp.Status)
continue
}
return resp, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("httpclient: request failed after %d attempts", maxAttempts)
}
// DoWithBreaker applies per-host circuit breaking then retries transient failures.
func DoWithBreaker(ctx context.Context, hc *http.Client, req *http.Request, maxAttempts int) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, fmt.Errorf("httpclient: nil request")
}
br := breakerForHost(req.URL.Hostname())
if !br.allow() {
return nil, fmt.Errorf("httpclient: circuit open for %s", req.URL.Hostname())
}
resp, err := DoWithRetry(ctx, hc, req, maxAttempts)
if err != nil {
br.recordFailure()
return nil, err
}
if resp.StatusCode >= 500 {
br.recordFailure()
return resp, nil
}
br.recordSuccess()
return resp, nil
}
+39
View File
@@ -0,0 +1,39 @@
package httpclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestDoWithRetry_retriesOn500(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls < 3 {
http.Error(w, "fail", http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := DoWithRetry(context.Background(), New(5*time.Second), req, 3)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d", resp.StatusCode)
}
if calls != 3 {
t.Fatalf("want 3 calls, got %d", calls)
}
}
+6 -3
View File
@@ -3,11 +3,11 @@ package ingest
import (
"context"
"log"
"net/http"
"time"
"evobgp/internal/broker"
"evobgp/internal/config"
"evobgp/internal/httpclient"
"evobgp/internal/pipeline"
"evobgp/internal/store"
)
@@ -24,7 +24,7 @@ func Run(ctx context.Context, deps *Deps) {
if deps == nil || deps.Store == nil {
log.Fatalf("evobgp-ingest: missing store (pass ingest.Deps from BootstrapWorkers or evobgp-all)")
}
hc := &http.Client{Timeout: 45 * time.Second}
hc := httpclient.New(httpclient.DefaultTimeout)
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-ingest: active (CDN conditional GET / ETag prefetch)")
@@ -34,7 +34,10 @@ func Run(ctx context.Context, deps *Deps) {
log.Printf("evobgp-ingest: stopped")
return
case <-t.C:
if err := pipeline.PrefetchCDNSourceETags(context.Background(), deps.Store, hc); err != nil {
prefetchCtx, cancel := context.WithTimeout(ctx, 50*time.Second)
err := pipeline.PrefetchCDNSourceETags(prefetchCtx, deps.Store, hc)
cancel()
if err != nil {
log.Printf("evobgp-ingest: prefetch: %v", err)
}
}
+49 -4
View File
@@ -182,6 +182,8 @@ type Registry struct {
workerStart func(j *Job)
workerSem chan struct{}
onTerminal func(j *Job)
onEnqueued func(j *Job)
onRunning func(j *Job)
}
type idempoKey struct {
@@ -209,6 +211,44 @@ func (r *Registry) SetTerminalHook(fn func(j *Job)) {
r.onTerminal = fn
}
// SetPersistHooks registers best-effort callbacks for job lifecycle persistence.
func (r *Registry) SetPersistHooks(onEnqueued, onRunning, onTerminal func(j *Job)) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.onEnqueued = onEnqueued
r.onRunning = onRunning
if onTerminal != nil {
r.onTerminal = onTerminal
}
}
func (r *Registry) fireEnqueued(j *Job) {
if r == nil || j == nil {
return
}
r.mu.RLock()
fn := r.onEnqueued
r.mu.RUnlock()
if fn != nil {
fn(j)
}
}
func (r *Registry) fireRunning(j *Job) {
if r == nil || j == nil {
return
}
r.mu.RLock()
fn := r.onRunning
r.mu.RUnlock()
if fn != nil {
fn(j)
}
}
func (r *Registry) fireTerminal(j *Job) {
if r == nil || j == nil {
return
@@ -271,8 +311,6 @@ func (r *Registry) pruneTerminalIfOver(maxJobs int) {
// Enqueue creates a job or returns an existing one for the same idempotency key.
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
maxJobs := registryMaxJobsFromEnv()
r.pruneTerminalIfOver(maxJobs)
@@ -281,6 +319,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
if existing, ok := r.byIdempo[k]; ok {
st := existing.statusLocked()
if st == StatusQueued || st == StatusRunning {
r.mu.Unlock()
return existing, false, nil
}
delete(r.byIdempo, k)
@@ -302,8 +341,14 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
}
r.byID[j.ID] = j
r.pruneTerminalIfOver(maxJobs)
enqueuedHook := r.onEnqueued
workerStart := r.workerStart
r.mu.Unlock()
if r.workerStart != nil {
if enqueuedHook != nil {
enqueuedHook(j)
}
if workerStart != nil {
go func() {
r.workerSem <- struct{}{}
active := len(r.workerSem)
@@ -313,7 +358,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
<-r.workerSem
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
}()
r.workerStart(j)
workerStart(j)
}()
}
return j, true, nil
+5 -1
View File
@@ -13,6 +13,7 @@ import (
"evobgp/internal/birddeploy"
"evobgp/internal/birdfmt"
"evobgp/internal/httpclient"
"evobgp/internal/nodedispatch"
"evobgp/internal/observability"
"evobgp/internal/pipeline"
@@ -70,7 +71,7 @@ type revisionLogEntry struct {
Message string `json:"message"`
}
var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second}
var defaultWorkerHTTP = httpclient.New(httpclient.DefaultTimeout)
func (w *Worker) httpClient() *http.Client {
if w != nil && w.HTTPClient != nil {
@@ -94,6 +95,9 @@ func (w *Worker) Process(j *Job) {
return
}
j.MarkRunning()
if w != nil && w.Registry != nil {
w.Registry.fireRunning(j)
}
if j.IsCancelRequested() {
j.MarkCancelled()
return
+11 -2
View File
@@ -16,6 +16,7 @@ import (
"evobgp/internal/birdfmt"
"evobgp/internal/bundle"
"evobgp/internal/httpclient"
"evobgp/internal/signing"
)
@@ -55,6 +56,10 @@ func CmdPullBundle(args []string) int {
return 0
}
func nodeHTTPClient() *http.Client {
return httpclient.New(60 * time.Second)
}
func fetchLatestRevision(base, token, speaker string) (string, error) {
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
req, err := http.NewRequest(http.MethodGet, u, nil)
@@ -62,7 +67,9 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := httpclient.DoWithRetry(ctx, nodeHTTPClient(), req, 3)
if err != nil {
return "", err
}
@@ -90,7 +97,9 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
resp, err := httpclient.DoWithRetry(ctx, nodeHTTPClient(), req, 3)
if err != nil {
return nil, err
}
+7 -26
View File
@@ -156,33 +156,14 @@ func WakeReplicas(ctx context.Context, st store.Backend, tenantID, revisionID st
return out
}
// CheckHealth GETs /v1/agent/health for UI Connected/Offline status.
// CheckHealth is deprecated; use FetchAgentHealth.
func CheckHealth(ctx context.Context, sp *store.Speaker, opts Options) (ok bool, detail string) {
if sp == nil {
return false, "nil speaker"
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
url := store.AgentHealthURL(meta)
if url == "" {
return false, "agent_domain not configured"
}
secret := strings.TrimSpace(meta.AgentSecret)
if secret == "" {
return false, "agent_secret missing"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err.Error()
}
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := opts.client().Do(req)
if err != nil {
return false, err.Error()
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
res := FetchAgentHealth(ctx, sp, opts)
if res.OK {
return true, "connected"
}
b, _ := io.ReadAll(resp.Body)
return false, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
if res.Error != "" {
return false, res.Error
}
return false, "agent unhealthy"
}
+69
View File
@@ -0,0 +1,69 @@
package nodedispatch
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"evobgp/internal/store"
)
// AgentHealthResult is the parsed outcome of GET /v1/agent/health on a replica.
type AgentHealthResult struct {
OK bool
Error string
LastAppliedRevisionID string
LastSyncAt string
}
// FetchAgentHealth GETs /v1/agent/health for UI Connected/Offline status.
func FetchAgentHealth(ctx context.Context, sp *store.Speaker, opts Options) AgentHealthResult {
if sp == nil {
return AgentHealthResult{Error: "nil speaker"}
}
meta := store.ParseSpeakerMeta(sp.MetaJSON)
url := store.AgentHealthURL(meta)
if url == "" {
return AgentHealthResult{Error: "agent_domain not configured"}
}
secret := strings.TrimSpace(meta.AgentSecret)
if secret == "" {
return AgentHealthResult{Error: "agent_secret missing"}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return AgentHealthResult{Error: err.Error()}
}
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := opts.client().Do(req)
if err != nil {
return AgentHealthResult{Error: err.Error()}
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return AgentHealthResult{
Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))),
}
}
var out struct {
OK bool `json:"ok"`
LastAppliedRevisionID string `json:"last_applied_revision_id"`
LastSyncAt string `json:"last_sync_at"`
}
if err := json.Unmarshal(b, &out); err != nil {
return AgentHealthResult{Error: err.Error()}
}
res := AgentHealthResult{
OK: out.OK,
LastAppliedRevisionID: strings.TrimSpace(out.LastAppliedRevisionID),
LastSyncAt: strings.TrimSpace(out.LastSyncAt),
}
if !res.OK {
res.Error = "agent reported ok=false"
}
return res
}
+8 -4
View File
@@ -11,6 +11,7 @@ import (
)
func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
@@ -25,7 +26,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
}
var gotIfNoneMatch string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotIfNoneMatch = strings.TrimSpace(r.Header.Get("If-None-Match"))
w.Header().Set("ETag", "etag-new")
_, _ = w.Write([]byte("198.51.100.0/24\n"))
@@ -54,6 +55,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
}
func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
@@ -67,7 +69,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified)
}))
defer srv.Close()
@@ -99,6 +101,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
}
func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
@@ -112,7 +115,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified)
}))
defer srv.Close()
@@ -140,6 +143,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
}
func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
@@ -154,7 +158,7 @@ func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
}
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "etag-stable" {
+16 -4
View File
@@ -119,6 +119,12 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
if u == "" {
return nil, nil
}
if _, err := ValidateCDNURL(u); err != nil {
return nil, err
}
if err := ResolveCDNURLHost(ctx, u); err != nil {
return nil, err
}
sourceKey := cdnSourceKey(src.ID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
@@ -127,7 +133,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
if etag := strings.TrimSpace(src.Etag); etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := hc.Do(req)
resp, err := upstreamHTTPDo(ctx, hc, req)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
@@ -143,7 +149,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
if err != nil {
return nil, err
}
resp, err = hc.Do(req2)
resp, err = upstreamHTTPDo(ctx, hc, req2)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
@@ -189,6 +195,12 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
if u == "" {
return nil, nil
}
if _, err := ValidateCDNURL(u); err != nil {
return nil, err
}
if err := ResolveCDNURLHost(ctx, u); err != nil {
return nil, err
}
sourceKey := cdnSourceKey(src.ID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
@@ -197,7 +209,7 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
if etag := strings.TrimSpace(src.Etag); etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := hc.Do(req)
resp, err := upstreamHTTPDo(ctx, hc, req)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
@@ -212,7 +224,7 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
if err != nil {
return nil, err
}
resp, err = hc.Do(req2)
resp, err = upstreamHTTPDo(ctx, hc, req2)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
+116
View File
@@ -0,0 +1,116 @@
package pipeline
import (
"context"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"strings"
"time"
)
func isBlockedCDNIP(ip netip.Addr) bool {
if allowPrivateCDNURLs() {
return false
}
if !ip.IsValid() {
return true
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() ||
ip.IsUnspecified() || ip == netip.MustParseAddr("169.254.169.254")
}
func allowPrivateCDNURLs() bool {
v := strings.TrimSpace(os.Getenv("EVOBGP_CDN_ALLOW_PRIVATE"))
return v == "1" || strings.EqualFold(v, "true")
}
func isBlockedCDNHostname(host string) bool {
if allowPrivateCDNURLs() {
return false
}
h := strings.ToLower(strings.TrimSpace(host))
if h == "" || h == "localhost" {
return true
}
if strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".localhost") {
return true
}
return false
}
// ValidateCDNURL checks CDN source URLs for SSRF-safe HTTPS endpoints (hostname only; no DNS resolve).
func ValidateCDNURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("pipeline: cdn url is required")
}
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("pipeline: cdn url invalid: %w", err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("pipeline: cdn url must use https")
}
if u.User != nil {
return "", fmt.Errorf("pipeline: cdn url must not include credentials")
}
host := strings.TrimSpace(u.Hostname())
if host == "" {
return "", fmt.Errorf("pipeline: cdn url missing host")
}
if isBlockedCDNHostname(host) {
return "", fmt.Errorf("pipeline: cdn url blocked host")
}
if ip, err := netip.ParseAddr(host); err == nil {
if isBlockedCDNIP(ip) {
return "", fmt.Errorf("pipeline: cdn url blocked host")
}
}
return u.String(), nil
}
// ResolveCDNURLHost resolves a CDN hostname and rejects private/link-local targets (SSRF at fetch time).
func ResolveCDNURLHost(ctx context.Context, raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return err
}
host := strings.TrimSpace(u.Hostname())
if host == "" {
return fmt.Errorf("pipeline: cdn url missing host")
}
if ip, err := netip.ParseAddr(host); err == nil {
if isBlockedCDNIP(ip) {
return fmt.Errorf("pipeline: cdn url blocked host")
}
return nil
}
if isBlockedCDNHostname(host) {
return fmt.Errorf("pipeline: cdn url blocked host")
}
if ctx == nil {
ctx = context.Background()
}
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(resolveCtx, "ip", host)
if err != nil {
return fmt.Errorf("pipeline: cdn url dns lookup: %w", err)
}
if len(ips) == 0 {
return fmt.Errorf("pipeline: cdn url dns lookup: no addresses")
}
for _, ip := range ips {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
continue
}
if isBlockedCDNIP(addr) {
return fmt.Errorf("pipeline: cdn url resolves to blocked address")
}
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package pipeline
import "testing"
func TestValidateCDNURL(t *testing.T) {
tests := []struct {
raw string
ok bool
want string
}{
{"https://cdn.example.com/prefixes.txt", true, "https://cdn.example.com/prefixes.txt"},
{"http://cdn.example.com/x", false, ""},
{"https://127.0.0.1/x", false, ""},
{"https://10.0.0.1/x", false, ""},
{"https://169.254.169.254/latest/meta-data", false, ""},
{"https://localhost/x", false, ""},
{"file:///etc/passwd", false, ""},
{"https://user:pass@cdn.example.com/x", false, ""},
}
for _, tc := range tests {
got, err := ValidateCDNURL(tc.raw)
if tc.ok && err != nil {
t.Errorf("%q: unexpected err %v", tc.raw, err)
continue
}
if !tc.ok && err == nil {
t.Errorf("%q: expected error", tc.raw)
continue
}
if tc.ok && got != tc.want {
t.Errorf("%q: got %q want %q", tc.raw, got, tc.want)
}
}
}
+57 -2
View File
@@ -25,7 +25,7 @@ func prefixRowsForSource(rows []store.PrefixRow, sourceKey string) []store.Prefi
return out
}
func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module, list []*store.ASEntry) ([]store.PrefixRow, error) {
func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module, list []*store.ASEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
moduleID := mod.ID
legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0"
if legacy {
@@ -76,6 +76,41 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
}
pfxs, holder, err := resolveASNForEntry(ctx, st, hc, entry.ASN)
if err != nil {
if staleOnUpstreamError() {
if staleRows, staleHolder, ok := staleASNPrefixes(st, priorSnapshot, entry.ASN); ok {
logStaleUpstream("asn", fmt.Sprintf("AS%d: %v", entry.ASN, err))
src := fmt.Sprintf("as:%d", entry.ASN)
rows := append([]store.PrefixRow(nil), staleRows...)
for i := range rows {
rows[i].CommunityID = comm
rows[i].Source = src
}
results[idx] = entryResult{
rows: rows,
metaID: entry.ID,
asn: entry.ASN,
holder: staleHolder,
count: int64(len(rows)),
}
return
}
if pfxs2, holder2, ok := asnCacheExpired(st, entry.ASN); ok {
logStaleUpstream("asn", fmt.Sprintf("AS%d expired cache: %v", entry.ASN, err))
src := fmt.Sprintf("as:%d", entry.ASN)
var rows []store.PrefixRow
for _, pfx := range pfxs2 {
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm, Source: src})
}
results[idx] = entryResult{
rows: rows,
metaID: entry.ID,
asn: entry.ASN,
holder: holder2,
count: int64(len(pfxs2)),
}
return
}
}
results[idx] = entryResult{err: fmt.Errorf("resolve AS%d: %w", entry.ASN, err)}
return
}
@@ -167,6 +202,13 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
}
rows, err := fetchCDNSourceRows(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
if err != nil {
if staleOnUpstreamError() {
if cached, ok := staleCDNPrefixes(st, tenantID, moduleID, priorSnapshot, src.ID); ok {
logStaleUpstream("cdn", fmt.Sprintf("source %s: %v", src.ID, err))
results[idx] = srcResult{rows: cached}
return
}
}
results[idx] = srcResult{err: err}
return
}
@@ -190,7 +232,7 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
return out, nil
}
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
var validDom []*store.DomainEntry
for _, e := range entries {
if e != nil {
@@ -219,6 +261,19 @@ func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Mo
}
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
if err != nil {
if staleOnUpstreamError() {
if cached, ok := staleDomainPrefixes(priorSnapshot, entry.FQDN); ok {
logStaleUpstream("domain", fmt.Sprintf("%q: %v", entry.FQDN, err))
rows := append([]store.PrefixRow(nil), cached...)
for i := range rows {
if rows[i].CommunityID == nil {
rows[i].CommunityID = comm
}
}
results[idx] = domResult{rows: rows}
return
}
}
results[idx] = domResult{err: fmt.Errorf("resolve domain %q: %w", entry.FQDN, err)}
return
}
+86
View File
@@ -0,0 +1,86 @@
package pipeline
import (
"fmt"
"log"
"net/netip"
"os"
"strings"
"evobgp/internal/store"
)
// staleOnUpstreamError reports whether ingest should keep last-known prefixes when an upstream fetch fails.
// Enabled by default; set EVOBGP_STALE_ON_UPSTREAM_ERROR=0 to restore fail-fast behavior.
func staleOnUpstreamError() bool {
v := strings.TrimSpace(os.Getenv("EVOBGP_STALE_ON_UPSTREAM_ERROR"))
if v == "" || v == "1" || strings.EqualFold(v, "true") {
return true
}
return false
}
func logStaleUpstream(kind, detail string) {
log.Printf("pipeline: stale upstream fallback (%s): %s", kind, detail)
}
func staleASNPrefixes(st store.Backend, priorSnapshot []store.PrefixRow, asn int64) ([]store.PrefixRow, string, bool) {
sourceKey := fmt.Sprintf("as:%d", asn)
if cached := prefixRowsForSource(priorSnapshot, sourceKey); len(cached) > 0 {
return cached, "", true
}
if st == nil {
return nil, "", false
}
ent, ok, err := st.GetASNPrefixCache(asn)
if err != nil || !ok || ent == nil || len(ent.Prefixes) == 0 {
return nil, "", false
}
var rows []store.PrefixRow
for _, p := range ent.Prefixes {
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
if perr != nil {
continue
}
rows = append(rows, store.PrefixRow{Prefix: pfx.Masked().String(), Source: sourceKey})
}
if len(rows) == 0 {
return nil, "", false
}
return rows, ent.Holder, true
}
func staleDomainPrefixes(priorSnapshot []store.PrefixRow, fqdn string) ([]store.PrefixRow, bool) {
sourceKey := "domain:" + strings.TrimSpace(fqdn)
cached := prefixRowsForSource(priorSnapshot, sourceKey)
return cached, len(cached) > 0
}
func staleCDNPrefixes(st store.Backend, tenantID, moduleID string, priorSnapshot []store.PrefixRow, sourceID string) ([]store.PrefixRow, bool) {
sourceKey := cdnSourceKey(sourceID)
cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey)
return cached, len(cached) > 0
}
// asnCacheExpired returns cached ASN prefixes even past TTL (for stale fallback only).
func asnCacheExpired(st store.Backend, asn int64) ([]netip.Prefix, string, bool) {
if st == nil {
return nil, "", false
}
ent, ok, err := st.GetASNPrefixCache(asn)
if err != nil || !ok || ent == nil || len(ent.Prefixes) == 0 {
return nil, "", false
}
out := make([]netip.Prefix, 0, len(ent.Prefixes))
for _, p := range ent.Prefixes {
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
if perr != nil {
continue
}
out = append(out, pfx.Masked())
}
if len(out) == 0 {
return nil, "", false
}
return out, ent.Holder, true
}
+72
View File
@@ -0,0 +1,72 @@
package pipeline
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"evobgp/internal/store"
)
func TestCollectCDNPrefixRows_StaleOnFetchError(t *testing.T) {
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "1")
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream down", http.StatusServiceUnavailable)
}))
defer srv.Close()
st := store.NewMemory()
st.SeedDemo()
tenant, _, _, _, _ := st.DemoIDs()
mod, err := st.CreateModule(tenant, &store.Module{Type: "CDN_CIDRS", Name: "cdn", Enabled: true})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
ID: "s1", URL: srv.URL, SourceKind: "plain",
}); err != nil {
t.Fatal(err)
}
prior := []store.PrefixRow{
{Prefix: "203.0.113.0/24", Source: "cdn:s1"},
}
rows, err := collectCDNPrefixRows(context.Background(), st, srv.Client(), tenant, mod, []*store.CDNSource{{ID: "s1", URL: srv.URL, SourceKind: "plain"}}, prior)
if err != nil {
t.Fatalf("expected stale fallback, got err: %v", err)
}
if len(rows) != 1 || rows[0].Prefix != "203.0.113.0/24" {
t.Fatalf("unexpected rows: %+v", rows)
}
}
func TestCollectCDNPrefixRows_FailFastWhenNoStale(t *testing.T) {
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "0")
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream down", http.StatusServiceUnavailable)
}))
defer srv.Close()
st := store.NewMemory()
st.SeedDemo()
tenant, _, _, _, _ := st.DemoIDs()
mod, err := st.CreateModule(tenant, &store.Module{Type: "CDN_CIDRS", Name: "cdn", Enabled: true})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
ID: "s1", URL: srv.URL, SourceKind: "plain",
}); err != nil {
t.Fatal(err)
}
_, err = collectCDNPrefixRows(context.Background(), st, srv.Client(), tenant, mod, []*store.CDNSource{{ID: "s1", URL: srv.URL, SourceKind: "plain"}}, nil)
if err == nil {
t.Fatal("expected error when stale disabled and no cache")
}
}
+84 -54
View File
@@ -5,86 +5,116 @@ import (
"io"
"net/http"
"strings"
"sync"
"time"
"evobgp/internal/httpclient"
"evobgp/internal/store"
)
type prefetchTask struct {
tenantID string
mod *store.Module
src *store.CDNSource
}
// PrefetchCDNSourceETags performs conditional GETs for CDN sources; on 200 parses CIDRs into module_prefix_snapshot.
func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Client) error {
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
if ctx == nil {
ctx = context.Background()
}
tenants, err := st.ListTenantIDs()
if err != nil {
return err
}
now := time.Now().UTC()
var tasks []prefetchTask
for _, tid := range tenants {
for _, mod := range st.ListModules(tid) {
if mod == nil || !mod.Enabled || mod.Type != "CDN_CIDRS" {
continue
}
omod, err := st.GetModule(tid, mod.ID)
if err != nil {
continue
}
sources, err := st.ListCDNSources(tid, mod.ID)
if err != nil {
continue
}
var prior []store.PrefixRow
if snap, ok, _ := st.GetModulePrefixSnapshot(tid, mod.ID); ok && snap != nil {
prior = snap.Prefixes
}
for _, src := range sources {
if src == nil || strings.TrimSpace(src.URL) == "" {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(src.URL), nil)
if err != nil {
continue
}
if etag := strings.TrimSpace(src.Etag); etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := hc.Do(req)
if err != nil {
continue
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
_ = resp.Body.Close()
if err != nil {
continue
}
prefixStrs, err := parseCDNBody(string(body), src)
if err != nil {
continue
}
newEtag := strings.TrimSpace(resp.Header.Get("ETag"))
patch := &store.CDNSourcePatch{LastRefreshedAt: &now}
if newEtag != "" && newEtag != strings.TrimSpace(src.Etag) {
e := newEtag
patch.Etag = &e
}
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, patch)
rows := cdnRowsFromParsed(omod, src, prefixStrs)
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
_ = prior // prior may be stale after merge; refresh for next source in loop
if snap, ok, _ := st.GetModulePrefixSnapshot(tid, mod.ID); ok && snap != nil {
prior = snap.Prefixes
if src != nil && strings.TrimSpace(src.URL) != "" {
tasks = append(tasks, prefetchTask{tenantID: tid, mod: mod, src: src})
}
}
}
}
if len(tasks) == 0 {
return nil
}
sem := make(chan struct{}, collectConcurrency())
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t prefetchTask) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
prefetchOneCDNSource(ctx, st, hc, t)
}(task)
}
wg.Wait()
return nil
}
func prefetchOneCDNSource(ctx context.Context, st store.Backend, hc *http.Client, t prefetchTask) {
now := time.Now().UTC()
tid, mod, src := t.tenantID, t.mod, t.src
u := strings.TrimSpace(src.URL)
if _, err := ValidateCDNURL(u); err != nil {
return
}
if err := ResolveCDNURLHost(ctx, u); err != nil {
return
}
omod, err := st.GetModule(tid, mod.ID)
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return
}
if etag := strings.TrimSpace(src.Etag); etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := upstreamHTTPDo(ctx, hc, req)
if err != nil {
return
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
return
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
_ = resp.Body.Close()
if err != nil {
return
}
prefixStrs, err := parseCDNBody(string(body), src)
if err != nil {
return
}
newEtag := strings.TrimSpace(resp.Header.Get("ETag"))
patch := &store.CDNSourcePatch{LastRefreshedAt: &now}
if newEtag != "" && newEtag != strings.TrimSpace(src.Etag) {
e := newEtag
patch.Etag = &e
}
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, patch)
rows := cdnRowsFromParsed(omod, src, prefixStrs)
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
}
+6 -5
View File
@@ -19,6 +19,7 @@ import (
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/httpclient"
"evobgp/internal/observability"
"evobgp/internal/store"
@@ -50,7 +51,7 @@ func MaterializedASPrefixKey(asn int64) string {
// It does not create a new config revision.
func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string) error {
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
start := time.Now()
mod, err := st.GetModule(tenantID, moduleID)
@@ -84,7 +85,7 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
// If materialized prefixes are unchanged, returns latest revision id without creating a duplicate.
func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string) (revisionID string, err error) {
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
agg, err := aggregateTenantPrefixRowsAll(ctx, st, hc, tenantID)
if err != nil {
@@ -117,7 +118,7 @@ func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client
func RenderTenantRevisionFromPrefixes(ctx context.Context, st store.Backend, hc *http.Client, tenantID, triggerModuleID string, rows []store.PrefixRow) (revisionID string, err error) {
_ = ctx
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
agg := append([]store.PrefixRow(nil), rows...)
rawCount := len(agg)
@@ -176,7 +177,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
return nil, err
}
sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN })
return collectASPrefixRows(ctx, st, hc, tenantID, mod, list)
return collectASPrefixRows(ctx, st, hc, tenantID, mod, list, priorSnapshot)
case "CDN_CIDRS":
sources, err := st.ListCDNSources(tenantID, moduleID)
if err != nil {
@@ -192,7 +193,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
if err != nil {
return nil, err
}
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries, priorSnapshot)
default:
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
}
+2 -1
View File
@@ -7,13 +7,14 @@ import (
"strings"
"sync"
"evobgp/internal/httpclient"
"evobgp/internal/store"
)
// RefreshTenantModules ingests all listed modules in parallel and updates per-module snapshots.
func RefreshTenantModules(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, moduleIDs []string) error {
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
var ids []string
seen := make(map[string]struct{})
+23
View File
@@ -0,0 +1,23 @@
package pipeline
import (
"context"
"fmt"
"net/http"
"evobgp/internal/httpclient"
)
func upstreamHTTPDo(ctx context.Context, hc *http.Client, req *http.Request) (*http.Response, error) {
if hc == nil {
hc = httpclient.New(httpclient.DefaultTimeout)
}
resp, err := httpclient.DoWithBreaker(ctx, hc, req, 3)
if err != nil {
if req.URL != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", req.URL.String(), err)
}
return nil, err
}
return resp, nil
}
+19
View File
@@ -0,0 +1,19 @@
package repository
import (
"context"
"time"
)
const defaultRepoTimeout = 60 * time.Second
// boundedRepoCtx returns a context with default repository I/O timeout.
func boundedRepoCtx(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
if _, ok := parent.Deadline(); ok {
return parent, func() {}
}
return context.WithTimeout(parent, defaultRepoTimeout)
}
+23 -2
View File
@@ -20,6 +20,28 @@ func NewJobAuditWriter(pool *pgxpool.Pool) *JobAuditWriter {
return &JobAuditWriter{pool: pool}
}
// UpsertQueued inserts a queued job row (best-effort).
func (w *JobAuditWriter) UpsertQueued(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) {
if w == nil || w.pool == nil {
return
}
metaJSON, _ := json.Marshal(meta)
var idem any
if idempotencyKey != nil && *idempotencyKey != "" {
idem = *idempotencyKey
}
var mod any
if moduleID != nil && *moduleID != "" {
mod = *moduleID
}
_, _ = w.pool.Exec(ctx, `
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, module_id, meta_json, created_at)
VALUES ($1::uuid, $2::uuid, $3, 'queued', $4, $5::uuid, $6::jsonb, now())
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
DO UPDATE SET status='queued', meta_json=EXCLUDED.meta_json, module_id=EXCLUDED.module_id`,
jobID, tenantID, kind, idem, mod, metaJSON)
}
// UpsertRunning inserts or updates a running job row (best-effort).
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
if w == nil || w.pool == nil {
@@ -33,8 +55,7 @@ func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kin
_, _ = w.pool.Exec(ctx, `
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
DO UPDATE SET status='running', started_at=now(), meta_json=EXCLUDED.meta_json`,
ON CONFLICT (id) DO UPDATE SET status='running', started_at=COALESCE(job_audit.started_at, now()), meta_json=EXCLUDED.meta_json`,
jobID, tenantID, kind, idem, metaJSON)
}
+29 -1
View File
@@ -659,7 +659,8 @@ func (p *Postgres) DeleteSpeaker(tenantID, id string) error {
}
func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) {
ctx := context.Background()
ctx, cancel := boundedRepoCtx(context.Background())
defer cancel()
var r store.Revision
var mod *string
var parent *string
@@ -691,6 +692,33 @@ func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, er
return &r, nil
}
func (p *Postgres) GetRevisionSummary(tenantID, revisionID string) (*store.Revision, error) {
ctx, cancel := boundedRepoCtx(context.Background())
defer cancel()
var r store.Revision
var mod *string
var parent *string
var prefixCount int
err := p.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, module_id::text, content_hash, parent_revision_id::text,
COALESCE((meta_json->>'materialized_prefix_count')::int, 0), created_at
FROM config_revision WHERE id=$1 AND tenant_id=$2`, revisionID, tenantID).Scan(
&r.ID, &r.TenantID, &mod, &r.ContentHash, &parent, &prefixCount, &r.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
if mod != nil {
r.ModuleID = *mod
}
r.ParentRevisionID = strOrNil(parent)
r.MaterializedPrefixCount = prefixCount
r.PreviewFragments = map[string]string{}
return &r, nil
}
func (p *Postgres) ListRevisions(tenantID, moduleID string, cursor string, limit int) ([]*store.Revision, string, bool) {
if limit <= 0 {
limit = 50
+6 -2
View File
@@ -14,6 +14,7 @@ import (
"evobgp/internal/broker"
"evobgp/internal/config"
"evobgp/internal/httpclient"
"evobgp/internal/jobs"
"evobgp/internal/pipeline"
"evobgp/internal/store"
@@ -108,6 +109,9 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
if err != nil {
return err
}
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
@@ -115,9 +119,9 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
}
hc := deps.HTTP
if hc == nil {
hc = http.DefaultClient
hc = httpclient.New(httpclient.DefaultTimeout)
}
resp, err := hc.Do(req)
resp, err := httpclient.DoWithRetry(ctx, hc, req, 3)
if err != nil {
return err
}
+2
View File
@@ -76,6 +76,8 @@ type Backend interface {
DeleteSpeaker(tenantID, id string) error
GetRevision(tenantID, revisionID string) (*Revision, error)
// GetRevisionSummary returns revision metadata without preview_fragments payloads.
GetRevisionSummary(tenantID, revisionID string) (*Revision, error)
ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool)
ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) (prefixes []PrefixRow, next string, more bool)
CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error)
+12 -7
View File
@@ -424,14 +424,19 @@ func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rev, ok := m.revisions[revisionID]
if !ok {
return nil, ErrNotFound
return m.getRevisionLocked(tenantID, revisionID)
}
func (m *Memory) GetRevisionSummary(tenantID, revisionID string) (*Revision, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rev, err := m.getRevisionLocked(tenantID, revisionID)
if err != nil {
return nil, err
}
if rev.TenantID != tenantID {
return nil, ErrTenantScope
}
return rev, nil
cp := *rev
cp.PreviewFragments = nil
return &cp, nil
}
func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) {
+3 -1
View File
@@ -72,7 +72,9 @@ func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
if patch.LastDispatchAt != "" {
cur.LastDispatchAt = patch.LastDispatchAt
}
if patch.LastDispatchError != "" {
if patch.LastDispatchStatus == "ok" {
cur.LastDispatchError = ""
} else if patch.LastDispatchError != "" {
cur.LastDispatchError = patch.LastDispatchError
}
if patch.LastDispatchStatus != "" {
+21
View File
@@ -34,3 +34,24 @@ func TestAgentSyncURL(t *testing.T) {
t.Fatalf("got %q", u)
}
}
func TestMergeSpeakerMetaJSON_clearsDispatchErrorOnOk(t *testing.T) {
t.Parallel()
existing := store.SpeakerMetaJSON(store.SpeakerMeta{
LastDispatchError: "HTTP 502: bundle 403",
LastDispatchStatus: "error",
SyncStatus: "error",
})
merged := store.MergeSpeakerMetaJSON(existing, store.SpeakerMeta{
LastDispatchStatus: "ok",
SyncStatus: "synced",
LastDispatchAt: "2026-05-21T15:06:43Z",
})
m := store.ParseSpeakerMeta(merged)
if m.LastDispatchError != "" {
t.Fatalf("LastDispatchError should clear on ok dispatch, got %q", m.LastDispatchError)
}
if m.LastDispatchStatus != "ok" || m.SyncStatus != "synced" {
t.Fatalf("status: dispatch=%q sync=%q", m.LastDispatchStatus, m.SyncStatus)
}
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Warns about commits since the last tag that semantic-release cannot parse.
* Exit 0 always semantic-release still decides release/no-op.
*/
import { execSync } from 'node:child_process';
import parser from 'conventional-commits-parser';
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
function lastTag() {
try {
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
return '';
}
}
function commitsSince(ref) {
const range = ref ? `${ref}..HEAD` : 'HEAD';
const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim();
if (!out) return [];
return out.split('\n').map((line) => {
const [hash, subject] = line.split('\t');
return { hash: hash.trim(), subject: subject.trim() };
});
}
const tag = lastTag();
const commits = commitsSince(tag);
const unparseable = [];
const releasable = [];
for (const { hash, subject } of commits) {
const parsed = parser.sync(subject);
if (!parsed.type) {
unparseable.push({ hash: hash.slice(0, 7), subject });
continue;
}
if (RELEASABLE.has(parsed.type)) {
releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type });
}
}
if (commits.length === 0) {
console.log(`No new commits since ${tag || 'initial'}.`);
process.exit(0);
}
if (unparseable.length > 0) {
console.warn('::warning:: Commits not parseable by semantic-release (no version bump):');
for (const b of unparseable) {
console.warn(` ${b.hash} ${b.subject}`);
}
console.warn('Fix: single scope without commas, e.g. refactor(web): summary');
}
if (releasable.length > 0) {
console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`);
} else {
console.warn('::warning:: No releasable commits since last tag — release job will no-op.');
}
process.exit(0);
+29 -1
View File
@@ -156,6 +156,7 @@ export type PeerSessionOnSpeaker = {
speaker_id: string;
label: string;
state: string;
poll_error?: string;
};
export type PeerRow = {
@@ -172,7 +173,14 @@ export type PeerRow = {
established_on_speakers?: PeerSessionOnSpeaker[];
session_mismatch?: boolean;
};
export type PeersResponse = Page<PeerRow>;
export type LiveSpeakerPoll = {
speaker_id: string;
label: string;
ok: boolean;
session_count: number;
poll_error?: string;
};
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] };
export type BgpPeerCreate = {
name?: string;
neighbor: string;
@@ -183,6 +191,25 @@ export type BgpPeerCreate = {
export type BgpPeerPatch = Partial<BgpPeerCreate>;
// ---- Speakers ----
export type BgpSessionLive = {
name: string;
neighbor?: string;
state: string;
};
export type SpeakerLiveStatus = {
label?: string;
agent_ok?: boolean;
agent_error?: string;
agent_last_sync_at?: string;
agent_last_applied_revision_id?: string;
bgp_poll_ok?: boolean;
bgp_poll_error?: string;
bgp_sessions_total?: number;
bgp_established?: number;
sessions?: BgpSessionLive[];
};
export type SpeakerRow = {
id: string;
role: string;
@@ -199,6 +226,7 @@ export type SpeakerRow = {
last_dispatch_error?: string | null;
meta_json?: Record<string, unknown>;
agent_secret?: string;
live?: SpeakerLiveStatus;
};
export type SpeakersResponse = Page<SpeakerRow>;
export type BgpSpeakerCreate = {
@@ -0,0 +1,30 @@
<script lang="ts">
import { Label } from '$lib/ui/core/label/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
type Props = {
enabled?: boolean;
onchange?: (enabled: boolean) => void;
disabled?: boolean;
};
let {
enabled = $bindable(readNetworkAutoRefresh()),
onchange,
disabled = false
}: Props = $props();
function onToggle(checked: boolean) {
enabled = checked;
writeNetworkAutoRefresh(checked);
onchange?.(checked);
}
</script>
<div class="flex items-center gap-2">
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
Авто (~15 с)
</Label>
</div>
@@ -0,0 +1,240 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
aggregateNetworkMetrics,
collectNetworkIssues,
deriveNetworkOverallStatus,
networkOverallStatusHint,
networkOverallStatusLabel
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import XCircle from '@lucide/svelte/icons/x-circle';
import Share2 from '@lucide/svelte/icons/share-2';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import Server from '@lucide/svelte/icons/server';
import GitBranch from '@lucide/svelte/icons/git-branch';
import Activity from '@lucide/svelte/icons/activity';
import Bird from '@lucide/svelte/icons/bird';
import Gauge from '@lucide/svelte/icons/gauge';
type Props = {
peers: PeerRow[];
speakers: SpeakerRow[];
bird: BirdStatus | null;
loading?: boolean;
initialLoading?: boolean;
onSpeakerSelect?: (speaker: SpeakerRow) => void;
};
let {
peers,
speakers,
bird,
loading = false,
initialLoading = false,
onSpeakerSelect
}: Props = $props();
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-warning',
bg: 'bg-warning/5',
iconBg: 'bg-warning/15',
iconText: 'text-warning'
},
{
border: 'border-l-destructive',
bg: 'bg-destructive/5',
iconBg: 'bg-destructive/15',
iconText: 'text-destructive'
},
{
border: 'border-l-info',
bg: 'bg-info/10',
iconBg: 'bg-info/15',
iconText: 'text-info'
}
] as const;
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
const birdText = $derived.by(() => {
if (!bird?.birdc_configured) return '—';
if (bird.error) return '—';
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
});
const kpiCards = $derived.by(() => [
{
id: 'peers',
label: 'BGP-пиры',
value: initialLoading ? '—' : String(metrics.peersTotal),
description: initialLoading
? ''
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
icon: Share2,
accent: statAccents[0],
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
badgeClass:
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
},
{
id: 'established',
label: 'Активные сессии',
value: initialLoading ? '—' : String(metrics.peersEstablished),
description: 'Established среди включённых пиров',
icon: CheckCircle2,
accent: statAccents[1],
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
badgeClass:
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'speakers',
label: 'Спикеры online',
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
description: 'agent + BGP poll',
icon: Server,
accent: statAccents[2],
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
badgeClass:
metrics.speakersOnline === metrics.speakersTotal
? 'border-success/30 bg-success/15 text-success'
: 'border-warning/30 bg-warning/15 text-warning'
},
{
id: 'drift',
label: 'Drift',
value: initialLoading ? '—' : String(metrics.speakersDrift),
description: 'applied ≠ published',
icon: GitBranch,
accent: statAccents[3],
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
},
{
id: 'poll-errors',
label: 'Ошибки опроса',
value: initialLoading ? '—' : String(metrics.pollErrors),
description: 'agent или BGP poll',
icon: Activity,
accent: statAccents[4],
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
badgeClass:
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'cp-bird',
label: 'BGP на CP',
value: initialLoading ? '—' : birdText,
description: bird?.birdc_configured
? 'Established / total на API-хосте'
: (bird?.message ?? 'birdc не настроен'),
icon: Bird,
accent: statAccents[5],
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
href: '/monitoring' as const
}
]);
</script>
<div class="flex min-w-0 flex-col gap-6">
{#if !initialLoading && !loading}
{#if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc text-sm">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive">
<XCircle />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc text-sm">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{/if}
{/if}
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading || loading}
skeletonCount={6}
class="sm:grid-cols-2 xl:grid-cols-3"
/>
<section class="flex min-w-0 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-base font-semibold">Ноды</h2>
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
<Gauge class="size-3.5" />
Мониторинг API
</Button>
</div>
{#if speakers.length === 0 && !initialLoading && !loading}
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
{:else}
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
{#each speakers as speaker (speaker.id)}
<NetworkSpeakerStatusCard
{speaker}
{peers}
class="h-full"
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
/>
{/each}
</div>
{/if}
</section>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Label } from '$lib/ui/core/label/index.js';
@@ -77,20 +77,21 @@
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function peerNodeLine(s: PeerSessionOnSpeaker): string {
if (s.poll_error) return `${s.label}: опрос недоступен`;
if (s.state === 'Established') return `${s.label}: Established`;
if (s.state === 'absent') return `${s.label}: нет сессии`;
return `${s.label}: ${s.state || '—'}`;
}
function peerConnectedLabel(p: PeerRow): string {
const nodes = p.session_on_speakers ?? [];
if (nodes.length > 0) {
return nodes.map(peerNodeLine).join(' · ');
}
const established = p.established_on_speakers ?? [];
if (established.length > 0) {
const labels = established.map((s) => s.label).join(', ');
return established.length === 1
? `Подключён: ${labels}`
: `Подключён (${established.length} нод): ${labels}`;
}
if (p.connected_speaker_label?.trim()) {
return `На ноде: ${p.connected_speaker_label.trim()}`;
}
if (p.session_on_speakers && p.session_on_speakers.length > 0) {
const parts = p.session_on_speakers.map((s) => `${s.label} (${s.state})`);
return parts.join(', ');
return established.map((s) => `${s.label}: Established`).join(' · ');
}
return 'Не найден на опрошенных нодах';
}
@@ -0,0 +1,203 @@
<script lang="ts">
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
peersForSpeaker,
speakerDisplayStatus,
speakerDispatchError,
speakerHasDrift,
speakerLabel,
speakerLiveAgentError,
speakerLiveBgpError
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Separator } from '$lib/ui/core/separator/index.js';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '$lib/ui/core/sheet/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/ui/core/table/index.js';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
type Props = {
speaker: SpeakerRow | null;
peers: PeerRow[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onApply?: (speaker: SpeakerRow) => void;
};
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
const label = $derived(speaker ? speakerLabel(speaker) : '');
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
const sessions = $derived(speaker?.live?.sessions ?? []);
const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function formatSyncAt(iso: string | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
}
$effect(() => {
onOpenChange?.(open);
});
</script>
<Sheet bind:open>
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
{#if speaker}
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
<SheetHeader class="space-y-1 pr-8 text-left">
<SheetTitle class="truncate">{label}</SheetTitle>
<SheetDescription class="truncate">
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
</SheetDescription>
</SheetHeader>
<div class="flex flex-wrap items-center gap-2">
{#if status}
<Badge variant={status.variant}>{status.label}</Badge>
{/if}
{#if speakerHasDrift(speaker)}
<Badge variant="secondary">Drift</Badge>
{/if}
</div>
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP Established</dt>
<dd class="text-right font-medium tabular-nums">
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
</dd>
{#if speaker.live?.agent_last_sync_at}
<dt class="text-muted-foreground">Последний sync</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.live.agent_last_sync_at)}
</dd>
{/if}
<dt class="text-muted-foreground">Drift (app / pub)</dt>
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
{#if speaker.last_dispatch_at}
<dt class="text-muted-foreground">Dispatch</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.last_dispatch_at)}
</dd>
{/if}
</dl>
{#if dispatchError}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
<AlertDescription class="text-xs leading-relaxed"
>{dispatchError.detail}</AlertDescription
>
</Alert>
{/if}
{#if agentError}
<Alert variant="destructive">
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
</Alert>
{/if}
{#if bgpError}
<Alert variant="destructive">
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
</Alert>
{/if}
{#if onApply && speaker.published_revision_id}
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
Apply revision
</Button>
{/if}
<Separator />
<section class="min-w-0 space-y-2">
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
{#if sessions.length === 0}
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
{:else}
<div class="rounded-md border">
<Table class="table-fixed">
<TableHeader>
<TableRow>
<TableHead class="w-[65%]">Имя</TableHead>
<TableHead class="w-[35%] text-right">Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each sessions as sess, i (sess.name + i)}
<TableRow>
<TableCell class="align-top">
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
{#if sess.neighbor}
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
{sess.neighbor}
</p>
{/if}
</TableCell>
<TableCell class="text-right align-top">
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
{/if}
</section>
<Separator />
<section class="min-w-0 space-y-2">
<h3 class="text-sm font-medium">Пиры на ноде</h3>
{#if relatedPeers.length === 0}
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
{:else}
<ul class="divide-y rounded-md border">
{#each relatedPeers as p (p.id)}
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
<div class="min-w-0 flex-1">
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
{p.name?.trim() || p.neighbor}
</p>
{#if p.session_mismatch}
<p class="mt-0.5 text-xs text-warning">
Mismatch: сессия не на назначенной ноде
</p>
{/if}
</div>
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
</SheetContent>
</Sheet>
@@ -0,0 +1,85 @@
<script lang="ts">
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
speakerBgpText,
speakerDisplayStatus,
speakerHasDrift,
speakerLabel
} from '$lib/network/network-metrics.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { cn } from '$lib/utils.js';
import Server from '@lucide/svelte/icons/server';
type Props = {
speaker: SpeakerRow;
peers?: PeerRow[];
onclick?: () => void;
class?: string;
};
let { speaker, peers = [], onclick, class: className }: Props = $props();
const status = $derived(speakerDisplayStatus(speaker));
const label = $derived(speakerLabel(speaker));
const drift = $derived(speakerHasDrift(speaker));
const peerCount = $derived(
peers.filter(
(p) =>
p.bgp_speaker_id === speaker.id ||
p.bgp_speaker_id === null ||
p.bgp_speaker_id === undefined
).length
);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<Card
class={cn(
'flex h-full flex-col transition-colors',
onclick ? 'cursor-pointer hover:border-primary/35' : '',
className
)}
role={onclick ? 'button' : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={(e) => {
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick();
}
}}
>
<CardHeader class="pb-2">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-sm">
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="truncate" title={label}>{label}</span>
</CardTitle>
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
</div>
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
</div>
</CardHeader>
<CardContent class="mt-auto pt-0">
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP</dt>
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
<dt class="text-muted-foreground">Пиры</dt>
<dd class="tabular-nums">{peerCount}</dd>
<dt class="text-muted-foreground">Drift</dt>
<dd>
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
{drift ? 'есть' : 'нет'}
</Badge>
</dd>
</dl>
</CardContent>
</Card>
@@ -1,6 +1,11 @@
<script lang="ts">
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
import {
speakerBgpText,
speakerDisplayStatus,
speakerHasDrift
} from '$lib/network/network-metrics.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
@@ -29,6 +34,7 @@
import Play from '@lucide/svelte/icons/play';
import Copy from '@lucide/svelte/icons/copy';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Eye from '@lucide/svelte/icons/eye';
type Props = {
items: SpeakerRow[];
@@ -36,9 +42,17 @@
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
onSpeakerSelect?: (speaker: SpeakerRow) => void;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let {
items,
loading = false,
initialLoading = false,
error = null,
onRefresh,
onSpeakerSelect
}: Props = $props();
type SpeakerForm = {
endpoint: string;
@@ -72,6 +86,8 @@
const columns = [
{ id: 'status', label: 'Статус' },
{ id: 'live_agent', label: 'Agent' },
{ id: 'bgp', label: 'BGP' },
{
id: 'agent_domain',
label: 'Agent domain',
@@ -80,7 +96,7 @@
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'drift', label: 'Drift' },
{ id: 'actions', label: '', class: 'w-40' }
{ id: 'actions', label: '', class: 'w-44' }
] as const;
function parseIpv4FromEndpoint(ep: string): string {
@@ -152,16 +168,17 @@
}
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
return 'outline';
return speakerDisplayStatus(s).variant;
}
function statusLabel(s: SpeakerRow): string {
if (s.sync_status === 'synced') return 'Connected';
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
if (s.dispatch_status === 'ok') return 'Synced';
return 'Unknown';
return speakerDisplayStatus(s).label;
}
function liveAgentLabel(s: SpeakerRow): string {
if (!s.live) return '—';
if (s.live.agent_ok === true) return 'OK';
return s.live.agent_error ? 'Error' : 'Offline';
}
function driftLabel(s: SpeakerRow): string {
@@ -333,16 +350,29 @@ CF_DNS_API_TOKEN=<cloudflare token>
{#snippet cell({ row: s, column })}
{#if column.id === 'status'}
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
{:else if column.id === 'live_agent'}
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
{:else if column.id === 'bgp'}
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
{:else if column.id === 'agent_domain'}
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'drift'}
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
<span
class="font-mono text-xs text-muted-foreground"
title="applied / published"
class:text-warning={speakerHasDrift(s)}
>
{driftLabel(s)}
</span>
{:else if column.id === 'actions'}
<div class="flex flex-wrap gap-1">
{#if onSpeakerSelect}
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
<Eye class="size-3" />
</Button>
{/if}
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
<Copy class="size-3" />
</Button>
@@ -0,0 +1,120 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
aggregateNetworkMetrics,
collectNetworkIssues,
deriveNetworkOverallStatus,
networkOverallStatusHint,
networkOverallStatusLabel
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/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 CheckCircle from '@lucide/svelte/icons/check-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import XCircle from '@lucide/svelte/icons/x-circle';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import NetworkIcon from '@lucide/svelte/icons/network';
type Props = {
peers: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let { peers, speakers, loading = false, initialLoading = false, error = null }: Props = $props();
const metrics = $derived(aggregateNetworkMetrics(peers, speakers));
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
const issues = $derived(collectNetworkIssues(peers, speakers, 3));
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-base">
<NetworkIcon class="size-4" />
Сеть (BGP)
</CardTitle>
<CardDescription>Live-статус пиров и спикеров</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
Подробнее
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="space-y-3 p-4 pt-4">
{#if error}
<p class="text-sm text-destructive">{error}</p>
{:else if initialLoading || loading}
<p class="text-sm text-muted-foreground">Загрузка live-метрик…</p>
{:else if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5 py-3">
<CheckCircle class="text-success" />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5 py-3">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive" class="py-3">
<XCircle />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{/if}
<div class="flex flex-wrap gap-4 text-sm">
<div>
<p class="text-muted-foreground">Пиры Established</p>
<p class="text-xl font-bold tabular-nums">
{initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`}
</p>
</div>
<div>
<p class="text-muted-foreground">Спикеры online</p>
<p class="text-xl font-bold tabular-nums">
{initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`}
</p>
</div>
<div>
<p class="text-muted-foreground">Drift</p>
<p class="text-xl font-bold tabular-nums">{initialLoading ? '—' : metrics.speakersDrift}</p>
</div>
</div>
</CardContent>
</Card>
+295
View File
@@ -0,0 +1,295 @@
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
export type NetworkOverallStatus = 'ok' | 'warn' | 'error';
export type NetworkMetrics = {
peersTotal: number;
peersEnabled: number;
peersEstablished: number;
peersMismatch: number;
speakersTotal: number;
speakersOnline: number;
speakersRemote: number;
speakersRemoteOnline: number;
speakersDrift: number;
pollErrors: number;
hasLiveData: boolean;
};
export type SpeakerStatusBadge = {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
};
export type NetworkIssue = {
id: string;
message: string;
severity: 'warn' | 'error';
};
function isRemoteSpeaker(s: SpeakerRow): boolean {
const role = (s.role ?? '').toLowerCase();
return role !== 'master' && Boolean(s.agent_domain?.trim());
}
export function speakerHasDrift(s: SpeakerRow): boolean {
const pub = s.published_revision_id?.trim();
if (!pub) return false;
return (s.last_applied_revision_id ?? '') !== pub;
}
export function speakerIsOnline(s: SpeakerRow): boolean {
if (s.live) {
return s.live.agent_ok === true && s.live.bgp_poll_ok !== false;
}
if (s.sync_status === 'synced') return true;
if (s.sync_status === 'error' || s.last_dispatch_error) return false;
return s.dispatch_status === 'ok';
}
export function speakerDisplayStatus(s: SpeakerRow): SpeakerStatusBadge {
if (s.live) {
if (s.live.agent_ok === true && s.live.bgp_poll_ok !== false) {
return { label: 'Online', variant: 'default' };
}
if (s.live.bgp_poll_error || s.live.agent_error) {
return { label: 'Offline', variant: 'destructive' };
}
return { label: 'Degraded', variant: 'secondary' };
}
if (s.sync_status === 'synced') return { label: 'Connected', variant: 'default' };
if (s.sync_status === 'error' || s.last_dispatch_error) {
return { label: 'Offline', variant: 'destructive' };
}
if (s.dispatch_status === 'ok') return { label: 'Synced', variant: 'outline' };
return { label: 'Unknown', variant: 'outline' };
}
export function speakerLabel(s: SpeakerRow): string {
return s.live?.label ?? s.agent_domain ?? s.endpoint ?? s.id;
}
export function speakerBgpText(s: SpeakerRow): string {
if (s.live) {
return `${s.live.bgp_established ?? 0}/${s.live.bgp_sessions_total ?? 0}`;
}
return '—';
}
export function peersForSpeaker(peers: PeerRow[], speakerId: string): PeerRow[] {
return peers.filter(
(p) =>
p.bgp_speaker_id === speakerId || p.bgp_speaker_id === null || p.bgp_speaker_id === undefined
);
}
export function aggregateNetworkMetrics(
peers: PeerRow[],
speakers: SpeakerRow[],
bird?: BirdStatus | null
): NetworkMetrics {
const enabledPeers = peers.filter((p) => p.enabled !== false);
const established = enabledPeers.filter((p) => p.session_state === 'Established').length;
const mismatch = peers.filter((p) => p.session_mismatch).length;
const remoteSpeakers = speakers.filter(isRemoteSpeaker);
const online = speakers.filter(speakerIsOnline).length;
const remoteOnline = remoteSpeakers.filter(speakerIsOnline).length;
const drift = speakers.filter(speakerHasDrift).length;
const pollErrors = speakers.filter(
(s) => s.live?.bgp_poll_error || (s.live && s.live.agent_ok === false)
).length;
const hasLiveData =
speakers.some((s) => s.live != null) || peers.some((p) => p.session_on_speakers);
void bird;
return {
peersTotal: peers.length,
peersEnabled: enabledPeers.length,
peersEstablished: established,
peersMismatch: mismatch,
speakersTotal: speakers.length,
speakersOnline: online,
speakersRemote: remoteSpeakers.length,
speakersRemoteOnline: remoteOnline,
speakersDrift: drift,
pollErrors,
hasLiveData
};
}
export function deriveNetworkOverallStatus(metrics: NetworkMetrics): NetworkOverallStatus {
if (!metrics.hasLiveData && metrics.speakersTotal === 0 && metrics.peersTotal === 0) {
return 'ok';
}
const enabledNotEstablished =
metrics.peersEnabled > 0 ? metrics.peersEnabled - metrics.peersEstablished : 0;
const majorityPeersDown =
metrics.peersEnabled > 0 && enabledNotEstablished / metrics.peersEnabled > 0.5;
if ((metrics.speakersRemote > 0 && metrics.speakersRemoteOnline === 0) || majorityPeersDown) {
return 'error';
}
if (
metrics.pollErrors > 0 ||
metrics.peersMismatch > 0 ||
metrics.speakersDrift > 0 ||
metrics.speakersOnline < metrics.speakersTotal
) {
return 'warn';
}
return 'ok';
}
export function networkOverallStatusLabel(status: NetworkOverallStatus): string {
switch (status) {
case 'ok':
return 'В норме';
case 'warn':
return 'Требует внимания';
case 'error':
return 'Проблема';
}
}
export function networkOverallStatusHint(
status: NetworkOverallStatus,
metrics: NetworkMetrics
): string {
switch (status) {
case 'ok':
return metrics.hasLiveData
? `${metrics.peersEstablished} Established, ${metrics.speakersOnline}/${metrics.speakersTotal} спикеров online`
: 'Сеть настроена; обновите для live-статуса';
case 'warn':
return 'Есть drift, mismatch или недоступные ноды — проверьте детали';
case 'error':
return 'Критичная деградация BGP или все remote-ноды недоступны';
}
}
export function collectNetworkIssues(
peers: PeerRow[],
speakers: SpeakerRow[],
limit = 3
): NetworkIssue[] {
const issues: NetworkIssue[] = [];
for (const s of speakers) {
if (!speakerIsOnline(s)) {
issues.push({
id: `speaker-offline-${s.id}`,
message: `Нода offline: ${speakerLabel(s)}`,
severity: 'error'
});
} else if (speakerHasDrift(s)) {
issues.push({
id: `speaker-drift-${s.id}`,
message: `Drift ревизии: ${speakerLabel(s)}`,
severity: 'warn'
});
} else if (s.live?.bgp_poll_error) {
issues.push({
id: `speaker-poll-${s.id}`,
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
severity: 'warn'
});
} else if (s.last_dispatch_error) {
const err = formatSpeakerError(s.last_dispatch_error);
issues.push({
id: `speaker-dispatch-${s.id}`,
message: `Dispatch: ${speakerLabel(s)}${err ? `${err.detail.slice(0, 80)}` : ''}`,
severity: 'warn'
});
}
}
for (const p of peers) {
if (p.session_mismatch) {
const name = p.name?.trim() || p.neighbor;
issues.push({
id: `peer-mismatch-${p.id}`,
message: `Mismatch сессии: ${name}`,
severity: 'warn'
});
}
}
return issues.slice(0, limit);
}
export const NETWORK_AUTO_REFRESH_KEY = 'evobgp.network.autoRefresh';
export const NETWORK_AUTO_REFRESH_MS = 15_000;
export function readNetworkAutoRefresh(): boolean {
if (typeof localStorage === 'undefined') return false;
return localStorage.getItem(NETWORK_AUTO_REFRESH_KEY) === '1';
}
export function writeNetworkAutoRefresh(enabled: boolean): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
}
export type FormattedSpeakerError = {
title: string;
detail: string;
};
/** Humanize stored dispatch/agent errors (avoid raw JSON in UI). */
export function formatSpeakerError(raw: string | null | undefined): FormattedSpeakerError | null {
if (!raw?.trim()) return null;
const text = raw.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const obj = JSON.parse(jsonMatch[0]) as {
detail?: string;
title?: string;
status?: number;
};
const detail = String(obj.detail ?? text);
if (/403/.test(detail) && /bundle/i.test(detail)) {
return {
title: 'Dispatch: доступ к бандлу',
detail:
'Нода не смогла скачать бандл с CP (403). Проверьте node API-ключ (роль node) и EVOBGP_NODE_TOKEN на реплике — см. docs/access.md.'
};
}
const httpPrefix = text.match(/^HTTP \d+:\s*/)?.[0] ?? '';
return {
title: obj.title && obj.title !== 'Bad Gateway' ? obj.title : 'Ошибка dispatch',
detail: httpPrefix ? `${httpPrefix.trim()} ${detail}`.trim() : detail
};
} catch {
/* fall through */
}
}
if (/^HTTP \d+:/.test(text)) {
return { title: 'Ошибка HTTP', detail: text };
}
return { title: 'Ошибка', detail: text };
}
export function speakerDispatchError(s: SpeakerRow): FormattedSpeakerError | null {
const liveRev = s.live?.agent_last_applied_revision_id?.trim();
const pub = s.published_revision_id?.trim();
// CP meta can keep a stale dispatch error after a later successful agent sync.
if (s.live?.agent_ok && liveRev && pub && liveRev === pub) {
return null;
}
return formatSpeakerError(s.last_dispatch_error);
}
export function speakerLiveAgentError(s: SpeakerRow): FormattedSpeakerError | null {
return formatSpeakerError(s.live?.agent_error);
}
export function speakerLiveBgpError(s: SpeakerRow): FormattedSpeakerError | null {
return formatSpeakerError(s.live?.bgp_poll_error);
}
@@ -45,7 +45,7 @@
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid gap-4', className)}>
<div class={cn('grid auto-rows-fr gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
@@ -56,7 +56,7 @@
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm',
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
@@ -96,7 +96,7 @@
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="space-y-2">
<CardContent class="mt-auto space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
+51 -16
View File
@@ -31,6 +31,8 @@
import { notifyApiError } from '$lib/ui/app/toast.js';
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
import OverviewNetworkStatusCard from '$lib/components/overview/OverviewNetworkStatusCard.svelte';
import { aggregateNetworkMetrics } from '$lib/network/network-metrics.js';
import { cn } from '$lib/utils.js';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import XCircle from '@lucide/svelte/icons/x-circle';
@@ -47,6 +49,7 @@
import Share2 from '@lucide/svelte/icons/share-2';
import Play from '@lucide/svelte/icons/play';
import Gauge from '@lucide/svelte/icons/gauge';
import NetworkIcon from '@lucide/svelte/icons/network';
let healthy = $state<boolean | null>(null);
let moduleItems = $state<ModuleRow[]>([]);
@@ -106,6 +109,8 @@
return suffix;
}
const networkMetrics = $derived(aggregateNetworkMetrics(peerItems, speakerItems));
const kpiCards = $derived.by(() => [
{
id: 'modules',
@@ -120,22 +125,40 @@
{
id: 'peers',
label: 'Пиры',
value: initialLoading ? '—' : String(peerItems.length),
href: '/network' as const,
value: initialLoading
? '—'
: `${networkMetrics.peersEstablished}/${networkMetrics.peersEnabled}`,
href: '/network?tab=peers' as const,
icon: GitBranch,
description: 'BGP-соседи',
description: 'Established / включённых',
accent: statAccents[1],
badge: countBadge(peerItems.length, peersHasMore, 'peers')
badge:
networkMetrics.peersMismatch > 0
? `mismatch ${networkMetrics.peersMismatch}`
: countBadge(peerItems.length, peersHasMore, 'peers'),
badgeClass:
networkMetrics.peersMismatch > 0
? 'border-warning/30 bg-warning/15 text-warning'
: undefined
},
{
id: 'speakers',
label: 'Спикеры',
value: initialLoading ? '—' : String(speakerItems.length),
href: '/network' as const,
value: initialLoading
? '—'
: `${networkMetrics.speakersOnline}/${networkMetrics.speakersTotal}`,
href: '/network?tab=overview' as const,
icon: Radio,
description: 'BIRD-агенты',
description: 'online / всего',
accent: statAccents[2],
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
badge:
networkMetrics.speakersOnline < networkMetrics.speakersTotal
? 'есть offline'
: countBadge(speakerItems.length, speakersHasMore, 'agents'),
badgeClass:
networkMetrics.speakersOnline === networkMetrics.speakersTotal
? 'border-success/30 bg-success/15 text-success'
: 'border-warning/30 bg-warning/15 text-warning'
},
{
id: 'revisions',
@@ -183,8 +206,8 @@
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'),
apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
apiJSON<JobsResponse>('/v1/jobs?limit=10')
]);
@@ -251,11 +274,12 @@
<Info class="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
ревизии
Сводка по модулям, сети и фоновым задачам. BGP и ноды —
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}>Сеть</Button
>, префиксы
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой —
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
здоровье системы
здоровье API
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
</AlertDescription>
</Alert>
@@ -301,7 +325,7 @@
class="sm:grid-cols-2 lg:grid-cols-3"
/>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-4 lg:grid-cols-3">
<OverviewRecentJobsCard
items={recentJobs}
{moduleNameById}
@@ -315,6 +339,13 @@
{initialLoading}
error={loadError}
/>
<OverviewNetworkStatusCard
peers={peerItems}
speakers={speakerItems}
loading={refreshing}
{initialLoading}
error={loadError}
/>
</div>
<Card>
@@ -331,7 +362,11 @@
<Tags class="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" href={resolve('/network')}>
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
<NetworkIcon class="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" href={resolve('/network?tab=peers')}>
<Share2 class="size-4" />
Добавить пира
</Button>
+7 -3
View File
@@ -198,7 +198,7 @@
? 'border-success/30 bg-success/15 text-success'
: undefined,
error: birdError ?? bird?.error ?? null,
href: '/network' as const
href: '/network?tab=overview' as const
},
{
id: 'jobs',
@@ -468,7 +468,9 @@
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}
>Пиры и спикеры</Button
>
</div>
</CardHeader>
<CardContent class="space-y-4">
@@ -633,7 +635,9 @@
<AlertTitle>Низкий ratio BGP</AlertTitle>
<AlertDescription>
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
<Button variant="link" class="h-auto p-0" href={resolve('/network?tab=overview')}
>Сети</Button
>.
</AlertDescription>
</Alert>
<Alert>
+84 -78
View File
@@ -4,94 +4,50 @@
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 type {
BirdStatus,
PeerRow,
PeersResponse,
SpeakerRow,
SpeakersResponse
} from '$lib/api/types.js';
import { NETWORK_AUTO_REFRESH_MS } from '$lib/network/network-metrics.js';
import { Button } from '$lib/ui/core/button/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 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 BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
import NetworkOverviewTab from '$lib/components/network/NetworkOverviewTab.svelte';
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
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';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
type NetworkTab = 'peers' | 'speakers' | 'control-plane';
type NetworkTab = 'overview' | 'peers' | 'speakers' | 'control-plane';
function parseNetworkTab(value: string | null): NetworkTab {
if (value === 'speakers' || value === 'control-plane') return value;
return 'peers';
if (value === 'peers' || value === 'speakers' || value === 'control-plane') return value;
return 'overview';
}
let peers = $state<PeerRow[]>([]);
let speakers = $state<SpeakerRow[]>([]);
let birdStatus = $state<BirdStatus | null>(null);
let peersLoading = $state(false);
let speakersLoading = $state(false);
let initialLoading = $state(true);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
let activeTab = $state<NetworkTab>('peers');
let activeTab = $state<NetworkTab>('overview');
let tabSyncReady = $state(false);
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'peers',
label: 'BGP-пиры',
value: initialLoading ? '—' : String(peers.length),
description: 'настроенные BGP-соседи',
icon: Share2,
accent: statAccents[0],
badge: 'peers'
},
{
id: 'established',
label: 'Активные сессии',
value: initialLoading ? '—' : String(establishedCount),
description:
establishedCount > 0 ? 'Established из текущей выборки' : 'нет установленных сессий',
icon: CheckCircle2,
accent: statAccents[1],
badge: establishedCount > 0 ? 'Established' : 'нет сессий',
badgeClass: establishedCount > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'speakers',
label: 'Спикеры',
value: initialLoading ? '—' : String(speakers.length),
description: 'BIRD-агенты на нодах',
icon: Server,
accent: statAccents[2],
badge: 'agents'
}
]);
let autoRefresh = $state(false);
let detailSpeaker = $state<SpeakerRow | null>(null);
let detailOpen = $state(false);
const refreshing = $derived(peersLoading || speakersLoading);
@@ -112,7 +68,7 @@
async function loadSpeakers() {
speakersLoading = true;
try {
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200');
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1');
speakers = sr.items;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
@@ -123,10 +79,18 @@
}
}
async function loadBirdStatus() {
try {
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
} catch {
birdStatus = null;
}
}
async function load() {
loadError = null;
try {
await Promise.all([loadPeers(), loadSpeakers()]);
await Promise.all([loadPeers(), loadSpeakers(), loadBirdStatus()]);
lastUpdated = new Date();
} catch {
// errors handled in loaders
@@ -153,6 +117,16 @@
}
}
function openSpeakerDetail(speaker: SpeakerRow) {
detailSpeaker = speaker;
detailOpen = true;
}
function handleApplyFromDetail(_speaker: SpeakerRow) {
detailOpen = false;
activeTab = 'speakers';
}
onMount(() => {
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
@@ -162,7 +136,7 @@
function syncTabToUrl(tab: NetworkTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'peers') url.searchParams.delete('tab');
if (tab === 'overview') 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}`) {
@@ -174,18 +148,38 @@
if (!tabSyncReady) return;
syncTabToUrl(activeTab);
});
$effect(() => {
if (!autoRefresh) return;
const id = setInterval(() => {
void load();
}, NETWORK_AUTO_REFRESH_MS);
return () => clearInterval(id);
});
$effect(() => {
if (detailSpeaker) {
const updated = speakers.find((s) => s.id === detailSpeaker!.id);
if (updated) detailSpeaker = updated;
}
});
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Сеть"
description={lastUpdated
? `BGP-пиры и спикеры (BIRD-агенты). Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'BGP-пиры и спикеры (BIRD-агенты).'}
? `BGP-топология CP и нод. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'BGP-пиры, спикеры и live-метрики нод.'}
icon={NetworkIcon}
iconClass="bg-chart-3/15 text-chart-3"
>
{#snippet actions()}
<Button variant="ghost" size="sm" href={resolve('/')}>
<LayoutDashboard class="size-3.5" />
Обзор
</Button>
<NetworkAutoRefreshToggle bind:enabled={autoRefresh} disabled={refreshing} />
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
@@ -197,26 +191,30 @@
<Info class="text-info" />
<AlertTitle>О сетевой конфигурации</AlertTitle>
<AlertDescription>
Пиры привязаны к спикерам (BIRD-агентам). Apply запускает применение ревизии на ноде. Полный
список ревизий и задач — на странице
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии на
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
</AlertDescription>
</Alert>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Tabs bind:value={activeTab}>
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="peers">Пиры</TabsTrigger>
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-4 min-w-0">
<NetworkOverviewTab
{peers}
{speakers}
bird={birdStatus}
loading={refreshing}
{initialLoading}
onSpeakerSelect={openSpeakerDetail}
/>
</TabsContent>
<TabsContent value="peers" class="mt-4">
<NetworkPeersCard
items={peers}
@@ -235,6 +233,7 @@
{initialLoading}
error={loadError}
onRefresh={refreshSpeakers}
onSpeakerSelect={openSpeakerDetail}
/>
</TabsContent>
@@ -243,3 +242,10 @@
</TabsContent>
</Tabs>
</div>
<NetworkSpeakerDetailSheet
speaker={detailSpeaker}
{peers}
bind:open={detailOpen}
onApply={handleApplyFromDetail}
/>