check / check (push) Canceled after 0s
The home page gets cards for the newest and oldest kept route, the newest and oldest updated AS, the AS with the most prefixes, the ASes with a single prefix and fifteen well-known networks. Two more cards count the bogon announcements and the prefixes with several origin ASes. They link to the new pages /bogons and /multiple-origins, which list up to 1,000 entries each. The new package internal/bogon tells reserved prefixes and AS numbers, from the IANA special-purpose and address space registries. Table.Overview walks the table and lets the read lock go every 10,000 prefixes. The server works the cards out once a minute and serves that copy. A new index on asns(last_seen) finds the newest and oldest updated AS. Model: opus-5-5
786 lines
28 KiB
HTML
786 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RouteWatch - BGP Route Monitor</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 {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 30px 20px;
|
|
}
|
|
|
|
/* Stats overview */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 40px;
|
|
}
|
|
.stat-card {
|
|
background: white;
|
|
padding: 24px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
}
|
|
.stat-value {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #2c3e50;
|
|
margin-bottom: 8px;
|
|
}
|
|
.stat-label {
|
|
font-size: 14px;
|
|
color: #7f8c8d;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.stat-card.connected .stat-value {
|
|
color: #27ae60;
|
|
}
|
|
.stat-card.disconnected .stat-value,
|
|
.stale {
|
|
color: #e74c3c;
|
|
}
|
|
/* The card has no room beside "Connected", so the spinner and "stale" go under it */
|
|
#freshness {
|
|
display: block;
|
|
font-size: 20px;
|
|
}
|
|
|
|
/* Search section */
|
|
.search-section {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
gap: 24px;
|
|
margin-bottom: 40px;
|
|
}
|
|
.search-card {
|
|
background: white;
|
|
padding: 24px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.search-card h2 {
|
|
margin: 0 0 16px 0;
|
|
font-size: 18px;
|
|
color: #2c3e50;
|
|
}
|
|
.search-input-group {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.search-input-group input {
|
|
flex: 1;
|
|
padding: 12px 16px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.search-input-group input:focus {
|
|
border-color: #3498db;
|
|
}
|
|
.search-input-group button {
|
|
padding: 12px 24px;
|
|
background: #3498db;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.search-input-group button:hover {
|
|
background: #2980b9;
|
|
}
|
|
.search-input-group button:disabled {
|
|
background: #bdc3c7;
|
|
cursor: not-allowed;
|
|
}
|
|
.search-hint {
|
|
font-size: 12px;
|
|
color: #95a5a6;
|
|
margin-top: 8px;
|
|
}
|
|
#asname-results p {
|
|
color: #7f8c8d;
|
|
margin-top: 12px;
|
|
}
|
|
#asname-results .search-message {
|
|
font-size: 13px;
|
|
}
|
|
#asname-results .error {
|
|
color: #c00;
|
|
}
|
|
.asname-list {
|
|
list-style: none;
|
|
margin: 12px 0 0 0;
|
|
padding: 0;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
.asname-list li {
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.asname-list a {
|
|
display: block;
|
|
padding: 8px 4px;
|
|
color: #2c3e50;
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
}
|
|
.asname-list a:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
.asname-list .asn {
|
|
font-weight: 600;
|
|
color: #3498db;
|
|
}
|
|
.asname-list .description {
|
|
display: block;
|
|
color: #7f8c8d;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* IP Lookup result */
|
|
.ip-result {
|
|
margin-top: 16px;
|
|
display: none;
|
|
}
|
|
.ip-result.visible {
|
|
display: block;
|
|
}
|
|
.ip-result-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
.ip-result-header h3 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
color: #2c3e50;
|
|
}
|
|
.ip-result-header button {
|
|
background: none;
|
|
border: none;
|
|
color: #e74c3c;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
.ip-result pre,
|
|
#ip-api pre {
|
|
background: #2c3e50;
|
|
color: #ecf0f1;
|
|
padding: 16px;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
.ip-result .error,
|
|
#ip-api .error {
|
|
background: #fee;
|
|
color: #c00;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
}
|
|
.ip-result .loading,
|
|
#ip-api .loading {
|
|
color: #7f8c8d;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* IP information API card: the command, then its answer */
|
|
#ip-api p {
|
|
margin: 0;
|
|
color: #7f8c8d;
|
|
font-size: 14px;
|
|
}
|
|
#ip-api pre {
|
|
margin-top: 12px;
|
|
}
|
|
|
|
/* Cards with figures of the routing table, worked out once a minute */
|
|
#table-cards p,
|
|
#well-known p {
|
|
margin: 0 0 8px 0;
|
|
font-size: 14px;
|
|
}
|
|
#table-cards a,
|
|
#well-known a {
|
|
color: #3498db;
|
|
text-decoration: none;
|
|
}
|
|
#table-cards a:hover,
|
|
#well-known a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
#table-cards .card-figure {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
}
|
|
#table-cards .card-detail,
|
|
#table-cards .card-time {
|
|
color: #7f8c8d;
|
|
font-size: 12px;
|
|
}
|
|
#table-cards .prefix {
|
|
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|
}
|
|
#well-known {
|
|
margin-bottom: 40px;
|
|
}
|
|
#well-known table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 14px;
|
|
}
|
|
#well-known th,
|
|
#well-known td {
|
|
text-align: left;
|
|
padding: 8px 4px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
#well-known th {
|
|
color: #7f8c8d;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Footer */
|
|
.footer {
|
|
margin-top: 40px;
|
|
padding: 20px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
|
text-align: center;
|
|
color: #7f8c8d;
|
|
font-size: 14px;
|
|
}
|
|
.footer a {
|
|
color: #3498db;
|
|
text-decoration: none;
|
|
}
|
|
.footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.footer .separator {
|
|
margin: 0 10px;
|
|
color: #ddd;
|
|
}
|
|
</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="/" class="active">Home</a>
|
|
<a href="/status">Status</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="main-content">
|
|
<div class="stats-grid">
|
|
<div class="stat-card" id="status-card">
|
|
<div class="stat-value">
|
|
<span id="stat-status">-</span>
|
|
<span id="freshness"></span>
|
|
</div>
|
|
<div class="stat-label">Status</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-routes">-</div>
|
|
<div class="stat-label">Live Routes</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-asns">-</div>
|
|
<div class="stat-label">Autonomous Systems</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-prefixes">-</div>
|
|
<div class="stat-label">Prefixes</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-peers">-</div>
|
|
<div class="stat-label">BGP Peers</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="stat-updates">-</div>
|
|
<div class="stat-label">Updates/sec</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-section">
|
|
<div class="search-card">
|
|
<h2>AS Number Lookup</h2>
|
|
<form id="asn-form" class="search-input-group">
|
|
<input type="text" id="asn-input" placeholder="e.g., 15169 or AS15169" autocomplete="off">
|
|
<button type="submit">Lookup</button>
|
|
</form>
|
|
<p class="search-hint">Enter an AS number to view its announced prefixes and peers</p>
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>AS Name Search</h2>
|
|
<form id="asname-form" class="search-input-group">
|
|
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
<p class="search-hint">Search for autonomous systems by organization name or handle</p>
|
|
<div id="asname-results"></div>
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>IP Address Lookup</h2>
|
|
<form id="ip-form" class="search-input-group">
|
|
<input type="text" id="ip-input" placeholder="e.g., 8.8.8.8 or 2001:4860:4860::8888" autocomplete="off">
|
|
<button type="submit">Lookup</button>
|
|
</form>
|
|
<p class="search-hint">Get routing information for any IP address</p>
|
|
<div id="ip-result" class="ip-result">
|
|
<div class="ip-result-header">
|
|
<h3>Result</h3>
|
|
<button type="button" id="ip-result-close">Clear</button>
|
|
</div>
|
|
<pre id="ip-result-content"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{ with .Cards }}
|
|
<div class="search-section" id="table-cards">
|
|
<div class="search-card">
|
|
<h2>Newest Updated Route</h2>
|
|
{{ with .NewestRoute }}
|
|
<p><a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a> from {{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}no known origin{{ end }}</p>
|
|
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
|
|
{{ else }}
|
|
<p>The routing table keeps no route yet.</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>Oldest Updated Route</h2>
|
|
{{ with .OldestRoute }}
|
|
<p><a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a> from {{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}no known origin{{ end }}</p>
|
|
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
|
|
{{ else }}
|
|
<p>The routing table keeps no route yet.</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>Newest Updated AS</h2>
|
|
{{ with .NewestAS }}
|
|
<p><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Handle }}</p>
|
|
<p class="card-detail">{{ .Description }}</p>
|
|
<p class="card-time">Last seen {{ .LastSeen.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .LastSeen }}</p>
|
|
{{ else }}
|
|
<p>No AS has been seen yet.</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>Oldest Updated AS</h2>
|
|
{{ with .OldestAS }}
|
|
<p><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Handle }}</p>
|
|
<p class="card-detail">{{ .Description }}</p>
|
|
<p class="card-time">Last seen {{ .LastSeen.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .LastSeen }}</p>
|
|
{{ else }}
|
|
<p>No AS has been seen yet.</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>AS with the Most Prefixes</h2>
|
|
{{ if .MostPrefixes }}
|
|
<p><a href="/as/{{ .MostPrefixesASN }}">AS{{ .MostPrefixesASN }}</a></p>
|
|
<p>Prefixes it originates: {{ .MostPrefixes }}</p>
|
|
{{ else }}
|
|
<p>No AS originates a prefix yet.</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>ASes with the Fewest Prefixes</h2>
|
|
<p>ASes that originate a single prefix: {{ .SinglePrefixASes }}</p>
|
|
{{ with .NewestSinglePrefix }}
|
|
<p>The most recently updated of them: <a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>, with <a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a></p>
|
|
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>Bogon Announcements</h2>
|
|
<p class="card-figure"><a href="/bogons">{{ .BogonCount }}</a></p>
|
|
<p>Routes that announce a reserved prefix, or that come from a reserved AS number</p>
|
|
</div>
|
|
|
|
<div class="search-card">
|
|
<h2>Prefixes with Several Origins</h2>
|
|
<p class="card-figure"><a href="/multiple-origins">{{ .MultipleOriginCount }}</a></p>
|
|
<p>Prefixes announced by more than one origin AS</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-card" id="well-known">
|
|
<h2>Well-Known Networks</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Network</th>
|
|
<th>Prefixes</th>
|
|
<th>Last Update</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{ range .WellKnownNetworks }}
|
|
<tr>
|
|
<td><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Name }}</td>
|
|
<td>{{ .Prefixes }}</td>
|
|
<td>{{ with .Newest }}{{ timeSince . }}{{ else }}no route{{ end }}</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{ else }}
|
|
<div class="search-card" id="well-known">
|
|
<p>The figures of the routing table are being worked out. Reload the page in a minute.</p>
|
|
</div>
|
|
{{ end }}
|
|
|
|
<div class="search-card" id="ip-api">
|
|
<h2>IP Information API</h2>
|
|
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
|
|
<pre id="ip-api-command" data-client-ip="{{.ClientIP}}"></pre>
|
|
<pre id="ip-api-answer" class="loading">Loading...</pre>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="footer">
|
|
<span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span>
|
|
<span class="separator">|</span>
|
|
<span>{{appLicense}}</span>
|
|
<span class="separator">|</span>
|
|
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
|
|
</footer>
|
|
|
|
<script nonce="{{.CSPNonce}}">
|
|
// The stats are fetched every REFRESH_PERIOD_MS. Next to the status, a
|
|
// spinner turns one frame each time an answer counts more messages
|
|
// than the one before. After STALE_AFTER_REFRESHES refreshes without
|
|
// such an answer, the spinner gives way to "stale" and the time since.
|
|
const REFRESH_PERIOD_MS = 5000;
|
|
const STALE_AFTER_REFRESHES = 3;
|
|
const MS_PER_SECOND = 1000;
|
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
|
|
|
|
let spinnerFrame = 0;
|
|
let lastMessageCount = null;
|
|
let lastFreshAt = Date.now();
|
|
|
|
// Turn the spinner when the message count has risen since the last answer.
|
|
function noteMessageCount(count) {
|
|
if (lastMessageCount !== null && count > lastMessageCount) {
|
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
lastFreshAt = Date.now();
|
|
}
|
|
lastMessageCount = count;
|
|
showFreshness();
|
|
}
|
|
|
|
function showFreshness() {
|
|
const freshness = document.getElementById('freshness');
|
|
const sinceFreshMs = Date.now() - lastFreshAt;
|
|
const stale = sinceFreshMs >= STALE_AFTER_REFRESHES * REFRESH_PERIOD_MS;
|
|
freshness.classList.toggle('stale', stale);
|
|
if (stale) {
|
|
freshness.textContent = 'stale for ' + Math.floor(sinceFreshMs / MS_PER_SECOND) + 's';
|
|
freshness.style.color = '';
|
|
return;
|
|
}
|
|
freshness.textContent = SPINNER_FRAMES[spinnerFrame];
|
|
freshness.style.color = SPINNER_COLOURS[spinnerFrame % SPINNER_COLOURS.length];
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
if (num >= 1000000) {
|
|
return (num / 1000000).toFixed(1) + 'M';
|
|
} else if (num >= 1000) {
|
|
return (num / 1000).toFixed(1) + 'K';
|
|
}
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
// Fetch and display stats
|
|
function updateStats() {
|
|
fetch('/api/v1/stats')
|
|
.then(response => response.json())
|
|
.then(response => {
|
|
if (response.status !== 'ok') return;
|
|
const data = response.data;
|
|
|
|
const statusCard = document.getElementById('status-card');
|
|
const statusEl = document.getElementById('stat-status');
|
|
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
|
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
|
|
noteMessageCount(data.total_messages);
|
|
|
|
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
|
|
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
|
|
document.getElementById('stat-prefixes').textContent = formatNumber(data.prefixes);
|
|
|
|
if (data.stream) {
|
|
document.getElementById('stat-peers').textContent = formatNumber(data.stream.bgp_peer_count);
|
|
}
|
|
|
|
const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec;
|
|
document.getElementById('stat-updates').textContent = Math.round(totalUpdates).toLocaleString();
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('stat-status').textContent = 'Error';
|
|
document.getElementById('status-card').className = 'stat-card disconnected';
|
|
});
|
|
}
|
|
|
|
// ASN lookup
|
|
document.getElementById('asn-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
let asn = document.getElementById('asn-input').value.trim();
|
|
// Remove 'AS' prefix if present
|
|
asn = asn.replace(/^AS/i, '');
|
|
if (asn && /^\d+$/.test(asn)) {
|
|
window.location.href = '/as/' + asn;
|
|
}
|
|
});
|
|
|
|
// AS name search. The handles and descriptions come from outside data,
|
|
// so the results are built with textContent, never innerHTML.
|
|
const asNameResults = document.getElementById('asname-results');
|
|
|
|
// Replace the search results with one line of text.
|
|
function showASNameMessage(text, className) {
|
|
const line = document.createElement('p');
|
|
line.className = className;
|
|
line.textContent = text;
|
|
asNameResults.replaceChildren(line);
|
|
}
|
|
|
|
// List the matches of a search answer, each as a link to its AS page,
|
|
// and say how many more matched than the answer holds.
|
|
function showASNameMatches(data) {
|
|
if (data.total === 0) {
|
|
showASNameMessage('No AS matches "' + data.query + '".', 'search-message');
|
|
return;
|
|
}
|
|
|
|
const list = document.createElement('ul');
|
|
list.className = 'asname-list';
|
|
for (const match of data.results) {
|
|
const asn = document.createElement('span');
|
|
asn.className = 'asn';
|
|
asn.textContent = 'AS' + match.asn;
|
|
|
|
const description = document.createElement('span');
|
|
description.className = 'description';
|
|
description.textContent = match.description;
|
|
|
|
const link = document.createElement('a');
|
|
link.href = '/as/' + match.asn;
|
|
link.append(asn, ' ' + match.handle, description);
|
|
|
|
const item = document.createElement('li');
|
|
item.append(link);
|
|
list.append(item);
|
|
}
|
|
asNameResults.replaceChildren(list);
|
|
|
|
const more = data.total - data.results.length;
|
|
if (more > 0) {
|
|
const line = document.createElement('p');
|
|
line.className = 'search-message';
|
|
line.textContent = more.toLocaleString() + ' more matched. Narrow the search to see them.';
|
|
asNameResults.append(line);
|
|
}
|
|
}
|
|
|
|
document.getElementById('asname-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
const query = document.getElementById('asname-input').value.trim();
|
|
if (!query) return;
|
|
|
|
// A number, or AS and a number, opens that AS's page
|
|
const asn = query.replace(/^AS/i, '');
|
|
if (/^\d+$/.test(asn)) {
|
|
window.location.href = '/as/' + asn;
|
|
return;
|
|
}
|
|
|
|
showASNameMessage('Searching...', 'loading');
|
|
|
|
fetch('/api/v1/search/as?q=' + encodeURIComponent(query))
|
|
.then(response => response.json())
|
|
.then(response => {
|
|
if (response.status === 'error') {
|
|
showASNameMessage('Error: ' + response.error.msg, 'error');
|
|
} else {
|
|
showASNameMatches(response.data);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showASNameMessage('Error: ' + error.message, 'error');
|
|
});
|
|
});
|
|
|
|
// IP lookup
|
|
document.getElementById('ip-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
const ip = document.getElementById('ip-input').value.trim();
|
|
if (!ip) return;
|
|
|
|
const resultDiv = document.getElementById('ip-result');
|
|
const contentEl = document.getElementById('ip-result-content');
|
|
|
|
resultDiv.classList.add('visible');
|
|
contentEl.className = '';
|
|
contentEl.textContent = 'Loading...';
|
|
contentEl.classList.add('loading');
|
|
|
|
fetch('/ip/' + encodeURIComponent(ip))
|
|
.then(response => response.json())
|
|
.then(response => {
|
|
contentEl.classList.remove('loading');
|
|
if (response.status === 'error') {
|
|
contentEl.className = 'error';
|
|
contentEl.textContent = 'Error: ' + response.error.msg;
|
|
} else {
|
|
contentEl.className = '';
|
|
contentEl.textContent = JSON.stringify(response.data, null, 2);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
contentEl.classList.remove('loading');
|
|
contentEl.className = 'error';
|
|
contentEl.textContent = 'Error: ' + error.message;
|
|
});
|
|
});
|
|
|
|
// Close IP result
|
|
document.getElementById('ip-result-close').addEventListener('click', function() {
|
|
document.getElementById('ip-result').classList.remove('visible');
|
|
document.getElementById('ip-input').value = '';
|
|
});
|
|
|
|
// IP information API card: show the curl command that asks /ip/ about
|
|
// the visitor's own address, then make that same request and show the
|
|
// JSON it answers, an error answer included. The answer holds outside
|
|
// data, so it is set with textContent, never innerHTML.
|
|
const ipAPICommand = document.getElementById('ip-api-command');
|
|
const ipAPIAnswer = document.getElementById('ip-api-answer');
|
|
const ipAPIURL = window.location.origin + '/ip/' + ipAPICommand.dataset.clientIp;
|
|
|
|
ipAPICommand.textContent = 'curl ' + ipAPIURL;
|
|
|
|
fetch(ipAPIURL)
|
|
.then(response => response.json())
|
|
.then(answer => {
|
|
ipAPIAnswer.className = '';
|
|
ipAPIAnswer.textContent = JSON.stringify(answer, null, 2);
|
|
})
|
|
.catch(error => {
|
|
ipAPIAnswer.className = 'error';
|
|
ipAPIAnswer.textContent = 'Error: ' + error.message;
|
|
});
|
|
|
|
// Initial load and refresh stats every REFRESH_PERIOD_MS. The freshness
|
|
// check runs every second on its own, so a request that never returns
|
|
// still turns the spinner into "stale".
|
|
updateStats();
|
|
setInterval(updateStats, REFRESH_PERIOD_MS);
|
|
setInterval(showFreshness, MS_PER_SECOND);
|
|
</script>
|
|
</body>
|
|
</html>
|