Files
routewatch/internal/templates/prefix_detail.html
T
sneak 347e1456a2
check / check (push) Successful in 4m6s
Record origin changes and show them on the prefix page (closes #161)
The routing table records each prefix and origin AS pair's origin changes:
when it gains its first peer and when it loses its last. Each row holds the
prefix, the origin AS, announced or withdrawn, the peer count and the time.
The prefix handler writes them to a new origin_changes table, and the database
maintainer prunes each prefix to its newest 500 rows and 72 hours. The prefix
page lists them newest first, at most 5,000.

Model: deepseek-v4-pro
2026-11-05 04:07:39 +00:00

382 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Prefix}} - RouteWatch</title>
<style nonce="{{.CSPNonce}}">
* {
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 {
padding: 20px;
}
.container {
width: 90%;
max-width: 1600px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
margin: 0 0 10px 0;
color: #2c3e50;
font-family: monospace;
font-size: 28px;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 30px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.info-card {
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #3498db;
}
.info-label {
font-size: 14px;
color: #7f8c8d;
margin-bottom: 5px;
}
.info-value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.routes-section {
margin-top: 30px;
}
.routes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.routes-header h2 {
margin: 0;
color: #2c3e50;
}
.route-count {
background: #e74c3c;
color: white;
padding: 5px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.route-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.route-table th {
background: #34495e;
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
.route-table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
white-space: nowrap;
}
.route-table tr:hover {
background: #f8f9fa;
}
.route-table tr:last-child td {
border-bottom: none;
}
.as-link {
color: #3498db;
text-decoration: none;
}
.as-link:hover {
text-decoration: underline;
}
.peer-ip {
font-family: monospace;
font-size: 14px;
color: #555;
}
.as-path {
font-family: monospace;
font-size: 13px;
color: #666;
max-width: 600px;
word-wrap: break-word;
white-space: normal !important;
line-height: 1.5;
}
.as-path .as-link {
font-weight: 600;
}
.age {
color: #7f8c8d;
font-size: 14px;
}
.nav-link {
display: inline-block;
margin-bottom: 20px;
color: #3498db;
text-decoration: none;
}
.nav-link:hover {
text-decoration: underline;
}
.origins-section {
margin-top: 30px;
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
}
.origins-section h3 {
margin-top: 0;
color: #2c3e50;
}
.origin-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.origin-item {
background: white;
padding: 10px 15px;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.origin-item .peer-count {
color: #7f8c8d;
margin-left: 10px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
.info-grid {
grid-template-columns: 1fr;
}
.route-table {
font-size: 14px;
}
.as-path {
max-width: 100%;
}
}
</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="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<div class="container">
<h1>{{.Prefix}}</h1>
<p class="subtitle">IPv{{.IPVersion}} Prefix{{if .MaskLength}} • /{{.MaskLength}}{{end}}</p>
<div class="info-grid">
<div class="info-card">
<div class="info-label">Seen from Peers</div>
<div class="info-value">{{.PeerCount}}</div>
</div>
<div class="info-card">
<div class="info-label">Origin ASNs</div>
<div class="info-value">{{.OriginCount}}</div>
</div>
<div class="info-card">
<div class="info-label">IP Version</div>
<div class="info-value">IPv{{.IPVersion}}</div>
</div>
</div>
{{if .Origins}}
<div class="origins-section">
<h3>Origin ASNs</h3>
<div class="origin-list">
{{range .Origins}}
<div class="origin-item">
{{if .ASN}}
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
{{if .Handle}} ({{.Handle}}){{end}}
{{if .Description}} {{.Description}}{{end}}
{{else}}
No known origin
{{end}}
<span class="peer-count">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
{{if .OriginChanges}}
<div class="origins-section">
<h3>Origin Changes</h3>
{{if .MoreOriginChanges}}
<p>There are more than 5,000 changes. Showing the newest 5,000.</p>
{{end}}
<table class="route-table">
<thead>
<tr>
<th>Time (UTC)</th>
<th>Change</th>
<th>Origin AS</th>
<th>Peers</th>
</tr>
</thead>
<tbody>
{{range .OriginChanges}}
<tr>
<td>{{.Time.Format "2006-01-02 15:04:05"}}</td>
<td>{{if .Announced}}Announced{{else}}Withdrawn{{end}}</td>
<td>
{{if .OriginASN}}
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
{{else}}
No known origin
{{end}}
</td>
<td>{{.PeerCount}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if .Routes}}
<div class="routes-section">
<div class="routes-header">
<h2>Route Details</h2>
<span class="route-count">{{.PeerCount}} route{{if ne .PeerCount 1}}s{{end}}</span>
</div>
<table class="route-table">
<thead>
<tr>
<th>Origin AS</th>
<th>Peer IP</th>
<th>AS Path</th>
<th>Next Hop</th>
<th>Last Updated</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .Routes}}
<tr>
<td>
{{if .OriginASN}}
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
{{else}}
No known origin
{{end}}
</td>
<td class="peer-ip">{{.PeerIP}}</td>
<td class="as-path">{{range $i, $as := .ASPathWithHandle}}{{if $i}} → {{end}}<a href="/as/{{$as.ASN}}" class="as-link">{{if $as.Handle}}{{$as.Handle}}{{else}}AS{{$as.ASN}}{{end}}</a>{{end}}</td>
<td class="peer-ip">{{.NextHop}}</td>
<td>{{if not .LastUpdated.IsZero}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{end}}</td>
<td class="age">{{if not .LastUpdated.IsZero}}{{.LastUpdated | timeSince}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p>No routes found for this prefix</p>
</div>
{{end}}
</div>
</main>
</body>
</html>