6 Commits

Author SHA1 Message Date
5fb3fc0381 Fix prefix length page to show unique prefixes only
- Change GetRandomPrefixesByLength to return unique prefixes instead of all routes
- Use CTE to first select random unique prefixes, then join to get their latest route info
- This ensures each prefix appears only once in the list
2025-07-28 19:04:19 +02:00
9a63553f8d Add index to optimize COUNT(DISTINCT prefix) queries
- Add compound index on (ip_version, mask_length, prefix) to speed up prefix distribution queries
- This index will significantly improve performance of COUNT(DISTINCT prefix) operations
- Note: Existing databases will need to manually create this index or recreate the database
2025-07-28 19:01:45 +02:00
ba13c76c53 Fix prefix distribution bug and add prefix length pages
- Fix GetPrefixDistribution to count unique prefixes using COUNT(DISTINCT prefix) instead of COUNT(*)
- Add /prefixlength/<length> route showing random sample of 500 prefixes
- Make prefix counts on status page clickable links to prefix length pages
- Add GetRandomPrefixesByLength database method
- Create prefix_length.html template with sortable table
- Show prefix age and origin AS with descriptions
2025-07-28 18:42:38 +02:00
1dcde74a90 Update AS path display to show handles with clickable links
- Change AS path from descriptions to handles (short names)
- Make each AS in the path a clickable link to /as/<asn>
- Add font-weight to AS links in path for better visibility
- Prevent word wrapping on all table columns except AS path
- Remove unused maxASDescriptionLength constant
2025-07-28 18:31:35 +02:00
81267431f7 Increase batch sizes to improve write throughput
- Increase prefix batch size from 5K to 20K
- Increase ASN batch size from 10K to 30K
- Add comments warning not to reduce batch timeouts
- Add comments warning not to increase queue sizes above 100K
- Maintains existing batch timeouts for efficiency
2025-07-28 18:27:42 +02:00
dc3ceb8d94 Show AS descriptions in AS path on prefix detail page
- Display AS descriptions alongside AS numbers in format: Description (ASN)
- Truncate descriptions longer than 20 characters with ellipsis
- Increase container max width to 1600px for better display
- Enable word wrapping for AS path cells to handle long paths
- Update mobile responsive styles for AS path display
2025-07-28 18:25:26 +02:00
13 changed files with 2072 additions and 270951 deletions

View File

@@ -884,11 +884,11 @@ func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string)
return err return err
} }
// GetPrefixDistribution returns the distribution of prefixes by mask length // GetPrefixDistribution returns the distribution of unique prefixes by mask length
func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) { func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) {
// IPv4 distribution // IPv4 distribution - count unique prefixes, not routes
query := ` query := `
SELECT mask_length, COUNT(*) as count SELECT mask_length, COUNT(DISTINCT prefix) as count
FROM live_routes FROM live_routes
WHERE ip_version = 4 WHERE ip_version = 4
GROUP BY mask_length GROUP BY mask_length
@@ -908,9 +908,9 @@ func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []Pr
ipv4 = append(ipv4, dist) ipv4 = append(ipv4, dist)
} }
// IPv6 distribution // IPv6 distribution - count unique prefixes, not routes
query = ` query = `
SELECT mask_length, COUNT(*) as count SELECT mask_length, COUNT(DISTINCT prefix) as count
FROM live_routes FROM live_routes
WHERE ip_version = 6 WHERE ip_version = 6
GROUP BY mask_length GROUP BY mask_length
@@ -1227,3 +1227,58 @@ func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
return routes, nil return routes, nil
} }
// GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length
func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) {
// Select unique prefixes with their most recent route information
query := `
WITH unique_prefixes AS (
SELECT prefix, MAX(last_updated) as max_updated
FROM live_routes
WHERE mask_length = ? AND ip_version = ?
GROUP BY prefix
ORDER BY RANDOM()
LIMIT ?
)
SELECT lr.prefix, lr.mask_length, lr.ip_version, lr.origin_asn, lr.as_path,
lr.peer_ip, lr.last_updated
FROM live_routes lr
INNER JOIN unique_prefixes up ON lr.prefix = up.prefix AND lr.last_updated = up.max_updated
WHERE lr.mask_length = ? AND lr.ip_version = ?
`
rows, err := d.db.Query(query, maskLength, ipVersion, limit, maskLength, ipVersion)
if err != nil {
return nil, fmt.Errorf("failed to query random prefixes: %w", err)
}
defer func() {
_ = rows.Close()
}()
var routes []LiveRoute
for rows.Next() {
var route LiveRoute
var pathJSON string
err := rows.Scan(
&route.Prefix,
&route.MaskLength,
&route.IPVersion,
&route.OriginASN,
&pathJSON,
&route.PeerIP,
&route.LastUpdated,
)
if err != nil {
continue
}
// Decode AS path
if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil {
route.ASPath = []int{}
}
routes = append(routes, route)
}
return routes, nil
}

