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
This commit is contained in:
Jeffrey Paul 2025-07-28 18:42:38 +02:00
parent 1dcde74a90
commit ba13c76c53
8 changed files with 562 additions and 185276 deletions

View File

@ -884,11 +884,11 @@ func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string)
return err
}
// GetPrefixDistribution returns the distribution of prefixes by mask length
// GetPrefixDistribution returns the distribution of unique prefixes by mask length
func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error) {
// IPv4 distribution
// IPv4 distribution - count unique prefixes, not routes
query := `
SELECT mask_length, COUNT(*) as count
SELECT mask_length, COUNT(DISTINCT prefix) as count
FROM live_routes
WHERE ip_version = 4
GROUP BY mask_length
@ -908,9 +908,9 @@ func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []Pr
ipv4 = append(ipv4, dist)
}
// IPv6 distribution
// IPv6 distribution - count unique prefixes, not routes
query = `
SELECT mask_length, COUNT(*) as count
SELECT mask_length, COUNT(DISTINCT prefix) as count
FROM live_routes
WHERE ip_version = 6
GROUP BY mask_length
@ -1227,3 +1227,51 @@ func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
return routes, nil
}
// GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length
func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) {
query := `
SELECT DISTINCT
prefix, mask_length, ip_version, origin_asn, as_path,
peer_ip, last_updated
FROM live_routes
WHERE mask_length = ? AND ip_version = ?
ORDER BY RANDOM()
LIMIT ?
`
rows, err := d.db.Query(query, maskLength, ipVersion, limit)
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
GetASDetails(asn int) (*ASN, []LiveRoute, error)
GetPrefixDetails(prefix string) ([]LiveRoute, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
// Lifecycle
Close() error

View File

@ -221,6 +221,11 @@ func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error
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
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
m.mu.Lock()

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"html/template"
"net"
"net/http"
"net/url"
@ -15,7 +16,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/templates"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
)
@ -732,3 +733,115 @@ func (s *Server) handleIPRedirect() http.HandlerFunc {
http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther)
}
}
// handlePrefixLength shows a random sample of prefixes with the specified mask length
func (s *Server) handlePrefixLength() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lengthStr := chi.URLParam(r, "length")
if lengthStr == "" {
http.Error(w, "Length parameter is required", http.StatusBadRequest)
return
}
maskLength, err := strconv.Atoi(lengthStr)
if err != nil {
http.Error(w, "Invalid mask length", http.StatusBadRequest)
return
}
// Determine IP version based on mask length
const (
maxIPv4MaskLength = 32
maxIPv6MaskLength = 128
)
var ipVersion int
if maskLength <= maxIPv4MaskLength {
ipVersion = 4
} else if maskLength <= maxIPv6MaskLength {
ipVersion = 6
} else {
http.Error(w, "Invalid mask length", http.StatusBadRequest)
return
}
// Get random sample of prefixes
const maxPrefixes = 500
prefixes, err := s.db.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
r.Get("/as/{asn}", s.handleASDetail())
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/ip/{ip}", s.handleIPRedirect())
// API routes

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.innerHTML = `
<span class="metric-label">/${item.mask_length}</span>
<span class="metric-value">${formatNumber(item.count)}</span>
<a href="/prefixlength/${item.mask_length}" class="metric-value" style="text-decoration: none; color: inherit;">${formatNumber(item.count)}</a>
`;
container.appendChild(metric);
});

185548
log.txt

File diff suppressed because it is too large Load Diff