Compare commits
12 Commits
a78e5c6e92
...
6d46bbad5b
Author | SHA1 | Date | |
---|---|---|---|
6d46bbad5b | |||
9518519208 | |||
7d39bd18bc | |||
e0a4c8642e | |||
0196251906 | |||
62ed5e08aa | |||
5fb3fc0381 | |||
9a63553f8d | |||
ba13c76c53 | |||
1dcde74a90 | |||
81267431f7 | |||
dc3ceb8d94 |
2
Makefile
2
Makefile
@ -21,7 +21,7 @@ clean:
|
||||
rm -rf bin/
|
||||
|
||||
run: build
|
||||
./bin/routewatch
|
||||
DEBUG=routewatch ./bin/routewatch 2>&1 | tee log.txt
|
||||
|
||||
asupdate:
|
||||
@echo "Updating AS info data..."
|
||||
|
@ -2,6 +2,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
@ -747,41 +748,48 @@ func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, ti
|
||||
|
||||
// GetStats returns database statistics
|
||||
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
|
||||
|
||||
// Count ASNs
|
||||
err := d.queryRow("SELECT COUNT(*) FROM asns").Scan(&stats.ASNs)
|
||||
err := d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM asns").Scan(&stats.ASNs)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// Count prefixes
|
||||
err = d.queryRow("SELECT COUNT(*) FROM prefixes").Scan(&stats.Prefixes)
|
||||
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM prefixes").Scan(&stats.Prefixes)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// Count IPv4 and IPv6 prefixes
|
||||
const ipVersionV4 = 4
|
||||
err = d.queryRow("SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV4).Scan(&stats.IPv4Prefixes)
|
||||
err = d.db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV4).Scan(&stats.IPv4Prefixes)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
const ipVersionV6 = 6
|
||||
err = d.queryRow("SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV6).Scan(&stats.IPv6Prefixes)
|
||||
err = d.db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV6).Scan(&stats.IPv6Prefixes)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// Count peerings
|
||||
err = d.queryRow("SELECT COUNT(*) FROM peerings").Scan(&stats.Peerings)
|
||||
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM peerings").Scan(&stats.Peerings)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// Count peers
|
||||
err = d.queryRow("SELECT COUNT(*) FROM bgp_peers").Scan(&stats.Peers)
|
||||
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM bgp_peers").Scan(&stats.Peers)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
@ -796,13 +804,13 @@ func (d *Database) GetStats() (Stats, error) {
|
||||
}
|
||||
|
||||
// Get live routes count
|
||||
err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes").Scan(&stats.LiveRoutes)
|
||||
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes").Scan(&stats.LiveRoutes)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("failed to count live routes: %w", err)
|
||||
}
|
||||
|
||||
// Get prefix distribution
|
||||
stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistribution()
|
||||
stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistributionContext(ctx)
|
||||
if err != nil {
|
||||
// Log but don't fail
|
||||
d.logger.Warn("Failed to get prefix distribution", "error", err)
|
||||
@ -884,47 +892,61 @@ func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPrefixDistribution returns the distribution of prefixes by mask length
|
||||
// GetPrefixDistribution returns the distribution of unique prefixes by mask length
|
||||
func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) {
|
||||
// IPv4 distribution
|
||||
return d.GetPrefixDistributionContext(context.Background())
|
||||
}
|
||||
|
||||
// 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 := `
|
||||
SELECT mask_length, COUNT(*) as count
|
||||
SELECT mask_length, COUNT(DISTINCT prefix) as count
|
||||
FROM live_routes
|
||||
WHERE ip_version = 4
|
||||
GROUP BY mask_length
|
||||
ORDER BY mask_length
|
||||
`
|
||||
rows, err := d.db.Query(query)
|
||||
rows4, err := d.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to query IPv4 distribution: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer func() {
|
||||
if rows4 != nil {
|
||||
_ = rows4.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for rows.Next() {
|
||||
for rows4.Next() {
|
||||
var dist PrefixDistribution
|
||||
if err := rows.Scan(&dist.MaskLength, &dist.Count); err != nil {
|
||||
if err := rows4.Scan(&dist.MaskLength, &dist.Count); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan IPv4 distribution: %w", err)
|
||||
}
|
||||
ipv4 = append(ipv4, dist)
|
||||
}
|
||||
|
||||
// IPv6 distribution
|
||||
// IPv6 distribution - count unique prefixes, not routes
|
||||
query = `
|
||||
SELECT mask_length, COUNT(*) as count
|
||||
SELECT mask_length, COUNT(DISTINCT prefix) as count
|
||||
FROM live_routes
|
||||
WHERE ip_version = 6
|
||||
GROUP BY mask_length
|
||||
ORDER BY mask_length
|
||||
`
|
||||
rows, err = d.db.Query(query)
|
||||
rows6, err := d.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to query IPv6 distribution: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer func() {
|
||||
if rows6 != nil {
|
||||
_ = rows6.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for rows.Next() {
|
||||
for rows6.Next() {
|
||||
var dist PrefixDistribution
|
||||
if err := rows.Scan(&dist.MaskLength, &dist.Count); err != nil {
|
||||
if err := rows6.Scan(&dist.MaskLength, &dist.Count); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to scan IPv6 distribution: %w", err)
|
||||
}
|
||||
ipv6 = append(ipv6, dist)
|
||||
@ -935,14 +957,19 @@ func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []Pr
|
||||
|
||||
// GetLiveRouteCounts returns the count of IPv4 and IPv6 routes
|
||||
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
|
||||
err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE ip_version = 4").Scan(&ipv4Count)
|
||||
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes WHERE ip_version = 4").Scan(&ipv4Count)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to count IPv4 routes: %w", err)
|
||||
}
|
||||
|
||||
// Get IPv6 count
|
||||
err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE ip_version = 6").Scan(&ipv6Count)
|
||||
err = d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM live_routes WHERE ip_version = 6").Scan(&ipv6Count)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to count IPv6 routes: %w", err)
|
||||
}
|
||||
@ -952,6 +979,11 @@ func (d *Database) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
|
||||
|
||||
// GetASInfoForIP returns AS information for the given IP address
|
||||
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
|
||||
parsedIP := net.ParseIP(ip)
|
||||
if parsedIP == nil {
|
||||
@ -984,7 +1016,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
|
||||
var lastUpdated time.Time
|
||||
var handle, description sql.NullString
|
||||
|
||||
err := d.db.QueryRow(query, ipVersionV4, ipUint, ipUint).Scan(
|
||||
err := d.db.QueryRowContext(ctx, query, ipVersionV4, ipUint, ipUint).Scan(
|
||||
&prefix, &maskLength, &originASN, &lastUpdated, &handle, &description)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@ -1015,7 +1047,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
|
||||
ORDER BY lr.mask_length DESC
|
||||
`
|
||||
|
||||
rows, err := d.db.Query(query, ipVersionV6)
|
||||
rows, err := d.db.QueryContext(ctx, query, ipVersionV6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query routes: %w", err)
|
||||
}
|
||||
@ -1118,11 +1150,16 @@ func CalculateIPv4Range(cidr string) (start, end uint32, err error) {
|
||||
|
||||
// GetASDetails returns detailed information about an AS including prefixes
|
||||
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
|
||||
var asnInfo ASN
|
||||
var idStr string
|
||||
var handle, description sql.NullString
|
||||
err := d.db.QueryRow(
|
||||
err := d.db.QueryRowContext(ctx,
|
||||
"SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?",
|
||||
asn,
|
||||
).Scan(&idStr, &asnInfo.Number, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen)
|
||||
@ -1147,7 +1184,7 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
|
||||
GROUP BY prefix, mask_length, ip_version
|
||||
`
|
||||
|
||||
rows, err := d.db.Query(query, asn)
|
||||
rows, err := d.db.QueryContext(ctx, query, asn)
|
||||
if err != nil {
|
||||
return &asnInfo, nil, fmt.Errorf("failed to query prefixes: %w", err)
|
||||
}
|
||||
@ -1183,6 +1220,11 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
|
||||
|
||||
// GetPrefixDetails returns detailed information about a prefix
|
||||
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 := `
|
||||
SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, lr.last_updated,
|
||||
a.handle, a.description
|
||||
@ -1192,7 +1234,7 @@ func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
|
||||
ORDER BY lr.origin_asn, lr.peer_ip
|
||||
`
|
||||
|
||||
rows, err := d.db.Query(query, prefix)
|
||||
rows, err := d.db.QueryContext(ctx, query, prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query prefix details: %w", err)
|
||||
}
|
||||
@ -1227,3 +1269,64 @@ func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -35,6 +36,7 @@ type Store interface {
|
||||
|
||||
// Statistics
|
||||
GetStats() (Stats, error)
|
||||
GetStatsContext(ctx context.Context) (Stats, error)
|
||||
|
||||
// Peer operations
|
||||
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
|
||||
@ -46,14 +48,21 @@ type Store interface {
|
||||
DeleteLiveRoute(prefix string, originASN int, peerIP string) error
|
||||
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
|
||||
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
|
||||
GetPrefixDistributionContext(ctx context.Context) (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
|
||||
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
|
||||
GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error)
|
||||
|
||||
// IP lookup operations
|
||||
GetASInfoForIP(ip string) (*ASInfo, error)
|
||||
GetASInfoForIPContext(ctx context.Context, ip string) (*ASInfo, error)
|
||||
|
||||
// AS and prefix detail operations
|
||||
GetASDetails(asn int) (*ASN, []LiveRoute, error)
|
||||
GetASDetailsContext(ctx context.Context, asn int) (*ASN, []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
|
||||
Close() error
|
||||
|
@ -91,4 +91,6 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_mask_length ON live_routes(mask_lengt
|
||||
CREATE INDEX IF NOT EXISTS idx_live_routes_ip_version_mask ON live_routes(ip_version, mask_length);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_routes_last_updated ON live_routes(last_updated);
|
||||
-- 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);
|
@ -19,6 +19,7 @@ func logSlowQuery(logger *logger.Logger, query string, start time.Time) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
start := time.Now()
|
||||
defer logSlowQuery(d.logger, query, start)
|
||||
|
@ -163,6 +163,11 @@ func (m *mockStore) GetStats() (database.Stats, error) {
|
||||
}, 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
|
||||
func (m *mockStore) UpsertLiveRoute(route *database.LiveRoute) error {
|
||||
// Simple mock - just return nil
|
||||
@ -181,12 +186,22 @@ func (m *mockStore) GetPrefixDistribution() (ipv4 []database.PrefixDistribution,
|
||||
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
|
||||
func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
|
||||
// Return mock counts
|
||||
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
|
||||
func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
|
||||
// Simple mock - return a test AS
|
||||
@ -201,6 +216,11 @@ func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
|
||||
}, 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
|
||||
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
|
||||
m.mu.Lock()
|
||||
@ -215,12 +235,32 @@ func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute,
|
||||
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
|
||||
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
|
||||
// Return empty routes for now
|
||||
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
|
||||
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
|
||||
m.mu.Lock()
|
||||
|
@ -11,12 +11,14 @@ import (
|
||||
|
||||
const (
|
||||
// asHandlerQueueSize is the queue capacity for ASN operations
|
||||
// DO NOT set this higher than 100000 without explicit instructions
|
||||
asHandlerQueueSize = 100000
|
||||
|
||||
// asnBatchSize is the number of ASN operations to batch together
|
||||
asnBatchSize = 10000
|
||||
asnBatchSize = 30000
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
|
@ -15,12 +15,14 @@ import (
|
||||
|
||||
const (
|
||||
// prefixHandlerQueueSize is the queue capacity for prefix tracking operations
|
||||
// DO NOT set this higher than 100000 without explicit instructions
|
||||
prefixHandlerQueueSize = 100000
|
||||
|
||||
// prefixBatchSize is the number of prefix updates to batch together
|
||||
prefixBatchSize = 5000
|
||||
prefixBatchSize = 20000
|
||||
|
||||
// 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
|
||||
|
||||
// IP version constants
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@ -90,7 +91,7 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
||||
errChan := make(chan error)
|
||||
|
||||
go func() {
|
||||
dbStats, err := s.db.GetStats()
|
||||
dbStats, err := s.db.GetStatsContext(ctx)
|
||||
if err != nil {
|
||||
s.logger.Debug("Database stats query failed", "error", err)
|
||||
errChan <- err
|
||||
@ -124,7 +125,7 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
|
||||
const bitsPerMegabit = 1000000.0
|
||||
|
||||
// Get route counts from database
|
||||
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
|
||||
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCountsContext(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get live route counts", "error", err)
|
||||
// Continue with zero counts
|
||||
@ -232,7 +233,7 @@ func (s *Server) handleStats() http.HandlerFunc {
|
||||
errChan := make(chan error)
|
||||
|
||||
go func() {
|
||||
dbStats, err := s.db.GetStats()
|
||||
dbStats, err := s.db.GetStatsContext(ctx)
|
||||
if err != nil {
|
||||
s.logger.Debug("Database stats query failed", "error", err)
|
||||
errChan <- err
|
||||
@ -246,8 +247,7 @@ func (s *Server) handleStats() http.HandlerFunc {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Error("Database stats timeout")
|
||||
http.Error(w, "Database timeout", http.StatusRequestTimeout)
|
||||
|
||||
// Don't write response here - timeout middleware already handles it
|
||||
return
|
||||
case err := <-errChan:
|
||||
s.logger.Error("Failed to get database stats", "error", err)
|
||||
@ -266,7 +266,7 @@ func (s *Server) handleStats() http.HandlerFunc {
|
||||
const bitsPerMegabit = 1000000.0
|
||||
|
||||
// Get route counts from database
|
||||
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
|
||||
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCountsContext(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get live route counts", "error", err)
|
||||
// Continue with zero counts
|
||||
@ -353,7 +353,7 @@ func (s *Server) handleIPLookup() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Look up AS information for the IP
|
||||
asInfo, err := s.db.GetASInfoForIP(ip)
|
||||
asInfo, err := s.db.GetASInfoForIPContext(r.Context(), ip)
|
||||
if err != nil {
|
||||
// Check if it's an invalid IP error
|
||||
if errors.Is(err, database.ErrInvalidIP) {
|
||||
@ -384,7 +384,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
asInfo, prefixes, err := s.db.GetASDetails(asn)
|
||||
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNoRoute) {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
@ -437,7 +437,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := s.db.GetPrefixDetails(prefix)
|
||||
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNoRoute) {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
@ -479,7 +479,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
asInfo, prefixes, err := s.db.GetASDetails(asn)
|
||||
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNoRoute) {
|
||||
http.Error(w, "AS not found", http.StatusNotFound)
|
||||
@ -556,6 +556,14 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
||||
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")
|
||||
tmpl := templates.ASDetailTemplate()
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
@ -583,7 +591,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := s.db.GetPrefixDetails(prefix)
|
||||
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNoRoute) {
|
||||
http.Error(w, "Prefix not found", http.StatusNotFound)
|
||||
@ -606,7 +614,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
for _, route := range routes {
|
||||
if _, exists := originMap[route.OriginASN]; !exists {
|
||||
// Get AS info from database
|
||||
asInfo, _, _ := s.db.GetASDetails(route.OriginASN)
|
||||
asInfo, _, _ := s.db.GetASDetailsContext(r.Context(), route.OriginASN)
|
||||
handle := ""
|
||||
description := ""
|
||||
if asInfo != nil {
|
||||
@ -645,12 +653,41 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
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
|
||||
data := struct {
|
||||
Prefix string
|
||||
MaskLength int
|
||||
IPVersion int
|
||||
Routes []database.LiveRoute
|
||||
Routes []EnhancedRoute
|
||||
Origins []*ASNInfo
|
||||
PeerCount int
|
||||
OriginCount int
|
||||
@ -658,12 +695,20 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
Prefix: prefix,
|
||||
MaskLength: maskLength,
|
||||
IPVersion: ipVersion,
|
||||
Routes: routes,
|
||||
Routes: enhancedRoutes,
|
||||
Origins: origins,
|
||||
PeerCount: len(routes),
|
||||
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")
|
||||
tmpl := templates.PrefixDetailTemplate()
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
@ -702,3 +747,123 @@ func (s *Server) handleIPRedirect() http.HandlerFunc {
|
||||
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"
|
||||
}
|
||||
|
@ -108,6 +108,7 @@ type timeoutWriter struct {
|
||||
http.ResponseWriter
|
||||
mu sync.Mutex
|
||||
written bool
|
||||
header http.Header // cached header to prevent concurrent access
|
||||
}
|
||||
|
||||
func (tw *timeoutWriter) Write(b []byte) (int, error) {
|
||||
@ -133,6 +134,18 @@ func (tw *timeoutWriter) WriteHeader(statusCode int) {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -153,6 +166,7 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
|
||||
|
||||
tw := &timeoutWriter{
|
||||
ResponseWriter: w,
|
||||
header: make(http.Header),
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
@ -178,8 +192,12 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
|
||||
tw.markWritten() // Prevent the handler from writing after timeout
|
||||
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.WriteHeader(http.StatusRequestTimeout)
|
||||
tw.mu.Unlock()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"status": "error",
|
||||
|
@ -28,6 +28,7 @@ func (s *Server) setupRoutes() {
|
||||
// AS and prefix detail pages
|
||||
r.Get("/as/{asn}", s.handleASDetail())
|
||||
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
|
||||
r.Get("/prefixlength/{length}", s.handlePrefixLength())
|
||||
r.Get("/ip/{ip}", s.handleIPRedirect())
|
||||
|
||||
// API routes
|
||||
|
@ -7,8 +7,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -19,9 +19,12 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
metricsUpdateRate = time.Second
|
||||
minBackoffDelay = 5 * time.Second
|
||||
maxBackoffDelay = 320 * time.Second
|
||||
metricsLogInterval = 10 * time.Second
|
||||
bytesPerKB = 1024
|
||||
bytesPerMB = 1024 * 1024
|
||||
@ -95,6 +98,9 @@ func (s *Streamer) RegisterHandler(handler MessageHandler) {
|
||||
info := &handlerInfo{
|
||||
handler: handler,
|
||||
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)
|
||||
@ -131,9 +137,7 @@ func (s *Streamer) Start() error {
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := s.stream(ctx); err != nil {
|
||||
s.logger.Error("Streaming error", "error", err)
|
||||
}
|
||||
s.streamWithReconnect(ctx)
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
@ -170,7 +174,7 @@ func (s *Streamer) runHandlerWorker(info *handlerInfo) {
|
||||
info.metrics.totalTime += elapsed
|
||||
|
||||
// Update min time
|
||||
if info.metrics.minTime == 0 || elapsed < info.metrics.minTime {
|
||||
if elapsed < info.metrics.minTime {
|
||||
info.metrics.minTime = elapsed
|
||||
}
|
||||
|
||||
@ -320,6 +324,72 @@ func (s *Streamer) updateMetrics(messageBytes int) {
|
||||
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 {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", risLiveURL, nil)
|
||||
if err != nil {
|
||||
@ -390,10 +460,13 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
// Parse the message first
|
||||
var wrapper ristypes.RISLiveMessage
|
||||
if err := json.Unmarshal(line, &wrapper); err != nil {
|
||||
// Output the raw line and panic on parse failure
|
||||
fmt.Fprintf(os.Stderr, "Failed to parse JSON: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Raw line: %s\n", string(line))
|
||||
panic(fmt.Sprintf("JSON parse error: %v", err))
|
||||
// Log the error and return to trigger reconnection
|
||||
s.logger.Error("Failed to parse JSON",
|
||||
"error", err,
|
||||
"line", string(line),
|
||||
"line_length", len(line))
|
||||
|
||||
return fmt.Errorf("JSON parse error: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a ris_message wrapper
|
||||
@ -443,18 +516,11 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
// Peer state changes - silently ignore
|
||||
continue
|
||||
default:
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"UNKNOWN MESSAGE TYPE: %s\nRAW MESSAGE: %s\n",
|
||||
msg.Type,
|
||||
string(line),
|
||||
)
|
||||
panic(
|
||||
fmt.Sprintf(
|
||||
"Unknown RIS message type: %s",
|
||||
msg.Type,
|
||||
),
|
||||
s.logger.Error("Unknown message type",
|
||||
"type", msg.Type,
|
||||
"line", string(line),
|
||||
)
|
||||
panic(fmt.Sprintf("Unknown RIS message type: %s", msg.Type))
|
||||
}
|
||||
|
||||
// Dispatch to interested handlers
|
||||
|
@ -13,7 +13,8 @@
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 90%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
@ -91,6 +92,7 @@
|
||||
.route-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.route-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
@ -114,9 +116,13 @@
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
max-width: 300px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
max-width: 600px;
|
||||
word-wrap: break-word;
|
||||
white-space: normal !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.as-path .as-link {
|
||||
font-weight: 600;
|
||||
}
|
||||
.age {
|
||||
color: #7f8c8d;
|
||||
@ -168,7 +174,7 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.as-path {
|
||||
max-width: 150px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -234,7 +240,7 @@
|
||||
<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="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="peer-ip">{{.NextHop}}</td>
|
||||
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td class="age">{{.LastUpdated | timeSince}}</td>
|
||||
|
108
internal/templates/prefix_length.html
Normal file
108
internal/templates/prefix_length.html
Normal file
@ -0,0 +1,108 @@
|
||||
<!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>
|
@ -49,6 +49,16 @@
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
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 {
|
||||
color: #22c55e;
|
||||
}
|
||||
@ -231,7 +241,7 @@
|
||||
metric.className = 'metric';
|
||||
metric.innerHTML = `
|
||||
<span class="metric-label">/${item.mask_length}</span>
|
||||
<span class="metric-value">${formatNumber(item.count)}</span>
|
||||
<a href="/prefixlength/${item.mask_length}" class="metric-value metric-link">${formatNumber(item.count)}</a>
|
||||
`;
|
||||
container.appendChild(metric);
|
||||
});
|
||||
|
@ -18,11 +18,15 @@ var asDetailHTML string
|
||||
//go:embed prefix_detail.html
|
||||
var prefixDetailHTML string
|
||||
|
||||
//go:embed prefix_length.html
|
||||
var prefixLengthHTML string
|
||||
|
||||
// Templates contains all parsed templates
|
||||
type Templates struct {
|
||||
Status *template.Template
|
||||
ASDetail *template.Template
|
||||
PrefixDetail *template.Template
|
||||
PrefixLength *template.Template
|
||||
}
|
||||
|
||||
var (
|
||||
@ -99,6 +103,12 @@ func initTemplates() {
|
||||
if err != nil {
|
||||
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
|
||||
@ -122,3 +132,8 @@ func ASDetailTemplate() *template.Template {
|
||||
func PrefixDetailTemplate() *template.Template {
|
||||
return Get().PrefixDetail
|
||||
}
|
||||
|
||||
// PrefixLengthTemplate returns the parsed prefix length template
|
||||
func PrefixLengthTemplate() *template.Template {
|
||||
return Get().PrefixLength
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user