diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 30d3fdb..cf9fc8b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 29f90e0..ecdb8a2 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -385,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, diff --git a/internal/httpapi/speakers_live.go b/internal/httpapi/speakers_live.go new file mode 100644 index 0000000..3ecd82c --- /dev/null +++ b/internal/httpapi/speakers_live.go @@ -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 +} diff --git a/internal/httpapi/speakers_live_test.go b/internal/httpapi/speakers_live_test.go new file mode 100644 index 0000000..a620acf --- /dev/null +++ b/internal/httpapi/speakers_live_test.go @@ -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) + } +} diff --git a/internal/nodedispatch/dispatch.go b/internal/nodedispatch/dispatch.go index 7eeacd5..ba44bd5 100644 --- a/internal/nodedispatch/dispatch.go +++ b/internal/nodedispatch/dispatch.go @@ -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" } diff --git a/internal/nodedispatch/health.go b/internal/nodedispatch/health.go new file mode 100644 index 0000000..a1eeba4 --- /dev/null +++ b/internal/nodedispatch/health.go @@ -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 +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 1e425b5..a871031 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -173,7 +173,14 @@ export type PeerRow = { established_on_speakers?: PeerSessionOnSpeaker[]; session_mismatch?: boolean; }; -export type PeersResponse = Page; +export type LiveSpeakerPoll = { + speaker_id: string; + label: string; + ok: boolean; + session_count: number; + poll_error?: string; +}; +export type PeersResponse = Page & { live_speaker_poll?: LiveSpeakerPoll[] }; export type BgpPeerCreate = { name?: string; neighbor: string; @@ -184,6 +191,25 @@ export type BgpPeerCreate = { export type BgpPeerPatch = Partial; // ---- 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; @@ -200,6 +226,7 @@ export type SpeakerRow = { last_dispatch_error?: string | null; meta_json?: Record; agent_secret?: string; + live?: SpeakerLiveStatus; }; export type SpeakersResponse = Page; export type BgpSpeakerCreate = { diff --git a/web/src/lib/components/network/NetworkAutoRefreshToggle.svelte b/web/src/lib/components/network/NetworkAutoRefreshToggle.svelte new file mode 100644 index 0000000..c1a0ba1 --- /dev/null +++ b/web/src/lib/components/network/NetworkAutoRefreshToggle.svelte @@ -0,0 +1,30 @@ + + +
+ + +
diff --git a/web/src/lib/components/network/NetworkOverviewTab.svelte b/web/src/lib/components/network/NetworkOverviewTab.svelte new file mode 100644 index 0000000..49317fb --- /dev/null +++ b/web/src/lib/components/network/NetworkOverviewTab.svelte @@ -0,0 +1,236 @@ + + +{#if !initialLoading && !loading} + {#if overallStatus === 'ok'} + + + {networkOverallStatusLabel(overallStatus)} + {overallHint} + + {:else if overallStatus === 'warn'} + + + {networkOverallStatusLabel(overallStatus)} + + {overallHint} + {#if issues.length > 0} +
    + {#each issues as issue (issue.id)} +
  • {issue.message}
  • + {/each} +
+ {/if} +
+
+ {:else} + + + {networkOverallStatusLabel(overallStatus)} + + {overallHint} + {#if issues.length > 0} +
    + {#each issues as issue (issue.id)} +
  • {issue.message}
  • + {/each} +
+ {/if} +
+
+ {/if} +{/if} + + + +
+

Ноды

+ +
+ +{#if speakers.length === 0 && !initialLoading && !loading} +

Спикеры не зарегистрированы.

+{:else} +
+ {#each speakers as speaker (speaker.id)} + onSpeakerSelect(speaker) : undefined} + /> + {/each} +
+{/if} diff --git a/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte b/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte new file mode 100644 index 0000000..ba385c8 --- /dev/null +++ b/web/src/lib/components/network/NetworkSpeakerDetailSheet.svelte @@ -0,0 +1,172 @@ + + + + + {#if speaker} + + {label} + + {speaker.role} · {speaker.agent_domain ?? speaker.endpoint} + + + +
+
+ {#if status} + {status.label} + {/if} + {#if speakerHasDrift(speaker)} + Drift + {/if} +
+ +
+
+ BGP Established + + {speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'} + +
+ {#if speaker.live?.agent_last_sync_at} +
+ Последний sync + {speaker.live.agent_last_sync_at} +
+ {/if} +
+ Drift (app / pub) + {driftLabel(speaker)} +
+ {#if speaker.last_dispatch_at} +
+ Dispatch + {speaker.last_dispatch_at} +
+ {/if} + {#if speaker.last_dispatch_error} +

{speaker.last_dispatch_error}

+ {/if} + {#if speaker.live?.agent_error} +

Agent: {speaker.live.agent_error}

+ {/if} + {#if speaker.live?.bgp_poll_error} +

BGP poll: {speaker.live.bgp_poll_error}

+ {/if} +
+ + {#if onApply && speaker.published_revision_id} + + {/if} + + + +
+

BGP-сессии (live)

+ {#if sessions.length === 0} +

Нет данных или сессий нет.

+ {:else} + + + + Имя + Состояние + + + + {#each sessions as sess, i (sess.name + i)} + + + {sess.name} + {#if sess.neighbor} +
{sess.neighbor}
+ {/if} +
+ + {sess.state} + +
+ {/each} +
+
+ {/if} +
+ + + +
+

Пиры на ноде

+ {#if relatedPeers.length === 0} +

Нет привязанных пиров.

+ {:else} +
    + {#each relatedPeers as p (p.id)} +
  • +
    + {p.name?.trim() || p.neighbor} + {p.session_state || '—'} +
    + {#if p.session_mismatch} +

    Mismatch: сессия не на назначенной ноде

    + {/if} +
  • + {/each} +
+ {/if} +
+
+ {/if} +
+
diff --git a/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte b/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte new file mode 100644 index 0000000..5f8b39e --- /dev/null +++ b/web/src/lib/components/network/NetworkSpeakerStatusCard.svelte @@ -0,0 +1,87 @@ + + + + { + if (onclick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onclick(); + } + }} +> + +
+
+ + + {label} + + {speaker.role} +
+ {status.label} +
+
+ +
+ BGP + {speakerBgpText(speaker)} +
+
+ Пиры + {peerCount} +
+
+ Drift + + {drift ? 'есть' : 'нет'} + +
+
+
diff --git a/web/src/lib/components/network/NetworkSpeakersCard.svelte b/web/src/lib/components/network/NetworkSpeakersCard.svelte index 29920ce..c55b1e6 100644 --- a/web/src/lib/components/network/NetworkSpeakersCard.svelte +++ b/web/src/lib/components/network/NetworkSpeakersCard.svelte @@ -1,6 +1,11 @@ + + + +
+ + + Сеть (BGP) + + Live-статус пиров и спикеров +
+ +
+ + {#if error} +

{error}

+ {:else if initialLoading || loading} +

Загрузка live-метрик…

+ {:else if overallStatus === 'ok'} + + + {networkOverallStatusLabel(overallStatus)} + {overallHint} + + {:else if overallStatus === 'warn'} + + + {networkOverallStatusLabel(overallStatus)} + + {overallHint} + {#if issues.length > 0} +
    + {#each issues as issue (issue.id)} +
  • {issue.message}
  • + {/each} +
+ {/if} +
+
+ {:else} + + + {networkOverallStatusLabel(overallStatus)} + + {overallHint} + {#if issues.length > 0} +
    + {#each issues as issue (issue.id)} +
  • {issue.message}
  • + {/each} +
+ {/if} +
+
+ {/if} + +
+
+

Пиры Established

+

+ {initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`} +

+
+
+

Спикеры online

+

+ {initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`} +

+
+
+

Drift

+

{initialLoading ? '—' : metrics.speakersDrift}

+
+
+
+
diff --git a/web/src/lib/network/network-metrics.ts b/web/src/lib/network/network-metrics.ts new file mode 100644 index 0000000..f67b0f9 --- /dev/null +++ b/web/src/lib/network/network-metrics.ts @@ -0,0 +1,228 @@ +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' + }); + } + } + + 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'); +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 79cdb70..fa45977 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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(null); let moduleItems = $state([]); @@ -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('/v1/modules?limit=200'), - apiJSON('/v1/peers?limit=200'), - apiJSON('/v1/speakers?limit=200'), + apiJSON('/v1/peers?limit=200&live=1'), + apiJSON('/v1/speakers?limit=200&live=1'), apiJSON('/v1/revisions?limit=10'), apiJSON('/v1/jobs?limit=10') ]); @@ -251,11 +274,12 @@ Панель управления EvoBGP - Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице - , деплой и - ревизии — + Сводка по модулям, сети и фоновым задачам. BGP и ноды — + , префиксы — + , деплой — , - здоровье системы — + здоровье API — . @@ -301,7 +325,7 @@ class="sm:grid-cols-2 lg:grid-cols-3" /> -
+
+
@@ -331,7 +362,11 @@ Добавить community - + diff --git a/web/src/routes/monitoring/+page.svelte b/web/src/routes/monitoring/+page.svelte index 7457c75..cc6846d 100644 --- a/web/src/routes/monitoring/+page.svelte +++ b/web/src/routes/monitoring/+page.svelte @@ -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 @@ GET /v1/bird/status
- + @@ -633,7 +635,9 @@ Низкий ratio BGP Проверьте /v1/bird/status, затем состояние пиров в - . + . diff --git a/web/src/routes/network/+page.svelte b/web/src/routes/network/+page.svelte index 02769a6..06d9054 100644 --- a/web/src/routes/network/+page.svelte +++ b/web/src/routes/network/+page.svelte @@ -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([]); let speakers = $state([]); + let birdStatus = $state(null); let peersLoading = $state(false); let speakersLoading = $state(false); let initialLoading = $state(true); let loadError = $state(null); let lastUpdated = $state(null); - let activeTab = $state('peers'); + let activeTab = $state('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(null); + let detailOpen = $state(false); const refreshing = $derived(peersLoading || speakersLoading); @@ -112,7 +68,7 @@ async function loadSpeakers() { speakersLoading = true; try { - const sr = await apiJSON('/v1/speakers?limit=200'); + const sr = await apiJSON('/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('/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; + } + });
{#snippet actions()} + + . - - + Обзор Пиры Спикеры Control plane + + + + @@ -243,3 +242,10 @@
+ +