14 Commits

Author SHA1 Message Date
68cc06690b Use TRUNCATE mode for WAL checkpoints
PASSIVE checkpoints may not fully checkpoint when writers are active.
TRUNCATE mode blocks writers briefly but ensures complete checkpointing,
keeping the WAL small for consistent read performance under heavy load.
2026-01-01 06:49:58 -08:00
4f62b280c5 Reduce WAL checkpoint interval from 30s to 5s
Under heavy write load, 30 seconds is too long between checkpoints,
causing the WAL to grow and slow down read queries. More aggressive
checkpointing keeps the WAL small and maintains read performance.
2026-01-01 06:48:58 -08:00
f1d7c21478 Run WAL checkpoint on startup with logging
Explicitly checkpoint the WAL on database initialization to consolidate
any large WAL file from previous runs. Log the checkpoint results to
help diagnose issues.
2026-01-01 06:38:15 -08:00
a163449a28 Improve request logging and make health check lightweight
- Log slow requests (>1s) at WARNING level with slow=true flag
- Log request timeouts at WARNING level in TimeoutMiddleware
- Replace heavy GetStatsContext with lightweight Ping in health check
- Add Ping method to database interface (SELECT 1)
2026-01-01 06:06:20 -08:00
8f524485f7 Add periodic WAL checkpointing to fix slow queries
The WAL file was growing to 700MB+ which caused COUNT(*) queries to
timeout. Reads must scan the WAL to find current page versions, and
a large WAL makes this slow.

Add Checkpoint method to database interface and run PASSIVE checkpoints
every 30 seconds via the DBMaintainer. This keeps the WAL small and
maintains fast read performance under heavy write load.
2026-01-01 05:42:03 -08:00
c6fa2b0fbd Fix container to run app as routewatch user
Use runuser to drop privileges and execute the app as the routewatch
user (uid 1000). Fix data directory permissions at runtime since host
mounts may have incorrect ownership.
2025-12-31 16:17:59 -08:00
f788a0dbf9 set state dir properly in container 2025-12-31 16:08:17 -08:00
aebdd1b23e Add oldest and newest route timestamps to status page
Display oldest and newest route timestamps in the Routing Table card
on the /status page. Timestamps are shown as relative times (e.g.,
"5m ago", "2h 30m ago").

Changes:
- Add OldestRoute and NewestRoute fields to database.Stats
- Query MIN/MAX of last_updated across live_routes_v4 and v6 tables
- Include timestamps in both /status.json and /api/v1/stats responses
- Add formatRelativeTime JavaScript function for display
2025-12-31 15:47:57 -08:00
8fc10ae98d Fix NULL handling in GetWHOISStats query
When the asns table is empty, SUM() returns NULL which cannot be
scanned into an int. Wrap SUM expressions in COALESCE to return 0
instead of NULL.
2025-12-31 15:17:09 -08:00
d27536812f Remove heading from status page 2025-12-31 15:11:03 -08:00
58b5333c6c Fix navbar and simplify templates
- Fix nested anchor tags in navbar (invalid HTML)
- Hardcode app name and author in templates
- Remove hero section from index page
2025-12-31 15:10:24 -08:00
4284e923a6 Add navbar and home page with search functionality
- Create new home page (/) with overview stats, ASN lookup,
  AS name search, and IP address lookup with JSON display