View File

@@ -54,6 +54,7 @@ type Store interface {
// AS and prefix detail operations // AS and prefix detail operations
GetASDetails(asn int) (*ASN, []LiveRoute, error) GetASDetails(asn int) (*ASN, []LiveRoute, error)
GetPrefixDetails(prefix string) ([]LiveRoute, error) GetPrefixDetails(prefix string) ([]LiveRoute, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
// Lifecycle // Lifecycle
Close() error Close() error

View File

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

View File

@@ -221,6 +221,11 @@ func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error
return []database.LiveRoute{}, nil return []database.LiveRoute{}, nil
} }
func (m *mockStore) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]database.LiveRoute, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
}
// UpsertLiveRouteBatch mock implementation // UpsertLiveRouteBatch mock implementation
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error { func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
m.mu.Lock() m.mu.Lock()

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"html/template"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -15,6 +16,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/templates" "git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -645,12 +647,41 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
origins = append(origins, origin) origins = append(origins, origin)
} }
// Create enhanced routes with AS path handles
type ASPathEntry struct {
Number int
Handle string
}
type EnhancedRoute struct {
database.LiveRoute
ASPathWithHandle []ASPathEntry
}
enhancedRoutes := make([]EnhancedRoute, len(routes))
for i, route := range routes {
enhancedRoute := EnhancedRoute{
LiveRoute: route,
ASPathWithHandle: make([]ASPathEntry, len(route.ASPath)),
}
// Look up handle for each AS in the path
for j, asn := range route.ASPath {
handle := asinfo.GetHandle(asn)
enhancedRoute.ASPathWithHandle[j] = ASPathEntry{
Number: asn,
Handle: handle,
}
}
enhancedRoutes[i] = enhancedRoute
}
// Prepare template data // Prepare template data
data := struct { data := struct {
Prefix string Prefix string
MaskLength int MaskLength int
IPVersion int IPVersion int
Routes []database.LiveRoute Routes []EnhancedRoute
Origins []*ASNInfo Origins []*ASNInfo
PeerCount int PeerCount int
OriginCount int OriginCount int
@@ -658,7 +689,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
Prefix: prefix, Prefix: prefix,
MaskLength: maskLength, MaskLength: maskLength,
IPVersion: ipVersion, IPVersion: ipVersion,
Routes: routes, Routes: enhancedRoutes,
Origins: origins, Origins: origins,
PeerCount: len(routes), PeerCount: len(routes),
OriginCount: len(originMap), OriginCount: len(originMap),
@@ -702,3 +733,115 @@ func (s *Server) handleIPRedirect() http.HandlerFunc {
http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther) http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther)
} }
} }
// handlePrefixLength shows a random sample of prefixes with the specified mask length
func (s *Server) handlePrefixLength() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lengthStr := chi.URLParam(r, "length")
if lengthStr == "" {
http.Error(w, "Length parameter is required", http.StatusBadRequest)
return
}
maskLength, err := strconv.Atoi(lengthStr)
if err != nil {
http.Error(w, "Invalid mask length", http.StatusBadRequest)
return
}
// Determine IP version based on mask length
const (
maxIPv4MaskLength = 32
maxIPv6MaskLength = 128
)
var ipVersion int
if maskLength <= maxIPv4MaskLength {
ipVersion = 4
} else if maskLength <= maxIPv6MaskLength {
ipVersion = 6
} else {
http.Error(w, "Invalid mask length", http.StatusBadRequest)
return
}
// Get random sample of prefixes
const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLength(maskLength, ipVersion, maxPrefixes)
if err != nil {
s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Sort prefixes for display
sort.Slice(prefixes, func(i, j int) bool {
// First compare by IP version
if prefixes[i].IPVersion != prefixes[j].IPVersion {
return prefixes[i].IPVersion < prefixes[j].IPVersion
}
// Then by prefix
return prefixes[i].Prefix < prefixes[j].Prefix
})
// Create enhanced prefixes with AS descriptions
type EnhancedPrefix struct {
database.LiveRoute
OriginASDescription string
Age string
}
enhancedPrefixes := make([]EnhancedPrefix, len(prefixes))
for i, prefix := range prefixes {
enhancedPrefixes[i] = EnhancedPrefix{
LiveRoute: prefix,
Age: formatAge(prefix.LastUpdated),
}
// Get AS description
if asInfo, ok := asinfo.Get(prefix.OriginASN); ok {
enhancedPrefixes[i].OriginASDescription = asInfo.Description
}
}
// Render template
data := map[string]interface{}{
"MaskLength": maskLength,
"IPVersion": ipVersion,
"Prefixes": enhancedPrefixes,
"Count": len(prefixes),
}
tmpl := template.Must(template.ParseFiles("internal/templates/prefix_length.html"))
if err := tmpl.Execute(w, data); err != nil {
s.logger.Error("Failed to render prefix length template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// formatAge returns a human-readable age string
func formatAge(timestamp time.Time) string {
age := time.Since(timestamp)
const hoursPerDay = 24
if age < time.Minute {
return "< 1m"
} else if age < time.Hour {
minutes := int(age.Minutes())
return strconv.Itoa(minutes) + "m"
} else if age < hoursPerDay*time.Hour {
hours := int(age.Hours())
return strconv.Itoa(hours) + "h"
}
days := int(age.Hours() / hoursPerDay)
return strconv.Itoa(days) + "d"
}

View File

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

View File

@@ -7,6 +7,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"net/http" "net/http"
"os" "os"
"sync" "sync"
@@ -95,6 +96,9 @@ func (s *Streamer) RegisterHandler(handler MessageHandler) {
info := &handlerInfo{ info := &handlerInfo{
handler: handler, handler: handler,
queue: make(chan *ristypes.RISMessage, handler.QueueCapacity()), queue: make(chan *ristypes.RISMessage, handler.QueueCapacity()),
metrics: handlerMetrics{
minTime: time.Duration(math.MaxInt64), // Initialize to max so first value sets the floor
},
} }
s.handlers = append(s.handlers, info) s.handlers = append(s.handlers, info)
@@ -170,7 +174,7 @@ func (s *Streamer) runHandlerWorker(info *handlerInfo) {
info.metrics.totalTime += elapsed info.metrics.totalTime += elapsed
// Update min time // Update min time
if info.metrics.minTime == 0 || elapsed < info.metrics.minTime { if elapsed < info.metrics.minTime {
info.metrics.minTime = elapsed info.metrics.minTime = elapsed
} }

View File

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

View File

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

View File

@@ -231,7 +231,7 @@
metric.className = 'metric'; metric.className = 'metric';
metric.innerHTML = ` metric.innerHTML = `
<span class="metric-label">/${item.mask_length}</span> <span class="metric-label">/${item.mask_length}</span>
<span class="metric-value">${formatNumber(item.count)}</span> <a href="/prefixlength/${item.mask_length}" class="metric-value" style="text-decoration: none; color: inherit;">${formatNumber(item.count)}</a>
`; `;
container.appendChild(metric); container.appendChild(metric);
}); });

272658
log.txt

File diff suppressed because it is too large Load Diff