diff --git a/internal/routingtable/routingtable.go b/internal/routingtable/routingtable.go index e32f1d9..ccf9007 100644 --- a/internal/routingtable/routingtable.go +++ b/internal/routingtable/routingtable.go @@ -338,6 +338,28 @@ func (rt *RoutingTable) Clear() { rt.lastMetricsReset = time.Now() } +// RLock acquires a read lock on the routing table +// This is exposed for the snapshotter to use +func (rt *RoutingTable) RLock() { + rt.mu.RLock() +} + +// RUnlock releases a read lock on the routing table +// This is exposed for the snapshotter to use +func (rt *RoutingTable) RUnlock() { + rt.mu.RUnlock() +} + +// GetAllRoutesUnsafe returns all routes without copying +// IMPORTANT: Caller must hold RLock before calling this method +func (rt *RoutingTable) GetAllRoutesUnsafe() []*Route { + routes := make([]*Route, 0, len(rt.routes)) + for _, route := range rt.routes { + routes = append(routes, route) + } + return routes +} + // Helper methods for index management func (rt *RoutingTable) addToIndexes(key RouteKey, route *Route) { diff --git a/internal/server/server.go b/internal/server/server.go index 929c728..1bd360c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -126,6 +126,8 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { IPv6Prefixes int `json:"ipv6_prefixes"` Peerings int `json:"peerings"` LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` } return func(w http.ResponseWriter, r *http.Request) { @@ -191,6 +193,9 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { const bitsPerMegabit = 1000000.0 + // Get detailed routing table stats + rtStats := s.routingTable.GetDetailedStats() + stats := Stats{ Uptime: uptime, TotalMessages: metrics.TotalMessages, @@ -203,7 +208,9 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { IPv4Prefixes: dbStats.IPv4Prefixes, IPv6Prefixes: dbStats.IPv6Prefixes, Peerings: dbStats.Peerings, - LiveRoutes: s.routingTable.Size(), + LiveRoutes: rtStats.TotalRoutes, + IPv4Routes: rtStats.IPv4Routes, + IPv6Routes: rtStats.IPv6Routes, } w.Header().Set("Content-Type", "application/json") @@ -233,6 +240,8 @@ func (s *Server) handleStats() http.HandlerFunc { IPv6Prefixes int `json:"ipv6_prefixes"` Peerings int `json:"peerings"` LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` } return func(w http.ResponseWriter, r *http.Request) { @@ -291,6 +300,9 @@ func (s *Server) handleStats() http.HandlerFunc { const bitsPerMegabit = 1000000.0 + // Get detailed routing table stats + rtStats := s.routingTable.GetDetailedStats() + stats := StatsResponse{ Uptime: uptime, TotalMessages: metrics.TotalMessages, @@ -303,7 +315,9 @@ func (s *Server) handleStats() http.HandlerFunc { IPv4Prefixes: dbStats.IPv4Prefixes, IPv6Prefixes: dbStats.IPv6Prefixes, Peerings: dbStats.Peerings, - LiveRoutes: s.routingTable.Size(), + LiveRoutes: rtStats.TotalRoutes, + IPv4Routes: rtStats.IPv4Routes, + IPv6Routes: rtStats.IPv6Routes, } w.Header().Set("Content-Type", "application/json") diff --git a/internal/snapshotter/snapshotter.go b/internal/snapshotter/snapshotter.go new file mode 100644 index 0000000..feffb65 --- /dev/null +++ b/internal/snapshotter/snapshotter.go @@ -0,0 +1,239 @@ +// Package snapshotter provides functionality for creating periodic and on-demand +// snapshots of the routing table state. +package snapshotter + +import ( + "compress/gzip" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + "git.eeqj.de/sneak/routewatch/internal/routingtable" +) + +const ( + snapshotInterval = 10 * time.Minute + snapshotFilename = "routewatch-snapshot.json.gz" + tempFileSuffix = ".tmp" +) + +// Snapshotter handles periodic and on-demand snapshots of the routing table +type Snapshotter struct { + rt *routingtable.RoutingTable + stateDir string + logger *slog.Logger + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex // Ensures only one snapshot runs at a time + wg sync.WaitGroup + lastSnapshot time.Time +} + +// New creates a new Snapshotter instance +func New(ctx context.Context, rt *routingtable.RoutingTable, logger *slog.Logger) (*Snapshotter, error) { + stateDir, err := getStateDirectory() + if err != nil { + return nil, fmt.Errorf("failed to determine state directory: %w", err) + } + + // Ensure state directory exists + if err := os.MkdirAll(stateDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create state directory: %w", err) + } + + ctx, cancel := context.WithCancel(ctx) + + s := &Snapshotter{ + rt: rt, + stateDir: stateDir, + logger: logger, + ctx: ctx, + cancel: cancel, + } + + // Start periodic snapshot goroutine + s.wg.Add(1) + go s.periodicSnapshot() + + return s, nil +} + +// getStateDirectory returns the appropriate state directory based on the OS +func getStateDirectory() (string, error) { + switch runtime.GOOS { + case "darwin": + // macOS: Use ~/Library/Application Support/routewatch + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Application Support", "routewatch"), nil + case "linux", "freebsd", "openbsd", "netbsd": + // Unix-like: Use /var/lib/routewatch if running as root, otherwise use XDG_STATE_HOME + if os.Geteuid() == 0 { + return "/var/lib/routewatch", nil + } + // Check XDG_STATE_HOME first + if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" { + return filepath.Join(xdgState, "routewatch"), nil + } + // Fall back to ~/.local/state/routewatch + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state", "routewatch"), nil + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } +} + +// periodicSnapshot runs periodic snapshots +func (s *Snapshotter) periodicSnapshot() { + defer s.wg.Done() + + ticker := time.NewTicker(snapshotInterval) + defer ticker.Stop() + + // Take an initial snapshot + if err := s.TakeSnapshot(); err != nil { + s.logger.Error("Failed to take initial snapshot", "error", err) + } + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + if err := s.TakeSnapshot(); err != nil { + s.logger.Error("Failed to take periodic snapshot", "error", err) + } + } + } +} + +// TakeSnapshot creates a snapshot of the current routing table state +func (s *Snapshotter) TakeSnapshot() error { + // Ensure only one snapshot runs at a time + s.mu.Lock() + defer s.mu.Unlock() + + start := time.Now() + s.logger.Info("Starting routing table snapshot") + + // Get a copy of all routes while holding read lock + s.rt.RLock() + routes := s.rt.GetAllRoutesUnsafe() // We'll need to add this method + stats := s.rt.GetDetailedStats() + s.rt.RUnlock() + + // Create snapshot data structure + snapshot := struct { + Timestamp time.Time `json:"timestamp"` + Stats routingtable.DetailedStats `json:"stats"` + Routes []*routingtable.Route `json:"routes"` + }{ + Timestamp: time.Now().UTC(), + Stats: stats, + Routes: routes, + } + + // Serialize to JSON + jsonData, err := json.Marshal(snapshot) + if err != nil { + return fmt.Errorf("failed to marshal snapshot: %w", err) + } + + // Write compressed data to temporary file + tempPath := filepath.Join(s.stateDir, snapshotFilename+tempFileSuffix) + finalPath := filepath.Join(s.stateDir, snapshotFilename) + + tempFile, err := os.Create(tempPath) + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + defer func() { + tempFile.Close() + // Clean up temp file if it still exists + os.Remove(tempPath) + }() + + // Create gzip writer + gzipWriter := gzip.NewWriter(tempFile) + gzipWriter.Header.Comment = fmt.Sprintf("RouteWatch snapshot taken at %s", snapshot.Timestamp.Format(time.RFC3339)) + + // Write compressed data + if _, err := gzipWriter.Write(jsonData); err != nil { + return fmt.Errorf("failed to write compressed data: %w", err) + } + + // Close gzip writer to flush all data + if err := gzipWriter.Close(); err != nil { + return fmt.Errorf("failed to close gzip writer: %w", err) + } + + // Sync to disk + if err := tempFile.Sync(); err != nil { + return fmt.Errorf("failed to sync temporary file: %w", err) + } + + // Close temp file before rename + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close temporary file: %w", err) + } + + // Atomically rename temp file to final location + if err := os.Rename(tempPath, finalPath); err != nil { + return fmt.Errorf("failed to rename temporary file: %w", err) + } + + duration := time.Since(start) + s.lastSnapshot = time.Now() + + s.logger.Info("Routing table snapshot completed", + "duration", duration, + "routes", len(routes), + "ipv4_routes", stats.IPv4Routes, + "ipv6_routes", stats.IPv6Routes, + "size_bytes", len(jsonData), + "path", finalPath, + ) + + return nil +} + +// Shutdown performs a final snapshot and cleans up resources +func (s *Snapshotter) Shutdown() error { + s.logger.Info("Shutting down snapshotter") + + // Cancel context to stop periodic snapshots + s.cancel() + + // Wait for periodic snapshot goroutine to finish + s.wg.Wait() + + // Take final snapshot + if err := s.TakeSnapshot(); err != nil { + return fmt.Errorf("failed to take final snapshot: %w", err) + } + + return nil +} + +// GetLastSnapshotTime returns the time of the last successful snapshot +func (s *Snapshotter) GetLastSnapshotTime() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastSnapshot +} + +// GetSnapshotPath returns the path to the snapshot file +func (s *Snapshotter) GetSnapshotPath() string { + return filepath.Join(s.stateDir, snapshotFilename) +} diff --git a/internal/templates/status.html b/internal/templates/status.html index 279b009..8175a4d 100644 --- a/internal/templates/status.html +++ b/internal/templates/status.html @@ -46,7 +46,7 @@ color: #666; } .metric-value { - font-weight: 600; + font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace; color: #333; } .connected { @@ -126,6 +126,14 @@ Live Routes - +