- Fix nested anchor tags in navbar (invalid HTML) - Hardcode app name and author in templates - Remove hero section from index page
448 lines
15 KiB
HTML
448 lines
15 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>
|
|
* {
|
|
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;
|
|
}
|
|
|
|
/* 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</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>
|
|
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
|
|
document.getElementById('asname-form').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
const query = document.getElementById('asname-input').value.trim();
|
|
if (!query) return;
|
|
|
|
const resultsDiv = document.getElementById('asname-results');
|
|
resultsDiv.innerHTML = '<p class="loading" style="color: #7f8c8d; margin-top: 12px;">Searching...</p>';
|
|
|
|
// Use a simple client-side search against the asinfo data
|
|
// For now, redirect to AS page if it looks like an ASN
|
|
if (/^\d+$/.test(query)) {
|
|
window.location.href = '/as/' + query;
|
|
return;
|
|
}
|
|
|
|
// Show a message that server-side search is coming
|
|
resultsDiv.innerHTML = '<p style="color: #7f8c8d; margin-top: 12px; font-size: 13px;">AS name search coming soon. For now, try an AS number.</p>';
|
|
});
|
|
|
|
// 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>
|