From b5ed47902c5ae9123f6cb9fa1a035650432f0ffd Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 21 May 2026 15:40:44 +0700 Subject: [PATCH] feat(api): enhance peer session tracking and error handling - Added `PollError` field to `peerSessionOnSpeaker` and `liveSpeakerPoll` types to capture polling errors for speakers. - Updated `matchPeerOnSpeakers` function to handle polling errors and adjust session state reporting. - Modified frontend components to display polling error messages alongside session states, improving user visibility into peer connection statuses. - Enhanced API response structure to include live speaker polling information, facilitating better monitoring of speaker health. --- internal/birdfmt/bgp_sessions.go | 28 +++-- internal/httpapi/peers_live.go | 110 +++++++++++------- internal/httpapi/peers_live_test.go | 26 +++-- internal/httpapi/routes.go | 5 +- web/src/lib/api/types.ts | 1 + .../network/NetworkPeersCard.svelte | 25 ++-- 6 files changed, 125 insertions(+), 70 deletions(-) diff --git a/internal/birdfmt/bgp_sessions.go b/internal/birdfmt/bgp_sessions.go index 48a2540..8e46a91 100644 --- a/internal/birdfmt/bgp_sessions.go +++ b/internal/birdfmt/bgp_sessions.go @@ -19,29 +19,35 @@ func ParseBGPSessions(output string) []BGPSession { line := strings.TrimRight(raw, "\r") trim := strings.TrimSpace(line) if trim == "" { + cur = nil continue } low := strings.ToLower(trim) if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") { continue } - if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && isBGPProtocolSummaryRow(trim) { - fields := strings.Fields(trim) - state := extractBGPSessionStateLine(trim) - if state == "" && len(fields) >= 4 { - state = fields[3] + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + if isBGPProtocolSummaryRow(trim) { + fields := strings.Fields(trim) + state := extractBGPSessionStateLine(trim) + if state == "" && len(fields) >= 4 { + state = fields[3] + } + out = append(out, BGPSession{Name: fields[0], State: state}) + cur = &out[len(out)-1] + } else { + cur = nil } - cur = &BGPSession{Name: fields[0], State: state} - out = append(out, *cur) - cur = &out[len(out)-1] continue } if cur == nil { continue } - const neighborPrefix = "Neighbor address:" - if idx := strings.Index(trim, neighborPrefix); idx >= 0 { - cur.Neighbor = strings.TrimSpace(trim[idx+len(neighborPrefix):]) + for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} { + if idx := strings.Index(trim, prefix); idx >= 0 { + cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):]) + break + } } } return out diff --git a/internal/httpapi/peers_live.go b/internal/httpapi/peers_live.go index 9bb4639..581f64d 100644 --- a/internal/httpapi/peers_live.go +++ b/internal/httpapi/peers_live.go @@ -33,6 +33,15 @@ type peerSessionOnSpeaker struct { SpeakerID string `json:"speaker_id"` Label string `json:"label"` State string `json:"state"` + PollError string `json:"poll_error,omitempty"` +} + +type liveSpeakerPoll struct { + SpeakerID string `json:"speaker_id"` + Label string `json:"label"` + OK bool `json:"ok"` + SessionCount int `json:"session_count"` + PollError string `json:"poll_error,omitempty"` } func speakerDisplayLabel(sp *store.Speaker) string { @@ -136,6 +145,29 @@ func (s *Server) collectSpeakerBGPLive(ctx context.Context, tenantID string, fre return views } +func liveSpeakerPollJSON(views []speakerBGPLive) []liveSpeakerPoll { + out := make([]liveSpeakerPoll, 0, len(views)) + for _, v := range views { + out = append(out, liveSpeakerPoll{ + SpeakerID: v.SpeakerID, + Label: v.Label, + OK: v.Error == "", + SessionCount: len(v.Sessions), + PollError: v.Error, + }) + } + return out +} + +func findPeerSession(sessions []birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) *birdfmt.BGPSession { + for i := range sessions { + if peerSessionMatches(sessions[i], protoName, neighbor, hasNeighbor) { + return &sessions[i] + } + } + return nil +} + func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) ( bestState string, connectedID string, @@ -152,24 +184,33 @@ func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) ( for _, v := range views { if v.Error != "" && len(v.Sessions) == 0 { - continue - } - for _, sess := range v.Sessions { - if !peerSessionMatches(sess, protoName, neighbor, hasNeighbor) { - continue - } - hit := peerSessionOnSpeaker{ + on = append(on, peerSessionOnSpeaker{ SpeakerID: v.SpeakerID, Label: v.Label, - State: sess.State, - } - on = append(on, hit) - if strings.EqualFold(strings.TrimSpace(sess.State), "Established") { - establishedOn = append(establishedOn, hit) - } - if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) { - bestState = sess.State - } + PollError: v.Error, + }) + continue + } + sess := findPeerSession(v.Sessions, protoName, neighbor, hasNeighbor) + if sess == nil { + on = append(on, peerSessionOnSpeaker{ + SpeakerID: v.SpeakerID, + Label: v.Label, + State: "absent", + }) + continue + } + hit := peerSessionOnSpeaker{ + SpeakerID: v.SpeakerID, + Label: v.Label, + State: sess.State, + } + on = append(on, hit) + if strings.EqualFold(strings.TrimSpace(sess.State), "Established") { + establishedOn = append(establishedOn, hit) + } + if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) { + bestState = sess.State } } @@ -183,7 +224,7 @@ func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) ( if len(establishedOn) == 1 { connectedID = establishedOn[0].SpeakerID } - } else if len(on) == 1 { + } else if len(on) == 1 && on[0].PollError == "" && on[0].State != "" { connectedID = on[0].SpeakerID connectedLabel = on[0].Label } @@ -209,11 +250,11 @@ func peerSessionMatches(sess birdfmt.BGPSession, protoName string, neighbor neti if !hasNeighbor || strings.TrimSpace(sess.Neighbor) == "" { return false } - addr, err := netip.ParseAddr(strings.TrimSpace(sess.Neighbor)) - if err != nil { + peerAddr, ok := store.ParsePeerNeighbor(sess.Neighbor) + if !ok { return false } - return addr == neighbor + return peerAddr == neighbor } func sessionStateRank(state string) int { @@ -233,26 +274,17 @@ func sessionStateRank(state string) int { func applyPeerLiveFields(row map[string]any, peer *store.BGPPeer, views []speakerBGPLive) { state, connID, connLabel, establishedOn, on, mismatch := matchPeerOnSpeakers(peer, views) - if len(on) > 0 { - if state != "" { - row["session_state"] = state - } - row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID) - row["connected_speaker_label"] = connLabel - row["session_on_speakers"] = on - if len(establishedOn) > 0 { - row["established_on_speakers"] = establishedOn - } else { - row["established_on_speakers"] = []peerSessionOnSpeaker{} - } - row["session_conflict"] = false - row["session_mismatch"] = mismatch - return - } - row["session_on_speakers"] = []peerSessionOnSpeaker{} - row["established_on_speakers"] = []peerSessionOnSpeaker{} + row["session_on_speakers"] = on + row["established_on_speakers"] = establishedOn row["session_conflict"] = false - row["session_mismatch"] = false + row["session_mismatch"] = mismatch + if state != "" { + row["session_state"] = state + } + if connLabel != "" { + row["connected_speaker_label"] = connLabel + } + row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID) } func peerLiveSpeakerIDOrNull(id string) any { diff --git a/internal/httpapi/peers_live_test.go b/internal/httpapi/peers_live_test.go index e3e1b27..374a820 100644 --- a/internal/httpapi/peers_live_test.go +++ b/internal/httpapi/peers_live_test.go @@ -17,7 +17,7 @@ func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) { SpeakerID: "master-id", Label: "CP · bgp.shz.su", Sessions: []birdfmt.BGPSession{ - {Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Idle"}, + {Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"}, }, }, { @@ -29,23 +29,23 @@ func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) { }, } state, connID, connLabel, established, on, mismatch := matchPeerOnSpeakers(peer, views) - if state != "Established" || connID != "replica-id" || connLabel != "bgp2.shz.su" { + if state != "Established" || connID != "" || connLabel != "CP · bgp.shz.su, bgp2.shz.su" { t.Fatalf("got state=%q conn=%q label=%q", state, connID, connLabel) } - if mismatch || len(on) != 2 || len(established) != 1 { + if mismatch || len(on) != 2 || len(established) != 2 { t.Fatalf("on=%+v established=%+v mismatch=%v", on, established, mismatch) } } func TestMatchPeerOnSpeakers_multipleEstablished(t *testing.T) { - peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"} + peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2/32"} views := []speakerBGPLive{ {SpeakerID: "a", Label: "n1", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}}, {SpeakerID: "b", Label: "n2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}}, } - _, connID, label, established, _, mismatch := matchPeerOnSpeakers(peer, views) - if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 { - t.Fatalf("connID=%q label=%q established=%+v mismatch=%v", connID, label, established, mismatch) + _, connID, label, established, on, mismatch := matchPeerOnSpeakers(peer, views) + if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 || len(on) != 2 { + t.Fatalf("connID=%q label=%q established=%+v on=%+v mismatch=%v", connID, label, established, on, mismatch) } } @@ -65,3 +65,15 @@ func TestMatchPeerOnSpeakers_mismatchConfiguredSpeaker(t *testing.T) { t.Fatal("expected mismatch when configured replica has no Established") } } + +func TestMatchPeerOnSpeakers_pollError(t *testing.T) { + peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"} + views := []speakerBGPLive{ + {Label: "CP (local BIRD)", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}}, + {SpeakerID: "replica-id", Label: "bgp2.shz.su", Error: "HTTP 404: Not Found"}, + } + _, _, _, established, on, _ := matchPeerOnSpeakers(peer, views) + if len(established) != 1 || len(on) != 2 || on[1].PollError == "" { + t.Fatalf("on=%+v established=%+v", on, established) + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index d9a57b4..29f90e0 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -294,7 +294,10 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) { items = append(items, row) } writeJSON(w, http.StatusOK, map[string]any{ - "items": items, "next_cursor": strPtrOrNull(next), "has_more": more, + "items": items, + "next_cursor": strPtrOrNull(next), + "has_more": more, + "live_speaker_poll": liveSpeakerPollJSON(liveViews), }) } diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index e798e1d..1e425b5 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -156,6 +156,7 @@ export type PeerSessionOnSpeaker = { speaker_id: string; label: string; state: string; + poll_error?: string; }; export type PeerRow = { diff --git a/web/src/lib/components/network/NetworkPeersCard.svelte b/web/src/lib/components/network/NetworkPeersCard.svelte index b75e823..0e9f8e9 100644 --- a/web/src/lib/components/network/NetworkPeersCard.svelte +++ b/web/src/lib/components/network/NetworkPeersCard.svelte @@ -1,6 +1,6 @@