Compare commits

..

No commits in common. "6d46bbad5b112f8d283db143d3a563ea61e5a0f9" and "a78e5c6e922100af9d8812e2f6b6984077803208" have entirely different histories.

17 changed files with 271007 additions and 1760 deletions

View File

@ -21,7 +21,7 @@ clean:
rm -rf bin/ rm -rf bin/
run: build run: build
DEBUG=routewatch ./bin/routewatch 2>&1 | tee log.txt ./bin/routewatch
asupdate: asupdate:
@echo "Updating AS info data..." @echo "Updating AS info data..."

View File

@ -2,7 +2,6 @@
package database package database
import ( import (
"context"
"database/sql" "database/sql"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
@ -748,48 +747,41 @@ func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, ti
// GetStats returns database statistics // GetStats returns database statistics
func (d *Database) GetStats() (Stats, error) { func (d *Database) GetStats() (Stats, error) {
return d.GetStatsContext(context.Background())
}
// GetStatsContext returns database statistics with context support
func (d *Database) GetStatsContext(ctx context.Context) (Stats, error) {
var stats Stats var stats Stats
// Count ASNs // Count ASNs
err := d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM asns").Scan(&stats.ASNs) err := d.queryRow("SELECT COUNT(*) FROM asns").Scan(&stats.ASNs)
if err != nil { if err != nil {
return stats, err return stats, err
} }
// Count prefixes // Count prefixes
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM prefixes").Scan(&stats.Prefixes) err = d.queryRow("SELECT COUNT(*) FROM prefixes").Scan(&stats.Prefixes)
if err != nil { if err != nil {
return stats, err return stats, err
} }
// Count IPv4 and IPv6 prefixes // Count IPv4 and IPv6 prefixes
const ipVersionV4 = 4 const ipVersionV4 = 4
err = d.db.QueryRowContext(ctx, err = d.queryRow("SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV4).Scan(&stats.IPv4Prefixes)
"SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV4).Scan(&stats.IPv4Prefixes)
if err != nil { if err != nil {
return stats, err return stats, err
} }
const ipVersionV6 = 6 const ipVersionV6 = 6
err = d.db.QueryRowContext(ctx, err = d.queryRow("SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV6).Scan(&stats.IPv6Prefixes)
"SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV6).Scan(&stats.IPv6Prefixes)
if err != nil { if err != nil {
return stats, err return stats, err
} }
// Count peerings // Count peerings
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM peerings").Scan(&stats.Peerings) err = d.queryRow("SELECT COUNT(*) FROM peerings").Scan(&stats.Peerings)
if err != nil { if err != nil {
return stats, err return stats, err
} }
// Count peers // Count peers
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM bgp_peers").Scan(&stats.Peers) err = d.queryRow("SELECT COUNT(*) FROM bgp_peers").Scan(&stats.Peers)
if err != nil { if err != nil {
return stats, err return stats, err
} }
@ -804,13 +796,13 @@ func (d *Database) GetStatsContext(ctx context.Context) (Stats, error) {
} }
// Get live routes count // Get live routes count
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes").Scan(&stats.LiveRoutes) err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes").Scan(&stats.LiveRoutes)
if err != nil { if err != nil {
return stats, fmt.Errorf("failed to count live routes: %w", err) return stats, fmt.Errorf("failed to count live routes: %w", err)
} }
// Get prefix distribution // Get prefix distribution
stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistributionContext(ctx) stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistribution()
if err != nil { if err != nil {
// Log but don't fail // Log but don't fail
d.logger.Warn("Failed to get prefix distribution", "error", err) d.logger.Warn("Failed to get prefix distribution", "error", err)
@ -892,61 +884,47 @@ func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string)
return err return err
} }
// GetPrefixDistribution returns the distribution of unique prefixes by mask length // GetPrefixDistribution returns the distribution of prefixes by mask length
func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) { func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) {
return d.GetPrefixDistributionContext(context.Background()) // IPv4 distribution
}
// GetPrefixDistributionContext returns the distribution of unique prefixes by mask length with context support
func (d *Database) GetPrefixDistributionContext(ctx context.Context) (
ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) {
// IPv4 distribution - count unique prefixes, not routes
query := ` query := `
SELECT mask_length, COUNT(DISTINCT prefix) as count SELECT mask_length, COUNT(*) as count
FROM live_routes FROM live_routes
WHERE ip_version = 4 WHERE ip_version = 4
GROUP BY mask_length GROUP BY mask_length
ORDER BY mask_length ORDER BY mask_length
` `
rows4, err := d.db.QueryContext(ctx, query) rows, err := d.db.Query(query)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to query IPv4 distribution: %w", err) return nil, nil, fmt.Errorf("failed to query IPv4 distribution: %w", err)
} }
defer func() { defer func() { _ = rows.Close() }()
if rows4 != nil {
_ = rows4.Close()
}
}()
for rows4.Next() { for rows.Next() {
var dist PrefixDistribution var dist PrefixDistribution
if err := rows4.Scan(&dist.MaskLength, &dist.Count); err != nil { if err := rows.Scan(&dist.MaskLength, &dist.Count); err != nil {
return nil, nil, fmt.Errorf("failed to scan IPv4 distribution: %w", err) return nil, nil, fmt.Errorf("failed to scan IPv4 distribution: %w", err)
} }
ipv4 = append(ipv4, dist) ipv4 = append(ipv4, dist)
} }
// IPv6 distribution - count unique prefixes, not routes // IPv6 distribution
query = ` query = `
SELECT mask_length, COUNT(DISTINCT prefix) as count SELECT mask_length, COUNT(*) as count
FROM live_routes FROM live_routes
WHERE ip_version = 6 WHERE ip_version = 6
GROUP BY mask_length GROUP BY mask_length
ORDER BY mask_length ORDER BY mask_length
` `
rows6, err := d.db.QueryContext(ctx, query) rows, err = d.db.Query(query)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to query IPv6 distribution: %w", err) return nil, nil, fmt.Errorf("failed to query IPv6 distribution: %w", err)
} }
defer func() { defer func() { _ = rows.Close() }()
if rows6 != nil {
_ = rows6.Close()
}
}()
for rows6.Next() { for rows.Next() {
var dist PrefixDistribution var dist PrefixDistribution
if err := rows6.Scan(&dist.MaskLength, &dist.Count); err != nil { if err := rows.Scan(&dist.MaskLength, &dist.Count); err != nil {
return nil, nil, fmt.Errorf("failed to scan IPv6 distribution: %w", err) return nil, nil, fmt.Errorf("failed to scan IPv6 distribution: %w", err)
} }
ipv6 = append(ipv6, dist) ipv6 = append(ipv6, dist)
@ -957,19 +935,14 @@ func (d *Database) GetPrefixDistributionContext(ctx context.Context) (
// GetLiveRouteCounts returns the count of IPv4 and IPv6 routes // GetLiveRouteCounts returns the count of IPv4 and IPv6 routes
func (d *Database) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) { func (d *Database) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
return d.GetLiveRouteCountsContext(context.Background())
}
// GetLiveRouteCountsContext returns the count of IPv4 and IPv6 routes with context support
func (d *Database) GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error) {
// Get IPv4 count // Get IPv4 count
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes WHERE ip_version = 4").Scan(&ipv4Count) err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE ip_version = 4").Scan(&ipv4Count)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("failed to count IPv4 routes: %w", err) return 0, 0, fmt.Errorf("failed to count IPv4 routes: %w", err)
} }
// Get IPv6 count // Get IPv6 count
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes WHERE ip_version = 6").Scan(&ipv6Count) err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE ip_version = 6").Scan(&ipv6Count)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("failed to count IPv6 routes: %w", err) return 0, 0, fmt.Errorf("failed to count IPv6 routes: %w", err)
} }
@ -979,11 +952,6 @@ func (d *Database) GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ip
// GetASInfoForIP returns AS information for the given IP address // GetASInfoForIP returns AS information for the given IP address
func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
return d.GetASInfoForIPContext(context.Background(), ip)
}
// GetASInfoForIPContext returns AS information for the given IP address with context support
func (d *Database) GetASInfoForIPContext(ctx context.Context, ip string) (*ASInfo, error) {
// Parse the IP to validate it // Parse the IP to validate it
parsedIP := net.ParseIP(ip) parsedIP := net.ParseIP(ip)
if parsedIP == nil { if parsedIP == nil {
@ -1016,7 +984,7 @@ func (d *Database) GetASInfoForIPContext(ctx context.Context, ip string) (*ASInf
var lastUpdated time.Time var lastUpdated time.Time
var handle, description sql.NullString var handle, description sql.NullString
err := d.db.QueryRowContext(ctx, query, ipVersionV4, ipUint, ipUint).Scan( err := d.db.QueryRow(query, ipVersionV4, ipUint, ipUint).Scan(
&prefix, &maskLength, &originASN, &lastUpdated, &handle, &description) &prefix, &maskLength, &originASN, &lastUpdated, &handle, &description)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@ -1047,7 +1015,7 @@ func (d *Database) GetASInfoForIPContext(ctx context.Context, ip string) (*ASInf
ORDER BY lr.mask_length DESC ORDER BY lr.mask_length DESC
` `
rows, err := d.db.QueryContext(ctx, query, ipVersionV6) rows, err := d.db.Query(query, ipVersionV6)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query routes: %w", err) return nil, fmt.Errorf("failed to query routes: %w", err)
} }
@ -1150,16 +1118,11 @@ func CalculateIPv4Range(cidr string) (start, end uint32, err error) {
// GetASDetails returns detailed information about an AS including prefixes // GetASDetails returns detailed information about an AS including prefixes
func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) { func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
return d.GetASDetailsContext(context.Background(), asn)
}
// GetASDetailsContext returns detailed information about an AS including prefixes with context support
func (d *Database) GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error) {
// Get AS information // Get AS information
var asnInfo ASN var asnInfo ASN
var idStr string var idStr string
var handle, description sql.NullString var handle, description sql.NullString
err := d.db.QueryRowContext(ctx, err := d.db.QueryRow(
"SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?", "SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?",
asn, asn,
).Scan(&idStr, &asnInfo.Number, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen) ).Scan(&idStr, &asnInfo.Number, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen)
@ -1184,7 +1147,7 @@ func (d *Database) GetASDetailsContext(ctx context.Context, asn int) (*ASN, []Li
GROUP BY prefix, mask_length, ip_version GROUP BY prefix, mask_length, ip_version
` `
rows, err := d.db.QueryContext(ctx, query, asn) rows, err := d.db.Query(query, asn)
if err != nil { if err != nil {
return &asnInfo, nil, fmt.Errorf("failed to query prefixes: %w", err) return &asnInfo, nil, fmt.Errorf("failed to query prefixes: %w", err)
} }
@ -1220,11 +1183,6 @@ func (d *Database) GetASDetailsContext(ctx context.Context, asn int) (*ASN, []Li
// GetPrefixDetails returns detailed information about a prefix // GetPrefixDetails returns detailed information about a prefix
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) { func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
return d.GetPrefixDetailsContext(context.Background(), prefix)
}
// GetPrefixDetailsContext returns detailed information about a prefix with context support
func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error) {
query := ` query := `
SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, lr.last_updated, SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, lr.last_updated,
a.handle, a.description a.handle, a.description
@ -1234,7 +1192,7 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
ORDER BY lr.origin_asn, lr.peer_ip ORDER BY lr.origin_asn, lr.peer_ip
` `
rows, err := d.db.QueryContext(ctx, query, prefix) rows, err := d.db.Query(query, prefix)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query prefix details: %w", err) return nil, fmt.Errorf("failed to query prefix details: %w", err)
} }
@ -1269,64 +1227,3 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
return routes, nil return routes, nil
} }
// GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length
func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) {
return d.GetRandomPrefixesByLengthContext(context.Background(), maskLength, ipVersion, limit)
}
// GetRandomPrefixesByLengthContext returns a random sample of prefixes with context support
func (d *Database) GetRandomPrefixesByLengthContext(
ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) {
// Select unique prefixes with their most recent route information
query := `
WITH unique_prefixes AS (
SELECT prefix, MAX(last_updated) as max_updated
FROM live_routes
WHERE mask_length = ? AND ip_version = ?
GROUP BY prefix
ORDER BY RANDOM()
LIMIT ?
)
SELECT lr.prefix, lr.mask_length, lr.ip_version, lr.origin_asn, lr.as_path,
lr.peer_ip, lr.last_updated
FROM live_routes lr
INNER JOIN unique_prefixes up ON lr.prefix = up.prefix AND lr.last_updated = up.max_updated
WHERE lr.mask_length = ? AND lr.ip_version = ?
`
rows, err := d.db.QueryContext(ctx, query, maskLength, ipVersion, limit, maskLength, ipVersion)
if err != nil {
return nil, fmt.Errorf("failed to query random prefixes: %w", err)
}
defer func() {
_ = rows.Close()
}()
var routes []LiveRoute
for rows.Next() {
var route LiveRoute
var pathJSON string
err := rows.Scan(
&route.Prefix,
&route.MaskLength,
&route.IPVersion,
&route.OriginASN,
&pathJSON,
&route.PeerIP,
&route.LastUpdated,
)
if err != nil {
continue
}
// Decode AS path
if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil {
route.ASPath = []int{}
}
routes = append(routes, route)
}
return routes, nil
}

