check / check (push) Waiting to run
The prefix page looked up each origin AS's details only to show its handle and description, and that lookup reads every live route the AS originates. `GetPrefixDetailsContext` already selected the handle and description and dropped them; it now returns them keyed by AS number, and the page uses them. The origin list now also shows the description after the handle, which it never rendered. The JSON prefix answer is unchanged. The test serves the page from a store whose AS lookup always fails. Model: opus-5-5
335 lines
9.5 KiB
HTML
335 lines
9.5 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>
|
|
* {
|
|
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;
|
|
}
|
|
.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">
|
|
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
|
|
{{if .Handle}} ({{.Handle}}){{end}}
|
|
{{if .Description}} {{.Description}}{{end}}
|
|
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</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>
|
|
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
|
|
</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>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
|
|
<td class="age">{{.LastUpdated | timeSince}}</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> |