From c116b035bd5d1e541d6f02f4bcae2eee7e6d0249 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 30 Dec 2025 14:50:54 +0700 Subject: [PATCH] Add status page enhancements with new metrics and footer - Add GC statistics (run count, total/last pause, heap usage) - Add BGP peer count tracking from RIS Live OPEN/NOTIFICATION messages - Add route churn rate metric (announcements + withdrawals per second) - Add announcement and withdrawal counters - Add footer with attribution, license, and git revision - Embed git revision at build time via ldflags - Update HTML template to display all new metrics --- Makefile | 8 ++- internal/routewatch/prefixhandler.go | 8 +++ internal/server/handlers.go | 32 ++++++++++ internal/streamer/streamer.go | 23 ++++++- internal/templates/status.html | 95 ++++++++++++++++++++++++++++ internal/templates/templates.go | 15 ++++- internal/version/version.go | 34 ++++++++++ 7 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 internal/version/version.go diff --git a/Makefile b/Makefile index c8d1a8f..e41946b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,11 @@ export DEBUG = routewatch +# Git revision for version embedding +GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") +GIT_REVISION_SHORT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +VERSION_PKG := git.eeqj.de/sneak/routewatch/internal/version +LDFLAGS := -X $(VERSION_PKG).GitRevision=$(GIT_REVISION) -X $(VERSION_PKG).GitRevisionShort=$(GIT_REVISION_SHORT) + .PHONY: test fmt lint build clean run asupdate all: test @@ -15,7 +21,7 @@ lint: golangci-lint run build: - CGO_ENABLED=1 go build -o bin/routewatch cmd/routewatch/main.go + CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o bin/routewatch cmd/routewatch/main.go clean: rm -rf bin/ diff --git a/internal/routewatch/prefixhandler.go b/internal/routewatch/prefixhandler.go index ebc5337..4223b20 100644 --- a/internal/routewatch/prefixhandler.go +++ b/internal/routewatch/prefixhandler.go @@ -113,6 +113,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { timestamp: timestamp, path: msg.Path, }) + // Record announcement in metrics + if h.metrics != nil { + h.metrics.RecordAnnouncement() + } } } @@ -126,6 +130,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { timestamp: timestamp, path: msg.Path, }) + // Record withdrawal in metrics + if h.metrics != nil { + h.metrics.RecordWithdrawal() + } } // Check if we need to flush diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 9ac2dea..59a9720 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -469,6 +469,25 @@ func (s *Server) handleStats() http.HandlerFunc { connectionDuration = time.Since(metrics.ConnectedSince).Truncate(time.Second).String() } + // Get announcement/withdrawal stats from metrics tracker + metricsTracker := s.streamer.GetMetricsTracker() + announcements := metricsTracker.GetAnnouncementCount() + withdrawals := metricsTracker.GetWithdrawalCount() + churnRate := metricsTracker.GetChurnRate() + bgpPeerCount := metricsTracker.GetBGPPeerCount() + + // Calculate last GC pause + const ( + nanosecondsPerMillisecond = 1e6 + gcPauseHistorySize = 256 // Size of runtime.MemStats.PauseNs circular buffer + ) + var lastPauseMs float64 + if memStats.NumGC > 0 { + // PauseNs is a circular buffer, get the most recent pause + lastPauseIdx := (memStats.NumGC + gcPauseHistorySize - 1) % gcPauseHistorySize + lastPauseMs = float64(memStats.PauseNs[lastPauseIdx]) / nanosecondsPerMillisecond + } + stats := StatsResponse{ Uptime: uptime, TotalMessages: metrics.TotalMessages, @@ -483,6 +502,19 @@ func (s *Server) handleStats() http.HandlerFunc { GoVersion: runtime.Version(), Goroutines: runtime.NumGoroutine(), MemoryUsage: humanize.Bytes(memStats.Alloc), + GC: GCStats{ + NumGC: memStats.NumGC, + TotalPauseMs: memStats.PauseTotalNs / uint64(nanosecondsPerMillisecond), + LastPauseMs: lastPauseMs, + HeapAllocBytes: memStats.HeapAlloc, + HeapSysBytes: memStats.HeapSys, + }, + Stream: StreamStats{ + Announcements: announcements, + Withdrawals: withdrawals, + RouteChurnPerSec: churnRate, + BGPPeerCount: bgpPeerCount, + }, ASNs: dbStats.ASNs, Prefixes: dbStats.Prefixes, IPv4Prefixes: dbStats.IPv4Prefixes, diff --git a/internal/streamer/streamer.go b/internal/streamer/streamer.go index 3c1f48b..4814707 100644 --- a/internal/streamer/streamer.go +++ b/internal/streamer/streamer.go @@ -114,6 +114,8 @@ type Streamer struct { metrics *metrics.Tracker totalDropped uint64 // Total dropped messages across all handlers random *rand.Rand // Random number generator for backpressure drops + bgpPeers map[string]bool // Track active BGP peers by peer IP + bgpPeersMu sync.RWMutex // Protects bgpPeers map } // New creates a new Streamer instance configured to connect to the RIS Live API. @@ -132,7 +134,8 @@ func New(logger *logger.Logger, metrics *metrics.Tracker) *Streamer { handlers: make([]*handlerInfo, 0), metrics: metrics, //nolint:gosec // Non-cryptographic randomness is fine for backpressure - random: rand.New(rand.NewSource(time.Now().UnixNano())), + random: rand.New(rand.NewSource(time.Now().UnixNano())), + bgpPeers: make(map[string]bool), } } @@ -608,18 +611,32 @@ func (s *Streamer) stream(ctx context.Context) error { // BGP keepalive messages - silently process continue case "OPEN": - // BGP open messages + // BGP open messages - track peer as active + s.bgpPeersMu.Lock() + s.bgpPeers[msg.Peer] = true + peerCount := len(s.bgpPeers) + s.bgpPeersMu.Unlock() + s.metrics.SetBGPPeerCount(peerCount) + s.logger.Info("BGP session opened", "peer", msg.Peer, "peer_asn", msg.PeerASN, + "total_peers", peerCount, ) continue case "NOTIFICATION": - // BGP notification messages (errors) + // BGP notification messages (session closed) + s.bgpPeersMu.Lock() + delete(s.bgpPeers, msg.Peer) + peerCount := len(s.bgpPeers) + s.bgpPeersMu.Unlock() + s.metrics.SetBGPPeerCount(peerCount) + s.logger.Warn("BGP notification", "peer", msg.Peer, "peer_asn", msg.PeerASN, + "total_peers", peerCount, ) continue diff --git a/internal/templates/status.html b/internal/templates/status.html index 5225c90..41b7f9c 100644 --- a/internal/templates/status.html +++ b/internal/templates/status.html @@ -72,6 +72,27 @@ border-radius: 4px; margin-top: 20px; } + .footer { + margin-top: 40px; + padding: 20px; + background: white; + border-radius: 8px; + box-shadow: 0 -2px 10px rgba(0,0,0,0.1); + text-align: center; + color: #666; + font-size: 14px; + } + .footer a { + color: #0066cc; + text-decoration: none; + } + .footer a:hover { + text-decoration: underline; + } + .footer .separator { + margin: 0 10px; + color: #ccc; + } @@ -112,6 +133,10 @@ Reconnections - +
+ BGP Peers + - +
Total Messages - @@ -120,6 +145,18 @@ Messages/sec -
+
+ Announcements + - +
+
+ Withdrawals + - +
+
+ Route Churn/sec + - +
Total Data - @@ -129,6 +166,30 @@ -
+ +
+