View File

@ -1,7 +1,6 @@
package database package database
import ( import (
"context"
"time" "time"
) )
@ -36,7 +35,6 @@ type Store interface {
// Statistics // Statistics
GetStats() (Stats, error) GetStats() (Stats, error)
GetStatsContext(ctx context.Context) (Stats, error)
// Peer operations // Peer operations
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
@ -48,21 +46,14 @@ type Store interface {
DeleteLiveRoute(prefix string, originASN int, peerIP string) error DeleteLiveRoute(prefix string, originASN int, peerIP string) error
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetPrefixDistributionContext(ctx context.Context) (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error)
// IP lookup operations // IP lookup operations
GetASInfoForIP(ip string) (*ASInfo, error) GetASInfoForIP(ip string) (*ASInfo, error)
GetASInfoForIPContext(ctx context.Context, ip string) (*ASInfo, error)
// AS and prefix detail operations // AS and prefix detail operations
GetASDetails(asn int) (*ASN, []LiveRoute, error) GetASDetails(asn int) (*ASN, []LiveRoute, error)
GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error)
GetPrefixDetails(prefix string) ([]LiveRoute, error) GetPrefixDetails(prefix string) ([]LiveRoute, error)
GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error)
// Lifecycle // Lifecycle
Close() error Close() error

