Compare commits
	
		
			10 Commits
		
	
	
		
			2fc24bb937
			...
			40d7f0185b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 40d7f0185b | |||
| b9b0792df9 | |||
| 21921a170c | |||
| 78d6e17c76 | |||
| 9b649c98c9 | |||
| 48db8b9edf | |||
| df31cf880a | |||
| af9ff258b1 | |||
| aeeb5e7d7d | |||
| 27ae80ea2e | 
@ -62,7 +62,7 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
 | 
			
		||||
 | 
			
		||||
	// Add connection parameters for go-sqlite3
 | 
			
		||||
	// 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=OFF&cache=shared", dbPath)
 | 
			
		||||
	db, err := sql.Open("sqlite3", dsn)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to open database: %w", err)
 | 
			
		||||
@ -73,9 +73,10 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set connection pool parameters
 | 
			
		||||
	// Single connection to avoid locking issues with SQLite
 | 
			
		||||
	db.SetMaxOpenConns(1)
 | 
			
		||||
	db.SetMaxIdleConns(1)
 | 
			
		||||
	// Multiple connections for better concurrency
 | 
			
		||||
	const maxConns = 10
 | 
			
		||||
	db.SetMaxOpenConns(maxConns)
 | 
			
		||||
	db.SetMaxIdleConns(maxConns)
 | 
			
		||||
	db.SetConnMaxLifetime(0)
 | 
			
		||||
 | 
			
		||||
	database := &Database{db: db, logger: logger, path: dbPath}
 | 
			
		||||
@ -89,19 +90,22 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
 | 
			
		||||
 | 
			
		||||
// Initialize creates the database schema if it doesn't exist.
 | 
			
		||||