GC Statistics

+
+ GC Runs + - +
+
+ Total Pause + - +
+
+ Last Pause + - +
+
+ Heap Alloc + - +
+
+ Heap Sys + - +
+

Database Statistics

@@ -344,10 +405,19 @@ document.getElementById('memory_usage').textContent = '-'; document.getElementById('connection_duration').textContent = '-'; document.getElementById('reconnect_count').textContent = '-'; + document.getElementById('bgp_peer_count').textContent = '-'; document.getElementById('total_messages').textContent = '-'; document.getElementById('messages_per_sec').textContent = '-'; + document.getElementById('announcements').textContent = '-'; + document.getElementById('withdrawals').textContent = '-'; + document.getElementById('route_churn_per_sec').textContent = '-'; document.getElementById('total_wire_bytes').textContent = '-'; document.getElementById('wire_mbits_per_sec').textContent = '-'; + document.getElementById('gc_num').textContent = '-'; + document.getElementById('gc_total_pause').textContent = '-'; + document.getElementById('gc_last_pause').textContent = '-'; + document.getElementById('gc_heap_alloc').textContent = '-'; + document.getElementById('gc_heap_sys').textContent = '-'; document.getElementById('asns').textContent = '-'; document.getElementById('prefixes').textContent = '-'; document.getElementById('ipv4_prefixes').textContent = '-'; @@ -421,6 +491,23 @@ document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1); document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1); + // Update stream stats + if (data.stream) { + document.getElementById('bgp_peer_count').textContent = formatNumber(data.stream.bgp_peer_count); + document.getElementById('announcements').textContent = formatNumber(data.stream.announcements); + document.getElementById('withdrawals').textContent = formatNumber(data.stream.withdrawals); + document.getElementById('route_churn_per_sec').textContent = data.stream.route_churn_per_sec.toFixed(1); + } + + // Update GC stats + if (data.gc) { + document.getElementById('gc_num').textContent = formatNumber(data.gc.num_gc); + document.getElementById('gc_total_pause').textContent = data.gc.total_pause_ms + ' ms'; + document.getElementById('gc_last_pause').textContent = data.gc.last_pause_ms.toFixed(3) + ' ms'; + document.getElementById('gc_heap_alloc').textContent = formatBytes(data.gc.heap_alloc_bytes); + document.getElementById('gc_heap_sys').textContent = formatBytes(data.gc.heap_sys_bytes); + } + // Update WHOIS stats if (data.whois_stats) { document.getElementById('whois_fresh').textContent = formatNumber(data.whois_stats.fresh_asns); @@ -455,5 +542,13 @@ updateStatus(); setInterval(updateStatus, 2000); + + \ No newline at end of file diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 0a507e3..0163234 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -7,6 +7,8 @@ import ( "net/url" "sync" "time" + + "git.eeqj.de/sneak/routewatch/internal/version" ) //go:embed status.html @@ -86,12 +88,19 @@ func initTemplates() { // Create common template functions funcs := template.FuncMap{ - "timeSince": timeSince, - "urlEncode": url.QueryEscape, + "timeSince": timeSince, + "urlEncode": url.QueryEscape, + "appName": func() string { return version.Name }, + "appAuthor": func() string { return version.Author }, + "appAuthorURL": func() string { return version.AuthorURL }, + "appLicense": func() string { return version.License }, + "appRepoURL": func() string { return version.RepoURL }, + "appGitRevision": func() string { return version.GitRevisionShort }, + "appGitCommitURL": func() string { return version.CommitURL() }, } // Parse status template - defaultTemplates.Status, err = template.New("status").Parse(statusHTML) + defaultTemplates.Status, err = template.New("status").Funcs(funcs).Parse(statusHTML) if err != nil { panic("failed to parse status template: " + err.Error()) } diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..995c314 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,34 @@ +// Package version provides build version information +package version + +// Build-time variables set via ldflags +// +//nolint:gochecknoglobals // These must be variables to allow ldflags injection at build time +var ( + // GitRevision is the git commit hash + GitRevision = "unknown" + // GitRevisionShort is the short git commit hash (7 chars) + GitRevisionShort = "unknown" +) + +const ( + // Name is the program name + Name = "routewatch" + // Author is the program author + Author = "@sneak" + // AuthorURL is the author's website + AuthorURL = "https://sneak.berlin" + // License is the program license + License = "WTFPL" + // RepoURL is the git repository URL + RepoURL = "https://git.eeqj.de/sneak/routewatch" +) + +// CommitURL returns the URL to view the current commit +func CommitURL() string { + if GitRevision == "unknown" { + return RepoURL + } + + return RepoURL + "/commit/" + GitRevision +}