View File

@ -92,5 +92,3 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_ip_version_mask ON live_routes(ip_ver
CREATE INDEX IF NOT EXISTS idx_live_routes_last_updated ON live_routes(last_updated); CREATE INDEX IF NOT EXISTS idx_live_routes_last_updated ON live_routes(last_updated);
-- Indexes for IPv4 range queries -- Indexes for IPv4 range queries
CREATE INDEX IF NOT EXISTS idx_live_routes_ipv4_range ON live_routes(v4_ip_start, v4_ip_end) WHERE ip_version = 4; CREATE INDEX IF NOT EXISTS idx_live_routes_ipv4_range ON live_routes(v4_ip_start, v4_ip_end) WHERE ip_version = 4;
-- Index to optimize COUNT(DISTINCT prefix) queries
CREATE INDEX IF NOT EXISTS idx_live_routes_ip_mask_prefix ON live_routes(ip_version, mask_length, prefix);

View File

@ -19,7 +19,6 @@ func logSlowQuery(logger *logger.Logger, query string, start time.Time) {
} }
// queryRow wraps QueryRow with slow query logging // queryRow wraps QueryRow with slow query logging
// nolint:unused // kept for consistency with other query wrappers
func (d *Database) queryRow(query string, args ...interface{}) *sql.Row { func (d *Database) queryRow(query string, args ...interface{}) *sql.Row {
start := time.Now() start := time.Now()
defer logSlowQuery(d.logger, query, start) defer logSlowQuery(d.logger, query, start)

View File

@ -163,11 +163,6 @@ func (m *mockStore) GetStats() (database.Stats, error) {
}, nil }, nil
} }
// GetStatsContext returns statistics about the mock store with context support
func (m *mockStore) GetStatsContext(ctx context.Context) (database.Stats, error) {
return m.GetStats()
}
// UpsertLiveRoute mock implementation // UpsertLiveRoute mock implementation
func (m *mockStore) UpsertLiveRoute(route *database.LiveRoute) error { func (m *mockStore) UpsertLiveRoute(route *database.LiveRoute) error {
// Simple mock - just return nil // Simple mock - just return nil
@ -186,22 +181,12 @@ func (m *mockStore) GetPrefixDistribution() (ipv4 []database.PrefixDistribution,
return nil, nil, nil return nil, nil, nil
} }
// GetPrefixDistributionContext mock implementation with context support
func (m *mockStore) GetPrefixDistributionContext(ctx context.Context) (ipv4 []database.PrefixDistribution, ipv6 []database.PrefixDistribution, err error) {
return m.GetPrefixDistribution()
}
// GetLiveRouteCounts mock implementation // GetLiveRouteCounts mock implementation
func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) { func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
// Return mock counts // Return mock counts
return m.RouteCount / 2, m.RouteCount / 2, nil return m.RouteCount / 2, m.RouteCount / 2, nil
} }
// GetLiveRouteCountsContext mock implementation with context support
func (m *mockStore) GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error) {
return m.GetLiveRouteCounts()
}
// GetASInfoForIP mock implementation // GetASInfoForIP mock implementation
func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) { func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
// Simple mock - return a test AS // Simple mock - return a test AS
@ -216,11 +201,6 @@ func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
}, nil }, nil
} }
// GetASInfoForIPContext mock implementation with context support
func (m *mockStore) GetASInfoForIPContext(ctx context.Context, ip string) (*database.ASInfo, error) {
return m.GetASInfoForIP(ip)
}
// GetASDetails mock implementation // GetASDetails mock implementation
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) { func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
m.mu.Lock() m.mu.Lock()
@ -235,32 +215,12 @@ func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute,
return nil, nil, database.ErrNoRoute return nil, nil, database.ErrNoRoute
} }
// GetASDetailsContext mock implementation with context support
func (m *mockStore) GetASDetailsContext(ctx context.Context, asn int) (*database.ASN, []database.LiveRoute, error) {
return m.GetASDetails(asn)
}
// GetPrefixDetails mock implementation // GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) { func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
// Return empty routes for now // Return empty routes for now
return []database.LiveRoute{}, nil return []database.LiveRoute{}, nil
} }
// GetPrefixDetailsContext mock implementation with context support
func (m *mockStore) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]database.LiveRoute, error) {
return m.GetPrefixDetails(prefix)
}
func (m *mockStore) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]database.LiveRoute, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
}
// GetRandomPrefixesByLengthContext mock implementation with context support
func (m *mockStore) GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]database.LiveRoute, error) {
return m.GetRandomPrefixesByLength(maskLength, ipVersion, limit)
}
// UpsertLiveRouteBatch mock implementation // UpsertLiveRouteBatch mock implementation
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error { func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
m.mu.Lock() m.mu.Lock()

View File

@ -11,14 +11,12 @@ import (
const ( const (
// asHandlerQueueSize is the queue capacity for ASN operations // asHandlerQueueSize is the queue capacity for ASN operations
// DO NOT set this higher than 100000 without explicit instructions
asHandlerQueueSize = 100000 asHandlerQueueSize = 100000
// asnBatchSize is the number of ASN operations to batch together // asnBatchSize is the number of ASN operations to batch together
asnBatchSize = 30000 asnBatchSize = 10000
// asnBatchTimeout is the maximum time to wait before flushing a batch // asnBatchTimeout is the maximum time to wait before flushing a batch
// DO NOT reduce this timeout - larger batches are more efficient
asnBatchTimeout = 2 * time.Second asnBatchTimeout = 2 * time.Second
) )

View File

@ -15,14 +15,12 @@ import (
const ( const (
// prefixHandlerQueueSize is the queue capacity for prefix tracking operations // prefixHandlerQueueSize is the queue capacity for prefix tracking operations
// DO NOT set this higher than 100000 without explicit instructions
prefixHandlerQueueSize = 100000 prefixHandlerQueueSize = 100000
// prefixBatchSize is the number of prefix updates to batch together // prefixBatchSize is the number of prefix updates to batch together
prefixBatchSize = 20000 prefixBatchSize = 5000
// prefixBatchTimeout is the maximum time to wait before flushing a batch // prefixBatchTimeout is the maximum time to wait before flushing a batch
// DO NOT reduce this timeout - larger batches are more efficient
prefixBatchTimeout = 1 * time.Second prefixBatchTimeout = 1 * time.Second
// IP version constants // IP version constants

View File

@ -15,7 +15,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/templates" "git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@ -91,7 +90,7 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
errChan := make(chan error) errChan := make(chan error)
go func() { go func() {
dbStats, err := s.db.GetStatsContext(ctx) dbStats, err := s.db.GetStats()
if err != nil { if err != nil {
s.logger.Debug("Database stats query failed", "error", err) s.logger.Debug("Database stats query failed", "error", err)
errChan <- err errChan <- err
@ -125,7 +124,7 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
const bitsPerMegabit = 1000000.0 const bitsPerMegabit = 1000000.0
// Get route counts from database // Get route counts from database
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCountsContext(ctx) ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
if err != nil { if err != nil {
s.logger.Warn("Failed to get live route counts", "error", err) s.logger.Warn("Failed to get live route counts", "error", err)
// Continue with zero counts // Continue with zero counts
@ -233,7 +232,7 @@ func (s *Server) handleStats() http.HandlerFunc {
errChan := make(chan error) errChan := make(chan error)
go func() { go func() {
dbStats, err := s.db.GetStatsContext(ctx) dbStats, err := s.db.GetStats()
if err != nil { if err != nil {
s.logger.Debug("Database stats query failed", "error", err) s.logger.Debug("Database stats query failed", "error", err)
errChan <- err errChan <- err
@ -247,7 +246,8 @@ func (s *Server) handleStats() http.HandlerFunc {
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Error("Database stats timeout") s.logger.Error("Database stats timeout")
// Don't write response here - timeout middleware already handles it http.Error(w, "Database timeout", http.StatusRequestTimeout)
return return
case err := <-errChan: case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err) s.logger.Error("Failed to get database stats", "error", err)
@ -266,7 +266,7 @@ func (s *Server) handleStats() http.HandlerFunc {
const bitsPerMegabit = 1000000.0 const bitsPerMegabit = 1000000.0
// Get route counts from database // Get route counts from database
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCountsContext(ctx) ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
if err != nil { if err != nil {
s.logger.Warn("Failed to get live route counts", "error", err) s.logger.Warn("Failed to get live route counts", "error", err)
// Continue with zero counts // Continue with zero counts
@ -353,7 +353,7 @@ func (s *Server) handleIPLookup() http.HandlerFunc {
} }
// Look up AS information for the IP // Look up AS information for the IP
asInfo, err := s.db.GetASInfoForIPContext(r.Context(), ip) asInfo, err := s.db.GetASInfoForIP(ip)
if err != nil { if err != nil {
// Check if it's an invalid IP error // Check if it's an invalid IP error
if errors.Is(err, database.ErrInvalidIP) { if errors.Is(err, database.ErrInvalidIP) {
@ -384,7 +384,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
return return
} }
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn) asInfo, prefixes, err := s.db.GetASDetails(asn)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error()) writeJSONError(w, http.StatusNotFound, err.Error())
@ -437,7 +437,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return return
} }
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetails(prefix)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error()) writeJSONError(w, http.StatusNotFound, err.Error())
@ -479,7 +479,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
return return
} }
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn) asInfo, prefixes, err := s.db.GetASDetails(asn)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "AS not found", http.StatusNotFound) http.Error(w, "AS not found", http.StatusNotFound)
@ -556,14 +556,6 @@ func (s *Server) handleASDetail() http.HandlerFunc {
IPv6Count: len(ipv6Prefixes), IPv6Count: len(ipv6Prefixes),
} }
// Check if context is still valid before writing response
select {
case <-r.Context().Done():
// Request was cancelled, don't write response
return
default:
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.ASDetailTemplate() tmpl := templates.ASDetailTemplate()
if err := tmpl.Execute(w, data); err != nil { if err := tmpl.Execute(w, data); err != nil {
@ -591,7 +583,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
return return
} }
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetails(prefix)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "Prefix not found", http.StatusNotFound) http.Error(w, "Prefix not found", http.StatusNotFound)
@ -614,7 +606,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
for _, route := range routes { for _, route := range routes {
if _, exists := originMap[route.OriginASN]; !exists { if _, exists := originMap[route.OriginASN]; !exists {
// Get AS info from database // Get AS info from database
asInfo, _, _ := s.db.GetASDetailsContext(r.Context(), route.OriginASN) asInfo, _, _ := s.db.GetASDetails(route.OriginASN)
handle := "" handle := ""
description := "" description := ""
if asInfo != nil { if asInfo != nil {
@ -653,41 +645,12 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
origins = append(origins, origin) origins = append(origins, origin)
} }
// Create enhanced routes with AS path handles
type ASPathEntry struct {
Number int
Handle string
}
type EnhancedRoute struct {
database.LiveRoute
ASPathWithHandle []ASPathEntry
}
enhancedRoutes := make([]EnhancedRoute, len(routes))
for i, route := range routes {
enhancedRoute := EnhancedRoute{
LiveRoute: route,
ASPathWithHandle: make([]ASPathEntry, len(route.ASPath)),
}
// Look up handle for each AS in the path
for j, asn := range route.ASPath {
handle := asinfo.GetHandle(asn)
enhancedRoute.ASPathWithHandle[j] = ASPathEntry{
Number: asn,
Handle: handle,
}
}
enhancedRoutes[i] = enhancedRoute
}
// Prepare template data // Prepare template data
data := struct { data := struct {
Prefix string Prefix string
MaskLength int MaskLength int
IPVersion int IPVersion int
Routes []EnhancedRoute Routes []database.LiveRoute
Origins []*ASNInfo Origins []*ASNInfo
PeerCount int PeerCount int
OriginCount int OriginCount int
@ -695,20 +658,12 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
Prefix: prefix, Prefix: prefix,
MaskLength: maskLength, MaskLength: maskLength,
IPVersion: ipVersion, IPVersion: ipVersion,
Routes: enhancedRoutes, Routes: routes,
Origins: origins, Origins: origins,
PeerCount: len(routes), PeerCount: len(routes),
OriginCount: len(originMap), OriginCount: len(originMap),
} }
// Check if context is still valid before writing response
select {
case <-r.Context().Done():
// Request was cancelled, don't write response
return
default:
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.PrefixDetailTemplate() tmpl := templates.PrefixDetailTemplate()
if err := tmpl.Execute(w, data); err != nil { if err := tmpl.Execute(w, data); err != nil {
@ -747,123 +702,3 @@ func (s *Server) handleIPRedirect() http.HandlerFunc {
http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther) http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther)
} }
} }
// handlePrefixLength shows a random sample of prefixes with the specified mask length
func (s *Server) handlePrefixLength() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lengthStr := chi.URLParam(r, "length")
if lengthStr == "" {
http.Error(w, "Length parameter is required", http.StatusBadRequest)
return
}
maskLength, err := strconv.Atoi(lengthStr)
if err != nil {
http.Error(w, "Invalid mask length", http.StatusBadRequest)
return
}
// Determine IP version based on mask length
const (
maxIPv4MaskLength = 32
maxIPv6MaskLength = 128
)
var ipVersion int
if maskLength <= maxIPv4MaskLength {
ipVersion = 4
} else if maskLength <= maxIPv6MaskLength {
ipVersion = 6
} else {
http.Error(w, "Invalid mask length", http.StatusBadRequest)
return
}
// Get random sample of prefixes
const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes)
if err != nil {
s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Sort prefixes for display
sort.Slice(prefixes, func(i, j int) bool {
// First compare by IP version
if prefixes[i].IPVersion != prefixes[j].IPVersion {
return prefixes[i].IPVersion < prefixes[j].IPVersion
}
// Then by prefix
return prefixes[i].Prefix < prefixes[j].Prefix
})
// Create enhanced prefixes with AS descriptions
type EnhancedPrefix struct {
database.LiveRoute
OriginASDescription string
Age string
}
enhancedPrefixes := make([]EnhancedPrefix, len(prefixes))
for i, prefix := range prefixes {
enhancedPrefixes[i] = EnhancedPrefix{
LiveRoute: prefix,
Age: formatAge(prefix.LastUpdated),
}
// Get AS description
if asInfo, ok := asinfo.Get(prefix.OriginASN); ok {
enhancedPrefixes[i].OriginASDescription = asInfo.Description
}
}
// Render template
data := map[string]interface{}{
"MaskLength": maskLength,
"IPVersion": ipVersion,
"Prefixes": enhancedPrefixes,
"Count": len(prefixes),
}
// Check if context is still valid before writing response
select {
case <-r.Context().Done():
// Request was cancelled, don't write response
return
default:
}
tmpl := templates.PrefixLengthTemplate()
if err := tmpl.Execute(w, data); err != nil {
s.logger.Error("Failed to render prefix length template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// formatAge returns a human-readable age string
func formatAge(timestamp time.Time) string {
age := time.Since(timestamp)
const hoursPerDay = 24
if age < time.Minute {
return "< 1m"
} else if age < time.Hour {
minutes := int(age.Minutes())
return strconv.Itoa(minutes) + "m"
} else if age < hoursPerDay*time.Hour {
hours := int(age.Hours())
return strconv.Itoa(hours) + "h"
}
days := int(age.Hours() / hoursPerDay)
return strconv.Itoa(days) + "d"
}

View File

@ -108,7 +108,6 @@ type timeoutWriter struct {
http.ResponseWriter http.ResponseWriter
mu sync.Mutex mu sync.Mutex
written bool written bool
header http.Header // cached header to prevent concurrent access
} }
func (tw *timeoutWriter) Write(b []byte) (int, error) { func (tw *timeoutWriter) Write(b []byte) (int, error) {
@ -134,18 +133,6 @@ func (tw *timeoutWriter) WriteHeader(statusCode int) {
} }
func (tw *timeoutWriter) Header() http.Header { func (tw *timeoutWriter) Header() http.Header {
tw.mu.Lock()
defer tw.mu.Unlock()
if tw.written {
// Return a copy to prevent modifications after timeout
if tw.header == nil {
tw.header = make(http.Header)
}
return tw.header
}
return tw.ResponseWriter.Header() return tw.ResponseWriter.Header()
} }
@ -166,7 +153,6 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
tw := &timeoutWriter{ tw := &timeoutWriter{
ResponseWriter: w, ResponseWriter: w,
header: make(http.Header),
} }
done := make(chan struct{}) done := make(chan struct{})
@ -192,12 +178,8 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
tw.markWritten() // Prevent the handler from writing after timeout tw.markWritten() // Prevent the handler from writing after timeout
execTime := time.Since(startTime) execTime := time.Since(startTime)
// Write directly to the underlying writer since we've marked tw as written
// This is safe because markWritten() prevents the handler from writing
tw.mu.Lock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestTimeout) w.WriteHeader(http.StatusRequestTimeout)
tw.mu.Unlock()
response := map[string]interface{}{ response := map[string]interface{}{
"status": "error", "status": "error",

View File

@ -28,7 +28,6 @@ func (s *Server) setupRoutes() {
// AS and prefix detail pages // AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail()) r.Get("/as/{asn}", s.handleASDetail())
r.Get("/prefix/{prefix}", s.handlePrefixDetail()) r.Get("/prefix/{prefix}", s.handlePrefixDetail())
r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/ip/{ip}", s.handleIPRedirect()) r.Get("/ip/{ip}", s.handleIPRedirect())
// API routes // API routes

View File

@ -7,8 +7,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"net/http" "net/http"
"os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -19,12 +19,9 @@ import (
) )
const ( const (
risLiveURL = "https://ris-live.ripe.net/v1/stream/?format=json&" + risLiveURL = "https://ris-live.ripe.net/v1/stream/?format=json"
"client=https%3A%2F%2Fgit.eeqj.de%2Fsneak%2Froutewatch"
metricsWindowSize = 60 // seconds for rolling average metricsWindowSize = 60 // seconds for rolling average
metricsUpdateRate = time.Second metricsUpdateRate = time.Second
minBackoffDelay = 5 * time.Second
maxBackoffDelay = 320 * time.Second
metricsLogInterval = 10 * time.Second metricsLogInterval = 10 * time.Second
bytesPerKB = 1024 bytesPerKB = 1024
bytesPerMB = 1024 * 1024 bytesPerMB = 1024 * 1024
@ -98,9 +95,6 @@ func (s *Streamer) RegisterHandler(handler MessageHandler) {
info := &handlerInfo{ info := &handlerInfo{
handler: handler, handler: handler,
queue: make(chan *ristypes.RISMessage, handler.QueueCapacity()), queue: make(chan *ristypes.RISMessage, handler.QueueCapacity()),
metrics: handlerMetrics{
minTime: time.Duration(math.MaxInt64), // Initialize to max so first value sets the floor
},
} }
s.handlers = append(s.handlers, info) s.handlers = append(s.handlers, info)
@ -137,7 +131,9 @@ func (s *Streamer) Start() error {
} }
go func() { go func() {
s.streamWithReconnect(ctx) if err := s.stream(ctx); err != nil {
s.logger.Error("Streaming error", "error", err)
}
s.mu.Lock() s.mu.Lock()
s.running = false s.running = false
s.mu.Unlock() s.mu.Unlock()
@ -174,7 +170,7 @@ func (s *Streamer) runHandlerWorker(info *handlerInfo) {
info.metrics.totalTime += elapsed info.metrics.totalTime += elapsed
// Update min time // Update min time
if elapsed < info.metrics.minTime { if info.metrics.minTime == 0 || elapsed < info.metrics.minTime {
info.metrics.minTime = elapsed info.metrics.minTime = elapsed
} }
@ -324,72 +320,6 @@ func (s *Streamer) updateMetrics(messageBytes int) {
s.metrics.RecordMessage(int64(messageBytes)) s.metrics.RecordMessage(int64(messageBytes))
} }
// streamWithReconnect handles streaming with automatic reconnection and exponential backoff
func (s *Streamer) streamWithReconnect(ctx context.Context) {
backoffDelay := minBackoffDelay
consecutiveFailures := 0
for {
select {
case <-ctx.Done():
s.logger.Info("Stream context cancelled, stopping reconnection attempts")
return
default:
}
// Attempt to stream
startTime := time.Now()
err := s.stream(ctx)
streamDuration := time.Since(startTime)
if err == nil {
// Clean exit (context cancelled)
return
}
// Log the error
s.logger.Error("Stream disconnected",
"error", err,
"consecutive_failures", consecutiveFailures+1,
"stream_duration", streamDuration)
s.metrics.SetConnected(false)
// Check if context is cancelled
if ctx.Err() != nil {
return
}
// If we streamed for more than 30 seconds, reset the backoff
// This indicates we had a successful connection that received data
if streamDuration > 30*time.Second {
s.logger.Info("Resetting backoff delay due to successful connection",
"stream_duration", streamDuration)
backoffDelay = minBackoffDelay
consecutiveFailures = 0
} else {
// Increment consecutive failures
consecutiveFailures++
}
// Wait with exponential backoff
s.logger.Info("Waiting before reconnection attempt",
"delay_seconds", backoffDelay.Seconds(),
"consecutive_failures", consecutiveFailures)
select {
case <-ctx.Done():
return
case <-time.After(backoffDelay):
// Double the backoff delay for next time, up to max
backoffDelay *= 2
if backoffDelay > maxBackoffDelay {
backoffDelay = maxBackoffDelay
}
}
}
}
func (s *Streamer) stream(ctx context.Context) error { func (s *Streamer) stream(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", risLiveURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", risLiveURL, nil)
if err != nil { if err != nil {
@ -460,13 +390,10 @@ func (s *Streamer) stream(ctx context.Context) error {
// Parse the message first // Parse the message first
var wrapper ristypes.RISLiveMessage var wrapper ristypes.RISLiveMessage
if err := json.Unmarshal(line, &wrapper); err != nil { if err := json.Unmarshal(line, &wrapper); err != nil {
// Log the error and return to trigger reconnection // Output the raw line and panic on parse failure
s.logger.Error("Failed to parse JSON", fmt.Fprintf(os.Stderr, "Failed to parse JSON: %v\n", err)
"error", err, fmt.Fprintf(os.Stderr, "Raw line: %s\n", string(line))
"line", string(line), panic(fmt.Sprintf("JSON parse error: %v", err))
"line_length", len(line))
return fmt.Errorf("JSON parse error: %w", err)
} }
// Check if it's a ris_message wrapper // Check if it's a ris_message wrapper
@ -516,11 +443,18 @@ func (s *Streamer) stream(ctx context.Context) error {
// Peer state changes - silently ignore // Peer state changes - silently ignore
continue continue
default: default:
s.logger.Error("Unknown message type", fmt.Fprintf(
"type", msg.Type, os.Stderr,
"line", string(line), "UNKNOWN MESSAGE TYPE: %s\nRAW MESSAGE: %s\n",
msg.Type,
string(line),
)
panic(
fmt.Sprintf(
"Unknown RIS message type: %s",
msg.Type,
),
) )
panic(fmt.Sprintf("Unknown RIS message type: %s", msg.Type))
} }
// Dispatch to interested handlers // Dispatch to interested handlers

View File

@ -13,8 +13,7 @@
color: #333; color: #333;
} }
.container { .container {
width: 90%; max-width: 1200px;
max-width: 1600px;
margin: 0 auto; margin: 0 auto;
background: white; background: white;
padding: 30px; padding: 30px;
@ -92,7 +91,6 @@
.route-table td { .route-table td {
padding: 12px; padding: 12px;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
white-space: nowrap;
} }
.route-table tr:hover { .route-table tr:hover {
background: #f8f9fa; background: #f8f9fa;
@ -116,13 +114,9 @@
font-family: monospace; font-family: monospace;
font-size: 13px; font-size: 13px;
color: #666; color: #666;
max-width: 600px; max-width: 300px;
word-wrap: break-word; overflow-x: auto;
white-space: normal !important; white-space: nowrap;
line-height: 1.5;
}
.as-path .as-link {
font-weight: 600;
} }
.age { .age {
color: #7f8c8d; color: #7f8c8d;
@ -174,7 +168,7 @@
font-size: 14px; font-size: 14px;
} }
.as-path { .as-path {
max-width: 100%; max-width: 150px;
} }
} }
</style> </style>
@ -240,7 +234,7 @@
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a> <a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
</td> </td>
<td class="peer-ip">{{.PeerIP}}</td> <td class="peer-ip">{{.PeerIP}}</td>
<td class="as-path">{{range $i, $as := .ASPathWithHandle}}{{if $i}} → {{end}}<a href="/as/{{$as.Number}}" class="as-link">{{if $as.Handle}}{{$as.Handle}}{{else}}AS{{$as.Number}}{{end}}</a>{{end}}</td> <td class="as-path">{{range $i, $as := .ASPath}}{{if $i}} → {{end}}{{$as}}{{end}}</td>
<td class="peer-ip">{{.NextHop}}</td> <td class="peer-ip">{{.NextHop}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td> <td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td> <td class="age">{{.LastUpdated | timeSince}}</td>

View File

@ -1,108 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prefixes with /{{ .MaskLength }} - RouteWatch</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
.info-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #dee2e6;
}
td {
padding: 12px;
border-bottom: 1px solid #eee;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: #f8f9fa;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.prefix-link {
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.as-link {
white-space: nowrap;
}
.age {
color: #666;
font-size: 0.9em;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #0066cc;
}
</style>
</head>
<body>
<a href="/status" class="back-link">← Back to Status</a>
<h1>IPv{{ .IPVersion }} Prefixes with /{{ .MaskLength }}</h1>
<p class="subtitle">Showing {{ .Count }} randomly selected prefixes</p>
<table>
<thead>
<tr>
<th>Prefix</th>
<th>Age</th>
<th>Origin AS</th>
</tr>
</thead>
<tbody>
{{ range .Prefixes }}
<tr>
<td><a href="/prefix/{{ .Prefix | urlEncode }}" class="prefix-link">{{ .Prefix }}</a></td>
<td class="age">{{ .Age }}</td>
<td>
<a href="/as/{{ .OriginASN }}" class="as-link">
AS{{ .OriginASN }}{{ if .OriginASDescription }} ({{ .OriginASDescription }}){{ end }}
</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</body>
</html>

View File

@ -49,16 +49,6 @@
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace; font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
color: #333; color: #333;
} }
.metric-value.metric-link {
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
cursor: pointer;
}
.metric-value.metric-link:hover {
color: #0066cc;
text-decoration-style: solid;
}
.connected { .connected {
color: #22c55e; color: #22c55e;
} }
@ -241,7 +231,7 @@
metric.className = 'metric'; metric.className = 'metric';
metric.innerHTML = ` metric.innerHTML = `
<span class="metric-label">/${item.mask_length}</span> <span class="metric-label">/${item.mask_length}</span>
<a href="/prefixlength/${item.mask_length}" class="metric-value metric-link">${formatNumber(item.count)}</a> <span class="metric-value">${formatNumber(item.count)}</span>
`; `;
container.appendChild(metric); container.appendChild(metric);
}); });

View File

@ -18,15 +18,11 @@ var asDetailHTML string
//go:embed prefix_detail.html //go:embed prefix_detail.html
var prefixDetailHTML string var prefixDetailHTML string
//go:embed prefix_length.html
var prefixLengthHTML string
// Templates contains all parsed templates // Templates contains all parsed templates
type Templates struct { type Templates struct {
Status *template.Template Status *template.Template
ASDetail *template.Template ASDetail *template.Template
PrefixDetail *template.Template PrefixDetail *template.Template
PrefixLength *template.Template
} }
var ( var (
@ -103,12 +99,6 @@ func initTemplates() {
if err != nil { if err != nil {
panic("failed to parse prefix detail template: " + err.Error()) panic("failed to parse prefix detail template: " + err.Error())
} }
// Parse prefix length template
defaultTemplates.PrefixLength, err = template.New("prefixLength").Funcs(funcs).Parse(prefixLengthHTML)
if err != nil {
panic("failed to parse prefix length template: " + err.Error())
}
} }
// Get returns the singleton Templates instance // Get returns the singleton Templates instance
@ -132,8 +122,3 @@ func ASDetailTemplate() *template.Template {
func PrefixDetailTemplate() *template.Template { func PrefixDetailTemplate() *template.Template {
return Get().PrefixDetail return Get().PrefixDetail
} }
// PrefixLengthTemplate returns the parsed prefix length template
func PrefixLengthTemplate() *template.Template {
return Get().PrefixLength
}

272071
log.txt

File diff suppressed because it is too large Load Diff