Add separate IPv4/IPv6 route counts to status page and API
- Update server Stats and StatsResponse structs to include ipv4_routes and ipv6_routes - Fetch detailed routing table stats to get IPv4/IPv6 breakdown - Add IPv4 Routes and IPv6 Routes display to HTML status page - Change metric values to monospace font and remove bold styling
This commit is contained in:
parent
1d05372899
commit
283f2ddbf2
@ -338,6 +338,28 @@ func (rt *RoutingTable) Clear() {
|
|||||||
rt.lastMetricsReset = time.Now()
|
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
|
// Helper methods for index management
|
||||||
|
|
||||||
func (rt *RoutingTable) addToIndexes(key RouteKey, route *Route) {
|
func (rt *RoutingTable) addToIndexes(key RouteKey, route *Route) {
|
||||||
|
@ -126,6 +126,8 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|||||||
IPv6Prefixes int `json:"ipv6_prefixes"`
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
||||||
Peerings int `json:"peerings"`
|
Peerings int `json:"peerings"`
|
||||||
LiveRoutes int `json:"live_routes"`
|
LiveRoutes int `json:"live_routes"`
|
||||||
|
IPv4Routes int `json:"ipv4_routes"`
|
||||||
|
IPv6Routes int `json:"ipv6_routes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -191,6 +193,9 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|||||||
|
|
||||||
const bitsPerMegabit = 1000000.0
|
const bitsPerMegabit = 1000000.0
|
||||||
|
|
||||||
|
// Get detailed routing table stats
|
||||||
|
rtStats := s.routingTable.GetDetailedStats()
|
||||||
|
|
||||||
stats := Stats{
|
stats := Stats{
|
||||||
Uptime: uptime,
|
Uptime: uptime,
|
||||||
TotalMessages: metrics.TotalMessages,
|
TotalMessages: metrics.TotalMessages,
|
||||||
@ -203,7 +208,9 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|||||||
IPv4Prefixes: dbStats.IPv4Prefixes,
|
IPv4Prefixes: dbStats.IPv4Prefixes,
|
||||||
IPv6Prefixes: dbStats.IPv6Prefixes,
|
IPv6Prefixes: dbStats.IPv6Prefixes,
|
||||||
Peerings: dbStats.Peerings,
|
Peerings: dbStats.Peerings,
|
||||||
LiveRoutes: s.routingTable.Size(),
|
LiveRoutes: rtStats.TotalRoutes,
|
||||||
|
IPv4Routes: rtStats.IPv4Routes,
|
||||||
|
IPv6Routes: rtStats.IPv6Routes,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@ -233,6 +240,8 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
IPv6Prefixes int `json:"ipv6_prefixes"`
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
||||||
Peerings int `json:"peerings"`
|
Peerings int `json:"peerings"`
|
||||||
LiveRoutes int `json:"live_routes"`
|
LiveRoutes int `json:"live_routes"`
|
||||||
|
IPv4Routes int `json:"ipv4_routes"`
|
||||||
|
IPv6Routes int `json:"ipv6_routes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -291,6 +300,9 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
|
|
||||||
const bitsPerMegabit = 1000000.0
|
const bitsPerMegabit = 1000000.0
|
||||||
|
|
||||||
|
// Get detailed routing table stats
|
||||||
|
rtStats := s.routingTable.GetDetailedStats()
|
||||||
|
|
||||||
stats := StatsResponse{
|
stats := StatsResponse{
|
||||||
Uptime: uptime,
|
Uptime: uptime,
|
||||||
TotalMessages: metrics.TotalMessages,
|
TotalMessages: metrics.TotalMessages,
|
||||||
@ -303,7 +315,9 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
IPv4Prefixes: dbStats.IPv4Prefixes,
|
IPv4Prefixes: dbStats.IPv4Prefixes,
|
||||||
IPv6Prefixes: dbStats.IPv6Prefixes,
|
IPv6Prefixes: dbStats.IPv6Prefixes,
|
||||||
Peerings: dbStats.Peerings,
|
Peerings: dbStats.Peerings,
|
||||||
LiveRoutes: s.routingTable.Size(),
|
LiveRoutes: rtStats.TotalRoutes,
|
||||||
|
IPv4Routes: rtStats.IPv4Routes,
|
||||||
|
IPv6Routes: rtStats.IPv6Routes,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
239
internal/snapshotter/snapshotter.go
Normal file
239
internal/snapshotter/snapshotter.go
Normal file
@ -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)
|
||||||
|
}
|
@ -46,7 +46,7 @@
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
.metric-value {
|
.metric-value {
|
||||||
font-weight: 600;
|
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
.connected {
|
.connected {
|
||||||
@ -126,6 +126,14 @@
|
|||||||
<span class="metric-label">Live Routes</span>
|
<span class="metric-label">Live Routes</span>
|
||||||
<span class="metric-value" id="live_routes">-</span>
|
<span class="metric-value" id="live_routes">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">IPv4 Routes</span>
|
||||||
|
<span class="metric-value" id="ipv4_routes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">IPv6 Routes</span>
|
||||||
|
<span class="metric-value" id="ipv6_routes">-</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -173,6 +181,8 @@
|
|||||||
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
||||||
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
||||||
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
||||||
|
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
|
||||||
|
document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes);
|
||||||
|
|
||||||
// Clear any errors
|
// Clear any errors
|
||||||
document.getElementById('error').style.display = 'none';
|
document.getElementById('error').style.display = 'none';
|
||||||
|
Loading…
Reference in New Issue
Block a user