func (d *Database) Initialize() error {
 | 
			
		||||
	// Set SQLite pragmas for better performance
 | 
			
		||||
	// WARNING: These settings trade durability for speed
 | 
			
		||||
	// Set SQLite pragmas for extreme performance - prioritize speed over durability
 | 
			
		||||
	pragmas := []string{
 | 
			
		||||
		"PRAGMA journal_mode=WAL",         // Write-Ahead Logging
 | 
			
		||||
		"PRAGMA synchronous=OFF",          // Don't wait for disk writes - RISKY but FAST
 | 
			
		||||
		"PRAGMA cache_size=-1048576",      // 1GB cache (negative = KB)
 | 
			
		||||
		"PRAGMA temp_store=MEMORY",        // Use memory for temp tables
 | 
			
		||||
		"PRAGMA mmap_size=536870912",      // 512MB memory-mapped I/O
 | 
			
		||||
		"PRAGMA wal_autocheckpoint=10000", // Checkpoint every 10000 pages (less frequent)
 | 
			
		||||
		"PRAGMA wal_checkpoint(PASSIVE)",  // Checkpoint now
 | 
			
		||||
		"PRAGMA page_size=8192",           // Larger page size for better performance
 | 
			
		||||
		"PRAGMA busy_timeout=30000",       // 30 second busy timeout
 | 
			
		||||
		"PRAGMA optimize",                 // Run optimizer
 | 
			
		||||
		"PRAGMA journal_mode=WAL",          // Write-Ahead Logging
 | 
			
		||||
		"PRAGMA synchronous=OFF",           // Don't wait for disk writes
 | 
			
		||||
		"PRAGMA cache_size=-8388608",       // 8GB cache (negative = KB)
 | 
			
		||||
		"PRAGMA temp_store=MEMORY",         // Use memory for temp tables
 | 
			
		||||
		"PRAGMA mmap_size=10737418240",     // 10GB memory-mapped I/O
 | 
			
		||||
		"PRAGMA page_size=8192",            // 8KB pages for better performance
 | 
			
		||||
		"PRAGMA wal_autocheckpoint=100000", // Checkpoint every 100k pages (800MB)
 | 
			
		||||
		"PRAGMA wal_checkpoint(TRUNCATE)",  // Checkpoint and truncate WAL now
 | 
			
		||||
		"PRAGMA busy_timeout=5000",         // 5 second busy timeout
 | 
			
		||||
		"PRAGMA locking_mode=NORMAL",       // Normal locking for multiple connections
 | 
			
		||||
		"PRAGMA read_uncommitted=true",     // Allow dirty reads
 | 
			
		||||
		"PRAGMA analysis_limit=0",          // Disable automatic ANALYZE
 | 
			
		||||
		"PRAGMA threads=4",                 // Use multiple threads for sorting
 | 
			
		||||
		"PRAGMA cache_spill=false",         // Keep cache in memory, don't spill to disk
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, pragma := range pragmas {
 | 
			
		||||
@ -111,8 +115,17 @@ func (d *Database) Initialize() error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err := d.exec(dbSchema)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return err
 | 
			
		||||
	// Run VACUUM on startup to optimize database
 | 
			
		||||
	d.logger.Info("Running VACUUM to optimize database (this may take a moment)")
 | 
			
		||||
	if err := d.exec("VACUUM"); err != nil {
 | 
			
		||||
		d.logger.Warn("Failed to VACUUM database", "error", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Close closes the database connection.
 | 
			
		||||
@ -130,6 +143,241 @@ func (d *Database) beginTx() (*loggingTx, error) {
 | 
			
		||||
	return &loggingTx{Tx: tx, logger: d.logger}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpsertLiveRouteBatch inserts or updates multiple live routes in a single transaction
 | 
			
		||||
func (d *Database) UpsertLiveRouteBatch(routes []*LiveRoute) error {
 | 
			
		||||
	if len(routes) == 0 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := d.beginTx()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to begin transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
 | 
			
		||||
			d.logger.Error("Failed to rollback transaction", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Use prepared statement for better performance
 | 
			
		||||
	query := `
 | 
			
		||||
		INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop, 
 | 
			
		||||
			last_updated, v4_ip_start, v4_ip_end)
 | 
			
		||||
		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,
 | 
			
		||||
			v4_ip_start = excluded.v4_ip_start,
 | 
			
		||||
			v4_ip_end = excluded.v4_ip_end
 | 
			
		||||
	`
 | 
			
		||||
 | 
			
		||||
	stmt, err := tx.Prepare(query)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = stmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	for _, route := range routes {
 | 
			
		||||
		// Encode AS path as JSON
 | 
			
		||||
		pathJSON, err := json.Marshal(route.ASPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to encode AS path: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Convert v4_ip_start and v4_ip_end to interface{} for SQL NULL handling
 | 
			
		||||
		var v4Start, v4End interface{}
 | 
			
		||||
		if route.V4IPStart != nil {
 | 
			
		||||
			v4Start = *route.V4IPStart
 | 
			
		||||
		}
 | 
			
		||||
		if route.V4IPEnd != nil {
 | 
			
		||||
			v4End = *route.V4IPEnd
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		_, err = stmt.Exec(
 | 
			
		||||
			route.ID.String(),
 | 
			
		||||
			route.Prefix,
 | 
			
		||||
			route.MaskLength,
 | 
			
		||||
			route.IPVersion,
 | 
			
		||||
			route.OriginASN,
 | 
			
		||||
			route.PeerIP,
 | 
			
		||||
			string(pathJSON),
 | 
			
		||||
			route.NextHop,
 | 
			
		||||
			route.LastUpdated,
 | 
			
		||||
			v4Start,
 | 
			
		||||
			v4End,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to upsert route %s: %w", route.Prefix, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err = tx.Commit(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to commit transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeleteLiveRouteBatch deletes multiple live routes in a single transaction
 | 
			
		||||
func (d *Database) DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error {
 | 
			
		||||
	if len(deletions) == 0 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := d.beginTx()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to begin transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
 | 
			
		||||
			d.logger.Error("Failed to rollback transaction", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Separate deletions by type and use prepared statements
 | 
			
		||||
	var withOrigin []LiveRouteDeletion
 | 
			
		||||
	var withoutOrigin []LiveRouteDeletion
 | 
			
		||||
 | 
			
		||||
	for _, del := range deletions {
 | 
			
		||||
		if del.OriginASN == 0 {
 | 
			
		||||
			withoutOrigin = append(withoutOrigin, del)
 | 
			
		||||
		} else {
 | 
			
		||||
			withOrigin = append(withOrigin, del)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process deletions with origin ASN
 | 
			
		||||
	if len(withOrigin) > 0 {
 | 
			
		||||
		stmt, err := tx.Prepare(`DELETE FROM live_routes WHERE prefix = ? AND origin_asn = ? AND peer_ip = ?`)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to prepare delete statement: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		defer func() { _ = stmt.Close() }()
 | 
			
		||||
 | 
			
		||||
		for _, del := range withOrigin {
 | 
			
		||||
			_, err = stmt.Exec(del.Prefix, del.OriginASN, del.PeerIP)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to delete route %s: %w", del.Prefix, err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process deletions without origin ASN
 | 
			
		||||
	if len(withoutOrigin) > 0 {
 | 
			
		||||
		stmt, err := tx.Prepare(`DELETE FROM live_routes WHERE prefix = ? AND peer_ip = ?`)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to prepare delete statement: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		defer func() { _ = stmt.Close() }()
 | 
			
		||||
 | 
			
		||||
		for _, del := range withoutOrigin {
 | 
			
		||||
			_, err = stmt.Exec(del.Prefix, del.PeerIP)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to delete route %s: %w", del.Prefix, err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err = tx.Commit(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to commit transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetOrCreateASNBatch creates or updates multiple ASNs in a single transaction
 | 
			
		||||
func (d *Database) GetOrCreateASNBatch(asns map[int]time.Time) error {
 | 
			
		||||
	if len(asns) == 0 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := d.beginTx()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to begin transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
 | 
			
		||||
			d.logger.Error("Failed to rollback transaction", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Prepare statements
 | 
			
		||||
	selectStmt, err := tx.Prepare(
 | 
			
		||||
		"SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare select statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = selectStmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	updateStmt, err := tx.Prepare("UPDATE asns SET last_seen = ? WHERE id = ?")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare update statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = updateStmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	insertStmt, err := tx.Prepare(
 | 
			
		||||
		"INSERT INTO asns (id, number, handle, description, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?)")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare insert statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = insertStmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	for number, timestamp := range asns {
 | 
			
		||||
		var asn ASN
 | 
			
		||||
		var idStr string
 | 
			
		||||
		var handle, description sql.NullString
 | 
			
		||||
 | 
			
		||||
		err = selectStmt.QueryRow(number).Scan(&idStr, &asn.Number, &handle, &description, &asn.FirstSeen, &asn.LastSeen)
 | 
			
		||||
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			// ASN exists, update last_seen
 | 
			
		||||
			asn.ID, _ = uuid.Parse(idStr)
 | 
			
		||||
			_, err = updateStmt.Exec(timestamp, asn.ID.String())
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to update ASN %d: %w", number, err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err == sql.ErrNoRows {
 | 
			
		||||
			// ASN doesn't exist, create it
 | 
			
		||||
			asn = ASN{
 | 
			
		||||
				ID:        generateUUID(),
 | 
			
		||||
				Number:    number,
 | 
			
		||||
				FirstSeen: timestamp,
 | 
			
		||||
				LastSeen:  timestamp,
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Look up ASN info
 | 
			
		||||
			if info, ok := asinfo.Get(number); ok {
 | 
			
		||||
				asn.Handle = info.Handle
 | 
			
		||||
				asn.Description = info.Description
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			_, err = insertStmt.Exec(asn.ID.String(), asn.Number, asn.Handle, asn.Description, asn.FirstSeen, asn.LastSeen)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to insert ASN %d: %w", number, err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to query ASN %d: %w", number, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err = tx.Commit(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to commit transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetOrCreateASN retrieves an existing ASN or creates a new one if it doesn't exist.
 | 
			
		||||
func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) {
 | 
			
		||||
	tx, err := d.beginTx()
 | 
			
		||||
@ -333,6 +581,69 @@ func (d *Database) RecordPeering(asA, asB int, timestamp time.Time) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdatePeerBatch updates or creates multiple BGP peer records in a single transaction
 | 
			
		||||
func (d *Database) UpdatePeerBatch(peers map[string]PeerUpdate) error {
 | 
			
		||||
	if len(peers) == 0 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tx, err := d.beginTx()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to begin transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
 | 
			
		||||
			d.logger.Error("Failed to rollback transaction", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Prepare statements
 | 
			
		||||
	checkStmt, err := tx.Prepare("SELECT EXISTS(SELECT 1 FROM bgp_peers WHERE peer_ip = ?)")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare check statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = checkStmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	updateStmt, err := tx.Prepare(
 | 
			
		||||
		"UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, last_message_type = ? WHERE peer_ip = ?")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare update statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = updateStmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	insertStmt, err := tx.Prepare(
 | 
			
		||||
		"INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, last_seen, last_message_type) VALUES (?, ?, ?, ?, ?, ?)")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to prepare insert statement: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = insertStmt.Close() }()
 | 
			
		||||
 | 
			
		||||
	for _, update := range peers {
 | 
			
		||||
		var exists bool
 | 
			
		||||
		err = checkStmt.QueryRow(update.PeerIP).Scan(&exists)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to check peer %s: %w", update.PeerIP, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if exists {
 | 
			
		||||
			_, err = updateStmt.Exec(update.PeerASN, update.Timestamp, update.MessageType, update.PeerIP)
 | 
			
		||||
		} else {
 | 
			
		||||
			_, err = insertStmt.Exec(
 | 
			
		||||
				generateUUID().String(), update.PeerIP, update.PeerASN,
 | 
			
		||||
				update.Timestamp, update.Timestamp, update.MessageType)
 | 
			
		||||
		}
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to update peer %s: %w", update.PeerIP, err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err = tx.Commit(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to commit transaction: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdatePeer updates or creates a BGP peer record
 | 
			
		||||
func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
 | 
			
		||||
	tx, err := d.beginTx()
 | 
			
		||||
@ -743,3 +1054,115 @@ func CalculateIPv4Range(cidr string) (start, end uint32, err error) {
 | 
			
		||||
 | 
			
		||||
	return start, end, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetASDetails returns detailed information about an AS including prefixes
 | 
			
		||||
func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
 | 
			
		||||
	// Get AS information
 | 
			
		||||
	var asnInfo ASN
 | 
			
		||||
	var idStr string
 | 
			
		||||
	var handle, description sql.NullString
 | 
			
		||||
	err := d.db.QueryRow(
 | 
			
		||||
		"SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?",
 | 
			
		||||
		asn,
 | 
			
		||||
	).Scan(&idStr, &asnInfo.Number, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if err == sql.ErrNoRows {
 | 
			
		||||
			return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return nil, nil, fmt.Errorf("failed to query AS: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	asnInfo.ID, _ = uuid.Parse(idStr)
 | 
			
		||||
	asnInfo.Handle = handle.String
 | 
			
		||||
	asnInfo.Description = description.String
 | 
			
		||||
 | 
			
		||||
	// Get prefixes announced by this AS (unique prefixes with most recent update)
 | 
			
		||||
	query := `
 | 
			
		||||
		SELECT prefix, mask_length, ip_version, MAX(last_updated) as last_updated
 | 
			
		||||
		FROM live_routes
 | 
			
		||||
		WHERE origin_asn = ?
 | 
			
		||||
		GROUP BY prefix, mask_length, ip_version
 | 
			
		||||
	`
 | 
			
		||||
 | 
			
		||||
	rows, err := d.db.Query(query, asn)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return &asnInfo, nil, fmt.Errorf("failed to query prefixes: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = rows.Close() }()
 | 
			
		||||
 | 
			
		||||
	var prefixes []LiveRoute
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		var route LiveRoute
 | 
			
		||||
		var lastUpdatedStr string
 | 
			
		||||
		err := rows.Scan(&route.Prefix, &route.MaskLength, &route.IPVersion, &lastUpdatedStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			d.logger.Error("Failed to scan prefix row", "error", err, "asn", asn)
 | 
			
		||||
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		// Parse the timestamp string
 | 
			
		||||
		route.LastUpdated, err = time.Parse("2006-01-02 15:04:05-07:00", lastUpdatedStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			// Try without timezone
 | 
			
		||||
			route.LastUpdated, err = time.Parse("2006-01-02 15:04:05", lastUpdatedStr)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				d.logger.Error("Failed to parse timestamp", "error", err, "timestamp", lastUpdatedStr)
 | 
			
		||||
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		route.OriginASN = asn
 | 
			
		||||
		prefixes = append(prefixes, route)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &asnInfo, prefixes, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetPrefixDetails returns detailed information about a prefix
 | 
			
		||||
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
 | 
			
		||||
	query := `
 | 
			
		||||
		SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, lr.last_updated,
 | 
			
		||||
			   a.handle, a.description
 | 
			
		||||
		FROM live_routes lr
 | 
			
		||||
		LEFT JOIN asns a ON a.number = lr.origin_asn
 | 
			
		||||
		WHERE lr.prefix = ?
 | 
			
		||||
		ORDER BY lr.origin_asn, lr.peer_ip
 | 
			
		||||
	`
 | 
			
		||||
 | 
			
		||||
	rows, err := d.db.Query(query, prefix)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to query prefix details: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer func() { _ = rows.Close() }()
 | 
			
		||||
 | 
			
		||||
	var routes []LiveRoute
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		var route LiveRoute
 | 
			
		||||
		var pathJSON string
 | 
			
		||||
		var handle, description sql.NullString
 | 
			
		||||
 | 
			
		||||
		err := rows.Scan(
 | 
			
		||||
			&route.OriginASN, &route.PeerIP, &pathJSON, &route.NextHop,
 | 
			
		||||
			&route.LastUpdated, &handle, &description,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Decode AS path
 | 
			
		||||
		if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil {
 | 
			
		||||
			route.ASPath = []int{}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		route.Prefix = prefix
 | 
			
		||||
		routes = append(routes, route)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(routes) == 0 {
 | 
			
		||||
		return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return routes, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,7 @@ type Stats struct {
 | 
			
		||||
type Store interface {
 | 
			
		||||
	// ASN operations
 | 
			
		||||
	GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
 | 
			
		||||
	GetOrCreateASNBatch(asns map[int]time.Time) error
 | 
			
		||||
 | 
			
		||||
	// Prefix operations
 | 
			
		||||
	GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error)
 | 
			
		||||
@ -37,16 +38,23 @@ type Store interface {
 | 
			
		||||
 | 
			
		||||
	// Peer operations
 | 
			
		||||
	UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
 | 
			
		||||
	UpdatePeerBatch(peers map[string]PeerUpdate) error
 | 
			
		||||
 | 
			
		||||
	// Live route operations
 | 
			
		||||
	UpsertLiveRoute(route *LiveRoute) error
 | 
			
		||||
	UpsertLiveRouteBatch(routes []*LiveRoute) error
 | 
			
		||||
	DeleteLiveRoute(prefix string, originASN int, peerIP string) error
 | 
			
		||||
	DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
 | 
			
		||||
	GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
 | 
			
		||||
	GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
 | 
			
		||||
 | 
			
		||||
	// IP lookup operations
 | 
			
		||||
	GetASInfoForIP(ip string) (*ASInfo, error)
 | 
			
		||||
 | 
			
		||||
	// AS and prefix detail operations
 | 
			
		||||
	GetASDetails(asn int) (*ASN, []LiveRoute, error)
 | 
			
		||||
	GetPrefixDetails(prefix string) ([]LiveRoute, error)
 | 
			
		||||
 | 
			
		||||
	// Lifecycle
 | 
			
		||||
	Close() error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -77,3 +77,18 @@ type ASInfo struct {
 | 
			
		||||
	LastUpdated time.Time `json:"last_updated"`
 | 
			
		||||
	Age         string    `json:"age"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// LiveRouteDeletion represents parameters for deleting a live route
 | 
			
		||||
type LiveRouteDeletion struct {
 | 
			
		||||
	Prefix    string
 | 
			
		||||
	OriginASN int
 | 
			
		||||
	PeerIP    string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// PeerUpdate represents parameters for updating a peer
 | 
			
		||||
type PeerUpdate struct {
 | 
			
		||||
	PeerIP      string
 | 
			
		||||
	PeerASN     int
 | 
			
		||||
	MessageType string
 | 
			
		||||
	Timestamp   time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -139,6 +139,10 @@ func (rw *RouteWatch) Shutdown() {
 | 
			
		||||
		rw.logger.Info("Flushing prefix handler")
 | 
			
		||||
		rw.prefixHandler.Stop()
 | 
			
		||||
	}
 | 
			
		||||
	if rw.peeringHandler != nil {
 | 
			
		||||
		rw.logger.Info("Flushing peering handler")
 | 
			
		||||
		rw.peeringHandler.Stop()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Stop services
 | 
			
		||||
	rw.streamer.Stop()
 | 
			
		||||
 | 
			
		||||
@ -201,6 +201,84 @@ func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetASDetails mock implementation
 | 
			
		||||
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
 | 
			
		||||
	m.mu.Lock()
 | 
			
		||||
	defer m.mu.Unlock()
 | 
			
		||||
 | 
			
		||||
	// Check if ASN exists
 | 
			
		||||
	if asnInfo, exists := m.ASNs[asn]; exists {
 | 
			
		||||
		// Return empty prefixes for now
 | 
			
		||||
		return asnInfo, []database.LiveRoute{}, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil, nil, database.ErrNoRoute
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetPrefixDetails mock implementation
 | 
			
		||||
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
 | 
			
		||||
	// Return empty routes for now
 | 
			
		||||
	return []database.LiveRoute{}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpsertLiveRouteBatch mock implementation
 | 
			
		||||
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
 | 
			
		||||
	m.mu.Lock()
 | 
			
		||||
	defer m.mu.Unlock()
 | 
			
		||||
 | 
			
		||||
	for _, route := range routes {
 | 
			
		||||
		// Track prefix
 | 
			
		||||
		if _, exists := m.Prefixes[route.Prefix]; !exists {
 | 
			
		||||
			m.Prefixes[route.Prefix] = &database.Prefix{
 | 
			
		||||
				ID:        uuid.New(),
 | 
			
		||||
				Prefix:    route.Prefix,
 | 
			
		||||
				IPVersion: route.IPVersion,
 | 
			
		||||
				FirstSeen: route.LastUpdated,
 | 
			
		||||
				LastSeen:  route.LastUpdated,
 | 
			
		||||
			}
 | 
			
		||||
			m.PrefixCount++
 | 
			
		||||
			if route.IPVersion == 4 {
 | 
			
		||||
				m.IPv4Prefixes++
 | 
			
		||||
			} else {
 | 
			
		||||
				m.IPv6Prefixes++
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		m.RouteCount++
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeleteLiveRouteBatch mock implementation
 | 
			
		||||
func (m *mockStore) DeleteLiveRouteBatch(deletions []database.LiveRouteDeletion) error {
 | 
			
		||||
	// Simple mock - just return nil
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetOrCreateASNBatch mock implementation
 | 
			
		||||
func (m *mockStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
 | 
			
		||||
	m.mu.Lock()
 | 
			
		||||
	defer m.mu.Unlock()
 | 
			
		||||
 | 
			
		||||
	for number, timestamp := range asns {
 | 
			
		||||
		if _, exists := m.ASNs[number]; !exists {
 | 
			
		||||
			m.ASNs[number] = &database.ASN{
 | 
			
		||||
				ID:        uuid.New(),
 | 
			
		||||
				Number:    number,
 | 
			
		||||
				FirstSeen: timestamp,
 | 
			
		||||
				LastSeen:  timestamp,
 | 
			
		||||
			}
 | 
			
		||||
			m.ASNCount++
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdatePeerBatch mock implementation
 | 
			
		||||
func (m *mockStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) error {
 | 
			
		||||
	// Simple mock - just return nil
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestRouteWatchLiveFeed(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Create mock database
 | 
			
		||||
 | 
			
		||||
@ -144,11 +144,9 @@ func (h *ASHandler) flushBatchLocked() {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for asn, ts := range asnMap {
 | 
			
		||||
		_, err := h.db.GetOrCreateASN(asn, ts)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			h.logger.Error("Failed to get/create ASN", "asn", asn, "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	// Process all ASNs in a single batch transaction
 | 
			
		||||
	if err := h.db.GetOrCreateASNBatch(asnMap); err != nil {
 | 
			
		||||
		h.logger.Error("Failed to process ASN batch", "error", err, "count", len(asnMap))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Clear batch
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,8 @@ import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"syscall"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
@ -14,30 +16,67 @@ import (
 | 
			
		||||
const (
 | 
			
		||||
	// shutdownTimeout is the maximum time allowed for graceful shutdown
 | 
			
		||||
	shutdownTimeout = 60 * time.Second
 | 
			
		||||
	// debugInterval is how often to log debug stats
 | 
			
		||||
	debugInterval = 60 * time.Second
 | 
			
		||||
	// bytesPerMB is bytes per megabyte
 | 
			
		||||
	bytesPerMB = 1024 * 1024
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// logDebugStats logs goroutine count and memory usage
 | 
			
		||||
func logDebugStats(logger *logger.Logger) {
 | 
			
		||||
	// Only run if DEBUG env var contains "routewatch"
 | 
			
		||||
	debugEnv := os.Getenv("DEBUG")
 | 
			
		||||
	if !strings.Contains(debugEnv, "routewatch") {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ticker := time.NewTicker(debugInterval)
 | 
			
		||||
	defer ticker.Stop()
 | 
			
		||||
 | 
			
		||||
	for range ticker.C {
 | 
			
		||||
		var m runtime.MemStats
 | 
			
		||||
		runtime.ReadMemStats(&m)
 | 
			
		||||
 | 
			
		||||
		logger.Debug("System stats",
 | 
			
		||||
			"goroutines", runtime.NumGoroutine(),
 | 
			
		||||
			"alloc_mb", m.Alloc/bytesPerMB,
 | 
			
		||||
			"total_alloc_mb", m.TotalAlloc/bytesPerMB,
 | 
			
		||||
			"sys_mb", m.Sys/bytesPerMB,
 | 
			
		||||
			"num_gc", m.NumGC,
 | 
			
		||||
			"heap_alloc_mb", m.HeapAlloc/bytesPerMB,
 | 
			
		||||
			"heap_sys_mb", m.HeapSys/bytesPerMB,
 | 
			
		||||
			"heap_idle_mb", m.HeapIdle/bytesPerMB,
 | 
			
		||||
			"heap_inuse_mb", m.HeapInuse/bytesPerMB,
 | 
			
		||||
			"heap_released_mb", m.HeapReleased/bytesPerMB,
 | 
			
		||||
			"stack_inuse_mb", m.StackInuse/bytesPerMB,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CLIEntry is the main entry point for the CLI
 | 
			
		||||
func CLIEntry() {
 | 
			
		||||
	app := fx.New(
 | 
			
		||||
		getModule(),
 | 
			
		||||
		fx.StopTimeout(shutdownTimeout), // Allow 60 seconds for graceful shutdown
 | 
			
		||||
		fx.Invoke(func(lc fx.Lifecycle, rw *RouteWatch, logger *logger.Logger) {
 | 
			
		||||
		fx.Invoke(func(lc fx.Lifecycle, rw *RouteWatch, logger *logger.Logger, shutdowner fx.Shutdowner) {
 | 
			
		||||
			lc.Append(fx.Hook{
 | 
			
		||||
				OnStart: func(_ context.Context) error {
 | 
			
		||||
				OnStart: func(ctx context.Context) error {
 | 
			
		||||
					// Start debug stats logging
 | 
			
		||||
					go logDebugStats(logger)
 | 
			
		||||
 | 
			
		||||
					// Handle shutdown signals
 | 
			
		||||
					sigCh := make(chan os.Signal, 1)
 | 
			
		||||
					signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
 | 
			
		||||
 | 
			
		||||
					go func() {
 | 
			
		||||
						ctx, cancel := context.WithCancel(context.Background())
 | 
			
		||||
						defer cancel()
 | 
			
		||||
 | 
			
		||||
						// Handle shutdown signals
 | 
			
		||||
						sigCh := make(chan os.Signal, 1)
 | 
			
		||||
						signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
 | 
			
		||||
 | 
			
		||||
						go func() {
 | 
			
		||||
							<-sigCh
 | 
			
		||||
							logger.Info("Received shutdown signal")
 | 
			
		||||
							cancel()
 | 
			
		||||
						}()
 | 
			
		||||
						<-sigCh
 | 
			
		||||
						logger.Info("Received shutdown signal")
 | 
			
		||||
						if err := shutdowner.Shutdown(); err != nil {
 | 
			
		||||
							logger.Error("Failed to shutdown gracefully", "error", err)
 | 
			
		||||
						}
 | 
			
		||||
					}()
 | 
			
		||||
 | 
			
		||||
					go func() {
 | 
			
		||||
						if err := rw.Run(ctx); err != nil {
 | 
			
		||||
							logger.Error("RouteWatch error", "error", err)
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
@ -135,18 +135,22 @@ func (h *PeerHandler) flushBatchLocked() {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Apply updates
 | 
			
		||||
	for _, update := range peerMap {
 | 
			
		||||
		if err := h.db.UpdatePeer(update.peerIP, update.peerASN, update.messageType, update.timestamp); err != nil {
 | 
			
		||||
			h.logger.Error("Failed to update peer",
 | 
			
		||||
				"peer", update.peerIP,
 | 
			
		||||
				"peer_asn", update.peerASN,
 | 
			
		||||
				"message_type", update.messageType,
 | 
			
		||||
				"error", err,
 | 
			
		||||
			)
 | 
			
		||||
	// Convert to database format
 | 
			
		||||
	dbPeerMap := make(map[string]database.PeerUpdate)
 | 
			
		||||
	for peerIP, update := range peerMap {
 | 
			
		||||
		dbPeerMap[peerIP] = database.PeerUpdate{
 | 
			
		||||
			PeerIP:      update.peerIP,
 | 
			
		||||
			PeerASN:     update.peerASN,
 | 
			
		||||
			MessageType: update.messageType,
 | 
			
		||||
			Timestamp:   update.timestamp,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process all peers in a single batch transaction
 | 
			
		||||
	if err := h.db.UpdatePeerBatch(dbPeerMap); err != nil {
 | 
			
		||||
		h.logger.Error("Failed to process peer batch", "error", err, "count", len(dbPeerMap))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Clear batch
 | 
			
		||||
	h.peerBatch = h.peerBatch[:0]
 | 
			
		||||
	h.lastFlush = time.Now()
 | 
			
		||||
 | 
			
		||||
@ -18,10 +18,10 @@ const (
 | 
			
		||||
	prefixHandlerQueueSize = 100000
 | 
			
		||||
 | 
			
		||||
	// prefixBatchSize is the number of prefix updates to batch together
 | 
			
		||||
	prefixBatchSize = 25000
 | 
			
		||||
	prefixBatchSize = 5000
 | 
			
		||||
 | 
			
		||||
	// prefixBatchTimeout is the maximum time to wait before flushing a batch
 | 
			
		||||
	prefixBatchTimeout = 2 * time.Second
 | 
			
		||||
	prefixBatchTimeout = 1 * time.Second
 | 
			
		||||
 | 
			
		||||
	// IP version constants
 | 
			
		||||
	ipv4Version = 4
 | 
			
		||||
@ -163,6 +163,9 @@ func (h *PrefixHandler) flushBatchLocked() {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	startTime := time.Now()
 | 
			
		||||
	batchSize := len(h.batch)
 | 
			
		||||
 | 
			
		||||
	// Group updates by prefix to deduplicate
 | 
			
		||||
	// For each prefix, keep the latest update
 | 
			
		||||
	prefixMap := make(map[string]prefixUpdate)
 | 
			
		||||
@ -173,27 +176,55 @@ func (h *PrefixHandler) flushBatchLocked() {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Apply updates to database
 | 
			
		||||
	// Collect routes to upsert and delete
 | 
			
		||||
	var routesToUpsert []*database.LiveRoute
 | 
			
		||||
	var routesToDelete []database.LiveRouteDeletion
 | 
			
		||||
 | 
			
		||||
	// Skip the prefix table updates entirely - just update live_routes
 | 
			
		||||
	// The prefix table is not critical for routing lookups
 | 
			
		||||
	for _, update := range prefixMap {
 | 
			
		||||
		// Get or create prefix (this maintains the prefixes table)
 | 
			
		||||
		prefix, err := h.db.GetOrCreatePrefix(update.prefix, update.timestamp)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			h.logger.Error("Failed to get/create prefix",
 | 
			
		||||
				"prefix", update.prefix,
 | 
			
		||||
				"error", err,
 | 
			
		||||
			)
 | 
			
		||||
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// For announcements, get ASN info and create announcement record
 | 
			
		||||
		if update.messageType == "announcement" && update.originASN > 0 {
 | 
			
		||||
			h.processAnnouncement(prefix, update)
 | 
			
		||||
			// Create live route for batch upsert
 | 
			
		||||
			route := h.createLiveRoute(update)
 | 
			
		||||
			if route != nil {
 | 
			
		||||
				routesToUpsert = append(routesToUpsert, route)
 | 
			
		||||
			}
 | 
			
		||||
		} else if update.messageType == "withdrawal" {
 | 
			
		||||
			h.processWithdrawal(prefix, update)
 | 
			
		||||
			// Create deletion record for batch delete
 | 
			
		||||
			routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
 | 
			
		||||
				Prefix:    update.prefix,
 | 
			
		||||
				OriginASN: update.originASN,
 | 
			
		||||
				PeerIP:    update.peer,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Process batch operations
 | 
			
		||||
	successCount := 0
 | 
			
		||||
	if len(routesToUpsert) > 0 {
 | 
			
		||||
		if err := h.db.UpsertLiveRouteBatch(routesToUpsert); err != nil {
 | 
			
		||||
			h.logger.Error("Failed to upsert route batch", "error", err, "count", len(routesToUpsert))
 | 
			
		||||
		} else {
 | 
			
		||||
			successCount += len(routesToUpsert)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(routesToDelete) > 0 {
 | 
			
		||||
		if err := h.db.DeleteLiveRouteBatch(routesToDelete); err != nil {
 | 
			
		||||
			h.logger.Error("Failed to delete route batch", "error", err, "count", len(routesToDelete))
 | 
			
		||||
		} else {
 | 
			
		||||
			successCount += len(routesToDelete)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	elapsed := time.Since(startTime)
 | 
			
		||||
	h.logger.Debug("Flushed prefix batch",
 | 
			
		||||
		"batch_size", batchSize,
 | 
			
		||||
		"unique_prefixes", len(prefixMap),
 | 
			
		||||
		"success", successCount,
 | 
			
		||||
		"duration_ms", elapsed.Milliseconds(),
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// Clear batch
 | 
			
		||||
	h.batch = h.batch[:0]
 | 
			
		||||
	h.lastFlush = time.Now()
 | 
			
		||||
@ -215,6 +246,7 @@ func parseCIDR(prefix string) (maskLength int, ipVersion int, err error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// processAnnouncement handles storing an announcement in the database
 | 
			
		||||
// nolint:unused // kept for potential future use
 | 
			
		||||
func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpdate) {
 | 
			
		||||
	// Parse CIDR to get mask length
 | 
			
		||||
	maskLength, ipVersion, err := parseCIDR(update.prefix)
 | 
			
		||||
@ -271,7 +303,143 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// createLiveRoute creates a LiveRoute from a prefix update
 | 
			
		||||
func (h *PrefixHandler) createLiveRoute(update prefixUpdate) *database.LiveRoute {
 | 
			
		||||
	// Parse CIDR to get mask length
 | 
			
		||||
	maskLength, ipVersion, err := parseCIDR(update.prefix)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		h.logger.Error("Failed to parse CIDR",
 | 
			
		||||
			"prefix", update.prefix,
 | 
			
		||||
			"error", err,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Track route update metrics
 | 
			
		||||
	if h.metrics != nil {
 | 
			
		||||
		if ipVersion == ipv4Version {
 | 
			
		||||
			h.metrics.RecordIPv4Update()
 | 
			
		||||
		} else {
 | 
			
		||||
			h.metrics.RecordIPv6Update()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// For IPv4, calculate the IP range
 | 
			
		||||
	if ipVersion == ipv4Version {
 | 
			
		||||
		start, end, err := database.CalculateIPv4Range(update.prefix)
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			liveRoute.V4IPStart = &start
 | 
			
		||||
			liveRoute.V4IPEnd = &end
 | 
			
		||||
		} else {
 | 
			
		||||
			h.logger.Error("Failed to calculate IPv4 range",
 | 
			
		||||
				"prefix", update.prefix,
 | 
			
		||||
				"error", err,
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return liveRoute
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// processAnnouncementDirect handles storing an announcement directly without prefix table
 | 
			
		||||
// nolint:unused // kept for potential future use
 | 
			
		||||
func (h *PrefixHandler) processAnnouncementDirect(update prefixUpdate) {
 | 
			
		||||
	// Parse CIDR to get mask length
 | 
			
		||||
	maskLength, ipVersion, err := parseCIDR(update.prefix)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		h.logger.Error("Failed to parse CIDR",
 | 
			
		||||
			"prefix", update.prefix,
 | 
			
		||||
			"error", err,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Track route update metrics
 | 
			
		||||
	if h.metrics != nil {
 | 
			
		||||
		if ipVersion == ipv4Version {
 | 
			
		||||
			h.metrics.RecordIPv4Update()
 | 
			
		||||
		} else {
 | 
			
		||||
			h.metrics.RecordIPv6Update()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// For IPv4, calculate the IP range
 | 
			
		||||
	if ipVersion == ipv4Version {
 | 
			
		||||
		start, end, err := database.CalculateIPv4Range(update.prefix)
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			liveRoute.V4IPStart = &start
 | 
			
		||||
			liveRoute.V4IPEnd = &end
 | 
			
		||||
		} else {
 | 
			
		||||
			h.logger.Error("Failed to calculate IPv4 range",
 | 
			
		||||
				"prefix", update.prefix,
 | 
			
		||||
				"error", err,
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := h.db.UpsertLiveRoute(liveRoute); err != nil {
 | 
			
		||||
		h.logger.Error("Failed to upsert live route",
 | 
			
		||||
			"prefix", update.prefix,
 | 
			
		||||
			"error", err,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// processWithdrawalDirect handles removing a route directly without prefix table
 | 
			
		||||
// nolint:unused // kept for potential future use
 | 
			
		||||
func (h *PrefixHandler) processWithdrawalDirect(update prefixUpdate) {
 | 
			
		||||
	// For withdrawals, we need to delete the route from live_routes
 | 
			
		||||
	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, just delete all routes for this prefix from this peer
 | 
			
		||||
		if err := h.db.DeleteLiveRoute(update.prefix, 0, update.peer); err != nil {
 | 
			
		||||
			h.logger.Error("Failed to delete live route (no origin ASN)",
 | 
			
		||||
				"prefix", update.prefix,
 | 
			
		||||
				"peer", update.peer,
 | 
			
		||||
				"error", err,
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// processWithdrawal handles removing a route from the live routing table
 | 
			
		||||
// nolint:unused // kept for potential future use
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										704
									
								
								internal/server/handlers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										704
									
								
								internal/server/handlers.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,704 @@
 | 
			
		||||
package server
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/database"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/templates"
 | 
			
		||||
	"github.com/dustin/go-humanize"
 | 
			
		||||
	"github.com/go-chi/chi/v5"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// handleRoot returns a handler that redirects to /status
 | 
			
		||||
func (s *Server) handleRoot() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		http.Redirect(w, r, "/status", http.StatusSeeOther)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// writeJSONError writes a standardized JSON error response
 | 
			
		||||
func writeJSONError(w http.ResponseWriter, statusCode int, message string) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	w.WriteHeader(statusCode)
 | 
			
		||||
	_ = json.NewEncoder(w).Encode(map[string]interface{}{
 | 
			
		||||
		"status": "error",
 | 
			
		||||
		"error": map[string]interface{}{
 | 
			
		||||
			"msg":  message,
 | 
			
		||||
			"code": statusCode,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// writeJSONSuccess writes a standardized JSON success response
 | 
			
		||||
func writeJSONSuccess(w http.ResponseWriter, data interface{}) error {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
 | 
			
		||||
	return json.NewEncoder(w).Encode(map[string]interface{}{
 | 
			
		||||
		"status": "ok",
 | 
			
		||||
		"data":   data,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleStatusJSON returns a handler that serves JSON statistics
 | 
			
		||||
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"`
 | 
			
		||||
		GoVersion              string                        `json:"go_version"`
 | 
			
		||||
		Goroutines             int                           `json:"goroutines"`
 | 
			
		||||
		MemoryUsage            string                        `json:"memory_usage"`
 | 
			
		||||
		ASNs                   int                           `json:"asns"`
 | 
			
		||||
		Prefixes               int                           `json:"prefixes"`
 | 
			
		||||
		IPv4Prefixes           int                           `json:"ipv4_prefixes"`
 | 
			
		||||
		IPv6Prefixes           int                           `json:"ipv6_prefixes"`
 | 
			
		||||
		Peerings               int                           `json:"peerings"`
 | 
			
		||||
		Peers                  int                           `json:"peers"`
 | 
			
		||||
		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) {
 | 
			
		||||
		// Create a 1 second timeout context for this request
 | 
			
		||||
		ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
 | 
			
		||||
		defer cancel()
 | 
			
		||||
 | 
			
		||||
		metrics := s.streamer.GetMetrics()
 | 
			
		||||
 | 
			
		||||
		// Get database stats with timeout
 | 
			
		||||
		statsChan := make(chan database.Stats)
 | 
			
		||||
		errChan := make(chan error)
 | 
			
		||||
 | 
			
		||||
		go func() {
 | 
			
		||||
			dbStats, err := s.db.GetStats()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				s.logger.Debug("Database stats query failed", "error", err)
 | 
			
		||||
				errChan <- err
 | 
			
		||||
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			statsChan <- dbStats
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		var dbStats database.Stats
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			s.logger.Error("Database stats timeout in status.json")
 | 
			
		||||
			writeJSONError(w, http.StatusRequestTimeout, "Database timeout")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case err := <-errChan:
 | 
			
		||||
			s.logger.Error("Failed to get database stats", "error", err)
 | 
			
		||||
			writeJSONError(w, http.StatusInternalServerError, err.Error())
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case dbStats = <-statsChan:
 | 
			
		||||
			// Success
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
 | 
			
		||||
		if metrics.ConnectedSince.IsZero() {
 | 
			
		||||
			uptime = "0s"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const bitsPerMegabit = 1000000.0
 | 
			
		||||
 | 
			
		||||
		// Get route counts from database
 | 
			
		||||
		ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			s.logger.Warn("Failed to get live route counts", "error", err)
 | 
			
		||||
			// Continue with zero counts
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get route update metrics
 | 
			
		||||
		routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
 | 
			
		||||
 | 
			
		||||
		// Get memory stats
 | 
			
		||||
		var memStats runtime.MemStats
 | 
			
		||||
		runtime.ReadMemStats(&memStats)
 | 
			
		||||
 | 
			
		||||
		stats := Stats{
 | 
			
		||||
			Uptime:                 uptime,
 | 
			
		||||
			TotalMessages:          metrics.TotalMessages,
 | 
			
		||||
			TotalBytes:             metrics.TotalBytes,
 | 
			
		||||
			MessagesPerSec:         metrics.MessagesPerSec,
 | 
			
		||||
			MbitsPerSec:            metrics.BitsPerSec / bitsPerMegabit,
 | 
			
		||||
			Connected:              metrics.Connected,
 | 
			
		||||
			GoVersion:              runtime.Version(),
 | 
			
		||||
			Goroutines:             runtime.NumGoroutine(),
 | 
			
		||||
			MemoryUsage:            humanize.Bytes(memStats.Alloc),
 | 
			
		||||
			ASNs:                   dbStats.ASNs,
 | 
			
		||||
			Prefixes:               dbStats.Prefixes,
 | 
			
		||||
			IPv4Prefixes:           dbStats.IPv4Prefixes,
 | 
			
		||||
			IPv6Prefixes:           dbStats.IPv6Prefixes,
 | 
			
		||||
			Peerings:               dbStats.Peerings,
 | 
			
		||||
			Peers:                  dbStats.Peers,
 | 
			
		||||
			DatabaseSizeBytes:      dbStats.FileSizeBytes,
 | 
			
		||||
			LiveRoutes:             dbStats.LiveRoutes,
 | 
			
		||||
			IPv4Routes:             ipv4Routes,
 | 
			
		||||
			IPv6Routes:             ipv6Routes,
 | 
			
		||||
			IPv4UpdatesPerSec:      routeMetrics.IPv4UpdatesPerSec,
 | 
			
		||||
			IPv6UpdatesPerSec:      routeMetrics.IPv6UpdatesPerSec,
 | 
			
		||||
			IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
 | 
			
		||||
			IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := writeJSONSuccess(w, stats); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode stats", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleStats returns a handler that serves API v1 statistics
 | 
			
		||||
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
 | 
			
		||||
	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"`
 | 
			
		||||
		GoVersion              string                        `json:"go_version"`
 | 
			
		||||
		Goroutines             int                           `json:"goroutines"`
 | 
			
		||||
		MemoryUsage            string                        `json:"memory_usage"`
 | 
			
		||||
		ASNs                   int                           `json:"asns"`
 | 
			
		||||
		Prefixes               int                           `json:"prefixes"`
 | 
			
		||||
		IPv4Prefixes           int                           `json:"ipv4_prefixes"`
 | 
			
		||||
		IPv6Prefixes           int                           `json:"ipv6_prefixes"`
 | 
			
		||||
		Peerings               int                           `json:"peerings"`
 | 
			
		||||
		Peers                  int                           `json:"peers"`
 | 
			
		||||
		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) {
 | 
			
		||||
		// Create a 1 second timeout context for this request
 | 
			
		||||
		ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
 | 
			
		||||
		defer cancel()
 | 
			
		||||
 | 
			
		||||
		// Check if context is already cancelled
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			http.Error(w, "Request timeout", http.StatusRequestTimeout)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		default:
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		metrics := s.streamer.GetMetrics()
 | 
			
		||||
 | 
			
		||||
		// Get database stats with timeout
 | 
			
		||||
		statsChan := make(chan database.Stats)
 | 
			
		||||
		errChan := make(chan error)
 | 
			
		||||
 | 
			
		||||
		go func() {
 | 
			
		||||
			dbStats, err := s.db.GetStats()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				s.logger.Debug("Database stats query failed", "error", err)
 | 
			
		||||
				errChan <- err
 | 
			
		||||
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			statsChan <- dbStats
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		var dbStats database.Stats
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			s.logger.Error("Database stats timeout")
 | 
			
		||||
			http.Error(w, "Database timeout", http.StatusRequestTimeout)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case err := <-errChan:
 | 
			
		||||
			s.logger.Error("Failed to get database stats", "error", err)
 | 
			
		||||
			http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case dbStats = <-statsChan:
 | 
			
		||||
			// Success
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
 | 
			
		||||
		if metrics.ConnectedSince.IsZero() {
 | 
			
		||||
			uptime = "0s"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const bitsPerMegabit = 1000000.0
 | 
			
		||||
 | 
			
		||||
		// Get route counts from database
 | 
			
		||||
		ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			s.logger.Warn("Failed to get live route counts", "error", err)
 | 
			
		||||
			// Continue with zero counts
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get route update metrics
 | 
			
		||||
		routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
 | 
			
		||||
 | 
			
		||||
		// 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,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get memory stats
 | 
			
		||||
		var memStats runtime.MemStats
 | 
			
		||||
		runtime.ReadMemStats(&memStats)
 | 
			
		||||
 | 
			
		||||
		stats := StatsResponse{
 | 
			
		||||
			Uptime:                 uptime,
 | 
			
		||||
			TotalMessages:          metrics.TotalMessages,
 | 
			
		||||
			TotalBytes:             metrics.TotalBytes,
 | 
			
		||||
			MessagesPerSec:         metrics.MessagesPerSec,
 | 
			
		||||
			MbitsPerSec:            metrics.BitsPerSec / bitsPerMegabit,
 | 
			
		||||
			Connected:              metrics.Connected,
 | 
			
		||||
			GoVersion:              runtime.Version(),
 | 
			
		||||
			Goroutines:             runtime.NumGoroutine(),
 | 
			
		||||
			MemoryUsage:            humanize.Bytes(memStats.Alloc),
 | 
			
		||||
			ASNs:                   dbStats.ASNs,
 | 
			
		||||
			Prefixes:               dbStats.Prefixes,
 | 
			
		||||
			IPv4Prefixes:           dbStats.IPv4Prefixes,
 | 
			
		||||
			IPv6Prefixes:           dbStats.IPv6Prefixes,
 | 
			
		||||
			Peerings:               dbStats.Peerings,
 | 
			
		||||
			Peers:                  dbStats.Peers,
 | 
			
		||||
			DatabaseSizeBytes:      dbStats.FileSizeBytes,
 | 
			
		||||
			LiveRoutes:             dbStats.LiveRoutes,
 | 
			
		||||
			IPv4Routes:             ipv4Routes,
 | 
			
		||||
			IPv6Routes:             ipv6Routes,
 | 
			
		||||
			IPv4UpdatesPerSec:      routeMetrics.IPv4UpdatesPerSec,
 | 
			
		||||
			IPv6UpdatesPerSec:      routeMetrics.IPv6UpdatesPerSec,
 | 
			
		||||
			HandlerStats:           handlerStatsInfo,
 | 
			
		||||
			IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
 | 
			
		||||
			IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := writeJSONSuccess(w, stats); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode stats", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleStatusHTML returns a handler that serves the HTML status page
 | 
			
		||||
func (s *Server) handleStatusHTML() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, _ *http.Request) {
 | 
			
		||||
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 | 
			
		||||
 | 
			
		||||
		tmpl := templates.StatusTemplate()
 | 
			
		||||
		if err := tmpl.Execute(w, nil); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to render template", "error", err)
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleIPLookup returns a handler that looks up AS information for an IP address
 | 
			
		||||
func (s *Server) handleIPLookup() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		ip := chi.URLParam(r, "ip")
 | 
			
		||||
		if ip == "" {
 | 
			
		||||
			writeJSONError(w, http.StatusBadRequest, "IP parameter is required")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Look up AS information for the IP
 | 
			
		||||
		asInfo, err := s.db.GetASInfoForIP(ip)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			// Check if it's an invalid IP error
 | 
			
		||||
			if errors.Is(err, database.ErrInvalidIP) {
 | 
			
		||||
				writeJSONError(w, http.StatusBadRequest, err.Error())
 | 
			
		||||
			} else {
 | 
			
		||||
				// All other errors (including ErrNoRoute) are 404
 | 
			
		||||
				writeJSONError(w, http.StatusNotFound, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Return successful response
 | 
			
		||||
		if err := writeJSONSuccess(w, asInfo); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode AS info", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleASDetailJSON returns AS details as JSON
 | 
			
		||||
func (s *Server) handleASDetailJSON() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		asnStr := chi.URLParam(r, "asn")
 | 
			
		||||
		asn, err := strconv.Atoi(asnStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			writeJSONError(w, http.StatusBadRequest, "Invalid ASN")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		asInfo, prefixes, err := s.db.GetASDetails(asn)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if errors.Is(err, database.ErrNoRoute) {
 | 
			
		||||
				writeJSONError(w, http.StatusNotFound, err.Error())
 | 
			
		||||
			} else {
 | 
			
		||||
				writeJSONError(w, http.StatusInternalServerError, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Group prefixes by IP version
 | 
			
		||||
		const ipVersionV4 = 4
 | 
			
		||||
		var ipv4Prefixes, ipv6Prefixes []database.LiveRoute
 | 
			
		||||
		for _, p := range prefixes {
 | 
			
		||||
			if p.IPVersion == ipVersionV4 {
 | 
			
		||||
				ipv4Prefixes = append(ipv4Prefixes, p)
 | 
			
		||||
			} else {
 | 
			
		||||
				ipv6Prefixes = append(ipv6Prefixes, p)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		response := map[string]interface{}{
 | 
			
		||||
			"asn":           asInfo,
 | 
			
		||||
			"ipv4_prefixes": ipv4Prefixes,
 | 
			
		||||
			"ipv6_prefixes": ipv6Prefixes,
 | 
			
		||||
			"total_count":   len(prefixes),
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := writeJSONSuccess(w, response); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode AS details", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handlePrefixDetailJSON returns prefix details as JSON
 | 
			
		||||
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		prefixParam := chi.URLParam(r, "prefix")
 | 
			
		||||
		if prefixParam == "" {
 | 
			
		||||
			writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// URL decode the prefix parameter
 | 
			
		||||
		prefix, err := url.QueryUnescape(prefixParam)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			writeJSONError(w, http.StatusBadRequest, "Invalid prefix parameter")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		routes, err := s.db.GetPrefixDetails(prefix)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if errors.Is(err, database.ErrNoRoute) {
 | 
			
		||||
				writeJSONError(w, http.StatusNotFound, err.Error())
 | 
			
		||||
			} else {
 | 
			
		||||
				writeJSONError(w, http.StatusInternalServerError, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Group by origin AS
 | 
			
		||||
		originMap := make(map[int][]database.LiveRoute)
 | 
			
		||||
		for _, route := range routes {
 | 
			
		||||
			originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		response := map[string]interface{}{
 | 
			
		||||
			"prefix":       prefix,
 | 
			
		||||
			"routes":       routes,
 | 
			
		||||
			"origins":      originMap,
 | 
			
		||||
			"peer_count":   len(routes),
 | 
			
		||||
			"origin_count": len(originMap),
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := writeJSONSuccess(w, response); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode prefix details", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleASDetail returns a handler that serves the AS detail HTML page
 | 
			
		||||
func (s *Server) handleASDetail() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		asnStr := chi.URLParam(r, "asn")
 | 
			
		||||
		asn, err := strconv.Atoi(asnStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, "Invalid ASN", http.StatusBadRequest)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		asInfo, prefixes, err := s.db.GetASDetails(asn)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if errors.Is(err, database.ErrNoRoute) {
 | 
			
		||||
				http.Error(w, "AS not found", http.StatusNotFound)
 | 
			
		||||
			} else {
 | 
			
		||||
				s.logger.Error("Failed to get AS details", "error", err)
 | 
			
		||||
				http.Error(w, "Internal server error", http.StatusInternalServerError)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Group prefixes by IP version
 | 
			
		||||
		const ipVersionV4 = 4
 | 
			
		||||
		var ipv4Prefixes, ipv6Prefixes []database.LiveRoute
 | 
			
		||||
		for _, p := range prefixes {
 | 
			
		||||
			if p.IPVersion == ipVersionV4 {
 | 
			
		||||
				ipv4Prefixes = append(ipv4Prefixes, p)
 | 
			
		||||
			} else {
 | 
			
		||||
				ipv6Prefixes = append(ipv6Prefixes, p)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Sort prefixes by network address
 | 
			
		||||
		sort.Slice(ipv4Prefixes, func(i, j int) bool {
 | 
			
		||||
			// Parse the prefixes to compare network addresses
 | 
			
		||||
			ipI, netI, _ := net.ParseCIDR(ipv4Prefixes[i].Prefix)
 | 
			
		||||
			ipJ, netJ, _ := net.ParseCIDR(ipv4Prefixes[j].Prefix)
 | 
			
		||||
 | 
			
		||||
			// Compare by network address first
 | 
			
		||||
			cmp := bytes.Compare(ipI.To4(), ipJ.To4())
 | 
			
		||||
			if cmp != 0 {
 | 
			
		||||
				return cmp < 0
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// If network addresses are equal, compare by mask length
 | 
			
		||||
			onesI, _ := netI.Mask.Size()
 | 
			
		||||
			onesJ, _ := netJ.Mask.Size()
 | 
			
		||||
 | 
			
		||||
			return onesI < onesJ
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		sort.Slice(ipv6Prefixes, func(i, j int) bool {
 | 
			
		||||
			// Parse the prefixes to compare network addresses
 | 
			
		||||
			ipI, netI, _ := net.ParseCIDR(ipv6Prefixes[i].Prefix)
 | 
			
		||||
			ipJ, netJ, _ := net.ParseCIDR(ipv6Prefixes[j].Prefix)
 | 
			
		||||
 | 
			
		||||
			// Compare by network address first
 | 
			
		||||
			cmp := bytes.Compare(ipI.To16(), ipJ.To16())
 | 
			
		||||
			if cmp != 0 {
 | 
			
		||||
				return cmp < 0
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// If network addresses are equal, compare by mask length
 | 
			
		||||
			onesI, _ := netI.Mask.Size()
 | 
			
		||||
			onesJ, _ := netJ.Mask.Size()
 | 
			
		||||
 | 
			
		||||
			return onesI < onesJ
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		// Prepare template data
 | 
			
		||||
		data := struct {
 | 
			
		||||
			ASN          *database.ASN
 | 
			
		||||
			IPv4Prefixes []database.LiveRoute
 | 
			
		||||
			IPv6Prefixes []database.LiveRoute
 | 
			
		||||
			TotalCount   int
 | 
			
		||||
			IPv4Count    int
 | 
			
		||||
			IPv6Count    int
 | 
			
		||||
		}{
 | 
			
		||||
			ASN:          asInfo,
 | 
			
		||||
			IPv4Prefixes: ipv4Prefixes,
 | 
			
		||||
			IPv6Prefixes: ipv6Prefixes,
 | 
			
		||||
			TotalCount:   len(prefixes),
 | 
			
		||||
			IPv4Count:    len(ipv4Prefixes),
 | 
			
		||||
			IPv6Count:    len(ipv6Prefixes),
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 | 
			
		||||
		tmpl := templates.ASDetailTemplate()
 | 
			
		||||
		if err := tmpl.Execute(w, data); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to render AS detail template", "error", err)
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handlePrefixDetail returns a handler that serves the prefix detail HTML page
 | 
			
		||||
func (s *Server) handlePrefixDetail() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		prefixParam := chi.URLParam(r, "prefix")
 | 
			
		||||
		if prefixParam == "" {
 | 
			
		||||
			http.Error(w, "Prefix parameter is required", http.StatusBadRequest)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// URL decode the prefix parameter
 | 
			
		||||
		prefix, err := url.QueryUnescape(prefixParam)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, "Invalid prefix parameter", http.StatusBadRequest)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		routes, err := s.db.GetPrefixDetails(prefix)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if errors.Is(err, database.ErrNoRoute) {
 | 
			
		||||
				http.Error(w, "Prefix not found", http.StatusNotFound)
 | 
			
		||||
			} else {
 | 
			
		||||
				s.logger.Error("Failed to get prefix details", "error", err)
 | 
			
		||||
				http.Error(w, "Internal server error", http.StatusInternalServerError)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Group by origin AS and collect unique AS info
 | 
			
		||||
		type ASNInfo struct {
 | 
			
		||||
			Number      int
 | 
			
		||||
			Handle      string
 | 
			
		||||
			Description string
 | 
			
		||||
			PeerCount   int
 | 
			
		||||
		}
 | 
			
		||||
		originMap := make(map[int]*ASNInfo)
 | 
			
		||||
		for _, route := range routes {
 | 
			
		||||
			if _, exists := originMap[route.OriginASN]; !exists {
 | 
			
		||||
				// Get AS info from database
 | 
			
		||||
				asInfo, _, _ := s.db.GetASDetails(route.OriginASN)
 | 
			
		||||
				handle := ""
 | 
			
		||||
				description := ""
 | 
			
		||||
				if asInfo != nil {
 | 
			
		||||
					handle = asInfo.Handle
 | 
			
		||||
					description = asInfo.Description
 | 
			
		||||
				}
 | 
			
		||||
				originMap[route.OriginASN] = &ASNInfo{
 | 
			
		||||
					Number:      route.OriginASN,
 | 
			
		||||
					Handle:      handle,
 | 
			
		||||
					Description: description,
 | 
			
		||||
					PeerCount:   0,
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			originMap[route.OriginASN].PeerCount++
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the first route to extract some common info
 | 
			
		||||
		var maskLength, ipVersion int
 | 
			
		||||
		if len(routes) > 0 {
 | 
			
		||||
			// Parse CIDR to get mask length and IP version
 | 
			
		||||
			_, ipNet, err := net.ParseCIDR(prefix)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				ones, _ := ipNet.Mask.Size()
 | 
			
		||||
				maskLength = ones
 | 
			
		||||
				if ipNet.IP.To4() != nil {
 | 
			
		||||
					ipVersion = 4
 | 
			
		||||
				} else {
 | 
			
		||||
					ipVersion = 6
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Convert origin map to sorted slice
 | 
			
		||||
		var origins []*ASNInfo
 | 
			
		||||
		for _, origin := range originMap {
 | 
			
		||||
			origins = append(origins, origin)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Prepare template data
 | 
			
		||||
		data := struct {
 | 
			
		||||
			Prefix      string
 | 
			
		||||
			MaskLength  int
 | 
			
		||||
			IPVersion   int
 | 
			
		||||
			Routes      []database.LiveRoute
 | 
			
		||||
			Origins     []*ASNInfo
 | 
			
		||||
			PeerCount   int
 | 
			
		||||
			OriginCount int
 | 
			
		||||
		}{
 | 
			
		||||
			Prefix:      prefix,
 | 
			
		||||
			MaskLength:  maskLength,
 | 
			
		||||
			IPVersion:   ipVersion,
 | 
			
		||||
			Routes:      routes,
 | 
			
		||||
			Origins:     origins,
 | 
			
		||||
			PeerCount:   len(routes),
 | 
			
		||||
			OriginCount: len(originMap),
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 | 
			
		||||
		tmpl := templates.PrefixDetailTemplate()
 | 
			
		||||
		if err := tmpl.Execute(w, data); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to render prefix detail template", "error", err)
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleIPRedirect looks up the prefix containing the IP and redirects to its detail page
 | 
			
		||||
func (s *Server) handleIPRedirect() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		ip := chi.URLParam(r, "ip")
 | 
			
		||||
		if ip == "" {
 | 
			
		||||
			http.Error(w, "IP parameter is required", http.StatusBadRequest)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Look up AS information for the IP (which includes the prefix)
 | 
			
		||||
		asInfo, err := s.db.GetASInfoForIP(ip)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if errors.Is(err, database.ErrInvalidIP) {
 | 
			
		||||
				http.Error(w, "Invalid IP address", http.StatusBadRequest)
 | 
			
		||||
			} else if errors.Is(err, database.ErrNoRoute) {
 | 
			
		||||
				http.Error(w, "No route found for this IP", http.StatusNotFound)
 | 
			
		||||
			} else {
 | 
			
		||||
				s.logger.Error("Failed to look up IP", "error", err)
 | 
			
		||||
				http.Error(w, "Internal server error", http.StatusInternalServerError)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Redirect to the prefix detail page (URL encode the prefix)
 | 
			
		||||
		http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								internal/server/routes.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								internal/server/routes.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
package server
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-chi/chi/v5"
 | 
			
		||||
	"github.com/go-chi/chi/v5/middleware"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// setupRoutes configures the HTTP routes
 | 
			
		||||
func (s *Server) setupRoutes() {
 | 
			
		||||
	r := chi.NewRouter()
 | 
			
		||||
 | 
			
		||||
	// Middleware
 | 
			
		||||
	r.Use(middleware.RequestID)
 | 
			
		||||
	r.Use(middleware.RealIP)
 | 
			
		||||
	r.Use(middleware.Logger)
 | 
			
		||||
	r.Use(middleware.Recoverer)
 | 
			
		||||
	const requestTimeout = 2 * time.Second
 | 
			
		||||
	r.Use(TimeoutMiddleware(requestTimeout))
 | 
			
		||||
	r.Use(JSONResponseMiddleware)
 | 
			
		||||
 | 
			
		||||
	// Routes
 | 
			
		||||
	r.Get("/", s.handleRoot())
 | 
			
		||||
	r.Get("/status", s.handleStatusHTML())
 | 
			
		||||
	r.Get("/status.json", s.handleStatusJSON())
 | 
			
		||||
 | 
			
		||||
	// AS and prefix detail pages
 | 
			
		||||
	r.Get("/as/{asn}", s.handleASDetail())
 | 
			
		||||
	r.Get("/prefix/{prefix}", s.handlePrefixDetail())
 | 
			
		||||
	r.Get("/ip/{ip}", s.handleIPRedirect())
 | 
			
		||||
 | 
			
		||||
	// API routes
 | 
			
		||||
	r.Route("/api/v1", func(r chi.Router) {
 | 
			
		||||
		r.Get("/stats", s.handleStats())
 | 
			
		||||
		r.Get("/ip/{ip}", s.handleIPLookup())
 | 
			
		||||
		r.Get("/as/{asn}", s.handleASDetailJSON())
 | 
			
		||||
		r.Get("/prefix/{prefix}", s.handlePrefixDetailJSON())
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	s.router = r
 | 
			
		||||
}
 | 
			
		||||
@ -3,20 +3,14 @@ package server
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/database"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/logger"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/streamer"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/templates"
 | 
			
		||||
	"github.com/dustin/go-humanize"
 | 
			
		||||
	"github.com/go-chi/chi/v5"
 | 
			
		||||
	"github.com/go-chi/chi/v5/middleware"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Server provides HTTP endpoints for status monitoring
 | 
			
		||||
@ -41,33 +35,6 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger)
 | 
			
		||||
	return s
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// setupRoutes configures the HTTP routes
 | 
			
		||||
func (s *Server) setupRoutes() {
 | 
			
		||||
	r := chi.NewRouter()
 | 
			
		||||
 | 
			
		||||
	// Middleware
 | 
			
		||||
	r.Use(middleware.RequestID)
 | 
			
		||||
	r.Use(middleware.RealIP)
 | 
			
		||||
	r.Use(middleware.Logger)
 | 
			
		||||
	r.Use(middleware.Recoverer)
 | 
			
		||||
	const requestTimeout = 2 * time.Second
 | 
			
		||||
	r.Use(TimeoutMiddleware(requestTimeout))
 | 
			
		||||
	r.Use(JSONResponseMiddleware)
 | 
			
		||||
 | 
			
		||||
	// Routes
 | 
			
		||||
	r.Get("/", s.handleRoot())
 | 
			
		||||
	r.Get("/status", s.handleStatusHTML())
 | 
			
		||||
	r.Get("/status.json", s.handleStatusJSON())
 | 
			
		||||
 | 
			
		||||
	// API routes
 | 
			
		||||
	r.Route("/api/v1", func(r chi.Router) {
 | 
			
		||||
		r.Get("/stats", s.handleStats())
 | 
			
		||||
		r.Get("/ip/{ip}", s.handleIPLookup())
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	s.router = r
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Start starts the HTTP server
 | 
			
		||||
func (s *Server) Start() error {
 | 
			
		||||
	port := os.Getenv("PORT")
 | 
			
		||||
@ -103,357 +70,3 @@ func (s *Server) Stop(ctx context.Context) error {
 | 
			
		||||
 | 
			
		||||
	return s.srv.Shutdown(ctx)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleRoot returns a handler that redirects to /status
 | 
			
		||||
func (s *Server) handleRoot() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		http.Redirect(w, r, "/status", http.StatusSeeOther)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// writeJSONError writes a standardized JSON error response
 | 
			
		||||
func writeJSONError(w http.ResponseWriter, statusCode int, message string) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	w.WriteHeader(statusCode)
 | 
			
		||||
	_ = json.NewEncoder(w).Encode(map[string]interface{}{
 | 
			
		||||
		"status": "error",
 | 
			
		||||
		"error": map[string]interface{}{
 | 
			
		||||
			"msg":  message,
 | 
			
		||||
			"code": statusCode,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// writeJSONSuccess writes a standardized JSON success response
 | 
			
		||||
func writeJSONSuccess(w http.ResponseWriter, data interface{}) error {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
 | 
			
		||||
	return json.NewEncoder(w).Encode(map[string]interface{}{
 | 
			
		||||
		"status": "ok",
 | 
			
		||||
		"data":   data,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleStatusJSON returns a handler that serves JSON statistics
 | 
			
		||||
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"`
 | 
			
		||||
		GoVersion              string                        `json:"go_version"`
 | 
			
		||||
		Goroutines             int                           `json:"goroutines"`
 | 
			
		||||
		MemoryUsage            string                        `json:"memory_usage"`
 | 
			
		||||
		ASNs                   int                           `json:"asns"`
 | 
			
		||||
		Prefixes               int                           `json:"prefixes"`
 | 
			
		||||
		IPv4Prefixes           int                           `json:"ipv4_prefixes"`
 | 
			
		||||
		IPv6Prefixes           int                           `json:"ipv6_prefixes"`
 | 
			
		||||
		Peerings               int                           `json:"peerings"`
 | 
			
		||||
		Peers                  int                           `json:"peers"`
 | 
			
		||||
		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) {
 | 
			
		||||
		// Create a 1 second timeout context for this request
 | 
			
		||||
		ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
 | 
			
		||||
		defer cancel()
 | 
			
		||||
 | 
			
		||||
		metrics := s.streamer.GetMetrics()
 | 
			
		||||
 | 
			
		||||
		// Get database stats with timeout
 | 
			
		||||
		statsChan := make(chan database.Stats)
 | 
			
		||||
		errChan := make(chan error)
 | 
			
		||||
 | 
			
		||||
		go func() {
 | 
			
		||||
			dbStats, err := s.db.GetStats()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				s.logger.Debug("Database stats query failed", "error", err)
 | 
			
		||||
				errChan <- err
 | 
			
		||||
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			statsChan <- dbStats
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		var dbStats database.Stats
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			s.logger.Error("Database stats timeout in status.json")
 | 
			
		||||
			writeJSONError(w, http.StatusRequestTimeout, "Database timeout")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case err := <-errChan:
 | 
			
		||||
			s.logger.Error("Failed to get database stats", "error", err)
 | 
			
		||||
			writeJSONError(w, http.StatusInternalServerError, err.Error())
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case dbStats = <-statsChan:
 | 
			
		||||
			// Success
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
 | 
			
		||||
		if metrics.ConnectedSince.IsZero() {
 | 
			
		||||
			uptime = "0s"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const bitsPerMegabit = 1000000.0
 | 
			
		||||
 | 
			
		||||
		// Get route counts from database
 | 
			
		||||
		ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			s.logger.Warn("Failed to get live route counts", "error", err)
 | 
			
		||||
			// Continue with zero counts
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get route update metrics
 | 
			
		||||
		routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
 | 
			
		||||
 | 
			
		||||
		// Get memory stats
 | 
			
		||||
		var memStats runtime.MemStats
 | 
			
		||||
		runtime.ReadMemStats(&memStats)
 | 
			
		||||
 | 
			
		||||
		stats := Stats{
 | 
			
		||||
			Uptime:                 uptime,
 | 
			
		||||
			TotalMessages:          metrics.TotalMessages,
 | 
			
		||||
			TotalBytes:             metrics.TotalBytes,
 | 
			
		||||
			MessagesPerSec:         metrics.MessagesPerSec,
 | 
			
		||||
			MbitsPerSec:            metrics.BitsPerSec / bitsPerMegabit,
 | 
			
		||||
			Connected:              metrics.Connected,
 | 
			
		||||
			GoVersion:              runtime.Version(),
 | 
			
		||||
			Goroutines:             runtime.NumGoroutine(),
 | 
			
		||||
			MemoryUsage:            humanize.Bytes(memStats.Alloc),
 | 
			
		||||
			ASNs:                   dbStats.ASNs,
 | 
			
		||||
			Prefixes:               dbStats.Prefixes,
 | 
			
		||||
			IPv4Prefixes:           dbStats.IPv4Prefixes,
 | 
			
		||||
			IPv6Prefixes:           dbStats.IPv6Prefixes,
 | 
			
		||||
			Peerings:               dbStats.Peerings,
 | 
			
		||||
			Peers:                  dbStats.Peers,
 | 
			
		||||
			DatabaseSizeBytes:      dbStats.FileSizeBytes,
 | 
			
		||||
			LiveRoutes:             dbStats.LiveRoutes,
 | 
			
		||||
			IPv4Routes:             ipv4Routes,
 | 
			
		||||
			IPv6Routes:             ipv6Routes,
 | 
			
		||||
			IPv4UpdatesPerSec:      routeMetrics.IPv4UpdatesPerSec,
 | 
			
		||||
			IPv6UpdatesPerSec:      routeMetrics.IPv6UpdatesPerSec,
 | 
			
		||||
			IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
 | 
			
		||||
			IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := writeJSONSuccess(w, stats); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode stats", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleStats returns a handler that serves API v1 statistics
 | 
			
		||||
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
 | 
			
		||||
	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"`
 | 
			
		||||
		GoVersion              string                        `json:"go_version"`
 | 
			
		||||
		Goroutines             int                           `json:"goroutines"`
 | 
			
		||||
		MemoryUsage            string                        `json:"memory_usage"`
 | 
			
		||||
		ASNs                   int                           `json:"asns"`
 | 
			
		||||
		Prefixes               int                           `json:"prefixes"`
 | 
			
		||||
		IPv4Prefixes           int                           `json:"ipv4_prefixes"`
 | 
			
		||||
		IPv6Prefixes           int                           `json:"ipv6_prefixes"`
 | 
			
		||||
		Peerings               int                           `json:"peerings"`
 | 
			
		||||
		Peers                  int                           `json:"peers"`
 | 
			
		||||
		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) {
 | 
			
		||||
		// Create a 1 second timeout context for this request
 | 
			
		||||
		ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
 | 
			
		||||
		defer cancel()
 | 
			
		||||
 | 
			
		||||
		// Check if context is already cancelled
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			http.Error(w, "Request timeout", http.StatusRequestTimeout)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		default:
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		metrics := s.streamer.GetMetrics()
 | 
			
		||||
 | 
			
		||||
		// Get database stats with timeout
 | 
			
		||||
		statsChan := make(chan database.Stats)
 | 
			
		||||
		errChan := make(chan error)
 | 
			
		||||
 | 
			
		||||
		go func() {
 | 
			
		||||
			dbStats, err := s.db.GetStats()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				s.logger.Debug("Database stats query failed", "error", err)
 | 
			
		||||
				errChan <- err
 | 
			
		||||
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			statsChan <- dbStats
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		var dbStats database.Stats
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			s.logger.Error("Database stats timeout")
 | 
			
		||||
			http.Error(w, "Database timeout", http.StatusRequestTimeout)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case err := <-errChan:
 | 
			
		||||
			s.logger.Error("Failed to get database stats", "error", err)
 | 
			
		||||
			http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		case dbStats = <-statsChan:
 | 
			
		||||
			// Success
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
 | 
			
		||||
		if metrics.ConnectedSince.IsZero() {
 | 
			
		||||
			uptime = "0s"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const bitsPerMegabit = 1000000.0
 | 
			
		||||
 | 
			
		||||
		// Get route counts from database
 | 
			
		||||
		ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			s.logger.Warn("Failed to get live route counts", "error", err)
 | 
			
		||||
			// Continue with zero counts
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get route update metrics
 | 
			
		||||
		routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
 | 
			
		||||
 | 
			
		||||
		// 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,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get memory stats
 | 
			
		||||
		var memStats runtime.MemStats
 | 
			
		||||
		runtime.ReadMemStats(&memStats)
 | 
			
		||||
 | 
			
		||||
		stats := StatsResponse{
 | 
			
		||||
			Uptime:                 uptime,
 | 
			
		||||
			TotalMessages:          metrics.TotalMessages,
 | 
			
		||||
			TotalBytes:             metrics.TotalBytes,
 | 
			
		||||
			MessagesPerSec:         metrics.MessagesPerSec,
 | 
			
		||||
			MbitsPerSec:            metrics.BitsPerSec / bitsPerMegabit,
 | 
			
		||||
			Connected:              metrics.Connected,
 | 
			
		||||
			GoVersion:              runtime.Version(),
 | 
			
		||||
			Goroutines:             runtime.NumGoroutine(),
 | 
			
		||||
			MemoryUsage:            humanize.Bytes(memStats.Alloc),
 | 
			
		||||
			ASNs:                   dbStats.ASNs,
 | 
			
		||||
			Prefixes:               dbStats.Prefixes,
 | 
			
		||||
			IPv4Prefixes:           dbStats.IPv4Prefixes,
 | 
			
		||||
			IPv6Prefixes:           dbStats.IPv6Prefixes,
 | 
			
		||||
			Peerings:               dbStats.Peerings,
 | 
			
		||||
			Peers:                  dbStats.Peers,
 | 
			
		||||
			DatabaseSizeBytes:      dbStats.FileSizeBytes,
 | 
			
		||||
			LiveRoutes:             dbStats.LiveRoutes,
 | 
			
		||||
			IPv4Routes:             ipv4Routes,
 | 
			
		||||
			IPv6Routes:             ipv6Routes,
 | 
			
		||||
			IPv4UpdatesPerSec:      routeMetrics.IPv4UpdatesPerSec,
 | 
			
		||||
			IPv6UpdatesPerSec:      routeMetrics.IPv6UpdatesPerSec,
 | 
			
		||||
			HandlerStats:           handlerStatsInfo,
 | 
			
		||||
			IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
 | 
			
		||||
			IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := writeJSONSuccess(w, stats); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode stats", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleStatusHTML returns a handler that serves the HTML status page
 | 
			
		||||
func (s *Server) handleStatusHTML() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, _ *http.Request) {
 | 
			
		||||
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 | 
			
		||||
 | 
			
		||||
		tmpl := templates.StatusTemplate()
 | 
			
		||||
		if err := tmpl.Execute(w, nil); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to render template", "error", err)
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleIPLookup returns a handler that looks up AS information for an IP address
 | 
			
		||||
func (s *Server) handleIPLookup() http.HandlerFunc {
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		ip := chi.URLParam(r, "ip")
 | 
			
		||||
		if ip == "" {
 | 
			
		||||
			writeJSONError(w, http.StatusBadRequest, "IP parameter is required")
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Look up AS information for the IP
 | 
			
		||||
		asInfo, err := s.db.GetASInfoForIP(ip)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			// Check if it's an invalid IP error
 | 
			
		||||
			if errors.Is(err, database.ErrInvalidIP) {
 | 
			
		||||
				writeJSONError(w, http.StatusBadRequest, err.Error())
 | 
			
		||||
			} else {
 | 
			
		||||
				// All other errors (including ErrNoRoute) are 404
 | 
			
		||||
				writeJSONError(w, http.StatusNotFound, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Return successful response
 | 
			
		||||
		if err := writeJSONSuccess(w, asInfo); err != nil {
 | 
			
		||||
			s.logger.Error("Failed to encode AS info", "error", err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										228
									
								
								internal/templates/as_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								internal/templates/as_detail.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,228 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
    <title>AS{{.ASN.Number}} - {{.ASN.Handle}} - RouteWatch</title>
 | 
			
		||||
    <style>
 | 
			
		||||
        body {
 | 
			
		||||
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            padding: 20px;
 | 
			
		||||
            background: #f5f5f5;
 | 
			
		||||
            color: #333;
 | 
			
		||||
        }
 | 
			
		||||
        .container {
 | 
			
		||||
            max-width: 1200px;
 | 
			
		||||
            margin: 0 auto;
 | 
			
		||||
            background: white;
 | 
			
		||||
            padding: 30px;
 | 
			
		||||
            border-radius: 8px;
 | 
			
		||||
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
			
		||||
        }
 | 
			
		||||
        h1 {
 | 
			
		||||
            margin: 0 0 10px 0;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
        }
 | 
			
		||||
        .subtitle {
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
            margin-bottom: 30px;
 | 
			
		||||
        }
 | 
			
		||||
        .info-grid {
 | 
			
		||||
            display: grid;
 | 
			
		||||
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 | 
			
		||||
            gap: 20px;
 | 
			
		||||
            margin-bottom: 30px;
 | 
			
		||||
        }
 | 
			
		||||
        .info-card {
 | 
			
		||||
            background: #f8f9fa;
 | 
			
		||||
            padding: 20px;
 | 
			
		||||
            border-radius: 6px;
 | 
			
		||||
            border-left: 4px solid #3498db;
 | 
			
		||||
        }
 | 
			
		||||
        .info-label {
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
            margin-bottom: 5px;
 | 
			
		||||
        }
 | 
			
		||||
        .info-value {
 | 
			
		||||
            font-size: 24px;
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-section {
 | 
			
		||||
            margin-top: 30px;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-header {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            justify-content: space-between;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            margin-bottom: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-header h2 {
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-count {
 | 
			
		||||
            background: #e74c3c;
 | 
			
		||||
            color: white;
 | 
			
		||||
            padding: 5px 12px;
 | 
			
		||||
            border-radius: 20px;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-table {
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            border-collapse: collapse;
 | 
			
		||||
            background: white;
 | 
			
		||||
            border: 1px solid #e0e0e0;
 | 
			
		||||
            border-radius: 6px;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-table th {
 | 
			
		||||
            background: #34495e;
 | 
			
		||||
            color: white;
 | 
			
		||||
            padding: 12px;
 | 
			
		||||
            text-align: left;
 | 
			
		||||
            font-weight: 600;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-table td {
 | 
			
		||||
            padding: 12px;
 | 
			
		||||
            border-bottom: 1px solid #e0e0e0;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-table tr:hover {
 | 
			
		||||
            background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-table tr:last-child td {
 | 
			
		||||
            border-bottom: none;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-link {
 | 
			
		||||
            color: #3498db;
 | 
			
		||||
            text-decoration: none;
 | 
			
		||||
            font-family: monospace;
 | 
			
		||||
        }
 | 
			
		||||
        .prefix-link:hover {
 | 
			
		||||
            text-decoration: underline;
 | 
			
		||||
        }
 | 
			
		||||
        .age {
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
        .nav-link {
 | 
			
		||||
            display: inline-block;
 | 
			
		||||
            margin-bottom: 20px;
 | 
			
		||||
            color: #3498db;
 | 
			
		||||
            text-decoration: none;
 | 
			
		||||
        }
 | 
			
		||||
        .nav-link:hover {
 | 
			
		||||
            text-decoration: underline;
 | 
			
		||||
        }
 | 
			
		||||
        .empty-state {
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            padding: 40px;
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
        }
 | 
			
		||||
        @media (max-width: 768px) {
 | 
			
		||||
            .container {
 | 
			
		||||
                padding: 20px;
 | 
			
		||||
            }
 | 
			
		||||
            .info-grid {
 | 
			
		||||
                grid-template-columns: 1fr;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
    <div class="container">
 | 
			
		||||
        <a href="/status" class="nav-link">← Back to Status</a>
 | 
			
		||||
        
 | 
			
		||||
        <h1>AS{{.ASN.Number}}{{if .ASN.Handle}} - {{.ASN.Handle}}{{end}}</h1>
 | 
			
		||||
        {{if .ASN.Description}}
 | 
			
		||||
        <p class="subtitle">{{.ASN.Description}}</p>
 | 
			
		||||
        {{end}}
 | 
			
		||||
        
 | 
			
		||||
        <div class="info-grid">
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">Total Prefixes</div>
 | 
			
		||||
                <div class="info-value">{{.TotalCount}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">IPv4 Prefixes</div>
 | 
			
		||||
                <div class="info-value">{{.IPv4Count}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">IPv6 Prefixes</div>
 | 
			
		||||
                <div class="info-value">{{.IPv6Count}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">First Seen</div>
 | 
			
		||||
                <div class="info-value">{{.ASN.FirstSeen.Format "2006-01-02"}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {{if .IPv4Prefixes}}
 | 
			
		||||
        <div class="prefix-section">
 | 
			
		||||
            <div class="prefix-header">
 | 
			
		||||
                <h2>IPv4 Prefixes</h2>
 | 
			
		||||
                <span class="prefix-count">{{.IPv4Count}}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <table class="prefix-table">
 | 
			
		||||
                <thead>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <th>Prefix</th>
 | 
			
		||||
                        <th>Mask Length</th>
 | 
			
		||||
                        <th>Last Updated</th>
 | 
			
		||||
                        <th>Age</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                </thead>
 | 
			
		||||
                <tbody>
 | 
			
		||||
                    {{range .IPv4Prefixes}}
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td>
 | 
			
		||||
                        <td>/{{.MaskLength}}</td>
 | 
			
		||||
                        <td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
 | 
			
		||||
                        <td class="age">{{.LastUpdated | timeSince}}</td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                    {{end}}
 | 
			
		||||
                </tbody>
 | 
			
		||||
            </table>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{end}}
 | 
			
		||||
 | 
			
		||||
        {{if .IPv6Prefixes}}
 | 
			
		||||
        <div class="prefix-section">
 | 
			
		||||
            <div class="prefix-header">
 | 
			
		||||
                <h2>IPv6 Prefixes</h2>
 | 
			
		||||
                <span class="prefix-count">{{.IPv6Count}}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <table class="prefix-table">
 | 
			
		||||
                <thead>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <th>Prefix</th>
 | 
			
		||||
                        <th>Mask Length</th>
 | 
			
		||||
                        <th>Last Updated</th>
 | 
			
		||||
                        <th>Age</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                </thead>
 | 
			
		||||
                <tbody>
 | 
			
		||||
                    {{range .IPv6Prefixes}}
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td>
 | 
			
		||||
                        <td>/{{.MaskLength}}</td>
 | 
			
		||||
                        <td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
 | 
			
		||||
                        <td class="age">{{.LastUpdated | timeSince}}</td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                    {{end}}
 | 
			
		||||
                </tbody>
 | 
			
		||||
            </table>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{end}}
 | 
			
		||||
 | 
			
		||||
        {{if eq .TotalCount 0}}
 | 
			
		||||
        <div class="empty-state">
 | 
			
		||||
            <p>No prefixes announced by this AS</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{end}}
 | 
			
		||||
    </div>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										253
									
								
								internal/templates/prefix_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								internal/templates/prefix_detail.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,253 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
    <title>{{.Prefix}} - RouteWatch</title>
 | 
			
		||||
    <style>
 | 
			
		||||
        body {
 | 
			
		||||
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            padding: 20px;
 | 
			
		||||
            background: #f5f5f5;
 | 
			
		||||
            color: #333;
 | 
			
		||||
        }
 | 
			
		||||
        .container {
 | 
			
		||||
            max-width: 1200px;
 | 
			
		||||
            margin: 0 auto;
 | 
			
		||||
            background: white;
 | 
			
		||||
            padding: 30px;
 | 
			
		||||
            border-radius: 8px;
 | 
			
		||||
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
			
		||||
        }
 | 
			
		||||
        h1 {
 | 
			
		||||
            margin: 0 0 10px 0;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
            font-family: monospace;
 | 
			
		||||
            font-size: 28px;
 | 
			
		||||
        }
 | 
			
		||||
        .subtitle {
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
            margin-bottom: 30px;
 | 
			
		||||
        }
 | 
			
		||||
        .info-grid {
 | 
			
		||||
            display: grid;
 | 
			
		||||
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 | 
			
		||||
            gap: 20px;
 | 
			
		||||
            margin-bottom: 30px;
 | 
			
		||||
        }
 | 
			
		||||
        .info-card {
 | 
			
		||||
            background: #f8f9fa;
 | 
			
		||||
            padding: 20px;
 | 
			
		||||
            border-radius: 6px;
 | 
			
		||||
            border-left: 4px solid #3498db;
 | 
			
		||||
        }
 | 
			
		||||
        .info-label {
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
            margin-bottom: 5px;
 | 
			
		||||
        }
 | 
			
		||||
        .info-value {
 | 
			
		||||
            font-size: 24px;
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
        }
 | 
			
		||||
        .routes-section {
 | 
			
		||||
            margin-top: 30px;
 | 
			
		||||
        }
 | 
			
		||||
        .routes-header {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            justify-content: space-between;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            margin-bottom: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        .routes-header h2 {
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
        }
 | 
			
		||||
        .route-count {
 | 
			
		||||
            background: #e74c3c;
 | 
			
		||||
            color: white;
 | 
			
		||||
            padding: 5px 12px;
 | 
			
		||||
            border-radius: 20px;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
        }
 | 
			
		||||
        .route-table {
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            border-collapse: collapse;
 | 
			
		||||
            background: white;
 | 
			
		||||
            border: 1px solid #e0e0e0;
 | 
			
		||||
            border-radius: 6px;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
        .route-table th {
 | 
			
		||||
            background: #34495e;
 | 
			
		||||
            color: white;
 | 
			
		||||
            padding: 12px;
 | 
			
		||||
            text-align: left;
 | 
			
		||||
            font-weight: 600;
 | 
			
		||||
        }
 | 
			
		||||
        .route-table td {
 | 
			
		||||
            padding: 12px;
 | 
			
		||||
            border-bottom: 1px solid #e0e0e0;
 | 
			
		||||
        }
 | 
			
		||||
        .route-table tr:hover {
 | 
			
		||||
            background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
        .route-table tr:last-child td {
 | 
			
		||||
            border-bottom: none;
 | 
			
		||||
        }
 | 
			
		||||
        .as-link {
 | 
			
		||||
            color: #3498db;
 | 
			
		||||
            text-decoration: none;
 | 
			
		||||
        }
 | 
			
		||||
        .as-link:hover {
 | 
			
		||||
            text-decoration: underline;
 | 
			
		||||
        }
 | 
			
		||||
        .peer-ip {
 | 
			
		||||
            font-family: monospace;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            color: #555;
 | 
			
		||||
        }
 | 
			
		||||
        .as-path {
 | 
			
		||||
            font-family: monospace;
 | 
			
		||||
            font-size: 13px;
 | 
			
		||||
            color: #666;
 | 
			
		||||
            max-width: 300px;
 | 
			
		||||
            overflow-x: auto;
 | 
			
		||||
            white-space: nowrap;
 | 
			
		||||
        }
 | 
			
		||||
        .age {
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
        .nav-link {
 | 
			
		||||
            display: inline-block;
 | 
			
		||||
            margin-bottom: 20px;
 | 
			
		||||
            color: #3498db;
 | 
			
		||||
            text-decoration: none;
 | 
			
		||||
        }
 | 
			
		||||
        .nav-link:hover {
 | 
			
		||||
            text-decoration: underline;
 | 
			
		||||
        }
 | 
			
		||||
        .origins-section {
 | 
			
		||||
            margin-top: 30px;
 | 
			
		||||
            background: #f8f9fa;
 | 
			
		||||
            padding: 20px;
 | 
			
		||||
            border-radius: 6px;
 | 
			
		||||
        }
 | 
			
		||||
        .origins-section h3 {
 | 
			
		||||
            margin-top: 0;
 | 
			
		||||
            color: #2c3e50;
 | 
			
		||||
        }
 | 
			
		||||
        .origin-list {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            flex-wrap: wrap;
 | 
			
		||||
            gap: 10px;
 | 
			
		||||
        }
 | 
			
		||||
        .origin-item {
 | 
			
		||||
            background: white;
 | 
			
		||||
            padding: 10px 15px;
 | 
			
		||||
            border-radius: 4px;
 | 
			
		||||
            border: 1px solid #e0e0e0;
 | 
			
		||||
        }
 | 
			
		||||
        .empty-state {
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            padding: 40px;
 | 
			
		||||
            color: #7f8c8d;
 | 
			
		||||
        }
 | 
			
		||||
        @media (max-width: 768px) {
 | 
			
		||||
            .container {
 | 
			
		||||
                padding: 20px;
 | 
			
		||||
            }
 | 
			
		||||
            .info-grid {
 | 
			
		||||
                grid-template-columns: 1fr;
 | 
			
		||||
            }
 | 
			
		||||
            .route-table {
 | 
			
		||||
                font-size: 14px;
 | 
			
		||||
            }
 | 
			
		||||
            .as-path {
 | 
			
		||||
                max-width: 150px;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
    <div class="container">
 | 
			
		||||
        <a href="/status" class="nav-link">← Back to Status</a>
 | 
			
		||||
        
 | 
			
		||||
        <h1>{{.Prefix}}</h1>
 | 
			
		||||
        <p class="subtitle">IPv{{.IPVersion}} Prefix{{if .MaskLength}} • /{{.MaskLength}}{{end}}</p>
 | 
			
		||||
        
 | 
			
		||||
        <div class="info-grid">
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">Seen from Peers</div>
 | 
			
		||||
                <div class="info-value">{{.PeerCount}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">Origin ASNs</div>
 | 
			
		||||
                <div class="info-value">{{.OriginCount}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="info-card">
 | 
			
		||||
                <div class="info-label">IP Version</div>
 | 
			
		||||
                <div class="info-value">IPv{{.IPVersion}}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {{if .Origins}}
 | 
			
		||||
        <div class="origins-section">
 | 
			
		||||
            <h3>Origin ASNs</h3>
 | 
			
		||||
            <div class="origin-list">
 | 
			
		||||
                {{range .Origins}}
 | 
			
		||||
                <div class="origin-item">
 | 
			
		||||
                    <a href="/as/{{.Number}}" class="as-link">AS{{.Number}}</a>
 | 
			
		||||
                    {{if .Handle}} ({{.Handle}}){{end}}
 | 
			
		||||
                    <span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                {{end}}
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{end}}
 | 
			
		||||
 | 
			
		||||
        {{if .Routes}}
 | 
			
		||||
        <div class="routes-section">
 | 
			
		||||
            <div class="routes-header">
 | 
			
		||||
                <h2>Route Details</h2>
 | 
			
		||||
                <span class="route-count">{{.PeerCount}} route{{if ne .PeerCount 1}}s{{end}}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <table class="route-table">
 | 
			
		||||
                <thead>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <th>Origin AS</th>
 | 
			
		||||
                        <th>Peer IP</th>
 | 
			
		||||
                        <th>AS Path</th>
 | 
			
		||||
                        <th>Next Hop</th>
 | 
			
		||||
                        <th>Last Updated</th>
 | 
			
		||||
                        <th>Age</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                </thead>
 | 
			
		||||
                <tbody>
 | 
			
		||||
                    {{range .Routes}}
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <td>
 | 
			
		||||
                            <a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="peer-ip">{{.PeerIP}}</td>
 | 
			
		||||
                        <td class="as-path">{{range $i, $as := .ASPath}}{{if $i}} → {{end}}{{$as}}{{end}}</td>
 | 
			
		||||
                        <td class="peer-ip">{{.NextHop}}</td>
 | 
			
		||||
                        <td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
 | 
			
		||||
                        <td class="age">{{.LastUpdated | timeSince}}</td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                    {{end}}
 | 
			
		||||
                </tbody>
 | 
			
		||||
            </table>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{else}}
 | 
			
		||||
        <div class="empty-state">
 | 
			
		||||
            <p>No routes found for this prefix</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        {{end}}
 | 
			
		||||
    </div>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
@ -4,15 +4,25 @@ package templates
 | 
			
		||||
import (
 | 
			
		||||
	_ "embed"
 | 
			
		||||
	"html/template"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
//go:embed status.html
 | 
			
		||||
var statusHTML string
 | 
			
		||||
 | 
			
		||||
//go:embed as_detail.html
 | 
			
		||||
var asDetailHTML string
 | 
			
		||||
 | 
			
		||||
//go:embed prefix_detail.html
 | 
			
		||||
var prefixDetailHTML string
 | 
			
		||||
 | 
			
		||||
// Templates contains all parsed templates
 | 
			
		||||
type Templates struct {
 | 
			
		||||
	Status *template.Template
 | 
			
		||||
	Status       *template.Template
 | 
			
		||||
	ASDetail     *template.Template
 | 
			
		||||
	PrefixDetail *template.Template
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
@ -22,17 +32,73 @@ var (
 | 
			
		||||
	once sync.Once
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	hoursPerDay  = 24
 | 
			
		||||
	daysPerMonth = 30
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// timeSince returns a human-readable duration since the given time
 | 
			
		||||
func timeSince(t time.Time) string {
 | 
			
		||||
	duration := time.Since(t)
 | 
			
		||||
	if duration < time.Minute {
 | 
			
		||||
		return "just now"
 | 
			
		||||
	}
 | 
			
		||||
	if duration < time.Hour {
 | 
			
		||||
		minutes := int(duration.Minutes())
 | 
			
		||||
		if minutes == 1 {
 | 
			
		||||
			return "1 minute ago"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return duration.Truncate(time.Minute).String() + " ago"
 | 
			
		||||
	}
 | 
			
		||||
	if duration < hoursPerDay*time.Hour {
 | 
			
		||||
		hours := int(duration.Hours())
 | 
			
		||||
		if hours == 1 {
 | 
			
		||||
			return "1 hour ago"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return duration.Truncate(time.Hour).String() + " ago"
 | 
			
		||||
	}
 | 
			
		||||
	days := int(duration.Hours() / hoursPerDay)
 | 
			
		||||
	if days == 1 {
 | 
			
		||||
		return "1 day ago"
 | 
			
		||||
	}
 | 
			
		||||
	if days < daysPerMonth {
 | 
			
		||||
		return duration.Truncate(hoursPerDay*time.Hour).String() + " ago"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return t.Format("2006-01-02")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// initTemplates parses all embedded templates
 | 
			
		||||
func initTemplates() {
 | 
			
		||||
	var err error
 | 
			
		||||
 | 
			
		||||
	defaultTemplates = &Templates{}
 | 
			
		||||
 | 
			
		||||
	// Create common template functions
 | 
			
		||||
	funcs := template.FuncMap{
 | 
			
		||||
		"timeSince": timeSince,
 | 
			
		||||
		"urlEncode": url.QueryEscape,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse status template
 | 
			
		||||
	defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic("failed to parse status template: " + err.Error())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse AS detail template
 | 
			
		||||
	defaultTemplates.ASDetail, err = template.New("asDetail").Funcs(funcs).Parse(asDetailHTML)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic("failed to parse AS detail template: " + err.Error())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse prefix detail template
 | 
			
		||||
	defaultTemplates.PrefixDetail, err = template.New("prefixDetail").Funcs(funcs).Parse(prefixDetailHTML)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic("failed to parse prefix detail template: " + err.Error())
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get returns the singleton Templates instance
 | 
			
		||||
@ -46,3 +112,13 @@ func Get() *Templates {
 | 
			
		||||
func StatusTemplate() *template.Template {
 | 
			
		||||
	return Get().Status
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ASDetailTemplate returns the parsed AS detail template
 | 
			
		||||
func ASDetailTemplate() *template.Template {
 | 
			
		||||
	return Get().ASDetail
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// PrefixDetailTemplate returns the parsed prefix detail template
 | 
			
		||||
func PrefixDetailTemplate() *template.Template {
 | 
			
		||||
	return Get().PrefixDetail
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										125
									
								
								log.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								log.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,125 @@
 | 
			
		||||
[Fx] PROVIDE	fx.Lifecycle <= go.uber.org/fx.New.func1()
 | 
			
		||||
[Fx] PROVIDE	fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
 | 
			
		||||
[Fx] PROVIDE	fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
 | 
			
		||||
[Fx] PROVIDE	*logger.Logger <= git.eeqj.de/sneak/routewatch/internal/logger.New()
 | 
			
		||||
[Fx] PROVIDE	*config.Config <= git.eeqj.de/sneak/routewatch/internal/config.New()
 | 
			
		||||
[Fx] PROVIDE	*metrics.Tracker <= git.eeqj.de/sneak/routewatch/internal/metrics.New()
 | 
			
		||||
[Fx] PROVIDE	database.Store <= fx.Annotate(git.eeqj.de/sneak/routewatch/internal/database.New(), fx.As([[database.Store]])
 | 
			
		||||
[Fx] PROVIDE	*streamer.Streamer <= git.eeqj.de/sneak/routewatch/internal/streamer.New()
 | 
			
		||||
[Fx] PROVIDE	*server.Server <= git.eeqj.de/sneak/routewatch/internal/server.New()
 | 
			
		||||
[Fx] PROVIDE	*routewatch.RouteWatch <= git.eeqj.de/sneak/routewatch/internal/routewatch.New()
 | 
			
		||||
[Fx] INVOKE		git.eeqj.de/sneak/routewatch/internal/routewatch.CLIEntry.func1()
 | 
			
		||||
[Fx] BEFORE RUN	provide: go.uber.org/fx.New.func1()
 | 
			
		||||
[Fx] RUN	provide: go.uber.org/fx.New.func1() in 6.292µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: git.eeqj.de/sneak/routewatch/internal/config.New()
 | 
			
		||||
[Fx] RUN	provide: git.eeqj.de/sneak/routewatch/internal/config.New() in 6.458µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: git.eeqj.de/sneak/routewatch/internal/logger.New()
 | 
			
		||||
[Fx] RUN	provide: git.eeqj.de/sneak/routewatch/internal/logger.New() in 4.417µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: fx.Annotate(git.eeqj.de/sneak/routewatch/internal/database.New(), fx.As([[database.Store]])
 | 
			
		||||
{"time":"2025-07-28T17:12:49.977025+02:00","level":"INFO","msg":"Opening database","source":"database.go:55","func":"database.New","path":"/Users/user/Library/Application Support/berlin.sneak.app.routewatch/db.sqlite"}
 | 
			
		||||
{"time":"2025-07-28T17:12:50.21775+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"PRAGMA wal_checkpoint(TRUNCATE)","duration":115272875}
 | 
			
		||||
{"time":"2025-07-28T17:12:50.217936+02:00","level":"INFO","msg":"Running VACUUM to optimize database (this may take a moment)","source":"database.go:122","func":"database.(*Database).Initialize"}
 | 
			
		||||
{"time":"2025-07-28T17:12:59.531431+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"VACUUM","duration":9313432750}
 | 
			
		||||
[Fx] RUN	provide: fx.Annotate(git.eeqj.de/sneak/routewatch/internal/database.New(), fx.As([[database.Store]]) in 9.554516041s
 | 
			
		||||
[Fx] BEFORE RUN	provide: git.eeqj.de/sneak/routewatch/internal/metrics.New()
 | 
			
		||||
[Fx] RUN	provide: git.eeqj.de/sneak/routewatch/internal/metrics.New() in 38.292µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: git.eeqj.de/sneak/routewatch/internal/streamer.New()
 | 
			
		||||
[Fx] RUN	provide: git.eeqj.de/sneak/routewatch/internal/streamer.New() in 4.5µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: git.eeqj.de/sneak/routewatch/internal/server.New()
 | 
			
		||||
[Fx] RUN	provide: git.eeqj.de/sneak/routewatch/internal/server.New() in 60.125µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: git.eeqj.de/sneak/routewatch/internal/routewatch.New()
 | 
			
		||||
[Fx] RUN	provide: git.eeqj.de/sneak/routewatch/internal/routewatch.New() in 3.083µs
 | 
			
		||||
[Fx] BEFORE RUN	provide: go.uber.org/fx.(*App).shutdowner-fm()
 | 
			
		||||
[Fx] RUN	provide: go.uber.org/fx.(*App).shutdowner-fm() in 7.167µs
 | 
			
		||||
[Fx] HOOK OnStart		git.eeqj.de/sneak/routewatch/internal/routewatch.CLIEntry.func1.1() executing (caller: git.eeqj.de/sneak/routewatch/internal/routewatch.CLIEntry.func1)
 | 
			
		||||
[Fx] HOOK OnStart		git.eeqj.de/sneak/routewatch/internal/routewatch.CLIEntry.func1.1() called by git.eeqj.de/sneak/routewatch/internal/routewatch.CLIEntry.func1 ran successfully in 162.25µs
 | 
			
		||||
[Fx] RUNNING
 | 
			
		||||
{"time":"2025-07-28T17:12:59.53194+02:00","level":"INFO","msg":"Starting RouteWatch","source":"app.go:64","func":"routewatch.(*RouteWatch).Run"}
 | 
			
		||||
{"time":"2025-07-28T17:12:59.531973+02:00","level":"INFO","msg":"Using batched database handlers for improved performance","source":"app.go:76","func":"routewatch.(*RouteWatch).Run"}
 | 
			
		||||
{"time":"2025-07-28T17:12:59.533095+02:00","level":"INFO","msg":"Starting HTTP server","source":"server.go:52","func":"server.(*Server).Start","port":"8080"}
 | 
			
		||||
2025/07/28 17:13:00 [akrotiri/R2eLWiud8V-000001] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56646 - 200 3622B in 324.489792ms
 | 
			
		||||
2025/07/28 17:13:00 [akrotiri/R2eLWiud8V-000002] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56646 - 200 3622B in 323.379916ms
 | 
			
		||||
{"time":"2025-07-28T17:13:00.934924+02:00","level":"INFO","msg":"Connected to RIS Live stream","source":"streamer.go:343","func":"streamer.(*Streamer).stream"}
 | 
			
		||||
2025/07/28 17:13:01 [akrotiri/R2eLWiud8V-000003] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56646 - 200 3655B in 340.457292ms
 | 
			
		||||
{"time":"2025-07-28T17:13:01.836921+02:00","level":"INFO","msg":"BGP session opened","source":"streamer.go:428","func":"streamer.(*Streamer).stream","peer":"193.107.13.3","peer_asn":"47787"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.283639+02:00","level":"ERROR","msg":"Database stats timeout","source":"handlers.go:248","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1"}
 | 
			
		||||
2025/07/28 17:13:02 [akrotiri/R2eLWiud8V-000004] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56646 - 408 17B in 1.000718708s
 | 
			
		||||
{"time":"2025-07-28T17:13:02.805274+02:00","level":"ERROR","msg":"Database stats timeout","source":"handlers.go:248","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1"}
 | 
			
		||||
2025/07/28 17:13:02 [akrotiri/R2eLWiud8V-000005] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56649 - 408 17B in 1.00114125s
 | 
			
		||||
{"time":"2025-07-28T17:13:02.87048+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":1587465542}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.871544+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":568061250}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.871628+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":587415667}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.871894+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 23634: database table is locked","count":483}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.883924+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":1079722417}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.928747+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":122623584}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.94791+02:00","level":"ERROR","msg":"Failed to process peer batch","source":"peerhandler.go:151","func":"routewatch.(*PeerHandler).flushBatchLocked","error":"failed to update peer 2001:7f8:24::8d: database table is locked","count":276}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.948072+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.948115+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.948089+02:00","level":"ERROR","msg":"Failed to get database stats","source":"handlers.go:253","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
2025/07/28 17:13:02 [akrotiri/R2eLWiud8V-000006] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 500 67B in 663.970667ms
 | 
			
		||||
{"time":"2025-07-28T17:13:02.948473+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":142440958}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.9485+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.948509+02:00","level":"ERROR","msg":"Failed to get database stats","source":"handlers.go:253","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
2025/07/28 17:13:02 [akrotiri/R2eLWiud8V-000007] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56651 - 500 67B in 645.064042ms
 | 
			
		||||
{"time":"2025-07-28T17:13:02.948853+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 49432: database table is locked","count":503}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.961369+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.987269+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.987286+02:00","level":"ERROR","msg":"Failed to get database stats","source":"handlers.go:253","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.987269+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:02.987299+02:00","level":"ERROR","msg":"Failed to get database stats","source":"handlers.go:253","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
2025/07/28 17:13:02 [akrotiri/R2eLWiud8V-000009] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56653 - 500 67B in 181.208959ms
 | 
			
		||||
2025/07/28 17:13:02 [akrotiri/R2eLWiud8V-000008] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56652 - 500 67B in 181.296125ms
 | 
			
		||||
{"time":"2025-07-28T17:13:03.124495+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1731,"success":1731,"duration_ms":1852}
 | 
			
		||||
{"time":"2025-07-28T17:13:03.178923+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1107,"success":1107,"duration_ms":54}
 | 
			
		||||
{"time":"2025-07-28T17:13:03.577435+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 138583: database table is locked","count":563}
 | 
			
		||||
2025/07/28 17:13:03 [akrotiri/R2eLWiud8V-000010] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3679B in 321.707916ms
 | 
			
		||||
{"time":"2025-07-28T17:13:03.931676+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":135058125}
 | 
			
		||||
{"time":"2025-07-28T17:13:03.944156+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 265315: database table is locked","count":486}
 | 
			
		||||
{"time":"2025-07-28T17:13:03.948721+02:00","level":"INFO","msg":"BGP session opened","source":"streamer.go:428","func":"streamer.(*Streamer).stream","peer":"196.60.8.170","peer_asn":"327781"}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.067453+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"DELETE FROM live_routes WHERE prefix = ? AND peer_ip = ?","duration":115385375}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.068433+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 3491: database table is locked","count":512}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.10943+02:00","level":"WARN","msg":"BGP notification","source":"streamer.go:436","func":"streamer.(*Streamer).stream","peer":"80.81.192.113","peer_asn":"35320"}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.159034+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"DELETE FROM live_routes WHERE prefix = ? AND peer_ip = ?","duration":51181709}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.159666+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5021,"unique_prefixes":1552,"success":1552,"duration_ms":649}
 | 
			
		||||
2025/07/28 17:13:04 [akrotiri/R2eLWiud8V-000011] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3685B in 449.669375ms
 | 
			
		||||
{"time":"2025-07-28T17:13:04.246417+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 141216: database table is locked","count":510}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.396504+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"SELECT COUNT(*) FROM asns","duration":94807500}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.397204+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 28329: database table is locked","count":500}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.416404+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.416423+02:00","level":"ERROR","msg":"Failed to get database stats","source":"handlers.go:253","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
2025/07/28 17:13:04 [akrotiri/R2eLWiud8V-000012] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 500 67B in 114.792667ms
 | 
			
		||||
{"time":"2025-07-28T17:13:04.419385+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5001,"unique_prefixes":1311,"success":1311,"duration_ms":259}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.427118+02:00","level":"ERROR","msg":"Failed to process peer batch","source":"peerhandler.go:151","func":"routewatch.(*PeerHandler).flushBatchLocked","error":"failed to update peer 2001:7f8:4::f2d7:1: database table is locked","count":558}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.640385+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 9002: database table is locked","count":588}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.643686+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1884,"success":1884,"duration_ms":224}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.796451+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 16276: database table is locked","count":472}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.79782+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5007,"unique_prefixes":1111,"success":1111,"duration_ms":153}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.825693+02:00","level":"DEBUG","msg":"Database stats query failed","source":"handlers.go:237","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
{"time":"2025-07-28T17:13:04.825713+02:00","level":"ERROR","msg":"Failed to get database stats","source":"handlers.go:253","func":"server.(*Server).setupRoutes.func1.(*Server).handleStats.1","error":"failed to count live routes: database table is locked: live_routes"}
 | 
			
		||||
2025/07/28 17:13:04 [akrotiri/R2eLWiud8V-000013] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 500 67B in 19.796125ms
 | 
			
		||||
{"time":"2025-07-28T17:13:05.014116+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 134484: database table is locked","count":576}
 | 
			
		||||
{"time":"2025-07-28T17:13:05.014915+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1797,"success":1797,"duration_ms":216}
 | 
			
		||||
{"time":"2025-07-28T17:13:05.179676+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5062,"unique_prefixes":1485,"success":1485,"duration_ms":164}
 | 
			
		||||
2025/07/28 17:13:05 [akrotiri/R2eLWiud8V-000014] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3780B in 320.984667ms
 | 
			
		||||
{"time":"2025-07-28T17:13:05.818964+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1357,"success":1357,"duration_ms":147}
 | 
			
		||||
{"time":"2025-07-28T17:13:06.16192+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"UPDATE asns SET last_seen = ? WHERE id = ?","duration":50454000}
 | 
			
		||||
2025/07/28 17:13:06 [akrotiri/R2eLWiud8V-000015] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3785B in 317.421541ms
 | 
			
		||||
{"time":"2025-07-28T17:13:06.459642+02:00","level":"DEBUG","msg":"Slow query","source":"slowquery.go:17","func":"database.logSlowQuery","query":"\n\t\tINSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop, \n\t\t\tlast_updated, v4_ip_start, v4_ip_end)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(prefix, origin_asn, peer_ip) DO UPDATE SET\n\t\t\tmask_length = excluded.mask_length,\n\t\t\tip_version = excluded.ip_version,\n\t\t\tas_path = excluded.as_path,\n\t\t\tnext_hop = excluded.next_hop,\n\t\t\tlast_updated = excluded.last_updated,\n\t\t\tv4_ip_start = excluded.v4_ip_start,\n\t\t\tv4_ip_end = excluded.v4_ip_end\n\t","duration":86616625}
 | 
			
		||||
{"time":"2025-07-28T17:13:06.45968+02:00","level":"ERROR","msg":"Failed to upsert route batch","source":"prefixhandler.go:206","func":"routewatch.(*PrefixHandler).flushBatchLocked","error":"failed to upsert route 14.232.144.0/20: database table is locked: live_routes","count":1582}
 | 
			
		||||
{"time":"2025-07-28T17:13:06.460464+02:00","level":"ERROR","msg":"Failed to delete route batch","source":"prefixhandler.go:214","func":"routewatch.(*PrefixHandler).flushBatchLocked","error":"failed to delete route 2a06:de02:5bb:0:0:0:0:0/48: database table is locked: live_routes","count":68}
 | 
			
		||||
{"time":"2025-07-28T17:13:06.460474+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1650,"success":0,"duration_ms":109}
 | 
			
		||||
{"time":"2025-07-28T17:13:06.549698+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 58453: database table is locked","count":508}
 | 
			
		||||
2025/07/28 17:13:06 [akrotiri/R2eLWiud8V-000016] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3785B in 319.24825ms
 | 
			
		||||
{"time":"2025-07-28T17:13:06.914088+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":2065,"success":2065,"duration_ms":213}
 | 
			
		||||
2025/07/28 17:13:07 [akrotiri/R2eLWiud8V-000017] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3787B in 326.68525ms
 | 
			
		||||
{"time":"2025-07-28T17:13:07.510509+02:00","level":"ERROR","msg":"Failed to upsert route batch","source":"prefixhandler.go:206","func":"routewatch.(*PrefixHandler).flushBatchLocked","error":"failed to upsert route 189.28.84.0/24: database table is locked: live_routes","count":1795}
 | 
			
		||||
{"time":"2025-07-28T17:13:07.511781+02:00","level":"ERROR","msg":"Failed to delete route batch","source":"prefixhandler.go:214","func":"routewatch.(*PrefixHandler).flushBatchLocked","error":"failed to delete route 2406:7f40:8300:0:0:0:0:0/40: database table is locked: live_routes","count":91}
 | 
			
		||||
{"time":"2025-07-28T17:13:07.511764+02:00","level":"ERROR","msg":"Failed to process ASN batch","source":"ashandler.go:149","func":"routewatch.(*ASHandler).flushBatchLocked","error":"failed to update ASN 45192: database table is locked","count":589}
 | 
			
		||||
{"time":"2025-07-28T17:13:07.5118+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5000,"unique_prefixes":1886,"success":0,"duration_ms":31}
 | 
			
		||||
2025/07/28 17:13:07 [akrotiri/R2eLWiud8V-000018] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3786B in 322.780666ms
 | 
			
		||||
{"time":"2025-07-28T17:13:08.182061+02:00","level":"WARN","msg":"BGP notification","source":"streamer.go:436","func":"streamer.(*Streamer).stream","peer":"193.239.118.249","peer_asn":"41255"}
 | 
			
		||||
{"time":"2025-07-28T17:13:08.200973+02:00","level":"ERROR","msg":"Failed to upsert route batch","source":"prefixhandler.go:206","func":"routewatch.(*PrefixHandler).flushBatchLocked","error":"failed to upsert route 158.172.251.0/24: database table is locked","count":1061}
 | 
			
		||||
2025/07/28 17:13:08 [akrotiri/R2eLWiud8V-000019] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3790B in 331.584125ms
 | 
			
		||||
{"time":"2025-07-28T17:13:08.201058+02:00","level":"ERROR","msg":"Failed to delete route batch","source":"prefixhandler.go:214","func":"routewatch.(*PrefixHandler).flushBatchLocked","error":"failed to delete route 2a06:de02:4a0:0:0:0:0:0/48: database table is locked","count":88}
 | 
			
		||||
{"time":"2025-07-28T17:13:08.201066+02:00","level":"DEBUG","msg":"Flushed prefix batch","source":"prefixhandler.go:221","func":"routewatch.(*PrefixHandler).flushBatchLocked","batch_size":5001,"unique_prefixes":1149,"success":0,"duration_ms":16}
 | 
			
		||||
2025/07/28 17:13:08 [akrotiri/R2eLWiud8V-000020] "GET http://127.0.0.1:8080/api/v1/stats HTTP/1.1" from 127.0.0.1:56650 - 200 3786B in 319.2075ms
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user