Compare commits
No commits in common. "d15a5e91b949bd447afa066f035d62a2efecb7db" and "6593a7be763434fde61d0e08bc51b8c2301d3406" have entirely different histories.
d15a5e91b9
...
6593a7be76
@ -1,91 +0,0 @@
|
|||||||
// Package config provides centralized configuration management for RouteWatch
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// AppIdentifier is the reverse domain name identifier for the app
|
|
||||||
AppIdentifier = "berlin.sneak.app.routewatch"
|
|
||||||
|
|
||||||
// dirPermissions for creating directories
|
|
||||||
dirPermissions = 0750 // rwxr-x---
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds configuration for the entire application
|
|
||||||
type Config struct {
|
|
||||||
// StateDir is the directory for all application state (database, snapshots)
|
|
||||||
StateDir string
|
|
||||||
|
|
||||||
// MaxRuntime is the maximum runtime (0 = run forever)
|
|
||||||
MaxRuntime time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Config with default paths based on the OS
|
|
||||||
func New() (*Config, error) {
|
|
||||||
stateDir, err := getStateDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to determine state directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Config{
|
|
||||||
StateDir: stateDir,
|
|
||||||
MaxRuntime: 0, // Run forever by default
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStateDir returns the state directory path
|
|
||||||
func (c *Config) GetStateDir() string {
|
|
||||||
return c.StateDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStateDirectory returns the appropriate state directory based on the OS
|
|
||||||
func getStateDirectory() (string, error) {
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
// macOS: ~/Library/Application Support/berlin.sneak.app.routewatch
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(home, "Library", "Application Support", AppIdentifier), nil
|
|
||||||
|
|
||||||
case "linux", "freebsd", "openbsd", "netbsd":
|
|
||||||
// Unix-like: /var/lib/berlin.sneak.app.routewatch if root, else XDG_DATA_HOME
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
return filepath.Join("/var/lib", AppIdentifier), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check XDG_DATA_HOME first
|
|
||||||
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
|
|
||||||
return filepath.Join(xdgData, AppIdentifier), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to ~/.local/share/berlin.sneak.app.routewatch
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(home, ".local", "share", AppIdentifier), nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureDirectories creates all necessary directories if they don't exist
|
|
||||||
func (c *Config) EnsureDirectories() error {
|
|
||||||
// Ensure state directory exists
|
|
||||||
if err := os.MkdirAll(c.StateDir, dirPermissions); err != nil {
|
|
||||||
return fmt.Errorf("failed to create state directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -8,9 +8,9 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
||||||
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
|
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
|
||||||
@ -28,22 +28,77 @@ type Database struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new database connection and initializes the schema.
|
// Config holds database configuration
|
||||||
func New(cfg *config.Config, logger *slog.Logger) (*Database, error) {
|
type Config struct {
|
||||||
dbPath := filepath.Join(cfg.GetStateDir(), "db.sqlite")
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultDatabasePath returns the appropriate database path for the OS
|
||||||
|
func getDefaultDatabasePath() string {
|
||||||
|
const dbFilename = "db.sqlite"
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
// macOS: ~/Library/Application Support/berlin.sneak.app.routewatch/db.sqlite
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return dbFilename
|
||||||
|
}
|
||||||
|
appSupport := filepath.Join(home, "Library", "Application Support", "berlin.sneak.app.routewatch")
|
||||||
|
if err := os.MkdirAll(appSupport, dirPermissions); err != nil {
|
||||||
|
return dbFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(appSupport, dbFilename)
|
||||||
|
default:
|
||||||
|
// Linux and others: /var/lib/routewatch/db.sqlite
|
||||||
|
dbDir := "/var/lib/routewatch"
|
||||||
|
if err := os.MkdirAll(dbDir, dirPermissions); err != nil {
|
||||||
|
// Fall back to user's home directory if can't create system directory
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return dbFilename
|
||||||
|
}
|
||||||
|
userDir := filepath.Join(home, ".local", "share", "routewatch")
|
||||||
|
if err := os.MkdirAll(userDir, dirPermissions); err != nil {
|
||||||
|
return dbFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(userDir, dbFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dbDir, dbFilename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfig provides default database configuration
|
||||||
|
func NewConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Path: getDefaultDatabasePath(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new database connection and initializes the schema.
|
||||||
|
func New(logger *slog.Logger) (*Database, error) {
|
||||||
|
config := NewConfig()
|
||||||
|
|
||||||
|
return NewWithConfig(config, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithConfig creates a new database connection with custom configuration
|
||||||
|
func NewWithConfig(config Config, logger *slog.Logger) (*Database, error) {
|
||||||
// Log database path
|
// Log database path
|
||||||
logger.Info("Opening database", "path", dbPath)
|
logger.Info("Opening database", "path", config.Path)
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
dir := filepath.Dir(dbPath)
|
dir := filepath.Dir(config.Path)
|
||||||
if err := os.MkdirAll(dir, dirPermissions); err != nil {
|
if err := os.MkdirAll(dir, dirPermissions); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add connection parameters for go-sqlite3
|
// Add connection parameters for go-sqlite3
|
||||||
// Enable WAL mode and other performance optimizations
|
// Enable WAL mode and other performance optimizations
|
||||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&cache=shared", dbPath)
|
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&cache=shared", config.Path)
|
||||||
db, err := sql.Open("sqlite3", dsn)
|
db, err := sql.Open("sqlite3", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
@ -58,7 +113,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Database, error) {
|
|||||||
db.SetMaxIdleConns(1)
|
db.SetMaxIdleConns(1)
|
||||||
db.SetConnMaxLifetime(0)
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
database := &Database{db: db, logger: logger, path: dbPath}
|
database := &Database{db: db, logger: logger, path: config.Path}
|
||||||
|
|
||||||
if err := database.Initialize(); err != nil {
|
if err := database.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||||
|
@ -11,7 +11,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/routingtable"
|
"git.eeqj.de/sneak/routewatch/internal/routingtable"
|
||||||
@ -22,11 +21,23 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config contains runtime configuration for RouteWatch
|
||||||
|
type Config struct {
|
||||||
|
MaxRuntime time.Duration // Maximum runtime (0 = run forever)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// routingTableStatsInterval is how often we log routing table statistics
|
// routingTableStatsInterval is how often we log routing table statistics
|
||||||
routingTableStatsInterval = 15 * time.Second
|
routingTableStatsInterval = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewConfig provides default configuration
|
||||||
|
func NewConfig() Config {
|
||||||
|
return Config{
|
||||||
|
MaxRuntime: 0, // Run forever by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dependencies contains all dependencies for RouteWatch
|
// Dependencies contains all dependencies for RouteWatch
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
fx.In
|
fx.In
|
||||||
@ -36,7 +47,7 @@ type Dependencies struct {
|
|||||||
Streamer *streamer.Streamer
|
Streamer *streamer.Streamer
|
||||||
Server *server.Server
|
Server *server.Server
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
Config *config.Config
|
Config Config `optional:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteWatch represents the main application instance
|
// RouteWatch represents the main application instance
|
||||||
@ -52,17 +63,6 @@ type RouteWatch struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTruthy returns true if the value is considered truthy
|
|
||||||
// Empty string, "0", and "false" are considered falsy, everything else is truthy
|
|
||||||
func isTruthy(value string) bool {
|
|
||||||
return value != "" && value != "0" && value != "false"
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSnapshotterEnabled checks if the snapshotter should be enabled based on environment variable
|
|
||||||
func isSnapshotterEnabled() bool {
|
|
||||||
return !isTruthy(os.Getenv("ROUTEWATCH_DISABLE_SNAPSHOTTER"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new RouteWatch instance
|
// New creates a new RouteWatch instance
|
||||||
func New(deps Dependencies) *RouteWatch {
|
func New(deps Dependencies) *RouteWatch {
|
||||||
rw := &RouteWatch{
|
rw := &RouteWatch{
|
||||||
@ -74,9 +74,9 @@ func New(deps Dependencies) *RouteWatch {
|
|||||||
maxRuntime: deps.Config.MaxRuntime,
|
maxRuntime: deps.Config.MaxRuntime,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create snapshotter if enabled
|
// Create snapshotter unless disabled (for tests)
|
||||||
if isSnapshotterEnabled() {
|
if os.Getenv("ROUTEWATCH_DISABLE_SNAPSHOTTER") != "1" {
|
||||||
snap, err := snapshotter.New(deps.RoutingTable, deps.Config, deps.Logger)
|
snap, err := snapshotter.New(deps.RoutingTable, deps.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deps.Logger.Error("Failed to create snapshotter", "error", err)
|
deps.Logger.Error("Failed to create snapshotter", "error", err)
|
||||||
// Continue without snapshotter
|
// Continue without snapshotter
|
||||||
@ -235,10 +235,13 @@ func getModule() fx.Option {
|
|||||||
return fx.Options(
|
return fx.Options(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
NewLogger,
|
NewLogger,
|
||||||
config.New,
|
NewConfig,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
fx.Annotate(
|
|
||||||
database.New,
|
database.New,
|
||||||
|
fx.Annotate(
|
||||||
|
func(db *database.Database) database.Store {
|
||||||
|
return db
|
||||||
|
},
|
||||||
fx.As(new(database.Store)),
|
fx.As(new(database.Store)),
|
||||||
),
|
),
|
||||||
routingtable.New,
|
routingtable.New,
|
||||||
|
@ -7,7 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/routingtable"
|
"git.eeqj.de/sneak/routewatch/internal/routingtable"
|
||||||
@ -172,14 +171,8 @@ func TestRouteWatchLiveFeed(t *testing.T) {
|
|||||||
// Create streamer
|
// Create streamer
|
||||||
s := streamer.New(logger, metricsTracker)
|
s := streamer.New(logger, metricsTracker)
|
||||||
|
|
||||||
// Create test config with empty state dir (no snapshot loading)
|
|
||||||
cfg := &config.Config{
|
|
||||||
StateDir: "",
|
|
||||||
MaxRuntime: 5 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create routing table
|
// Create routing table
|
||||||
rt := routingtable.New(cfg, logger)
|
rt := routingtable.New(logger)
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
srv := server.New(mockDB, rt, s, logger)
|
srv := server.New(mockDB, rt, s, logger)
|
||||||
@ -191,7 +184,9 @@ func TestRouteWatchLiveFeed(t *testing.T) {
|
|||||||
Streamer: s,
|
Streamer: s,
|
||||||
Server: srv,
|
Server: srv,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Config: cfg,
|
Config: Config{
|
||||||
|
MaxRuntime: 5 * time.Second,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
rw := New(deps)
|
rw := New(deps)
|
||||||
|
|
||||||
|
@ -8,12 +8,12 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ const (
|
|||||||
routeStalenessThreshold = 30 * time.Minute
|
routeStalenessThreshold = 30 * time.Minute
|
||||||
|
|
||||||
// snapshotFilename is the name of the snapshot file
|
// snapshotFilename is the name of the snapshot file
|
||||||
snapshotFilename = "routingtable.json.gz"
|
snapshotFilename = "routewatch-snapshot.json.gz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route represents a single route entry in the routing table
|
// Route represents a single route entry in the routing table
|
||||||
@ -62,20 +62,16 @@ type RoutingTable struct {
|
|||||||
ipv4Updates uint64 // Updates counter for rate calculation
|
ipv4Updates uint64 // Updates counter for rate calculation
|
||||||
ipv6Updates uint64 // Updates counter for rate calculation
|
ipv6Updates uint64 // Updates counter for rate calculation
|
||||||
lastMetricsReset time.Time
|
lastMetricsReset time.Time
|
||||||
|
|
||||||
// Configuration
|
|
||||||
snapshotDir string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new routing table, loading from snapshot if available
|
// New creates a new routing table, loading from snapshot if available
|
||||||
func New(cfg *config.Config, logger *slog.Logger) *RoutingTable {
|
func New(logger *slog.Logger) *RoutingTable {
|
||||||
rt := &RoutingTable{
|
rt := &RoutingTable{
|
||||||
routes: make(map[RouteKey]*Route),
|
routes: make(map[RouteKey]*Route),
|
||||||
byPrefix: make(map[uuid.UUID]map[RouteKey]*Route),
|
byPrefix: make(map[uuid.UUID]map[RouteKey]*Route),
|
||||||
byOriginASN: make(map[uuid.UUID]map[RouteKey]*Route),
|
byOriginASN: make(map[uuid.UUID]map[RouteKey]*Route),
|
||||||
byPeerASN: make(map[int]map[RouteKey]*Route),
|
byPeerASN: make(map[int]map[RouteKey]*Route),
|
||||||
lastMetricsReset: time.Now(),
|
lastMetricsReset: time.Now(),
|
||||||
snapshotDir: cfg.GetStateDir(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load from snapshot
|
// Try to load from snapshot
|
||||||
@ -451,18 +447,51 @@ func isIPv6(prefix string) bool {
|
|||||||
return strings.Contains(prefix, ":")
|
return strings.Contains(prefix, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadFromSnapshot attempts to load the routing table from a snapshot file
|
// getStateDirectory returns the appropriate state directory based on the OS
|
||||||
func (rt *RoutingTable) loadFromSnapshot(logger *slog.Logger) error {
|
func getStateDirectory() (string, error) {
|
||||||
// If no snapshot directory specified, nothing to load
|
switch runtime.GOOS {
|
||||||
if rt.snapshotDir == "" {
|
case "darwin":
|
||||||
return nil
|
// macOS: Use ~/Library/Application Support/routewatch
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotPath := filepath.Join(rt.snapshotDir, snapshotFilename)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFromSnapshot attempts to load the routing table from a snapshot file
|
||||||
|
func (rt *RoutingTable) loadFromSnapshot(logger *slog.Logger) error {
|
||||||
|
stateDir, err := getStateDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to determine state directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotPath := filepath.Join(stateDir, snapshotFilename)
|
||||||
|
|
||||||
// Check if snapshot file exists
|
// Check if snapshot file exists
|
||||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||||
// No snapshot file exists, this is normal - start with empty routing table
|
logger.Info("No snapshot file found, starting with empty routing table")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,20 +6,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRoutingTable(t *testing.T) {
|
func TestRoutingTable(t *testing.T) {
|
||||||
// Create a test logger
|
// Create a test logger
|
||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
|
rt := New(logger)
|
||||||
// Create test config with empty state dir (no snapshot loading)
|
|
||||||
cfg := &config.Config{
|
|
||||||
StateDir: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
rt := New(cfg, logger)
|
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
prefixID1 := uuid.New()
|
prefixID1 := uuid.New()
|
||||||
@ -130,13 +123,7 @@ func TestRoutingTable(t *testing.T) {
|
|||||||
func TestRoutingTableConcurrency(t *testing.T) {
|
func TestRoutingTableConcurrency(t *testing.T) {
|
||||||
// Create a test logger
|
// Create a test logger
|
||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
|
rt := New(logger)
|
||||||
// Create test config with empty state dir (no snapshot loading)
|
|
||||||
cfg := &config.Config{
|
|
||||||
StateDir: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
rt := New(cfg, logger)
|
|
||||||
|
|
||||||
// Test concurrent access
|
// Test concurrent access
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@ -190,13 +177,7 @@ func TestRoutingTableConcurrency(t *testing.T) {
|
|||||||
func TestRouteUpdate(t *testing.T) {
|
func TestRouteUpdate(t *testing.T) {
|
||||||
// Create a test logger
|
// Create a test logger
|
||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
|
rt := New(logger)
|
||||||
// Create test config with empty state dir (no snapshot loading)
|
|
||||||
cfg := &config.Config{
|
|
||||||
StateDir: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
rt := New(cfg, logger)
|
|
||||||
|
|
||||||
prefixID := uuid.New()
|
prefixID := uuid.New()
|
||||||
originASNID := uuid.New()
|
originASNID := uuid.New()
|
||||||
|
@ -232,18 +232,6 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|||||||
|
|
||||||
// handleStats returns a handler that serves API v1 statistics
|
// handleStats returns a handler that serves API v1 statistics
|
||||||
func (s *Server) handleStats() http.HandlerFunc {
|
func (s *Server) handleStats() http.HandlerFunc {
|
||||||
// HandlerStatsInfo represents handler statistics in the API response
|
|
||||||
type HandlerStatsInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
QueueLength int `json:"queue_length"`
|
|
||||||
QueueCapacity int `json:"queue_capacity"`
|
|
||||||
ProcessedCount uint64 `json:"processed_count"`
|
|
||||||
DroppedCount uint64 `json:"dropped_count"`
|
|
||||||
AvgProcessTimeMs float64 `json:"avg_process_time_ms"`
|
|
||||||
MinProcessTimeMs float64 `json:"min_process_time_ms"`
|
|
||||||
MaxProcessTimeMs float64 `json:"max_process_time_ms"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatsResponse represents the API statistics response
|
// StatsResponse represents the API statistics response
|
||||||
type StatsResponse struct {
|
type StatsResponse struct {
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
@ -263,7 +251,6 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
IPv6Routes int `json:"ipv6_routes"`
|
IPv6Routes int `json:"ipv6_routes"`
|
||||||
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
||||||
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
||||||
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -325,23 +312,6 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
// Get detailed routing table stats
|
// Get detailed routing table stats
|
||||||
rtStats := s.routingTable.GetDetailedStats()
|
rtStats := s.routingTable.GetDetailedStats()
|
||||||
|
|
||||||
// Get handler stats
|
|
||||||
handlerStats := s.streamer.GetHandlerStats()
|
|
||||||
handlerStatsInfo := make([]HandlerStatsInfo, 0, len(handlerStats))
|
|
||||||
const microsecondsPerMillisecond = 1000.0
|
|
||||||
for _, hs := range handlerStats {
|
|
||||||
handlerStatsInfo = append(handlerStatsInfo, HandlerStatsInfo{
|
|
||||||
Name: hs.Name,
|
|
||||||
QueueLength: hs.QueueLength,
|
|
||||||
QueueCapacity: hs.QueueCapacity,
|
|
||||||
ProcessedCount: hs.ProcessedCount,
|
|
||||||
DroppedCount: hs.DroppedCount,
|
|
||||||
AvgProcessTimeMs: float64(hs.AvgProcessTime.Microseconds()) / microsecondsPerMillisecond,
|
|
||||||
MinProcessTimeMs: float64(hs.MinProcessTime.Microseconds()) / microsecondsPerMillisecond,
|
|
||||||
MaxProcessTimeMs: float64(hs.MaxProcessTime.Microseconds()) / microsecondsPerMillisecond,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := StatsResponse{
|
stats := StatsResponse{
|
||||||
Uptime: uptime,
|
Uptime: uptime,
|
||||||
TotalMessages: metrics.TotalMessages,
|
TotalMessages: metrics.TotalMessages,
|
||||||
@ -360,7 +330,6 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
IPv6Routes: rtStats.IPv6Routes,
|
IPv6Routes: rtStats.IPv6Routes,
|
||||||
IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate,
|
IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate,
|
||||||
IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate,
|
IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate,
|
||||||
HandlerStats: handlerStatsInfo,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
@ -10,16 +10,16 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/routingtable"
|
"git.eeqj.de/sneak/routewatch/internal/routingtable"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
snapshotInterval = 10 * time.Minute
|
snapshotInterval = 10 * time.Minute
|
||||||
snapshotFilename = "routingtable.json.gz"
|
snapshotFilename = "routewatch-snapshot.json.gz"
|
||||||
tempFileSuffix = ".tmp"
|
tempFileSuffix = ".tmp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -36,15 +36,16 @@ type Snapshotter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Snapshotter instance
|
// New creates a new Snapshotter instance
|
||||||
func New(rt *routingtable.RoutingTable, cfg *config.Config, logger *slog.Logger) (*Snapshotter, error) {
|
func New(rt *routingtable.RoutingTable, logger *slog.Logger) (*Snapshotter, error) {
|
||||||
stateDir := cfg.GetStateDir()
|
stateDir, err := getStateDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to determine state directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// If state directory is specified, ensure it exists
|
// Ensure state directory exists
|
||||||
if stateDir != "" {
|
|
||||||
const stateDirPerms = 0750
|
const stateDirPerms = 0750
|
||||||
if err := os.MkdirAll(stateDir, stateDirPerms); err != nil {
|
if err := os.MkdirAll(stateDir, stateDirPerms); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create snapshot directory: %w", err)
|
return nil, fmt.Errorf("failed to create state directory: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Snapshotter{
|
s := &Snapshotter{
|
||||||
@ -75,6 +76,38 @@ func (s *Snapshotter) Start(ctx context.Context) {
|
|||||||
go s.periodicSnapshot()
|
go s.periodicSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// periodicSnapshot runs periodic snapshots
|
||||||
func (s *Snapshotter) periodicSnapshot() {
|
func (s *Snapshotter) periodicSnapshot() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
@ -97,11 +130,6 @@ func (s *Snapshotter) periodicSnapshot() {
|
|||||||
|
|
||||||
// TakeSnapshot creates a snapshot of the current routing table state
|
// TakeSnapshot creates a snapshot of the current routing table state
|
||||||
func (s *Snapshotter) TakeSnapshot() error {
|
func (s *Snapshotter) TakeSnapshot() error {
|
||||||
// Can't take snapshot without a state directory
|
|
||||||
if s.stateDir == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure only one snapshot runs at a time
|
// Ensure only one snapshot runs at a time
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
@ -195,57 +195,6 @@ func (s *Streamer) GetMetrics() metrics.StreamMetrics {
|
|||||||
return s.metrics.GetStreamMetrics()
|
return s.metrics.GetStreamMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandlerStats represents metrics for a single handler
|
|
||||||
type HandlerStats struct {
|
|
||||||
Name string
|
|
||||||
QueueLength int
|
|
||||||
QueueCapacity int
|
|
||||||
ProcessedCount uint64
|
|
||||||
DroppedCount uint64
|
|
||||||
AvgProcessTime time.Duration
|
|
||||||
MinProcessTime time.Duration
|
|
||||||
MaxProcessTime time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHandlerStats returns current handler statistics
|
|
||||||
func (s *Streamer) GetHandlerStats() []HandlerStats {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
stats := make([]HandlerStats, 0, len(s.handlers))
|
|
||||||
|
|
||||||
for _, info := range s.handlers {
|
|
||||||
info.metrics.mu.Lock()
|
|
||||||
|
|
||||||
hs := HandlerStats{
|
|
||||||
Name: fmt.Sprintf("%T", info.handler),
|
|
||||||
QueueLength: len(info.queue),
|
|
||||||
QueueCapacity: cap(info.queue),
|
|
||||||
ProcessedCount: info.metrics.processedCount,
|
|
||||||
DroppedCount: info.metrics.droppedCount,
|
|
||||||
MinProcessTime: info.metrics.minTime,
|
|
||||||
MaxProcessTime: info.metrics.maxTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate average time
|
|
||||||
if info.metrics.processedCount > 0 {
|
|
||||||
processedCount := info.metrics.processedCount
|
|
||||||
const maxInt64 = 1<<63 - 1
|
|
||||||
if processedCount > maxInt64 {
|
|
||||||
processedCount = maxInt64
|
|
||||||
}
|
|
||||||
//nolint:gosec // processedCount is explicitly bounded above
|
|
||||||
hs.AvgProcessTime = info.metrics.totalTime / time.Duration(processedCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
info.metrics.mu.Unlock()
|
|
||||||
|
|
||||||
stats = append(stats, hs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDroppedMessages returns the total number of dropped messages
|
// GetDroppedMessages returns the total number of dropped messages
|
||||||
func (s *Streamer) GetDroppedMessages() uint64 {
|
func (s *Streamer) GetDroppedMessages() uint64 {
|
||||||
return atomic.LoadUint64(&s.totalDropped)
|
return atomic.LoadUint64(&s.totalDropped)
|
||||||
|
@ -153,10 +153,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="handler-stats-container" class="status-grid">
|
|
||||||
<!-- Handler stats will be dynamically added here -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
@ -170,45 +166,6 @@
|
|||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHandlerStats(handlerStats) {
|
|
||||||
const container = document.getElementById('handler-stats-container');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
handlerStats.forEach(handler => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'status-card';
|
|
||||||
|
|
||||||
// Extract handler name (remove package prefix)
|
|
||||||
const handlerName = handler.name.split('.').pop();
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<h2>${handlerName}</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Queue</span>
|
|
||||||
<span class="metric-value">${handler.queue_length}/${handler.queue_capacity}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Processed</span>
|
|
||||||
<span class="metric-value">${formatNumber(handler.processed_count)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Dropped</span>
|
|
||||||
<span class="metric-value ${handler.dropped_count > 0 ? 'disconnected' : ''}">${formatNumber(handler.dropped_count)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Avg Time</span>
|
|
||||||
<span class="metric-value">${handler.avg_process_time_ms.toFixed(2)} ms</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Min/Max Time</span>
|
|
||||||
<span class="metric-value">${handler.min_process_time_ms.toFixed(2)} / ${handler.max_process_time_ms.toFixed(2)} ms</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus() {
|
function updateStatus() {
|
||||||
fetch('/api/v1/stats')
|
fetch('/api/v1/stats')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@ -246,9 +203,6 @@
|
|||||||
document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1);
|
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);
|
document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1);
|
||||||
|
|
||||||
// Update handler stats
|
|
||||||
updateHandlerStats(data.handler_stats || []);
|
|
||||||
|
|
||||||
// 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