check / check (push) Successful in 1m3s
The routing table records a row each time a prefix and origin AS gets its first peer (announced) or loses its last (withdrawn), with the time and how many peers carry the prefix just after. The prefix handler writes the rows to a new origin_changes table, apart from routes. Every hour the database maintainer keeps each prefix's rows from the last 72 hours or its newest 500, whichever keeps more. The prefix page lists them newest first, at most 5,000. At most 100,000 rows wait in memory to be written; past that the oldest are dropped and logged. A stop loses the rows past its last write. Model: deepseek-v4-pro (implementation); opus-5-5 (commit message)
382 lines
11 KiB
HTML
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 {{.MaxOriginChanges}} changes. Showing the newest {{.MaxOriginChanges}}.</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> |