check / check (push) Canceled after 0s
The AS Name Search card on the home page said the search was coming soon.
It now asks the new GET /api/v1/search/as?q=<text> and lists the matches
as links to their AS pages. asinfo.Search ignores case, returns the first
matches up to a limit in AS number order, and counts all matches. An
empty q answers 400.
When the database has no record of an AS, its page and /api/v1/as/{asn}
take its handle and description from asinfo and still list its prefixes.
They answer 404 only for an AS with no record, no asinfo entry and no
prefix. AS 0 means no known origin: it always answers 404, and the search
never lists it.
Model: opus-5-5
545 lines
18 KiB
HTML
545 lines
18 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 {
|
|
color: #e74c3c;
|
|
}
|
|
|
|
/* 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 {
|
|
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 {
|
|
background: #fee;
|
|
color: #c00;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
}
|
|
.ip-result .loading {
|
|
color: #7f8c8d;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* 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" id="stat-status">-</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>
|
|
</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}}">
|
|
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');
|
|
|
|
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 = totalUpdates.toFixed(1);
|
|
})
|
|
.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 = '';
|
|
});
|
|
|
|
// Initial load and refresh stats every 5 seconds
|
|
updateStats();
|
|
setInterval(updateStats, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|