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