Compare commits
14 Commits
c116b035bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 68cc06690b | |||
| 4f62b280c5 | |||
| f1d7c21478 | |||
| a163449a28 | |||
| 8f524485f7 | |||
| c6fa2b0fbd | |||
| f788a0dbf9 | |||
| aebdd1b23e | |||
| 8fc10ae98d | |||
| d27536812f | |||
| 58b5333c6c | |||
| 4284e923a6 | |||
| 45810e3fc8 | |||
| 27909e021f |
13
Dockerfile
13
Dockerfile
@@ -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" ]
|
||||||
|
|||||||
@@ -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
7
entrypoint.sh
Normal 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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
447
internal/templates/index.html
Normal file
447
internal/templates/index.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user