Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1ada06a76 | ||
|
|
b5ed47902c | ||
|
|
8a19c2a3f4 | ||
|
|
1c0d78b552 | ||
|
|
e0a912a693 | ||
|
|
9740a34fdc |
@@ -27,9 +27,7 @@ services:
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
net.ipv4.ip_forward: "1"
|
||||
net.ipv6.conf.all.forwarding: "1"
|
||||
# sysctls нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md)
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
|
||||
@@ -755,6 +755,42 @@ components:
|
||||
bgp_speaker_id:
|
||||
type: ["string", "null"]
|
||||
description: "`null` - политика для всех спикеров."
|
||||
connected_speaker_id:
|
||||
type: ["string", "null"]
|
||||
description: >
|
||||
Live (GET /v1/peers?live=1): спикер, на котором сессия Established; опрос CP birdc + agent /v1/agent/bird/protocols.
|
||||
connected_speaker_label:
|
||||
type: string
|
||||
description: Человекочитаемая метка ноды из live-опроса.
|
||||
session_on_speakers:
|
||||
type: array
|
||||
description: Состояние протокола пира на каждой опрошенной ноде.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
speaker_id:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
established_on_speakers:
|
||||
type: array
|
||||
description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких).
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
speaker_id:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
session_mismatch:
|
||||
type: boolean
|
||||
description: >
|
||||
true если bgp_speaker_id задан, но на этой ноде нет Established
|
||||
(сессия может быть на других нодах — это не ошибка для tenant-wide пиров).
|
||||
policies_json:
|
||||
type: string
|
||||
description: >
|
||||
@@ -809,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:
|
||||
@@ -2156,6 +2275,13 @@ paths:
|
||||
- $ref: "#/components/parameters/Cursor"
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/SpeakerFilter"
|
||||
- name: live
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: ["1"]
|
||||
description: >
|
||||
Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*.
|
||||
responses:
|
||||
"200":
|
||||
description: Успешно.
|
||||
@@ -2173,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:
|
||||
@@ -2293,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: Успешно.
|
||||
@@ -2379,6 +2519,21 @@ paths:
|
||||
$ref: "#/components/responses/NotFound"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
delete:
|
||||
tags: [Speakers]
|
||||
summary: Удалить спикер
|
||||
description: >
|
||||
Удаляет BGP-спикер. Пиры с `bgp_speaker_id` этого спикера остаются, привязка сбрасывается (ON DELETE SET NULL).
|
||||
operationId: deleteSpeaker
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
responses:
|
||||
"204":
|
||||
description: Удалено.
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
/v1/revisions:
|
||||
get:
|
||||
@@ -2612,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]
|
||||
|
||||
+15
-1
@@ -47,10 +47,22 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch + health |
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, **`GET /v1/agent/bird/protocols`** (live peer sessions) |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | ACME | Traefik → 443 |
|
||||
|
||||
## Подготовка VPS (перед `docker compose up`)
|
||||
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1
|
||||
echo 'net.ipv4.ip_forward=1' | tee /etc/sysctl.d/99-evobgp-bird.conf
|
||||
echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.d/99-evobgp-bird.conf
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
## Безопасность (три участка)
|
||||
|
||||
1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`.
|
||||
@@ -82,6 +94,8 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
||||
|
||||
| Симптом | Проверка |
|
||||
|---------|----------|
|
||||
| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) |
|
||||
| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` |
|
||||
| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist |
|
||||
| dispatch error | CP logs job meta; firewall 443; `agent_secret` |
|
||||
| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed |
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodecli"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,7 @@ type Server struct {
|
||||
func New(cfg Config) *Server {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
|
||||
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
|
||||
return s
|
||||
}
|
||||
@@ -68,6 +70,30 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) handleBirdProtocols(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
return
|
||||
}
|
||||
sock := strings.TrimSpace(s.cfg.Socket)
|
||||
if sock == "" {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "EVOBGP_BIRDC_SOCKET not configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
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())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"sessions": birdfmt.ParseBGPSessions(out),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorize(r) {
|
||||
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package birdfmt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BGPSession is one BGP protocol block from `birdc show protocols all`.
|
||||
type BGPSession struct {
|
||||
Name string `json:"name"`
|
||||
Neighbor string `json:"neighbor,omitempty"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output.
|
||||
func ParseBGPSessions(output string) []BGPSession {
|
||||
var out []BGPSession
|
||||
var cur *BGPSession
|
||||
for _, raw := range strings.Split(output, "\n") {
|
||||
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") {
|
||||
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
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package birdfmt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBGPSessions_neighborAndState(t *testing.T) {
|
||||
sample := `
|
||||
BIRD 2.14 ready.
|
||||
Name Proto Table State Since Info
|
||||
device1 Device --- up 10:00:00
|
||||
evobgp_p_abc123 BGP master4 up 10:00:05 Established
|
||||
Neighbor address: 198.51.100.2
|
||||
Neighbor AS: 65001
|
||||
evobgp_p_def456 BGP master4 up 10:00:06 Active
|
||||
Neighbor address: 2001:db8::2
|
||||
`
|
||||
sessions := ParseBGPSessions(sample)
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("got %d sessions want 2", len(sessions))
|
||||
}
|
||||
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
|
||||
t.Fatalf("session0: %+v", sessions[0])
|
||||
}
|
||||
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
|
||||
t.Fatalf("session1: %+v", sessions[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package birdfmt
|
||||
|
||||
import "strings"
|
||||
|
||||
// PeerProtocolName returns the BIRD protocol name for a control-plane peer UUID.
|
||||
// Must stay in sync with pipeline peer rendering.
|
||||
func PeerProtocolName(peerID string) string {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
||||
if len(s) > 16 {
|
||||
s = s[:16]
|
||||
}
|
||||
if s == "" {
|
||||
s = "x"
|
||||
}
|
||||
return "evobgp_p_" + s
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
const peerLiveCacheTTL = 15 * time.Second
|
||||
|
||||
type speakerBGPLive struct {
|
||||
SpeakerID string
|
||||
Label string
|
||||
Sessions []birdfmt.BGPSession
|
||||
Error string
|
||||
}
|
||||
|
||||
type peerLiveCacheEntry struct {
|
||||
at time.Time
|
||||
views []speakerBGPLive
|
||||
}
|
||||
|
||||
var peerLiveCache sync.Map // tenantID -> peerLiveCacheEntry
|
||||
|
||||
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 {
|
||||
if sp == nil {
|
||||
return ""
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
host := strings.TrimSpace(meta.AgentDomain)
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(sp.Endpoint)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
|
||||
if host != "" {
|
||||
return "CP · " + host
|
||||
}
|
||||
return "CP (master)"
|
||||
}
|
||||
if host != "" {
|
||||
return host
|
||||
}
|
||||
return sp.ID
|
||||
}
|
||||
|
||||
func masterSpeakerID(speakers []*store.Speaker) string {
|
||||
for _, sp := range speakers {
|
||||
if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") {
|
||||
return sp.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) collectSpeakerBGPLive(ctx context.Context, tenantID string, fresh bool) []speakerBGPLive {
|
||||
if !fresh {
|
||||
if v, ok := peerLiveCache.Load(tenantID); ok {
|
||||
ent := v.(peerLiveCacheEntry)
|
||||
if time.Since(ent.at) < peerLiveCacheTTL {
|
||||
return ent.views
|
||||
}
|
||||
}
|
||||
}
|
||||
speakers := s.store.ListSpeakersForTenant(tenantID)
|
||||
views := make([]speakerBGPLive, 0, len(speakers)+1)
|
||||
|
||||
if sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")); sock != "" {
|
||||
v := speakerBGPLive{Label: "CP (local BIRD)"}
|
||||
if mid := masterSpeakerID(speakers); mid != "" {
|
||||
v.SpeakerID = mid
|
||||
for _, sp := range speakers {
|
||||
if sp != nil && sp.ID == mid {
|
||||
v.Label = speakerDisplayLabel(sp)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")))
|
||||
if err != nil {
|
||||
v.Error = err.Error()
|
||||
} else {
|
||||
v.Sessions = birdfmt.ParseBGPSessions(out)
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
opts := nodedispatch.Options{Timeout: 8 * time.Second}
|
||||
type resWrap struct {
|
||||
sp *store.Speaker
|
||||
res nodedispatch.BirdProtocolsResult
|
||||
}
|
||||
ch := make(chan resWrap, 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()
|
||||
ch <- resWrap{
|
||||
sp: speaker,
|
||||
res: nodedispatch.FetchBirdProtocols(ctx, speaker, opts),
|
||||
}
|
||||
}(sp)
|
||||
}
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
for rw := range ch {
|
||||
views = append(views, speakerBGPLive{
|
||||
SpeakerID: rw.sp.ID,
|
||||
Label: speakerDisplayLabel(rw.sp),
|
||||
Sessions: rw.res.Sessions,
|
||||
Error: rw.res.Error,
|
||||
})
|
||||
}
|
||||
|
||||
peerLiveCache.Store(tenantID, peerLiveCacheEntry{at: time.Now(), views: views})
|
||||
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,
|
||||
connectedLabel string,
|
||||
establishedOn []peerSessionOnSpeaker,
|
||||
on []peerSessionOnSpeaker,
|
||||
mismatch bool,
|
||||
) {
|
||||
if peer == nil {
|
||||
return "", "", "", nil, nil, false
|
||||
}
|
||||
neighbor, hasNeighbor := store.ParsePeerNeighbor(peer.Neighbor)
|
||||
protoName := birdfmt.PeerProtocolName(peer.ID)
|
||||
|
||||
for _, v := range views {
|
||||
if v.Error != "" && len(v.Sessions) == 0 {
|
||||
on = append(on, peerSessionOnSpeaker{
|
||||
SpeakerID: v.SpeakerID,
|
||||
Label: v.Label,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if len(establishedOn) > 0 {
|
||||
bestState = "Established"
|
||||
labels := make([]string, 0, len(establishedOn))
|
||||
for _, e := range establishedOn {
|
||||
labels = append(labels, e.Label)
|
||||
}
|
||||
connectedLabel = strings.Join(labels, ", ")
|
||||
if len(establishedOn) == 1 {
|
||||
connectedID = establishedOn[0].SpeakerID
|
||||
}
|
||||
} else if len(on) == 1 && on[0].PollError == "" && on[0].State != "" {
|
||||
connectedID = on[0].SpeakerID
|
||||
connectedLabel = on[0].Label
|
||||
}
|
||||
|
||||
if peer.SpeakerID != nil && strings.TrimSpace(*peer.SpeakerID) != "" && len(establishedOn) > 0 {
|
||||
want := strings.TrimSpace(*peer.SpeakerID)
|
||||
found := false
|
||||
for _, e := range establishedOn {
|
||||
if strings.EqualFold(strings.TrimSpace(e.SpeakerID), want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
mismatch = !found
|
||||
}
|
||||
return bestState, connectedID, connectedLabel, establishedOn, on, mismatch
|
||||
}
|
||||
|
||||
func peerSessionMatches(sess birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(sess.Name), protoName) {
|
||||
return true
|
||||
}
|
||||
if !hasNeighbor || strings.TrimSpace(sess.Neighbor) == "" {
|
||||
return false
|
||||
}
|
||||
peerAddr, ok := store.ParsePeerNeighbor(sess.Neighbor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return peerAddr == neighbor
|
||||
}
|
||||
|
||||
func sessionStateRank(state string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "established":
|
||||
return 100
|
||||
case "openconfirm", "opensent":
|
||||
return 80
|
||||
case "active", "connect":
|
||||
return 60
|
||||
case "idle":
|
||||
return 20
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
func applyPeerLiveFields(row map[string]any, peer *store.BGPPeer, views []speakerBGPLive) {
|
||||
state, connID, connLabel, establishedOn, on, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
row["session_on_speakers"] = on
|
||||
row["established_on_speakers"] = establishedOn
|
||||
row["session_conflict"] = 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 {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) {
|
||||
peer := &store.BGPPeer{
|
||||
ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Neighbor: "198.51.100.2",
|
||||
}
|
||||
views := []speakerBGPLive{
|
||||
{
|
||||
SpeakerID: "master-id",
|
||||
Label: "CP · bgp.shz.su",
|
||||
Sessions: []birdfmt.BGPSession{
|
||||
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"},
|
||||
},
|
||||
},
|
||||
{
|
||||
SpeakerID: "replica-id",
|
||||
Label: "bgp2.shz.su",
|
||||
Sessions: []birdfmt.BGPSession{
|
||||
{Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"},
|
||||
},
|
||||
},
|
||||
}
|
||||
state, connID, connLabel, established, on, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
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) != 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/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, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPeerOnSpeakers_mismatchConfiguredSpeaker(t *testing.T) {
|
||||
replica := "replica-id"
|
||||
peer := &store.BGPPeer{
|
||||
ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Neighbor: "198.51.100.2",
|
||||
SpeakerID: &replica,
|
||||
}
|
||||
views := []speakerBGPLive{
|
||||
{SpeakerID: "master-id", Label: "CP", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}},
|
||||
{SpeakerID: replica, Label: "bgp2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Idle"}}},
|
||||
}
|
||||
_, _, _, _, _, mismatch := matchPeerOnSpeakers(peer, views)
|
||||
if !mismatch {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+25
-15
@@ -283,17 +283,21 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
allPeers := s.store.ListPeers(a.TenantID)
|
||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
liveStates := s.liveBGPProtocolStates(r)
|
||||
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, p := range page {
|
||||
row := peerJSON(p)
|
||||
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
||||
row["session_state"] = strings.TrimSpace(st)
|
||||
}
|
||||
applyPeerLiveFields(row, p, liveViews)
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -366,16 +370,9 @@ func extractBGPSessionState(line string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// peerProtocolNameForID must stay in sync with pipeline peer protocol naming.
|
||||
// peerProtocolNameForID forwards to birdfmt for tests and legacy callers.
|
||||
func peerProtocolNameForID(peerID string) string {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
||||
if len(s) > 16 {
|
||||
s = s[:16]
|
||||
}
|
||||
if s == "" {
|
||||
s = "x"
|
||||
}
|
||||
return "evobgp_p_" + s
|
||||
return birdfmt.PeerProtocolName(peerID)
|
||||
}
|
||||
|
||||
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -388,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,
|
||||
|
||||
@@ -65,6 +65,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
||||
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
|
||||
m.HandleFunc("DELETE /speakers/{speaker_id}", s.handleDeleteSpeaker)
|
||||
|
||||
m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
|
||||
|
||||
@@ -1021,6 +1022,18 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,27 @@ func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSpeaker(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, "edkey|"+tenant+"|editor")
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/speakers/"+demoSpk, nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := srv.Store().GetSpeaker(tenant, demoSpk); err == nil {
|
||||
t.Fatal("speaker should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBundleSigningPublicKey(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
|
||||
@@ -219,8 +219,8 @@ func (w *Worker) runPeerReconcile(j *Job) {
|
||||
} else {
|
||||
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
||||
}
|
||||
j.Succeed()
|
||||
w.enqueueDeployAllSpeakers(j, j.TenantID, revID)
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
func (w *Worker) peerTriggerModuleID(tenantID string, latest []*store.Revision) (string, error) {
|
||||
@@ -346,8 +346,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||
} else {
|
||||
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
|
||||
}
|
||||
j.Succeed()
|
||||
w.enqueueDeployAllSpeakers(j, j.TenantID, rev)
|
||||
j.Succeed()
|
||||
}
|
||||
|
||||
// enqueueDeployAllSpeakers queues the same work as POST /v1/apply (all speakers, no speaker_id).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package nodedispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// BirdProtocolsResult is agent birdc scrape outcome.
|
||||
type BirdProtocolsResult struct {
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
Sessions []birdfmt.BGPSession `json:"sessions"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// FetchBirdProtocols GETs /v1/agent/bird/protocols on a replica agent.
|
||||
func FetchBirdProtocols(ctx context.Context, sp *store.Speaker, opts Options) BirdProtocolsResult {
|
||||
res := BirdProtocolsResult{}
|
||||
if sp != nil {
|
||||
res.SpeakerID = sp.ID
|
||||
}
|
||||
if sp == nil {
|
||||
res.Error = "nil speaker"
|
||||
return res
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
url := store.AgentBirdProtocolsURL(meta)
|
||||
if url == "" {
|
||||
res.Error = "agent_domain not configured"
|
||||
return res
|
||||
}
|
||||
secret := strings.TrimSpace(meta.AgentSecret)
|
||||
if secret == "" {
|
||||
res.Error = "agent_secret missing"
|
||||
return res
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := opts.client().Do(req)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
res.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
return res
|
||||
}
|
||||
var out struct {
|
||||
Sessions []birdfmt.BGPSession `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
res.Sessions = out.Sessions
|
||||
return res
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1029,7 +1029,7 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
|
||||
}
|
||||
pol := parsePeerPolicies(p.PoliciesJSON)
|
||||
lv4, lv6, asn := effectivePeerLocals(loc, pol)
|
||||
proto := peerProtocolName(p.ID)
|
||||
proto := birdfmt.PeerProtocolName(p.ID)
|
||||
ra := uint32(p.RemoteASN)
|
||||
if addr.Is4() {
|
||||
opts := birdfmt.BGPPeerFromTemplateOptions{
|
||||
@@ -1083,17 +1083,6 @@ func parsePeerPolicies(raw string) peerPolicyJSON {
|
||||
return pol
|
||||
}
|
||||
|
||||
func peerProtocolName(peerID string) string {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "")
|
||||
if len(s) > 16 {
|
||||
s = s[:16]
|
||||
}
|
||||
if s == "" {
|
||||
s = "x"
|
||||
}
|
||||
return "evobgp_p_" + s
|
||||
}
|
||||
|
||||
// buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview).
|
||||
func buildExpandedBirdText(main string, frags map[string]string) string {
|
||||
var b strings.Builder
|
||||
|
||||
@@ -646,6 +646,18 @@ func (p *Postgres) UpdateSpeaker(tenantID, id string, patch *store.SpeakerPatch)
|
||||
return p.GetSpeaker(tenantID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteSpeaker(tenantID, id string) error {
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_speaker WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) {
|
||||
ctx := context.Background()
|
||||
var r store.Revision
|
||||
|
||||
@@ -73,6 +73,7 @@ type Backend interface {
|
||||
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
||||
CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error)
|
||||
UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error)
|
||||
DeleteSpeaker(tenantID, id string) error
|
||||
|
||||
GetRevision(tenantID, revisionID string) (*Revision, error)
|
||||
ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool)
|
||||
|
||||
@@ -779,6 +779,18 @@ func (m *Memory) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speak
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteSpeaker(tenantID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
sp, ok := m.speakers[id]
|
||||
if !ok || sp.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.speakers, id)
|
||||
delete(m.publishedRevision, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
|
||||
@@ -124,11 +124,20 @@ func AgentSyncURL(meta SpeakerMeta) string {
|
||||
|
||||
// AgentHealthURL returns HTTPS health URL for agent_domain.
|
||||
func AgentHealthURL(meta SpeakerMeta) string {
|
||||
return agentHTTPSURL(meta, "/v1/agent/health")
|
||||
}
|
||||
|
||||
// AgentBirdProtocolsURL returns HTTPS bird protocols URL for agent_domain.
|
||||
func AgentBirdProtocolsURL(meta SpeakerMeta) string {
|
||||
return agentHTTPSURL(meta, "/v1/agent/bird/protocols")
|
||||
}
|
||||
|
||||
func agentHTTPSURL(meta SpeakerMeta, path string) string {
|
||||
domain := strings.TrimSpace(meta.AgentDomain)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/health"
|
||||
return "https://" + strings.TrimSuffix(domain, "/") + path
|
||||
}
|
||||
|
||||
// SpeakerNeedsRemoteDispatch reports whether deploy_apply should wake this speaker via agent HTTP.
|
||||
|
||||
@@ -152,6 +152,13 @@ export type BgpCommunityPatch = Partial<BgpCommunityCreate>;
|
||||
export type CommunitiesResponse = Page<BgpCommunity>;
|
||||
|
||||
// ---- Peers ----
|
||||
export type PeerSessionOnSpeaker = {
|
||||
speaker_id: string;
|
||||
label: string;
|
||||
state: string;
|
||||
poll_error?: string;
|
||||
};
|
||||
|
||||
export type PeerRow = {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -160,8 +167,20 @@ export type PeerRow = {
|
||||
enabled?: boolean;
|
||||
session_state: string;
|
||||
bgp_speaker_id: string | null;
|
||||
connected_speaker_id?: string | null;
|
||||
connected_speaker_label?: string;
|
||||
session_on_speakers?: PeerSessionOnSpeaker[];
|
||||
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;
|
||||
@@ -172,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;
|
||||
@@ -188,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,236 @@
|
||||
<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 { Badge } from '$lib/ui/core/badge/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>
|
||||
|
||||
{#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"
|
||||
/>
|
||||
|
||||
<div class="flex 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 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{#each speakers as speaker (speaker.id)}
|
||||
<NetworkSpeakerStatusCard
|
||||
{speaker}
|
||||
{peers}
|
||||
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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,12 +77,51 @@
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function speakerLabelById(id: string | null | undefined) {
|
||||
if (!id) return '—';
|
||||
return speakerById.get(id)?.endpoint ?? id;
|
||||
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 sessionBadge(state: string) {
|
||||
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) {
|
||||
return established.map((s) => `${s.label}: Established`).join(' · ');
|
||||
}
|
||||
return 'Не найден на опрошенных нодах';
|
||||
}
|
||||
|
||||
function peerSessionHint(p: PeerRow): string | null {
|
||||
if (!p.session_mismatch || !p.bgp_speaker_id) return null;
|
||||
const expected = speakerLabelById(p.bgp_speaker_id);
|
||||
const actual =
|
||||
p.established_on_speakers?.map((s) => s.label).join(', ') ||
|
||||
p.connected_speaker_label?.trim() ||
|
||||
'другие ноды';
|
||||
return `В конфиге: ${expected}; Established на: ${actual}`;
|
||||
}
|
||||
|
||||
function speakerLabelById(id: string | null | undefined) {
|
||||
if (!id) return 'Все спикеры';
|
||||
const s = speakerById.get(id);
|
||||
if (!s) return id.slice(0, 8) + '…';
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint;
|
||||
return host ? `CP · ${host}` : 'CP (master)';
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…';
|
||||
}
|
||||
|
||||
function sessionBadge(
|
||||
state: string,
|
||||
p: PeerRow
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (p.session_mismatch) return 'destructive';
|
||||
if (state === 'Established') return 'default';
|
||||
if (state === 'Active' || state === 'Connect') return 'secondary';
|
||||
return 'outline';
|
||||
@@ -203,9 +242,22 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if column.id === 'session_state'}
|
||||
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
|
||||
<span
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
|
||||
>
|
||||
{peerConnectedLabel(p)}
|
||||
</span>
|
||||
{#if peerSessionHint(p)}
|
||||
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'speaker'}
|
||||
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
|
||||
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
|
||||
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
||||
@@ -251,7 +303,7 @@
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="p-speaker" class="w-full">
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Не выбрано</SelectItem>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
peersForSpeaker,
|
||||
speakerDisplayStatus,
|
||||
speakerHasDrift,
|
||||
speakerLabel
|
||||
} 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 { 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';
|
||||
|
||||
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 ?? []);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onOpenChange?.(open);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sheet bind:open>
|
||||
<SheetContent class="flex w-full flex-col overflow-y-auto sm:max-w-lg">
|
||||
{#if speaker}
|
||||
<SheetHeader>
|
||||
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<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>
|
||||
|
||||
<div class="grid gap-2 text-sm">
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">BGP Established</span>
|
||||
<span class="font-medium tabular-nums">
|
||||
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
{#if speaker.live?.agent_last_sync_at}
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Последний sync</span>
|
||||
<span class="text-xs">{speaker.live.agent_last_sync_at}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Drift (app / pub)</span>
|
||||
<span class="font-mono text-xs">{driftLabel(speaker)}</span>
|
||||
</div>
|
||||
{#if speaker.last_dispatch_at}
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Dispatch</span>
|
||||
<span class="text-xs">{speaker.last_dispatch_at}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if speaker.last_dispatch_error}
|
||||
<p class="text-xs text-destructive">{speaker.last_dispatch_error}</p>
|
||||
{/if}
|
||||
{#if speaker.live?.agent_error}
|
||||
<p class="text-xs text-destructive">Agent: {speaker.live.agent_error}</p>
|
||||
{/if}
|
||||
{#if speaker.live?.bgp_poll_error}
|
||||
<p class="text-xs text-destructive">BGP poll: {speaker.live.bgp_poll_error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onApply && speaker.published_revision_id}
|
||||
<Button variant="outline" size="sm" onclick={() => onApply(speaker)}
|
||||
>Apply revision</Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="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}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each sessions as sess, i (sess.name + i)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-xs">
|
||||
{sess.name}
|
||||
{#if sess.neighbor}
|
||||
<div class="text-muted-foreground">{sess.neighbor}</div>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{sess.state}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="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="space-y-2">
|
||||
{#each relatedPeers as p (p.id)}
|
||||
<li class="rounded-lg border px-3 py-2 text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium">{p.name?.trim() || p.neighbor}</span>
|
||||
<Badge variant="outline">{p.session_state || '—'}</Badge>
|
||||
</div>
|
||||
{#if p.session_mismatch}
|
||||
<p class="mt-1 text-xs text-warning">Mismatch: сессия не на назначенной ноде</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -0,0 +1,87 @@
|
||||
<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(
|
||||
'cursor-pointer transition-colors hover:border-primary/35',
|
||||
onclick ? 'cursor-pointer' : '',
|
||||
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 justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<CardTitle class="flex items-center gap-2 truncate text-sm">
|
||||
<Server class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{label}</span>
|
||||
</CardTitle>
|
||||
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 pt-0 text-sm">
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">BGP</span>
|
||||
<span class="font-medium tabular-nums">{speakerBgpText(speaker)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Пиры</span>
|
||||
<span class="tabular-nums">{peerCount}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="text-muted-foreground">Drift</span>
|
||||
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||
{drift ? 'есть' : 'нет'}
|
||||
</Badge>
|
||||
</div>
|
||||
</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 {
|
||||
@@ -22,11 +27,14 @@
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
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[];
|
||||
@@ -34,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;
|
||||
@@ -70,6 +86,8 @@
|
||||
|
||||
const columns = [
|
||||
{ id: 'status', label: 'Статус' },
|
||||
{ id: 'live_agent', label: 'Agent' },
|
||||
{ id: 'bgp', label: 'BGP' },
|
||||
{
|
||||
id: 'agent_domain',
|
||||
label: 'Agent domain',
|
||||
@@ -78,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 {
|
||||
@@ -150,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 {
|
||||
@@ -180,6 +199,21 @@
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(s: SpeakerRow) {
|
||||
const label = s.agent_domain ?? s.endpoint ?? s.id;
|
||||
void confirm({
|
||||
title: 'Удалить спикера?',
|
||||
description: label,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Спикер удалён');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openApply(s: SpeakerRow) {
|
||||
applyTarget = s;
|
||||
applyRevisionId = s.published_revision_id ?? '';
|
||||
@@ -316,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>
|
||||
@@ -341,6 +388,15 @@ CF_DNS_API_TOKEN=<cloudflare token>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
title="Удалить спикера"
|
||||
onclick={() => requestDelete(s)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
+51
-16
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,101 +4,57 @@
|
||||
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);
|
||||
|
||||
async function loadPeers() {
|
||||
peersLoading = true;
|
||||
try {
|
||||
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200');
|
||||
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200&live=1');
|
||||
peers = pr.items;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
@@ -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">
|
||||
<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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user