- Add responsive navbar to all pages with app branding
- Navbar shows "routewatch by @sneak" with link to author
- Status page accessible via navbar link
- Remove redirect from / to /status, serve home page instead
2025-12-31 14:56:02 -08:00
45810e3fc8 Fix prefix URL routing for encoded CIDR notation
Change route from wildcard /prefix/* to explicit /prefix/{prefix}/{len}
to properly handle URL-encoded IPv6 addresses with CIDR notation.

- Separate prefix and length into individual path parameters
- Add prefixURL template function for generating correct links
- Remove url.QueryUnescape from handlers (chi handles decoding)
2026-01-01 05:37:37 +07:00
27909e021f ci 2025-12-31 06:14:44 +01:00
16 changed files with 1026 additions and 77 deletions

View File

@@ -39,9 +39,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Create non-root user # Create non-root user
RUN useradd -r -u 1000 -m routewatch RUN useradd -r -u 1000 -m routewatch
# Create state directory RUN mkdir -p /var/lib/berlin.sneak.app.routewatch && chown routewatch:routewatch /var/lib/berlin.sneak.app.routewatch
RUN mkdir -p /var/lib/routewatch && chown routewatch:routewatch /var/lib/routewatch
RUN mkdir /app
WORKDIR /app WORKDIR /app
# Copy binary and source archive from builder # Copy binary and source archive from builder
@@ -51,16 +51,15 @@ COPY --from=builder /routewatch-source.tar.zst /app/source/routewatch-source.tar
# Set ownership # Set ownership
RUN chown -R routewatch:routewatch /app RUN chown -R routewatch:routewatch /app
USER routewatch ENV XDG_DATA_HOME=/var/lib
# Default state directory
ENV ROUTEWATCH_STATE_DIR=/var/lib/routewatch
# Expose HTTP port # Expose HTTP port
EXPOSE 8080 EXPOSE 8080
COPY ./entrypoint.sh /entrypoint.sh
# Health check using the health endpoint # Health check using the health endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:8080/.well-known/healthcheck.json || exit 1 CMD curl -sf http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["/app/routewatch"] ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]

View File

@@ -2,6 +2,7 @@
RouteWatch is a real-time BGP routing table monitor that streams BGP UPDATE messages from the RIPE RIS Live service, maintains a live routing table in SQLite, and provides HTTP APIs for querying routing information. RouteWatch is a real-time BGP routing table monitor that streams BGP UPDATE messages from the RIPE RIS Live service, maintains a live routing table in SQLite, and provides HTTP APIs for querying routing information.
## Features ## Features
- Real-time streaming of BGP updates from RIPE RIS Live - Real-time streaming of BGP updates from RIPE RIS Live

7
entrypoint.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
cd /var/lib/berlin.sneak.app.routewatch
chown -R routewatch:routewatch .
chmod 700 .
exec runuser -u routewatch -- /app/routewatch

View File

@@ -106,14 +106,13 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
func (d *Database) Initialize() error { func (d *Database) Initialize() error {
// Set SQLite pragmas for performance // Set SQLite pragmas for performance
pragmas := []string{ pragmas := []string{
"PRAGMA journal_mode=WAL", // Write-Ahead Logging "PRAGMA journal_mode=WAL", // Write-Ahead Logging
"PRAGMA synchronous=OFF", // Don't wait for disk writes "PRAGMA synchronous=OFF", // Don't wait for disk writes
"PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB) "PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB)
"PRAGMA temp_store=MEMORY", // Use memory for temp tables "PRAGMA temp_store=MEMORY", // Use memory for temp tables
"PRAGMA wal_checkpoint(TRUNCATE)", // Checkpoint and truncate WAL now "PRAGMA busy_timeout=5000", // 5 second busy timeout
"PRAGMA busy_timeout=5000", // 5 second busy timeout "PRAGMA analysis_limit=0", // Disable automatic ANALYZE
"PRAGMA analysis_limit=0", // Disable automatic ANALYZE "PRAGMA auto_vacuum=INCREMENTAL", // Enable incremental vacuum
"PRAGMA auto_vacuum=INCREMENTAL", // Enable incremental vacuum
} }
for _, pragma := range pragmas { for _, pragma := range pragmas {
@@ -122,7 +121,20 @@ func (d *Database) Initialize() error {
} }
} }
err := d.exec(dbSchema) // Run WAL checkpoint on startup to consolidate any existing WAL
var walPages, checkpointedPages, movedPages int
err := d.db.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&walPages, &checkpointedPages, &movedPages)
if err != nil {
d.logger.Warn("Failed to checkpoint WAL on startup", "error", err)
} else {
d.logger.Info("WAL checkpoint on startup",
"wal_pages", walPages,
"checkpointed", checkpointedPages,
"moved", movedPages,
)
}
err = d.exec(dbSchema)
if err != nil { if err != nil {
return err return err
} }
@@ -946,6 +958,23 @@ func (d *Database) GetStatsContext(ctx context.Context) (Stats, error) {
} }
stats.LiveRoutes = v4Count + v6Count stats.LiveRoutes = v4Count + v6Count
// Get oldest and newest route timestamps
routeTimestampQuery := `
SELECT MIN(last_updated), MAX(last_updated) FROM (
SELECT last_updated FROM live_routes_v4
UNION ALL
SELECT last_updated FROM live_routes_v6
)
`
var oldestRoute, newestRoute *time.Time
err = d.db.QueryRowContext(ctx, routeTimestampQuery).Scan(&oldestRoute, &newestRoute)
if err != nil {
d.logger.Warn("Failed to get route timestamps", "error", err)
} else {
stats.OldestRoute = oldestRoute
stats.NewestRoute = newestRoute
}
// Get prefix distribution // Get prefix distribution
stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistributionContext(ctx) stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistributionContext(ctx)
if err != nil { if err != nil {
@@ -1669,9 +1698,9 @@ func (d *Database) GetWHOISStats(ctx context.Context, staleThreshold time.Durati
query := ` query := `
SELECT SELECT
COUNT(*) as total, COUNT(*) as total,
SUM(CASE WHEN whois_updated_at IS NULL THEN 1 ELSE 0 END) as never_fetched, COALESCE(SUM(CASE WHEN whois_updated_at IS NULL THEN 1 ELSE 0 END), 0) as never_fetched,
SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at < ? THEN 1 ELSE 0 END) as stale, COALESCE(SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at < ? THEN 1 ELSE 0 END), 0) as stale,
SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at >= ? THEN 1 ELSE 0 END) as fresh COALESCE(SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at >= ? THEN 1 ELSE 0 END), 0) as fresh
FROM asns FROM asns
` `
@@ -1969,3 +1998,26 @@ func (d *Database) Analyze(ctx context.Context) error {
return nil return nil
} }
// Checkpoint runs a WAL checkpoint to transfer data from the WAL to the main database.
// Uses TRUNCATE mode which blocks writers briefly but ensures complete checkpoint,
// keeping the WAL small for fast read performance.
func (d *Database) Checkpoint(ctx context.Context) error {
_, err := d.db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
if err != nil {
return fmt.Errorf("failed to checkpoint WAL: %w", err)
}
return nil
}
// Ping checks if the database connection is alive with a lightweight query.
func (d *Database) Ping(ctx context.Context) error {
var result int
err := d.db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
if err != nil {
return fmt.Errorf("database ping failed: %w", err)
}
return nil
}

View File

@@ -18,6 +18,8 @@ type Stats struct {
Peers int Peers int
FileSizeBytes int64 FileSizeBytes int64
LiveRoutes int LiveRoutes int
OldestRoute *time.Time
NewestRoute *time.Time
IPv4PrefixDistribution []PrefixDistribution IPv4PrefixDistribution []PrefixDistribution
IPv6PrefixDistribution []PrefixDistribution IPv6PrefixDistribution []PrefixDistribution
} }
@@ -85,6 +87,8 @@ type Store interface {
// Maintenance operations // Maintenance operations
Vacuum(ctx context.Context) error Vacuum(ctx context.Context) error
Analyze(ctx context.Context) error Analyze(ctx context.Context) error
Checkpoint(ctx context.Context) error
Ping(ctx context.Context) error
} }
// Ensure Database implements Store // Ensure Database implements Store

View File

@@ -415,6 +415,16 @@ func (m *mockStore) Analyze(ctx context.Context) error {
return nil return nil
} }
// Checkpoint mock implementation
func (m *mockStore) Checkpoint(ctx context.Context) error {
return nil
}
// Ping mock implementation
func (m *mockStore) Ping(ctx context.Context) error {
return nil
}
func TestRouteWatchLiveFeed(t *testing.T) { func TestRouteWatchLiveFeed(t *testing.T) {
// Create mock database // Create mock database

View File

@@ -12,6 +12,11 @@ import (
// Database maintenance configuration constants. // Database maintenance configuration constants.
const ( const (
// checkpointInterval is how often to run WAL checkpoint.
// Frequent checkpoints keep the WAL small, improving read performance.
// Under heavy write load, we need aggressive checkpointing.
checkpointInterval = 5 * time.Second
// vacuumInterval is how often to run incremental vacuum. // vacuumInterval is how often to run incremental vacuum.
// Since incremental vacuum only frees ~1000 pages (~4MB) per run, // Since incremental vacuum only frees ~1000 pages (~4MB) per run,
// we run it frequently to keep up with deletions. // we run it frequently to keep up with deletions.
@@ -20,6 +25,9 @@ const (
// analyzeInterval is how often to run ANALYZE. // analyzeInterval is how often to run ANALYZE.
analyzeInterval = 1 * time.Hour analyzeInterval = 1 * time.Hour
// checkpointTimeout is the max time for WAL checkpoint.
checkpointTimeout = 10 * time.Second
// vacuumTimeout is the max time for incremental vacuum (should be quick). // vacuumTimeout is the max time for incremental vacuum (should be quick).
vacuumTimeout = 30 * time.Second vacuumTimeout = 30 * time.Second
@@ -35,13 +43,16 @@ type DBMaintainer struct {
wg sync.WaitGroup wg sync.WaitGroup
// Stats tracking // Stats tracking
statsMu sync.Mutex statsMu sync.Mutex
lastVacuum time.Time lastCheckpoint time.Time
lastAnalyze time.Time lastVacuum time.Time
vacuumCount int lastAnalyze time.Time
analyzeCount int checkpointCount int
lastVacuumError error vacuumCount int
lastAnalyzeError error analyzeCount int
lastCheckpointError error
lastVacuumError error
lastAnalyzeError error
} }
// NewDBMaintainer creates a new database maintainer. // NewDBMaintainer creates a new database maintainer.
@@ -58,6 +69,7 @@ func (m *DBMaintainer) Start() {
m.wg.Add(1) m.wg.Add(1)
go m.run() go m.run()
m.logger.Info("Database maintainer started", m.logger.Info("Database maintainer started",
"checkpoint_interval", checkpointInterval,
"vacuum_interval", vacuumInterval, "vacuum_interval", vacuumInterval,
"analyze_interval", analyzeInterval, "analyze_interval", analyzeInterval,
) )
@@ -75,8 +87,10 @@ func (m *DBMaintainer) run() {
defer m.wg.Done() defer m.wg.Done()
// Use different timers for each task // Use different timers for each task
checkpointTimer := time.NewTimer(checkpointInterval)
vacuumTimer := time.NewTimer(vacuumInterval) vacuumTimer := time.NewTimer(vacuumInterval)
analyzeTimer := time.NewTimer(analyzeInterval) analyzeTimer := time.NewTimer(analyzeInterval)
defer checkpointTimer.Stop()
defer vacuumTimer.Stop() defer vacuumTimer.Stop()
defer analyzeTimer.Stop() defer analyzeTimer.Stop()
@@ -85,6 +99,10 @@ func (m *DBMaintainer) run() {
case <-m.stopCh: case <-m.stopCh:
return return
case <-checkpointTimer.C:
m.runCheckpoint()
checkpointTimer.Reset(checkpointInterval)
case <-vacuumTimer.C: case <-vacuumTimer.C:
m.runVacuum() m.runVacuum()
vacuumTimer.Reset(vacuumInterval) vacuumTimer.Reset(vacuumInterval)
@@ -96,6 +114,30 @@ func (m *DBMaintainer) run() {
} }
} }
// runCheckpoint performs a WAL checkpoint to keep the WAL file small.
func (m *DBMaintainer) runCheckpoint() {
ctx, cancel := context.WithTimeout(context.Background(), checkpointTimeout)
defer cancel()
startTime := time.Now()
err := m.db.Checkpoint(ctx)
m.statsMu.Lock()
m.lastCheckpoint = time.Now()
m.lastCheckpointError = err
if err == nil {
m.checkpointCount++
}
m.statsMu.Unlock()
if err != nil {
m.logger.Error("WAL checkpoint failed", "error", err, "duration", time.Since(startTime))
} else {
m.logger.Debug("WAL checkpoint completed", "duration", time.Since(startTime))
}
}
// runVacuum performs an incremental vacuum operation on the database. // runVacuum performs an incremental vacuum operation on the database.
func (m *DBMaintainer) runVacuum() { func (m *DBMaintainer) runVacuum() {
ctx, cancel := context.WithTimeout(context.Background(), vacuumTimeout) ctx, cancel := context.WithTimeout(context.Background(), vacuumTimeout)

View File

@@ -7,7 +7,6 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"net/url"
"runtime" "runtime"
"sort" "sort"
"strconv" "strconv"
@@ -38,6 +37,7 @@ type HealthCheckResponse struct {
// handleHealthCheck returns a handler that performs health checks. // handleHealthCheck returns a handler that performs health checks.
// Returns 200 if healthy, 503 if any check fails. // Returns 200 if healthy, 503 if any check fails.
// Uses lightweight checks to avoid timeout issues under load.
func (s *Server) handleHealthCheck() http.HandlerFunc { func (s *Server) handleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout) ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout)
@@ -46,13 +46,11 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
checks := make(map[string]string) checks := make(map[string]string)
healthy := true healthy := true
// Check database connectivity // Check database connectivity with lightweight ping
dbStats, err := s.db.GetStatsContext(ctx) err := s.db.Ping(ctx)
if err != nil { if err != nil {
checks["database"] = "error: " + err.Error() checks["database"] = "error: " + err.Error()
healthy = false healthy = false
} else if dbStats.ASNs == 0 && dbStats.Prefixes == 0 {
checks["database"] = "warning: empty database"
} else { } else {
checks["database"] = "ok" checks["database"] = "ok"
} }
@@ -88,10 +86,16 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
} }
} }
// handleRoot returns a handler that redirects to /status. // handleIndex returns a handler that serves the home page.
func (s *Server) handleRoot() http.HandlerFunc { func (s *Server) handleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther) w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.IndexTemplate()
if err := tmpl.Execute(w, nil); err != nil {
s.logger.Error("Failed to render index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
@@ -159,6 +163,8 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
LiveRoutes int `json:"live_routes"` LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"` IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"` IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
@@ -253,6 +259,8 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
LiveRoutes: dbStats.LiveRoutes, LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: ipv4Routes, IPv4Routes: ipv4Routes,
IPv6Routes: ipv6Routes, IPv6Routes: ipv6Routes,
OldestRoute: dbStats.OldestRoute,
NewestRoute: dbStats.NewestRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec, IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec, IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution, IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
@@ -364,6 +372,8 @@ func (s *Server) handleStats() http.HandlerFunc {
LiveRoutes int `json:"live_routes"` LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"` IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"` IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"` HandlerStats []HandlerStatsInfo `json:"handler_stats"`
@@ -525,6 +535,8 @@ func (s *Server) handleStats() http.HandlerFunc {
LiveRoutes: dbStats.LiveRoutes, LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: ipv4Routes, IPv4Routes: ipv4Routes,
IPv6Routes: ipv6Routes, IPv6Routes: ipv6Routes,
OldestRoute: dbStats.OldestRoute,
NewestRoute: dbStats.NewestRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec, IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec, IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
HandlerStats: handlerStatsInfo, HandlerStats: handlerStatsInfo,
@@ -736,21 +748,18 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
// handlePrefixDetailJSON returns prefix details as JSON // handlePrefixDetailJSON returns prefix details as JSON
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Get wildcard parameter (everything after /prefix/) // Get prefix and length from URL params
prefixParam := chi.URLParam(r, "*") prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" { lenParam := chi.URLParam(r, "len")
writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required")
if prefixParam == "" || lenParam == "" {
writeJSONError(w, http.StatusBadRequest, "Prefix and length parameters are required")
return return
} }
// URL decode the prefix parameter // Combine prefix and length into CIDR notation
prefix, err := url.QueryUnescape(prefixParam) prefix := prefixParam + "/" + lenParam
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid prefix parameter")
return
}
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
@@ -903,21 +912,18 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// handlePrefixDetail returns a handler that serves the prefix detail HTML page // handlePrefixDetail returns a handler that serves the prefix detail HTML page
func (s *Server) handlePrefixDetail() http.HandlerFunc { func (s *Server) handlePrefixDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Get wildcard parameter (everything after /prefix/) // Get prefix and length from URL params
prefixParam := chi.URLParam(r, "*") prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" { lenParam := chi.URLParam(r, "len")
http.Error(w, "Prefix parameter is required", http.StatusBadRequest)
if prefixParam == "" || lenParam == "" {
http.Error(w, "Prefix and length parameters are required", http.StatusBadRequest)
return return
} }
// URL decode the prefix parameter // Combine prefix and length into CIDR notation
prefix, err := url.QueryUnescape(prefixParam) prefix := prefixParam + "/" + lenParam
if err != nil {
http.Error(w, "Invalid prefix parameter", http.StatusBadRequest)
return
}
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {

View File

@@ -168,7 +168,7 @@ func (tw *timeoutWriter) markWritten() {
// The timeout parameter specifies the maximum duration allowed for request processing. // The timeout parameter specifies the maximum duration allowed for request processing.
// The returned middleware handles panics from the wrapped handler by re-panicking // The returned middleware handles panics from the wrapped handler by re-panicking
// after cleanup, and prevents concurrent writes to the response after timeout occurs. // after cleanup, and prevents concurrent writes to the response after timeout occurs.
func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now() startTime := time.Now()
@@ -204,6 +204,14 @@ 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)
// Log the timeout as a warning
logger.Warn("Request timeout",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", execTime.Milliseconds(),
"remote_addr", r.RemoteAddr,
)
// Write directly to the underlying writer since we've marked tw as written // Write directly to the underlying writer since we've marked tw as written
// This is safe because markWritten() prevents the handler from writing // This is safe because markWritten() prevents the handler from writing
tw.mu.Lock() tw.mu.Lock()
@@ -350,10 +358,16 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle
// Log request completion // Log request completion
duration := time.Since(start) duration := time.Since(start)
logLevel := slog.LevelInfo logLevel := slog.LevelInfo
// Slow query threshold (1 second)
const slowQueryThreshold = 1 * time.Second
if sw.statusCode >= http.StatusInternalServerError { if sw.statusCode >= http.StatusInternalServerError {
logLevel = slog.LevelError logLevel = slog.LevelError
} else if sw.statusCode >= http.StatusBadRequest { } else if sw.statusCode >= http.StatusBadRequest {
logLevel = slog.LevelWarn logLevel = slog.LevelWarn
} else if duration >= slowQueryThreshold {
logLevel = slog.LevelWarn
} }
logger.Log(r.Context(), logLevel, "HTTP request completed", logger.Log(r.Context(), logLevel, "HTTP request completed",
@@ -362,6 +376,7 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle
"status", sw.statusCode, "status", sw.statusCode,
"duration_ms", duration.Milliseconds(), "duration_ms", duration.Milliseconds(),
"remote_addr", r.RemoteAddr, "remote_addr", r.RemoteAddr,
"slow", duration >= slowQueryThreshold,
) )
}) })
} }

View File

@@ -17,18 +17,18 @@ func (s *Server) setupRoutes() {
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
const requestTimeout = 30 * time.Second // Increased from 8s for slow queries const requestTimeout = 30 * time.Second // Increased from 8s for slow queries
r.Use(TimeoutMiddleware(requestTimeout)) r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger))
r.Use(JSONResponseMiddleware) r.Use(JSONResponseMiddleware)
// Routes // Routes
r.Get("/", s.handleRoot()) r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML()) r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP) r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get("/.well-known/healthcheck.json", JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP) r.Get("/.well-known/healthcheck.json", JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
// 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/*", s.handlePrefixDetail()) r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetail())
r.Get("/prefixlength/{length}", s.handlePrefixLength()) r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/prefixlength6/{length}", s.handlePrefixLength6()) r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
@@ -45,7 +45,7 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleStats()) r.Get("/stats", s.handleStats())
r.Get("/ip/{ip}", s.handleIPLookup()) r.Get("/ip/{ip}", s.handleIPLookup())
r.Get("/as/{asn}", s.handleASDetailJSON()) r.Get("/as/{asn}", s.handleASDetailJSON())
r.Get("/prefix/*", s.handlePrefixDetailJSON()) r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
}) })
s.router = r s.router = r

View File

@@ -5,13 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AS{{.ASN.ASN}} - {{.ASN.Handle}} - RouteWatch</title> <title>AS{{.ASN.ASN}} - {{.ASN.Handle}} - RouteWatch</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0; margin: 0;
padding: 20px; padding: 0;
background: #f5f5f5; background: #f5f5f5;
color: #333; color: #333;
} }
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
padding: 20px;
}
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
@@ -133,8 +196,20 @@
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<div class="container"> <div class="container">
<a href="/status" class="nav-link">← Back to Status</a>
<h1>AS{{.ASN.ASN}}{{if .ASN.Handle}} - {{.ASN.Handle}}{{end}}</h1> <h1>AS{{.ASN.ASN}}{{if .ASN.Handle}} - {{.ASN.Handle}}{{end}}</h1>
{{if .ASN.Description}} {{if .ASN.Description}}
@@ -182,7 +257,7 @@
<tbody> <tbody>
{{range .IPv4Prefixes}} {{range .IPv4Prefixes}}
<tr> <tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td> <td><a href="{{.Prefix | prefixURL}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td> <td>/{{.MaskLength}}</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>
@@ -211,7 +286,7 @@
<tbody> <tbody>
{{range .IPv6Prefixes}} {{range .IPv6Prefixes}}
<tr> <tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td> <td><a href="{{.Prefix | prefixURL}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td> <td>/{{.MaskLength}}</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>
@@ -266,5 +341,6 @@
</div> </div>
{{end}} {{end}}
</div> </div>
</main>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RouteWatch - BGP Route Monitor</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
color: #333;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 30px 20px;
}
/* Stats overview */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card.connected .stat-value {
color: #27ae60;
}
.stat-card.disconnected .stat-value {
color: #e74c3c;
}
/* Search section */
.search-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.search-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.search-card h2 {
margin: 0 0 16px 0;
font-size: 18px;
color: #2c3e50;
}
.search-input-group {
display: flex;
gap: 10px;
}
.search-input-group input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input-group input:focus {
border-color: #3498db;
}
.search-input-group button {
padding: 12px 24px;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.search-input-group button:hover {
background: #2980b9;
}
.search-input-group button:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.search-hint {
font-size: 12px;
color: #95a5a6;
margin-top: 8px;
}
/* IP Lookup result */
.ip-result {
margin-top: 16px;
display: none;
}
.ip-result.visible {
display: block;
}
.ip-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ip-result-header h3 {
margin: 0;
font-size: 14px;
color: #2c3e50;
}
.ip-result-header button {
background: none;
border: none;
color: #e74c3c;
cursor: pointer;
font-size: 12px;
}
.ip-result pre {
background: #2c3e50;
color: #ecf0f1;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
margin: 0;
max-height: 400px;
overflow-y: auto;
}
.ip-result .error {
background: #fee;
color: #c00;
padding: 12px;
border-radius: 6px;
font-size: 14px;
}
.ip-result .loading {
color: #7f8c8d;
font-style: italic;
}
/* Footer */
.footer {
margin-top: 40px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
text-align: center;
color: #7f8c8d;
font-size: 14px;
}
.footer a {
color: #3498db;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.footer .separator {
margin: 0 10px;
color: #ddd;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/" class="active">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<div class="stats-grid">
<div class="stat-card" id="status-card">
<div class="stat-value" id="stat-status">-</div>
<div class="stat-label">Status</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-routes">-</div>
<div class="stat-label">Live Routes</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-asns">-</div>
<div class="stat-label">Autonomous Systems</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-prefixes">-</div>
<div class="stat-label">Prefixes</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-peers">-</div>
<div class="stat-label">BGP Peers</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-updates">-</div>
<div class="stat-label">Updates/sec</div>
</div>
</div>
<div class="search-section">
<div class="search-card">
<h2>AS Number Lookup</h2>
<form id="asn-form" class="search-input-group">
<input type="text" id="asn-input" placeholder="e.g., 15169 or AS15169" autocomplete="off">
<button type="submit">Lookup</button>
</form>
<p class="search-hint">Enter an AS number to view its announced prefixes and peers</p>
</div>
<div class="search-card">
<h2>AS Name Search</h2>
<form id="asname-form" class="search-input-group">
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
<button type="submit">Search</button>
</form>
<p class="search-hint">Search for autonomous systems by organization name</p>
<div id="asname-results"></div>
</div>
<div class="search-card">
<h2>IP Address Lookup</h2>
<form id="ip-form" class="search-input-group">
<input type="text" id="ip-input" placeholder="e.g., 8.8.8.8 or 2001:4860:4860::8888" autocomplete="off">
<button type="submit">Lookup</button>
</form>
<p class="search-hint">Get routing information for any IP address</p>
<div id="ip-result" class="ip-result">
<div class="ip-result-header">
<h3>Result</h3>
<button type="button" id="ip-result-close">Clear</button>
</div>
<pre id="ip-result-content"></pre>
</div>
</div>
</div>
</main>
<footer class="footer">
<span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span>
<span class="separator">|</span>
<span>{{appLicense}}</span>
<span class="separator">|</span>
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
</footer>
<script>
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
// Fetch and display stats
function updateStats() {
fetch('/api/v1/stats')
.then(response => response.json())
.then(response => {
if (response.status !== 'ok') return;
const data = response.data;
const statusCard = document.getElementById('status-card');
const statusEl = document.getElementById('stat-status');
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
document.getElementById('stat-prefixes').textContent = formatNumber(data.prefixes);
if (data.stream) {
document.getElementById('stat-peers').textContent = formatNumber(data.stream.bgp_peer_count);
}
const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec;
document.getElementById('stat-updates').textContent = totalUpdates.toFixed(1);
})
.catch(() => {
document.getElementById('stat-status').textContent = 'Error';
document.getElementById('status-card').className = 'stat-card disconnected';
});
}
// ASN lookup
document.getElementById('asn-form').addEventListener('submit', function(e) {
e.preventDefault();
let asn = document.getElementById('asn-input').value.trim();
// Remove 'AS' prefix if present
asn = asn.replace(/^AS/i, '');
if (asn && /^\d+$/.test(asn)) {
window.location.href = '/as/' + asn;
}
});
// AS name search
document.getElementById('asname-form').addEventListener('submit', function(e) {
e.preventDefault();
const query = document.getElementById('asname-input').value.trim();
if (!query) return;
const resultsDiv = document.getElementById('asname-results');
resultsDiv.innerHTML = '<p class="loading" style="color: #7f8c8d; margin-top: 12px;">Searching...</p>';
// Use a simple client-side search against the asinfo data
// For now, redirect to AS page if it looks like an ASN
if (/^\d+$/.test(query)) {
window.location.href = '/as/' + query;
return;
}
// Show a message that server-side search is coming
resultsDiv.innerHTML = '<p style="color: #7f8c8d; margin-top: 12px; font-size: 13px;">AS name search coming soon. For now, try an AS number.</p>';
});
// IP lookup
document.getElementById('ip-form').addEventListener('submit', function(e) {
e.preventDefault();
const ip = document.getElementById('ip-input').value.trim();
if (!ip) return;
const resultDiv = document.getElementById('ip-result');
const contentEl = document.getElementById('ip-result-content');
resultDiv.classList.add('visible');
contentEl.className = '';
contentEl.textContent = 'Loading...';
contentEl.classList.add('loading');
fetch('/ip/' + encodeURIComponent(ip))
.then(response => response.json())
.then(response => {
contentEl.classList.remove('loading');
if (response.status === 'error') {
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + response.error.msg;
} else {
contentEl.className = '';
contentEl.textContent = JSON.stringify(response.data, null, 2);
}
})
.catch(error => {
contentEl.classList.remove('loading');
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + error.message;
});
});
// Close IP result
document.getElementById('ip-result-close').addEventListener('click', function() {
document.getElementById('ip-result').classList.remove('visible');
document.getElementById('ip-input').value = '';
});
// Initial load and refresh stats every 5 seconds
updateStats();
setInterval(updateStats, 5000);
</script>
</body>
</html>

View File

@@ -5,13 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Prefix}} - RouteWatch</title> <title>{{.Prefix}} - RouteWatch</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0; margin: 0;
padding: 20px; padding: 0;
background: #f5f5f5; background: #f5f5f5;
color: #333; color: #333;
} }
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
padding: 20px;
}
.container { .container {
width: 90%; width: 90%;
max-width: 1600px; max-width: 1600px;
@@ -180,9 +243,20 @@
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <nav class="navbar">
<a href="/status" class="nav-link">← Back to Status</a> <div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<div class="container">
<h1>{{.Prefix}}</h1> <h1>{{.Prefix}}</h1>
<p class="subtitle">IPv{{.IPVersion}} Prefix{{if .MaskLength}} • /{{.MaskLength}}{{end}}</p> <p class="subtitle">IPv{{.IPVersion}} Prefix{{if .MaskLength}} • /{{.MaskLength}}{{end}}</p>
@@ -255,5 +329,6 @@
</div> </div>
{{end}} {{end}}
</div> </div>
</main>
</body> </body>
</html> </html>

View File

@@ -5,12 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prefixes with /{{ .MaskLength }} - RouteWatch</title> <title>Prefixes with /{{ .MaskLength }} - RouteWatch</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
background: #f5f5f5;
} }
h1 { h1 {
color: #333; color: #333;
@@ -78,7 +142,19 @@
</style> </style>
</head> </head>
<body> <body>
<a href="/status" class="back-link">← Back to Status</a> <nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<h1>IPv{{ .IPVersion }} Prefixes with /{{ .MaskLength }}</h1> <h1>IPv{{ .IPVersion }} Prefixes with /{{ .MaskLength }}</h1>
<p class="subtitle">Showing {{ .Count }} randomly selected prefixes</p> <p class="subtitle">Showing {{ .Count }} randomly selected prefixes</p>
@@ -93,7 +169,7 @@
<tbody> <tbody>
{{ range .Prefixes }} {{ range .Prefixes }}
<tr> <tr>
<td><a href="/prefix/{{ .Prefix | urlEncode }}" class="prefix-link">{{ .Prefix }}</a></td> <td><a href="{{ .Prefix | prefixURL }}" class="prefix-link">{{ .Prefix }}</a></td>
<td class="age">{{ .Age }}</td> <td class="age">{{ .Age }}</td>
<td> <td>
<a href="/as/{{ .OriginASN }}" class="as-link"> <a href="/as/{{ .OriginASN }}" class="as-link">
@@ -104,5 +180,6 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
</main>
</body> </body>
</html> </html>

View File

@@ -5,12 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RouteWatch Status</title> <title>RouteWatch Status</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
background: #f5f5f5;
} }
h1 { h1 {
color: #333; color: #333;
@@ -96,7 +160,19 @@
</style> </style>
</head> </head>
<body> <body>
<h1>RouteWatch Status</h1> <nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status" class="active">Status</a>
</div>
</nav>
<main class="main-content">
<div id="error" class="error" style="display: none;"></div> <div id="error" class="error" style="display: none;"></div>
<div class="status-grid"> <div class="status-grid">
<div class="status-card"> <div class="status-card">
@@ -245,6 +321,14 @@
<span class="metric-label">IPv6 Updates/sec</span> <span class="metric-label">IPv6 Updates/sec</span>
<span class="metric-value" id="ipv6_updates_per_sec">-</span> <span class="metric-value" id="ipv6_updates_per_sec">-</span>
</div> </div>
<div class="metric">
<span class="metric-label">Oldest Route</span>
<span class="metric-value" id="oldest_route">-</span>
</div>
<div class="metric">
<span class="metric-label">Newest Route</span>
<span class="metric-value" id="newest_route">-</span>
</div>
</div> </div>
<div class="status-card"> <div class="status-card">
@@ -325,6 +409,22 @@
} }
} }
function formatRelativeTime(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return diffSec + 's ago';
if (diffMin < 60) return diffMin + 'm ago';
if (diffHour < 24) return diffHour + 'h ' + (diffMin % 60) + 'm ago';
return diffDay + 'd ' + (diffHour % 24) + 'h ago';
}
function updatePrefixDistribution(elementId, distribution) { function updatePrefixDistribution(elementId, distribution) {
const container = document.getElementById(elementId); const container = document.getElementById(elementId);
container.innerHTML = ''; container.innerHTML = '';
@@ -430,6 +530,8 @@
document.getElementById('ipv6_routes').textContent = '-'; document.getElementById('ipv6_routes').textContent = '-';
document.getElementById('ipv4_updates_per_sec').textContent = '-'; document.getElementById('ipv4_updates_per_sec').textContent = '-';
document.getElementById('ipv6_updates_per_sec').textContent = '-'; document.getElementById('ipv6_updates_per_sec').textContent = '-';
document.getElementById('oldest_route').textContent = '-';
document.getElementById('newest_route').textContent = '-';
document.getElementById('whois_fresh').textContent = '-'; document.getElementById('whois_fresh').textContent = '-';
document.getElementById('whois_stale').textContent = '-'; document.getElementById('whois_stale').textContent = '-';
document.getElementById('whois_never').textContent = '-'; document.getElementById('whois_never').textContent = '-';
@@ -490,6 +592,8 @@
document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes); document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes);
document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1); document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1);
document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1); document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1);
document.getElementById('oldest_route').textContent = formatRelativeTime(data.oldest_route);
document.getElementById('newest_route').textContent = formatRelativeTime(data.newest_route);
// Update stream stats // Update stream stats
if (data.stream) { if (data.stream) {
@@ -542,6 +646,7 @@
updateStatus(); updateStatus();
setInterval(updateStatus, 2000); setInterval(updateStatus, 2000);
</script> </script>
</main>
<footer class="footer"> <footer class="footer">
<span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span> <span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span>

View File

@@ -5,12 +5,16 @@ import (
_ "embed" _ "embed"
"html/template" "html/template"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/version" "git.eeqj.de/sneak/routewatch/internal/version"
) )
//go:embed index.html
var indexHTML string
//go:embed status.html //go:embed status.html
var statusHTML string var statusHTML string
@@ -25,6 +29,8 @@ var prefixLengthHTML string
// Templates contains all parsed templates // Templates contains all parsed templates
type Templates struct { type Templates struct {
// Index is the template for the home page
Index *template.Template
// Status is the template for the main status page // Status is the template for the main status page
Status *template.Template Status *template.Template
// ASDetail is the template for displaying AS (Autonomous System) details // ASDetail is the template for displaying AS (Autonomous System) details
@@ -43,8 +49,9 @@ var (
) )
const ( const (
hoursPerDay = 24 hoursPerDay = 24
daysPerMonth = 30 daysPerMonth = 30
cidrPartCount = 2 // A CIDR has two parts: prefix and length
) )
// timeSince returns a human-readable duration since the given time // timeSince returns a human-readable duration since the given time
@@ -80,6 +87,20 @@ func timeSince(t time.Time) string {
return t.Format("2006-01-02") return t.Format("2006-01-02")
} }
// prefixURL generates a URL path for a prefix in CIDR notation.
// Takes a prefix like "192.168.1.0/24" and returns "/prefix/192.168.1.0/24"
// with the prefix part URL-encoded to handle IPv6 colons.
func prefixURL(cidr string) string {
// Split CIDR into prefix and length
parts := strings.SplitN(cidr, "/", cidrPartCount)
if len(parts) != cidrPartCount {
// Fallback if no slash found
return "/prefix/" + url.PathEscape(cidr) + "/0"
}
return "/prefix/" + url.PathEscape(parts[0]) + "/" + parts[1]
}
// initTemplates parses all embedded templates // initTemplates parses all embedded templates
func initTemplates() { func initTemplates() {
var err error var err error
@@ -90,6 +111,7 @@ func initTemplates() {
funcs := template.FuncMap{ funcs := template.FuncMap{
"timeSince": timeSince, "timeSince": timeSince,
"urlEncode": url.QueryEscape, "urlEncode": url.QueryEscape,
"prefixURL": prefixURL,
"appName": func() string { return version.Name }, "appName": func() string { return version.Name },
"appAuthor": func() string { return version.Author }, "appAuthor": func() string { return version.Author },
"appAuthorURL": func() string { return version.AuthorURL }, "appAuthorURL": func() string { return version.AuthorURL },
@@ -99,6 +121,12 @@ func initTemplates() {
"appGitCommitURL": func() string { return version.CommitURL() }, "appGitCommitURL": func() string { return version.CommitURL() },
} }
// Parse index template
defaultTemplates.Index, err = template.New("index").Funcs(funcs).Parse(indexHTML)
if err != nil {
panic("failed to parse index template: " + err.Error())
}
// Parse status template // Parse status template
defaultTemplates.Status, err = template.New("status").Funcs(funcs).Parse(statusHTML) defaultTemplates.Status, err = template.New("status").Funcs(funcs).Parse(statusHTML)
if err != nil { if err != nil {
@@ -131,6 +159,11 @@ func Get() *Templates {
return defaultTemplates return defaultTemplates
} }
// IndexTemplate returns the parsed index template
func IndexTemplate() *template.Template {
return Get().Index
}
// StatusTemplate returns the parsed status template // StatusTemplate returns the parsed status template
func StatusTemplate() *template.Template { func StatusTemplate() *template.Template {
return Get().Status return Get().Status