diff --git a/internal/config/config.go b/internal/config/config.go index 1ae3c5e..dcbfeee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,9 @@ const ( // dirPermissions for creating directories dirPermissions = 0750 // rwxr-x--- + + // defaultRouteExpirationMinutes is the default route expiration timeout in minutes + defaultRouteExpirationMinutes = 5 ) // Config holds configuration for the entire application @@ -27,6 +30,10 @@ type Config struct { // EnableBatchedDatabaseWrites enables batched database operations for better performance EnableBatchedDatabaseWrites bool + + // RouteExpirationTimeout is how long a route can go without being refreshed before expiring + // Default is 2 hours which is conservative for BGP (typical BGP hold time is 90-180 seconds) + RouteExpirationTimeout time.Duration } // New creates a new Config with default paths based on the OS @@ -38,8 +45,9 @@ func New() (*Config, error) { return &Config{ StateDir: stateDir, - MaxRuntime: 0, // Run forever by default - EnableBatchedDatabaseWrites: true, // Enable batching by default for better performance + MaxRuntime: 0, // Run forever by default + EnableBatchedDatabaseWrites: true, // Enable batching by default + RouteExpirationTimeout: defaultRouteExpirationMinutes * time.Minute, // For active route monitoring }, nil } diff --git a/internal/database/database.go b/internal/database/database.go index 6e2b1ad..a05a6fa 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -4,6 +4,7 @@ package database import ( "database/sql" _ "embed" + "encoding/json" "fmt" "os" "path/filepath" @@ -387,5 +388,120 @@ func (d *Database) GetStats() (Stats, error) { stats.FileSizeBytes = fileInfo.Size() } + // Get live routes count + err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes").Scan(&stats.LiveRoutes) + if err != nil { + return stats, fmt.Errorf("failed to count live routes: %w", err) + } + + // Get prefix distribution + stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistribution() + if err != nil { + // Log but don't fail + d.logger.Warn("Failed to get prefix distribution", "error", err) + } + return stats, nil } + +// UpsertLiveRoute inserts or updates a live route +func (d *Database) UpsertLiveRoute(route *LiveRoute) error { + query := ` + INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop, last_updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(prefix, origin_asn, peer_ip) DO UPDATE SET + mask_length = excluded.mask_length, + ip_version = excluded.ip_version, + as_path = excluded.as_path, + next_hop = excluded.next_hop, + last_updated = excluded.last_updated + ` + + // Encode AS path as JSON + pathJSON, err := json.Marshal(route.ASPath) + if err != nil { + return fmt.Errorf("failed to encode AS path: %w", err) + } + + _, err = d.db.Exec(query, + route.ID.String(), + route.Prefix, + route.MaskLength, + route.IPVersion, + route.OriginASN, + route.PeerIP, + string(pathJSON), + route.NextHop, + route.LastUpdated, + ) + + return err +} + +// DeleteLiveRoute deletes a live route +// If originASN is 0, deletes all routes for the prefix/peer combination +func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string) error { + var query string + var err error + + if originASN == 0 { + // Delete all routes for this prefix from this peer + query = `DELETE FROM live_routes WHERE prefix = ? AND peer_ip = ?` + _, err = d.db.Exec(query, prefix, peerIP) + } else { + // Delete specific route + query = `DELETE FROM live_routes WHERE prefix = ? AND origin_asn = ? AND peer_ip = ?` + _, err = d.db.Exec(query, prefix, originASN, peerIP) + } + + return err +} + +// GetPrefixDistribution returns the distribution of prefixes by mask length +func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) { + // IPv4 distribution + query := ` + SELECT mask_length, COUNT(*) as count + FROM live_routes + WHERE ip_version = 4 + GROUP BY mask_length + ORDER BY mask_length + ` + rows, err := d.db.Query(query) + if err != nil { + return nil, nil, fmt.Errorf("failed to query IPv4 distribution: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var dist PrefixDistribution + if err := rows.Scan(&dist.MaskLength, &dist.Count); err != nil { + return nil, nil, fmt.Errorf("failed to scan IPv4 distribution: %w", err) + } + ipv4 = append(ipv4, dist) + } + + // IPv6 distribution + query = ` + SELECT mask_length, COUNT(*) as count + FROM live_routes + WHERE ip_version = 6 + GROUP BY mask_length + ORDER BY mask_length + ` + rows, err = d.db.Query(query) + if err != nil { + return nil, nil, fmt.Errorf("failed to query IPv6 distribution: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var dist PrefixDistribution + if err := rows.Scan(&dist.MaskLength, &dist.Count); err != nil { + return nil, nil, fmt.Errorf("failed to scan IPv6 distribution: %w", err) + } + ipv6 = append(ipv6, dist) + } + + return ipv4, ipv6, nil +} diff --git a/internal/database/interface.go b/internal/database/interface.go index 8a7c33e..1f76b1a 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -6,12 +6,15 @@ import ( // Stats contains database statistics type Stats struct { - ASNs int - Prefixes int - IPv4Prefixes int - IPv6Prefixes int - Peerings int - FileSizeBytes int64 + ASNs int + Prefixes int + IPv4Prefixes int + IPv6Prefixes int + Peerings int + FileSizeBytes int64 + LiveRoutes int + IPv4PrefixDistribution []PrefixDistribution + IPv6PrefixDistribution []PrefixDistribution } // Store defines the interface for database operations @@ -34,6 +37,11 @@ type Store interface { // Peer operations UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error + // Live route operations + UpsertLiveRoute(route *LiveRoute) error + DeleteLiveRoute(prefix string, originASN int, peerIP string) error + GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) + // Lifecycle Close() error } diff --git a/internal/database/models.go b/internal/database/models.go index 022c7df..f57e0b2 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -45,3 +45,22 @@ type ASNPeering struct { FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` } + +// LiveRoute represents a route in the live routing table +type LiveRoute struct { + ID uuid.UUID `json:"id"` + Prefix string `json:"prefix"` + MaskLength int `json:"mask_length"` + IPVersion int `json:"ip_version"` + OriginASN int `json:"origin_asn"` + PeerIP string `json:"peer_ip"` + ASPath []int `json:"as_path"` + NextHop string `json:"next_hop"` + LastUpdated time.Time `json:"last_updated"` +} + +// PrefixDistribution represents the distribution of prefixes by mask length +type PrefixDistribution struct { + MaskLength int `json:"mask_length"` + Count int `json:"count"` +} diff --git a/internal/database/schema.sql b/internal/database/schema.sql index 6a40e90..02c7f10 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -68,4 +68,24 @@ CREATE INDEX IF NOT EXISTS idx_asns_number ON asns(number); -- Indexes for bgp_peers table CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn); CREATE INDEX IF NOT EXISTS idx_bgp_peers_last_seen ON bgp_peers(last_seen); -CREATE INDEX IF NOT EXISTS idx_bgp_peers_ip ON bgp_peers(peer_ip); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_bgp_peers_ip ON bgp_peers(peer_ip); + +-- Live routing table maintained by PrefixHandler +CREATE TABLE IF NOT EXISTS live_routes ( + id TEXT PRIMARY KEY, + prefix TEXT NOT NULL, + mask_length INTEGER NOT NULL, -- CIDR mask length (0-32 for IPv4, 0-128 for IPv6) + ip_version INTEGER NOT NULL, -- 4 or 6 + origin_asn INTEGER NOT NULL, + peer_ip TEXT NOT NULL, + as_path TEXT NOT NULL, -- JSON array + next_hop TEXT NOT NULL, + last_updated DATETIME NOT NULL, + UNIQUE(prefix, origin_asn, peer_ip) +); + +-- Indexes for live_routes table +CREATE INDEX IF NOT EXISTS idx_live_routes_prefix ON live_routes(prefix); +CREATE INDEX IF NOT EXISTS idx_live_routes_mask_length ON live_routes(mask_length); +CREATE INDEX IF NOT EXISTS idx_live_routes_ip_version_mask ON live_routes(ip_version, mask_length); +CREATE INDEX IF NOT EXISTS idx_live_routes_last_updated ON live_routes(last_updated); \ No newline at end of file diff --git a/internal/routewatch/app.go b/internal/routewatch/app.go index 07597e9..57a8030 100644 --- a/internal/routewatch/app.go +++ b/internal/routewatch/app.go @@ -178,6 +178,9 @@ func (rw *RouteWatch) Shutdown() { // Stop services rw.streamer.Stop() + // Stop routing table expiration + rw.routingTable.Stop() + // Stop HTTP server with a timeout const serverStopTimeout = 5 * time.Second stopCtx, cancel := context.WithTimeout(context.Background(), serverStopTimeout) diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index 47ac9ac..9e49e13 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -157,6 +157,24 @@ func (m *mockStore) GetStats() (database.Stats, error) { }, nil } +// UpsertLiveRoute mock implementation +func (m *mockStore) UpsertLiveRoute(route *database.LiveRoute) error { + // Simple mock - just return nil + return nil +} + +// DeleteLiveRoute mock implementation +func (m *mockStore) DeleteLiveRoute(prefix string, originASN int, peerIP string) error { + // Simple mock - just return nil + return nil +} + +// GetPrefixDistribution mock implementation +func (m *mockStore) GetPrefixDistribution() (ipv4 []database.PrefixDistribution, ipv6 []database.PrefixDistribution, err error) { + // Return empty distributions for now + return nil, nil, nil +} + func TestRouteWatchLiveFeed(t *testing.T) { // Disable snapshotter for tests t.Setenv("ROUTEWATCH_DISABLE_SNAPSHOTTER", "1") diff --git a/internal/routewatch/dbhandler.go b/internal/routewatch/dbhandler.go index 4d18277..6d705b3 100644 --- a/internal/routewatch/dbhandler.go +++ b/internal/routewatch/dbhandler.go @@ -11,10 +11,10 @@ import ( const ( // dbHandlerQueueSize is the queue capacity for database operations - dbHandlerQueueSize = 200000 + dbHandlerQueueSize = 50000 // batchSize is the number of operations to batch together - batchSize = 16000 + batchSize = 32000 // batchTimeout is the maximum time to wait before flushing a batch batchTimeout = 5 * time.Second diff --git a/internal/routewatch/peerhandler.go b/internal/routewatch/peerhandler.go index 2828819..3c149ef 100644 --- a/internal/routewatch/peerhandler.go +++ b/internal/routewatch/peerhandler.go @@ -12,7 +12,7 @@ import ( const ( // peerHandlerQueueSize is the queue capacity for peer tracking operations - peerHandlerQueueSize = 2000 + peerHandlerQueueSize = 50000 // peerBatchSize is the number of peer updates to batch together peerBatchSize = 500 diff --git a/internal/routewatch/prefixhandler.go b/internal/routewatch/prefixhandler.go index d42b22c..0f8ef1f 100644 --- a/internal/routewatch/prefixhandler.go +++ b/internal/routewatch/prefixhandler.go @@ -1,13 +1,15 @@ package routewatch import ( - "encoding/json" + "net" + "strings" "sync" "time" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/ristypes" + "github.com/google/uuid" ) const ( @@ -19,9 +21,14 @@ const ( // prefixBatchTimeout is the maximum time to wait before flushing a batch prefixBatchTimeout = 5 * time.Second + + // IP version constants + ipv4Version = 4 + ipv6Version = 6 ) -// PrefixHandler tracks BGP prefixes and maintains a routing table in the database +// PrefixHandler tracks BGP prefixes and maintains a live routing table in the database. +// Routes are added on announcement and deleted on withdrawal. type PrefixHandler struct { db database.Store logger *logger.Logger @@ -185,80 +192,73 @@ func (h *PrefixHandler) flushBatchLocked() { h.lastFlush = time.Now() } +// parseCIDR extracts the mask length and IP version from a prefix string +func parseCIDR(prefix string) (maskLength int, ipVersion int, err error) { + _, ipNet, err := net.ParseCIDR(prefix) + if err != nil { + return 0, 0, err + } + + ones, _ := ipNet.Mask.Size() + if strings.Contains(prefix, ":") { + return ones, ipv6Version, nil + } + + return ones, ipv4Version, nil +} + // processAnnouncement handles storing an announcement in the database -func (h *PrefixHandler) processAnnouncement(prefix *database.Prefix, update prefixUpdate) { - // Get or create origin ASN - originASN, err := h.db.GetOrCreateASN(update.originASN, update.timestamp) +func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpdate) { + // Parse CIDR to get mask length + maskLength, ipVersion, err := parseCIDR(update.prefix) if err != nil { - h.logger.Error("Failed to get/create origin ASN", - "asn", update.originASN, + h.logger.Error("Failed to parse CIDR", + "prefix", update.prefix, "error", err, ) return } - // Get or create peer ASN (first element in path if exists) - var peerASN *database.ASN - if len(update.path) > 0 { - peerASN, err = h.db.GetOrCreateASN(update.path[0], update.timestamp) - if err != nil { - h.logger.Error("Failed to get/create peer ASN", - "asn", update.path[0], - "error", err, - ) - - return - } - } else { - // If no path, use origin as peer - peerASN = originASN + // Create live route record + liveRoute := &database.LiveRoute{ + ID: uuid.New(), + Prefix: update.prefix, + MaskLength: maskLength, + IPVersion: ipVersion, + OriginASN: update.originASN, + PeerIP: update.peer, + ASPath: update.path, + NextHop: update.peer, // Using peer as next hop + LastUpdated: update.timestamp, } - // Encode AS path as JSON - pathJSON, err := json.Marshal(update.path) - if err != nil { - h.logger.Error("Failed to encode AS path", - "path", update.path, - "error", err, - ) - - return - } - - // Create announcement record - announcement := &database.Announcement{ - PrefixID: prefix.ID, - ASNID: peerASN.ID, - OriginASNID: originASN.ID, - Path: string(pathJSON), - NextHop: update.peer, - Timestamp: update.timestamp, - IsWithdrawal: false, - } - - if err := h.db.RecordAnnouncement(announcement); err != nil { - h.logger.Error("Failed to record announcement", + if err := h.db.UpsertLiveRoute(liveRoute); err != nil { + h.logger.Error("Failed to upsert live route", "prefix", update.prefix, "error", err, ) } } -// processWithdrawal handles storing a withdrawal in the database -func (h *PrefixHandler) processWithdrawal(prefix *database.Prefix, update prefixUpdate) { - // For withdrawals, create a withdrawal record - announcement := &database.Announcement{ - PrefixID: prefix.ID, - NextHop: update.peer, - Timestamp: update.timestamp, - IsWithdrawal: true, - } - - if err := h.db.RecordAnnouncement(announcement); err != nil { - h.logger.Error("Failed to record withdrawal", +// processWithdrawal handles removing a route from the live routing table +func (h *PrefixHandler) processWithdrawal(_ *database.Prefix, update prefixUpdate) { + // For withdrawals, we need to delete the route from live_routes + // Since we have the origin ASN from the update, we can delete the specific route + if update.originASN > 0 { + if err := h.db.DeleteLiveRoute(update.prefix, update.originASN, update.peer); err != nil { + h.logger.Error("Failed to delete live route", + "prefix", update.prefix, + "origin_asn", update.originASN, + "peer", update.peer, + "error", err, + ) + } + } else { + // If no origin ASN, log a warning + h.logger.Warn("Withdrawal without origin ASN", "prefix", update.prefix, - "error", err, + "peer", update.peer, ) } } diff --git a/internal/routingtable/routingtable.go b/internal/routingtable/routingtable.go index db97b6d..cf69a9d 100644 --- a/internal/routingtable/routingtable.go +++ b/internal/routingtable/routingtable.go @@ -64,18 +64,26 @@ type RoutingTable struct { lastMetricsReset time.Time // Configuration - snapshotDir string + snapshotDir string + routeExpirationTimeout time.Duration + logger *logger.Logger + + // Expiration management + stopExpiration chan struct{} } // New creates a new routing table, loading from snapshot if available func New(cfg *config.Config, logger *logger.Logger) *RoutingTable { rt := &RoutingTable{ - routes: make(map[RouteKey]*Route), - byPrefix: make(map[uuid.UUID]map[RouteKey]*Route), - byOriginASN: make(map[uuid.UUID]map[RouteKey]*Route), - byPeerASN: make(map[int]map[RouteKey]*Route), - lastMetricsReset: time.Now(), - snapshotDir: cfg.GetStateDir(), + routes: make(map[RouteKey]*Route), + byPrefix: make(map[uuid.UUID]map[RouteKey]*Route), + byOriginASN: make(map[uuid.UUID]map[RouteKey]*Route), + byPeerASN: make(map[int]map[RouteKey]*Route), + lastMetricsReset: time.Now(), + snapshotDir: cfg.GetStateDir(), + routeExpirationTimeout: cfg.RouteExpirationTimeout, + logger: logger, + stopExpiration: make(chan struct{}), } // Try to load from snapshot @@ -83,6 +91,9 @@ func New(cfg *config.Config, logger *logger.Logger) *RoutingTable { logger.Warn("Failed to load routing table from snapshot", "error", err) } + // Start expiration goroutine + go rt.expireRoutesLoop() + return rt } @@ -522,3 +533,72 @@ func (rt *RoutingTable) loadFromSnapshot(logger *logger.Logger) error { return nil } + +// expireRoutesLoop periodically removes expired routes +func (rt *RoutingTable) expireRoutesLoop() { + // Run every minute to check for expired routes + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + rt.expireStaleRoutes() + case <-rt.stopExpiration: + return + } + } +} + +// expireStaleRoutes removes routes that haven't been updated recently +func (rt *RoutingTable) expireStaleRoutes() { + rt.mu.Lock() + defer rt.mu.Unlock() + + now := time.Now().UTC() + cutoffTime := now.Add(-rt.routeExpirationTimeout) + expiredCount := 0 + + // Collect keys to delete (can't delete while iterating) + var keysToDelete []RouteKey + for key, route := range rt.routes { + // Use AnnouncedAt as the last update time + if route.AnnouncedAt.Before(cutoffTime) { + keysToDelete = append(keysToDelete, key) + } + } + + // Delete expired routes + for _, key := range keysToDelete { + route, exists := rt.routes[key] + if !exists { + continue + } + + rt.removeFromIndexes(key, route) + delete(rt.routes, key) + expiredCount++ + + // Update metrics + if isIPv6(route.Prefix) { + rt.ipv6Routes-- + } else { + rt.ipv4Routes-- + } + } + + if expiredCount > 0 { + rt.logger.Info("Expired stale routes", + "count", expiredCount, + "timeout", rt.routeExpirationTimeout, + "remaining_routes", len(rt.routes), + ) + } +} + +// Stop gracefully stops the routing table background tasks +func (rt *RoutingTable) Stop() { + if rt.stopExpiration != nil { + close(rt.stopExpiration) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 24258e3..eadcfba 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -114,23 +114,25 @@ func (s *Server) handleRoot() http.HandlerFunc { func (s *Server) handleStatusJSON() http.HandlerFunc { // Stats represents the statistics response type Stats struct { - Uptime string `json:"uptime"` - TotalMessages uint64 `json:"total_messages"` - TotalBytes uint64 `json:"total_bytes"` - MessagesPerSec float64 `json:"messages_per_sec"` - MbitsPerSec float64 `json:"mbits_per_sec"` - Connected bool `json:"connected"` - ASNs int `json:"asns"` - Prefixes int `json:"prefixes"` - IPv4Prefixes int `json:"ipv4_prefixes"` - IPv6Prefixes int `json:"ipv6_prefixes"` - Peerings int `json:"peerings"` - DatabaseSizeBytes int64 `json:"database_size_bytes"` - LiveRoutes int `json:"live_routes"` - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` - IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + Uptime string `json:"uptime"` + TotalMessages uint64 `json:"total_messages"` + TotalBytes uint64 `json:"total_bytes"` + MessagesPerSec float64 `json:"messages_per_sec"` + MbitsPerSec float64 `json:"mbits_per_sec"` + Connected bool `json:"connected"` + ASNs int `json:"asns"` + Prefixes int `json:"prefixes"` + IPv4Prefixes int `json:"ipv4_prefixes"` + IPv6Prefixes int `json:"ipv6_prefixes"` + Peerings int `json:"peerings"` + DatabaseSizeBytes int64 `json:"database_size_bytes"` + LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` + IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` } return func(w http.ResponseWriter, r *http.Request) { @@ -198,23 +200,25 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { rtStats := s.routingTable.GetDetailedStats() stats := Stats{ - Uptime: uptime, - TotalMessages: metrics.TotalMessages, - TotalBytes: metrics.TotalBytes, - MessagesPerSec: metrics.MessagesPerSec, - MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, - Connected: metrics.Connected, - ASNs: dbStats.ASNs, - Prefixes: dbStats.Prefixes, - IPv4Prefixes: dbStats.IPv4Prefixes, - IPv6Prefixes: dbStats.IPv6Prefixes, - Peerings: dbStats.Peerings, - DatabaseSizeBytes: dbStats.FileSizeBytes, - LiveRoutes: rtStats.TotalRoutes, - IPv4Routes: rtStats.IPv4Routes, - IPv6Routes: rtStats.IPv6Routes, - IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate, - IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate, + Uptime: uptime, + TotalMessages: metrics.TotalMessages, + TotalBytes: metrics.TotalBytes, + MessagesPerSec: metrics.MessagesPerSec, + MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, + Connected: metrics.Connected, + ASNs: dbStats.ASNs, + Prefixes: dbStats.Prefixes, + IPv4Prefixes: dbStats.IPv4Prefixes, + IPv6Prefixes: dbStats.IPv6Prefixes, + Peerings: dbStats.Peerings, + DatabaseSizeBytes: dbStats.FileSizeBytes, + LiveRoutes: dbStats.LiveRoutes, + IPv4Routes: rtStats.IPv4Routes, + IPv6Routes: rtStats.IPv6Routes, + IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate, + IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate, + IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution, + IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution, } w.Header().Set("Content-Type", "application/json") @@ -244,24 +248,26 @@ func (s *Server) handleStats() http.HandlerFunc { // StatsResponse represents the API statistics response type StatsResponse struct { - Uptime string `json:"uptime"` - TotalMessages uint64 `json:"total_messages"` - TotalBytes uint64 `json:"total_bytes"` - MessagesPerSec float64 `json:"messages_per_sec"` - MbitsPerSec float64 `json:"mbits_per_sec"` - Connected bool `json:"connected"` - ASNs int `json:"asns"` - Prefixes int `json:"prefixes"` - IPv4Prefixes int `json:"ipv4_prefixes"` - IPv6Prefixes int `json:"ipv6_prefixes"` - Peerings int `json:"peerings"` - DatabaseSizeBytes int64 `json:"database_size_bytes"` - LiveRoutes int `json:"live_routes"` - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` - IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` - HandlerStats []HandlerStatsInfo `json:"handler_stats"` + Uptime string `json:"uptime"` + TotalMessages uint64 `json:"total_messages"` + TotalBytes uint64 `json:"total_bytes"` + MessagesPerSec float64 `json:"messages_per_sec"` + MbitsPerSec float64 `json:"mbits_per_sec"` + Connected bool `json:"connected"` + ASNs int `json:"asns"` + Prefixes int `json:"prefixes"` + IPv4Prefixes int `json:"ipv4_prefixes"` + IPv6Prefixes int `json:"ipv6_prefixes"` + Peerings int `json:"peerings"` + DatabaseSizeBytes int64 `json:"database_size_bytes"` + LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` + IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + HandlerStats []HandlerStatsInfo `json:"handler_stats"` + IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` } return func(w http.ResponseWriter, r *http.Request) { @@ -339,24 +345,26 @@ func (s *Server) handleStats() http.HandlerFunc { } stats := StatsResponse{ - Uptime: uptime, - TotalMessages: metrics.TotalMessages, - TotalBytes: metrics.TotalBytes, - MessagesPerSec: metrics.MessagesPerSec, - MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, - Connected: metrics.Connected, - ASNs: dbStats.ASNs, - Prefixes: dbStats.Prefixes, - IPv4Prefixes: dbStats.IPv4Prefixes, - IPv6Prefixes: dbStats.IPv6Prefixes, - Peerings: dbStats.Peerings, - DatabaseSizeBytes: dbStats.FileSizeBytes, - LiveRoutes: rtStats.TotalRoutes, - IPv4Routes: rtStats.IPv4Routes, - IPv6Routes: rtStats.IPv6Routes, - IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate, - IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate, - HandlerStats: handlerStatsInfo, + Uptime: uptime, + TotalMessages: metrics.TotalMessages, + TotalBytes: metrics.TotalBytes, + MessagesPerSec: metrics.MessagesPerSec, + MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, + Connected: metrics.Connected, + ASNs: dbStats.ASNs, + Prefixes: dbStats.Prefixes, + IPv4Prefixes: dbStats.IPv4Prefixes, + IPv6Prefixes: dbStats.IPv6Prefixes, + Peerings: dbStats.Peerings, + DatabaseSizeBytes: dbStats.FileSizeBytes, + LiveRoutes: dbStats.LiveRoutes, + IPv4Routes: rtStats.IPv4Routes, + IPv6Routes: rtStats.IPv6Routes, + IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate, + IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate, + HandlerStats: handlerStatsInfo, + IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution, + IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution, } w.Header().Set("Content-Type", "application/json") diff --git a/internal/templates/status.html b/internal/templates/status.html index 7519da3..a8c4296 100644 --- a/internal/templates/status.html +++ b/internal/templates/status.html @@ -153,6 +153,22 @@ +
+
+

IPv4 Prefix Distribution

+
+ +
+
+ +
+

IPv6 Prefix Distribution

+
+ +
+
+
+
@@ -170,6 +186,29 @@ return num.toLocaleString(); } + function updatePrefixDistribution(elementId, distribution) { + const container = document.getElementById(elementId); + container.innerHTML = ''; + + if (!distribution || distribution.length === 0) { + container.innerHTML = '
No data
'; + return; + } + + // Sort by mask length + distribution.sort((a, b) => a.mask_length - b.mask_length); + + distribution.forEach(item => { + const metric = document.createElement('div'); + metric.className = 'metric'; + metric.innerHTML = ` + /${item.mask_length} + ${formatNumber(item.count)} + `; + container.appendChild(metric); + }); + } + function updateHandlerStats(handlerStats) { const container = document.getElementById('handler-stats-container'); container.innerHTML = ''; @@ -249,6 +288,10 @@ // Update handler stats updateHandlerStats(data.handler_stats || []); + // Update prefix distribution + updatePrefixDistribution('ipv4-prefix-distribution', data.ipv4_prefix_distribution); + updatePrefixDistribution('ipv6-prefix-distribution', data.ipv6_prefix_distribution); + // Clear any errors document.getElementById('error').style.display = 'none'; })