<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiveRelay Network</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%2316213e'/%3E%3Cpath d='M50 10 C 54 38 60 44 88 50 C 60 56 54 62 50 90 C 46 62 40 56 12 50 C 40 44 46 38 50 10 Z' fill='%23f5c842'/%3E%3C/svg%3E">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117;--card:#161b22;--card-border:#21262d;--card-hover:#1c2333;
--green:#3fb950;--orange:#d29922;--cyan:#58a6ff;--red:#f85149;--purple:#bc8cff;--accent:#f5c842;
--text:#c9d1d9;--text-dim:#8b949e;--text-bright:#f0f6fc;
--mono:'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
html{font-size:14px}
body{background:var(--bg) radial-gradient(1100px 500px at 50% -150px,#131a2b,transparent);color:var(--text);font-family:var(--sans);min-height:100vh;padding:0}
/* Top Bar */
.topbar{
background:var(--card);border-bottom:1px solid var(--card-border);
padding:1rem 2rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
position:sticky;top:0;z-index:100;
}
.topbar-logo{display:flex;align-items:center;gap:.75rem}
.hive-logo{width:32px;height:32px;flex-shrink:0}
.topbar h1{font-size:1.2rem;color:var(--text-bright);font-weight:600;letter-spacing:.3px}
.topbar-brand{display:flex;flex-direction:column;line-height:1.15}
.topbar-sub{font-size:.58rem;color:var(--text-dim);letter-spacing:.6px;text-transform:uppercase}
.topbar-nav{display:flex;gap:.2rem;margin-left:1rem;overflow-x:auto;scrollbar-width:none}
.topbar-nav::-webkit-scrollbar{display:none}
.topbar-nav a{
font-size:.8rem;color:var(--text-dim);text-decoration:none;white-space:nowrap;
padding:.4rem .8rem;border-radius:999px;transition:color .15s,background .15s;
}
.topbar-nav a:hover{color:var(--text-bright);background:rgba(255,255,255,.06)}
.topbar-nav a.active{color:#10131b;background:var(--accent);font-weight:600}
.topbar-stats{margin-left:auto;display:flex;gap:1.5rem;font-family:var(--mono);font-size:.8rem}
.topbar-stat{display:flex;align-items:center;gap:.4rem}
.topbar-stat .dot{width:8px;height:8px;border-radius:50%}
.topbar-stat .dot.green{background:var(--green)}
.topbar-stat .dot.orange{background:var(--orange)}
.topbar-stat .dot.red{background:var(--red)}
.container{max-width:1200px;margin:0 auto;padding:1.5rem 2rem}
/* Network Summary */
.summary-row{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
.summary-card{
background:var(--card);border:1px solid var(--card-border);border-radius:8px;padding:1.2rem;
}
.summary-label{font-size:.7rem;text-transform:uppercase;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:.5rem}
.summary-value{font-size:2rem;font-weight:700;font-family:var(--mono);color:var(--text-bright);line-height:1}
.summary-value.cyan{color:var(--cyan)}
.summary-value.green{color:var(--green)}
.summary-value.orange{color:var(--orange)}
.summary-value.purple{color:var(--purple)}
.summary-sub{font-size:.7rem;color:var(--text-dim);margin-top:.4rem;font-family:var(--mono)}
/* Section Headers */
.section-header{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:1rem;padding-bottom:.5rem;border-bottom:1px solid var(--card-border);
}
.section-header h2{font-size:1rem;color:var(--text-bright);font-weight:600}
.section-header .badge{
font-size:.7rem;font-family:var(--mono);padding:.2rem .6rem;
border-radius:10px;background:rgba(88,166,255,.1);color:var(--cyan);
}
/* Relay Cards */
.relay-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:1rem;margin-bottom:2rem}
.relay-card{
background:var(--card);border:1px solid var(--card-border);border-radius:10px;
padding:1.2rem;transition:border-color .2s,background .2s;cursor:default;
}
.relay-card:hover{border-color:var(--cyan);background:var(--card-hover)}
.relay-card.offline{opacity:.5}
.relay-card.offline:hover{border-color:var(--red)}
.relay-header{display:flex;align-items:center;gap:.6rem;margin-bottom:1rem}
.relay-status{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.relay-status.online{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.4)}
.relay-status.offline{background:var(--red);box-shadow:0 0 8px rgba(248,81,73,.4)}
.relay-name{font-size:.95rem;font-weight:600;color:var(--text-bright)}
.relay-region{
margin-left:auto;font-size:.7rem;font-family:var(--mono);
padding:.15rem .5rem;border-radius:4px;
background:rgba(188,140,255,.1);color:var(--purple);
}
.relay-pubkey{
font-family:var(--mono);font-size:.72rem;color:var(--cyan);
margin-bottom:.8rem;word-break:break-all;cursor:pointer;
padding:.4rem .6rem;background:rgba(88,166,255,.06);border-radius:6px;
border:1px solid transparent;transition:border-color .2s;position:relative;
}
.relay-pubkey:hover{border-color:var(--cyan)}
.relay-pubkey .copy-hint{
position:absolute;right:.5rem;top:50%;transform:translateY(-50%);
font-size:.65rem;color:var(--text-dim);opacity:0;transition:opacity .2s;
}
.relay-pubkey:hover .copy-hint{opacity:1}
.relay-pubkey.copied .copy-hint{color:var(--green);opacity:1}
.relay-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;margin-bottom:.8rem}
.relay-stat{text-align:center}
.relay-stat-val{font-family:var(--mono);font-size:1rem;font-weight:600;color:var(--text-bright)}
.relay-stat-label{font-size:.65rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-dim);margin-top:.15rem}
.relay-meta{display:flex;gap:1rem;font-size:.72rem;color:var(--text-dim);font-family:var(--mono);flex-wrap:wrap}
.relay-meta span{display:flex;align-items:center;gap:.3rem}
.relay-actions{
display:flex;gap:.5rem;margin-top:.8rem;padding-top:.8rem;
border-top:1px solid var(--card-border);
}
.btn{
padding:.35rem .7rem;border-radius:6px;font-size:.72rem;font-family:var(--mono);
cursor:pointer;border:1px solid var(--card-border);background:transparent;
color:var(--text);transition:all .2s;text-decoration:none;
}
.btn:hover{border-color:var(--cyan);color:var(--cyan);background:rgba(88,166,255,.08)}
.btn.primary{background:rgba(88,166,255,.1);border-color:var(--cyan);color:var(--cyan)}
.btn.primary:hover{background:rgba(88,166,255,.2)}
/* Connect Modal */
.modal-overlay{
position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;
display:none;align-items:center;justify-content:center;
}
.modal-overlay.show{display:flex}
.modal{
background:var(--card);border:1px solid var(--card-border);border-radius:12px;
padding:1.5rem;max-width:520px;width:90%;max-height:80vh;overflow-y:auto;
}
.modal h3{font-size:1rem;color:var(--text-bright);margin-bottom:1rem}
.modal-close{
float:right;background:none;border:none;color:var(--text-dim);
font-size:1.2rem;cursor:pointer;padding:.2rem;
}
.modal-close:hover{color:var(--text-bright)}
.code-block{
background:var(--bg);border:1px solid var(--card-border);border-radius:6px;
padding:.8rem;font-family:var(--mono);font-size:.75rem;color:var(--cyan);
margin:.8rem 0;overflow-x:auto;word-break:break-all;cursor:pointer;
position:relative;
}
.code-block:hover{border-color:var(--cyan)}
.code-block .copy-label{
position:absolute;top:.4rem;right:.5rem;font-size:.6rem;color:var(--text-dim);
}
.code-block:hover .copy-label{color:var(--cyan)}
.modal label{font-size:.8rem;color:var(--text-dim);display:block;margin-top:.6rem}
/* Add Relay */
.add-relay{
border:2px dashed var(--card-border);border-radius:10px;
display:flex;align-items:center;justify-content:center;
min-height:200px;cursor:pointer;transition:border-color .2s;color:var(--text-dim);
font-size:.85rem;
}
.add-relay:hover{border-color:var(--cyan);color:var(--cyan)}
/* Footer */
.footer{
text-align:center;padding:2rem;font-size:.72rem;color:var(--text-dim);
font-family:var(--mono);border-top:1px solid var(--card-border);margin-top:2rem;
}
.footer a{color:var(--cyan);text-decoration:none}
.footer a:hover{text-decoration:underline}
/* Toast */
.toast{
position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%);
background:var(--green);color:#000;padding:.5rem 1rem;border-radius:6px;
font-size:.8rem;font-family:var(--mono);opacity:0;transition:opacity .3s;
pointer-events:none;z-index:300;
}
.toast.show{opacity:1}
/* Loading */
.loading{text-align:center;padding:3rem;color:var(--text-dim)}
.loading .spinner{
display:inline-block;width:24px;height:24px;border:2px solid var(--card-border);
border-top-color:var(--cyan);border-radius:50%;animation:spin 1s linear infinite;
margin-bottom:.8rem;
}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:900px){
.summary-row{grid-template-columns:repeat(2,1fr)}
.relay-grid{grid-template-columns:1fr}
.topbar{padding:1rem}
.topbar-nav{order:3;width:100%;margin-left:0}
.container{padding:1rem}
}
@media(max-width:500px){
.summary-row{grid-template-columns:1fr}
.topbar-stats{display:none}
}
</style>
</head>
<body>
<!-- Top Bar -->
<div class="topbar">
<div class="topbar-logo">
<svg class="hive-logo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="sparkg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#ffe066"/><stop offset="100%" stop-color="#f5a623"/></linearGradient></defs>
<path d="M50 6 C 54.5 36, 61 42.5, 92 50 C 61 57.5, 54.5 64, 50 94 C 45.5 64, 39 57.5, 8 50 C 39 42.5, 45.5 36, 50 6 Z" fill="url(#sparkg)"/>
<path d="M79 10 C 80.5 19, 82.5 21, 91 23 C 82.5 25, 80.5 27, 79 36 C 77.5 27, 75.5 25, 67 23 C 75.5 21, 77.5 19, 79 10 Z" fill="#ffe066" opacity=".9"/>
</svg>
<div class="topbar-brand"><h1>HiveRelay Network</h1><div class="topbar-sub">relay discovery and bootstrap</div></div>
</div>
<nav class="topbar-nav">
<a href="/dashboard">Dashboard</a>
<a href="/catalog">Catalog</a>
<a href="/payments">Payments</a>
<a href="/calculator">Calculator</a>
<a href="/network" class="active">Network</a>
<a href="/fleet">Fleet</a>
<a href="/leaderboard">Leaderboard</a>
<a href="/docs">Docs</a>
</nav>
<div class="topbar-stats">
<div class="topbar-stat"><div class="dot green"></div><span id="topOnline">0 online</span></div>
<div class="topbar-stat"><div class="dot orange"></div><span id="topTotal">0 relays</span></div>
</div>
</div>
<div class="container">
<!-- Summary -->
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">Active Relays</div>
<div class="summary-value green" id="sumRelays">--</div>
<div class="summary-sub" id="sumRelaysSub"> </div>
</div>
<div class="summary-card">
<div class="summary-label">Total Connections</div>
<div class="summary-value cyan" id="sumConns">--</div>
<div class="summary-sub" id="sumConnsSub"> </div>
</div>
<div class="summary-card">
<div class="summary-label">Total Storage</div>
<div class="summary-value" id="sumStorage">--</div>
<div class="summary-sub" id="sumStorageSub"> </div>
</div>
<div class="summary-card">
<div class="summary-label">Network Uptime</div>
<div class="summary-value purple" id="sumUptime">--</div>
<div class="summary-sub" id="sumUptimeSub"> </div>
</div>
</div>
<!-- Relay Nodes -->
<div class="section-header">
<h2>Relay Nodes</h2>
<div style="display:flex;align-items:center;gap:1rem">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;color:var(--text-dim);cursor:pointer;user-select:none">
<input type="checkbox" id="filterNoApi" checked style="accent-color:var(--cyan)">
Hide relays without API
</label>
<div class="badge" id="lastUpdated">updating...</div>
</div>
</div>
<div class="relay-grid" id="relayGrid">
<div class="loading"><div class="spinner"></div><br>Discovering relays...</div>
</div>
</div>
<!-- Connect Modal -->
<div class="modal-overlay" id="connectModal">
<div class="modal">
<button type="button" class="modal-close" id="modalClose">×</button>
<h3 id="modalTitle">Connect to Relay</h3>
<label>Public Key</label>
<div class="code-block" id="modalPubkey">
<span class="copy-label">click to copy</span>
</div>
<label>API Endpoint</label>
<div class="code-block" id="modalEndpoint">
<span class="copy-label">click to copy</span>
</div>
<label>Connect via SDK</label>
<div class="code-block" id="modalCode">
<span class="copy-label">click to copy</span>
</div>
<div id="modalOnionRow" style="display:none">
<label>Tor Hidden Service</label>
<div class="code-block" id="modalOnion" style="color:var(--green)">
<span class="copy-label">click to copy</span>
</div>
</div>
<label>Dashboard</label>
<div class="code-block" id="modalDashboard">
<span class="copy-label">click to copy</span>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast">Copied!</div>
<!-- Footer -->
<div class="footer">
HiveRelay Network — P2P relay backbone for the <a href="https://holepunch.to" target="_blank" rel="noopener noreferrer">Holepunch</a> ecosystem
— <a href="/docs">Docs</a>
— <a href="/dashboard">Node Dashboard</a>
— <a href="https://github.com/bigdestiny2/P2P-Hiveswarm" target="_blank" rel="noopener noreferrer">GitHub</a>
</div>
<script>
/* ===== Configuration ===== */
// The dashboard fetches live relay data from the DHT-powered /api/network endpoint.
// No hardcoded list — relays are auto-discovered via the Hyperswarm DHT.
var USE_DHT_DISCOVERY = true;
/* ===== Helpers ===== */
function formatBytes(b) {
if (b == null || isNaN(b) || b === 0) return '0 B';
var units = ['B','KB','MB','GB','TB'];
var i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
return (b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function formatUptime(ms) {
if (!ms || ms < 0) return '--';
var s = Math.floor(ms / 1000);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function metricCount(value) {
var n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
}
function truncKey(hex, n) {
if (!hex) return '--';
n = n || 8;
return hex.slice(0, n) + '...' + hex.slice(-n);
}
function showToast(msg) {
var t = document.getElementById('toast');
t.textContent = msg || 'Copied!';
t.classList.add('show');
setTimeout(function() { t.classList.remove('show'); }, 1500);
}
function writeClipboard(text, successMessage, onSuccess) {
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) {
showToast('Clipboard unavailable');
return;
}
navigator.clipboard.writeText(text).then(function() {
if (onSuccess) onSuccess();
showToast(successMessage || 'Copied!');
}).catch(function() {
showToast('Copy failed');
});
}
function copyText(el) {
var text = el.getAttribute('data-copy') || el.textContent.replace('click to copy', '').trim();
writeClipboard(text, 'Copied!');
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, function(ch) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
});
}
function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function makeEl(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined && text !== null) el.textContent = String(text);
return el;
}
function appendEl(parent, tag, className, text) {
var el = makeEl(tag, className, text);
parent.appendChild(el);
return el;
}
function copyPubkey(el, key) {
writeClipboard(key, 'Public key copied!', function() {
var hint = el.querySelector('.copy-hint');
el.classList.add('copied');
if (hint) hint.textContent = 'copied!';
setTimeout(function() {
el.classList.remove('copied');
if (hint) hint.textContent = 'click to copy';
}, 2000);
});
}
function safeHttpUrl(raw, suffix) {
if (!raw) return null;
try {
var url = new URL(String(raw));
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (suffix) {
url.pathname = url.pathname.replace(/\/+$/, '') + suffix;
url.search = '';
url.hash = '';
}
return url.href;
} catch (e) {
return null;
}
}
function bindRelayGridActions(grid) {
if (grid.dataset.networkActionsBound) return;
grid.dataset.networkActionsBound = '1';
grid.addEventListener('click', function(e) {
var el = e.target.closest('[data-network-action]');
if (!el || !grid.contains(el)) return;
if (el.dataset.networkAction === 'copy-pubkey') {
copyPubkey(el, el.dataset.publicKey || '');
} else if (el.dataset.networkAction === 'connect') {
openConnectModal({
name: el.dataset.relayName || 'Relay',
url: el.dataset.relayUrl || ''
}, {
publicKey: el.dataset.publicKey || '--'
}, el.dataset.onion || '');
}
});
}
/* ===== Modal ===== */
function openConnectModal(relay, data, onion) {
var pk = data.publicKey || '--';
var endpoint = relay.url || '--';
document.getElementById('modalTitle').textContent = 'Connect to ' + (relay.name || 'Relay');
var pkEl = document.getElementById('modalPubkey');
pkEl.textContent = pk;
pkEl.setAttribute('data-copy', pk);
var epEl = document.getElementById('modalEndpoint');
epEl.textContent = endpoint;
epEl.setAttribute('data-copy', endpoint);
var code = "import { HiveRelayClient } from 'p2p-hiverelay-client'\n\n" +
"const client = new HiveRelayClient({\n" +
" bootstrap: [" + JSON.stringify(pk) + "]\n" +
"})\n" +
"await client.start()";
var codeEl = document.getElementById('modalCode');
codeEl.textContent = code;
codeEl.setAttribute('data-copy', code);
var dashEl = document.getElementById('modalDashboard');
var dashboardUrl = relay.url ? safeHttpUrl(relay.url, '/dashboard') : null;
dashEl.textContent = dashboardUrl || '--';
dashEl.setAttribute('data-copy', dashboardUrl || '--');
// Onion address section
var onionRow = document.getElementById('modalOnionRow');
var onionEl = document.getElementById('modalOnion');
if (onion) {
onionRow.style.display = 'block';
onionEl.textContent = onion;
onionEl.setAttribute('data-copy', onion);
} else {
onionRow.style.display = 'none';
}
document.getElementById('connectModal').classList.add('show');
}
function closeModal() {
document.getElementById('connectModal').classList.remove('show');
}
document.getElementById('connectModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.getElementById('modalClose').addEventListener('click', closeModal);
document.querySelectorAll('#connectModal .code-block').forEach(function(el) {
el.addEventListener('click', function() { copyText(el); });
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
/* ===== Fetch & Render ===== */
function renderRelayCard(data) {
var online = data._online;
var pk = String(data.publicKey || '--');
var uptime = data.uptime ? data.uptime.ms : 0;
var conns = metricCount(data.connections);
var apps = metricCount(data.seededApps);
var stored = data.storage ? data.storage.used : 0;
var storageMax = data.storage ? data.storage.max : 0;
var relayed = data.relay ? data.relay.totalBytesRelayed : 0;
var circuits = data.relay ? metricCount(data.relay.totalCircuitsServed) : 0;
var heapMB = data.memory ? Math.round(data.memory.heapUsed / 1048576) : 0;
var rssMB = data.memory ? Math.round(data.memory.rss / 1048576) : 0;
var torInfo = data.tor;
var hasTor = torInfo && torInfo.running;
var onionAddr = torInfo ? torInfo.onionAddress : null;
var relayName = String(data._name || 'Relay');
var relayUrl = safeHttpUrl(data._url, '');
var dashboardUrl = safeHttpUrl(data._url, '/dashboard');
var apiUrl = safeHttpUrl(data._url, '/api/overview');
var card = makeEl('div', 'relay-card' + (online ? '' : ' offline'));
var header = appendEl(card, 'div', 'relay-header');
appendEl(header, 'div', 'relay-status ' + (online ? 'online' : 'offline'));
appendEl(header, 'div', 'relay-name', relayName);
appendEl(header, 'div', 'relay-region', data._region || '--');
if (hasTor) {
var torBadge = appendEl(header, 'div', 'relay-region', 'TOR');
torBadge.style.background = 'rgba(63,185,80,.1)';
torBadge.style.color = 'var(--green)';
}
if (data._holesailKey) {
var hsBadge = appendEl(header, 'div', 'relay-region', 'HS');
hsBadge.style.background = 'rgba(56,132,244,.1)';
hsBadge.style.color = 'var(--blue,#3884f4)';
}
var pubkey = appendEl(card, 'div', 'relay-pubkey', truncKey(pk, 12));
pubkey.dataset.networkAction = 'copy-pubkey';
pubkey.dataset.publicKey = pk;
appendEl(pubkey, 'span', 'copy-hint', 'click to copy');
var stats = appendEl(card, 'div', 'relay-stats');
var statConns = appendEl(stats, 'div', 'relay-stat');
appendEl(statConns, 'div', 'relay-stat-val', conns);
appendEl(statConns, 'div', 'relay-stat-label', 'Connections');
var statApps = appendEl(stats, 'div', 'relay-stat');
appendEl(statApps, 'div', 'relay-stat-val', apps);
appendEl(statApps, 'div', 'relay-stat-label', 'Seeded Apps');
var statUptime = appendEl(stats, 'div', 'relay-stat');
appendEl(statUptime, 'div', 'relay-stat-val', formatUptime(uptime));
appendEl(statUptime, 'div', 'relay-stat-label', 'Uptime');
var meta = appendEl(card, 'div', 'relay-meta');
appendEl(meta, 'span', null, 'Storage: ' + formatBytes(stored) + ' / ' + formatBytes(storageMax));
appendEl(meta, 'span', null, 'Relayed: ' + formatBytes(relayed));
appendEl(meta, 'span', null, 'Circuits: ' + circuits);
appendEl(meta, 'span', null, 'Mem: ' + rssMB + ' MB');
if (onionAddr) {
var onion = appendEl(meta, 'span', null, '.onion: ' + String(onionAddr).slice(0, 16) + '...');
onion.style.color = 'var(--green)';
}
var actions = appendEl(card, 'div', 'relay-actions');
var connect = appendEl(actions, 'button', 'btn primary', 'Connect');
connect.type = 'button';
connect.dataset.networkAction = 'connect';
connect.dataset.relayName = relayName;
connect.dataset.relayUrl = relayUrl || '';
connect.dataset.publicKey = pk;
connect.dataset.onion = onionAddr || '';
if (dashboardUrl) {
var dashboard = appendEl(actions, 'a', 'btn', 'Dashboard');
dashboard.href = dashboardUrl;
dashboard.target = '_blank';
dashboard.rel = 'noopener noreferrer';
} else {
var dashboardDisabled = appendEl(actions, 'button', 'btn', 'Dashboard');
dashboardDisabled.type = 'button';
dashboardDisabled.disabled = true;
}
if (apiUrl) {
var api = appendEl(actions, 'a', 'btn', 'API');
api.href = apiUrl;
api.target = '_blank';
api.rel = 'noopener noreferrer';
} else {
var apiDisabled = appendEl(actions, 'button', 'btn', 'API');
apiDisabled.type = 'button';
apiDisabled.disabled = true;
}
return card;
}
var REQUEST_TIMEOUT_MS = 10000;
function fetchWithTimeout(path, opts) {
var options = Object.assign({}, opts || {});
if (typeof AbortController !== 'function') return fetch(path, options);
var controller = new AbortController();
var timeout = setTimeout(function() { controller.abort(); }, REQUEST_TIMEOUT_MS);
options.signal = controller.signal;
return fetch(path, options).finally(function() {
clearTimeout(timeout);
});
}
async function refresh() {
try {
var token = getUiToken();
var detailedHeaders = {};
if (token) detailedHeaders.Authorization = 'Bearer ' + token;
var res = await fetchWithTimeout('/api/network?detailed=1', {
headers: detailedHeaders
});
if (res.status === 401 || res.status === 403) {
res = await fetchWithTimeout('/api/network');
}
var network = await res.json();
} catch (e) {
document.getElementById('lastUpdated').textContent = 'fetch failed \u2014 retrying...';
return;
}
renderNetwork(network);
}
/* ===== WebSocket Live Feed ===== */
var wsConnected = false;
var ws = null;
var pollTimer = null;
var networkRefreshBusy = false;
var POLL_INTERVAL_WS = 30000; // 30s backup when WS is live
var POLL_INTERVAL_HTTP = 10000; // 10s fallback when WS is down
function canPoll(force) {
return force === true || document.hidden !== true;
}
function refreshNetwork(force) {
if (networkRefreshBusy) return;
if (!canPoll(force)) return;
networkRefreshBusy = true;
refresh().finally(function() {
networkRefreshBusy = false;
});
}
function setPollInterval(ms) {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(function() { refreshNetwork(false); }, ms);
}
function getUiToken() {
var m = document.querySelector('meta[name="hiverelay-ui-token"]');
return (m && m.getAttribute('content')) || '';
}
function sendWsAuth(sock) {
var t = getUiToken();
if (!t) return;
try { sock.send(JSON.stringify({ type: 'auth', token: t })); } catch (e) {}
}
function connectWS() {
if (document.hidden === true) return;
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/ws';
try { ws = new WebSocket(url); } catch (e) { return; }
ws.onopen = function() {
sendWsAuth(ws);
wsConnected = true;
// Reduce polling to backup interval
setPollInterval(POLL_INTERVAL_WS);
};
ws.onmessage = function(evt) {
try {
var msg = JSON.parse(evt.data);
if (msg.type === 'update' && msg.network) {
// Use the network data from WS instead of fetching
renderNetwork(msg.network);
}
} catch (e) {
console.warn('WS message parse error:', e);
}
};
ws.onclose = function() {
wsConnected = false;
ws = null;
// Fall back to fast HTTP polling
setPollInterval(POLL_INTERVAL_HTTP);
// Attempt reconnect after 3s
if (document.hidden !== true) setTimeout(connectWS, 3000);
};
ws.onerror = function() {
// onclose will fire after this — reconnect handled there
};
}
document.addEventListener('visibilitychange', function() {
if (document.hidden === true) return;
refreshNetwork(true);
if (!ws) connectWS();
});
var _lastNetwork = null;
document.getElementById('filterNoApi').addEventListener('change', function() {
if (_lastNetwork) renderNetwork(_lastNetwork);
});
function renderNetwork(network) {
_lastNetwork = network;
var summary = network.summary || {};
var relays = network.relays || [];
var hideNoApi = document.getElementById('filterNoApi').checked;
// Filter out relays without API if toggle is on
if (hideNoApi) {
relays = relays.filter(function(r) { return r.apiPort != null || r.apiReachable === true; });
}
// Normalize relay data for renderRelayCard
var results = relays.map(function(r) {
var apiUrl = r.host && r.apiPort ? 'http://' + r.host + ':' + r.apiPort : null;
return {
_online: r.online,
_url: apiUrl,
_name: r.name || ('Relay ' + (r.publicKey || '').slice(0, 8)),
_region: r.region || '--',
publicKey: r.publicKey,
connections: r.connections || 0,
seededApps: r.seededApps || 0,
uptime: r.uptime || null,
storage: r.storage || null,
relay: r.relay || null,
seeder: r.seeder || null,
memory: r.memory || null,
tor: r.tor || null,
_holesailKey: r.holesailKey || null,
_holesailConnected: r.holesailConnected || false,
errors: r.errors || 0
};
});
// Recompute summary from filtered results
var onlineCount = 0, totalConns = 0, totalStored = 0, totalStorageMax = 0;
results.forEach(function(r) {
if (r._online) onlineCount++;
totalConns += metricCount(r.connections);
if (r.storage) {
totalStored += r.storage.used || 0;
totalStorageMax += r.storage.max || 0;
}
});
var totalCount = results.length;
var unfilteredTotal = (network.relays || []).length;
// Find longest uptime
var maxUptime = 0;
results.forEach(function(d) {
if (d.uptime && d.uptime.ms > maxUptime) maxUptime = d.uptime.ms;
});
// Top bar
document.getElementById('topOnline').textContent = onlineCount + ' online';
document.getElementById('topTotal').textContent = totalCount + ' relays';
// Summary cards
document.getElementById('sumRelays').textContent = onlineCount + ' / ' + totalCount;
var hiddenCount = unfilteredTotal - totalCount;
var relaysSub = (totalCount - onlineCount) > 0 ? (totalCount - onlineCount) + ' offline' : 'all healthy';
if (hiddenCount > 0) relaysSub += ' (' + hiddenCount + ' hidden)';
document.getElementById('sumRelaysSub').textContent = relaysSub;
document.getElementById('sumConns').textContent = totalConns;
document.getElementById('sumConnsSub').textContent = 'across ' + onlineCount + ' nodes';
document.getElementById('sumStorage').textContent = formatBytes(totalStored);
document.getElementById('sumStorageSub').textContent = formatBytes(totalStorageMax) + ' capacity';
document.getElementById('sumUptime').textContent = formatUptime(maxUptime);
document.getElementById('sumUptimeSub').textContent = 'longest running node';
// Relay cards
var grid = document.getElementById('relayGrid');
bindRelayGridActions(grid);
clearNode(grid);
if (results.length === 0) {
var loading = appendEl(grid, 'div', 'loading');
loading.style.animation = 'none';
loading.appendChild(document.createTextNode('Discovering relays on the DHT...'));
loading.appendChild(document.createElement('br'));
var hint = appendEl(loading, 'span', null, 'New relays appear automatically as they join the network');
hint.style.fontSize = '.75rem';
hint.style.color = 'var(--text-dim)';
} else {
results.forEach(function(result) {
grid.appendChild(renderRelayCard(result));
});
}
// Timestamp
var now = new Date();
var source = wsConnected ? 'WS' : 'HTTP';
document.getElementById('lastUpdated').textContent =
onlineCount + ' discovered \u00B7 ' + source + ' \u00B7 ' +
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0');
}
// Initial load + start polling + try WS
refreshNetwork(true);
setPollInterval(POLL_INTERVAL_HTTP);
connectWS();
</script>
</body>
</html>