OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / dashboard / network.html

828 lines · html ref:
<!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">&nbsp;</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">&nbsp;</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">&nbsp;</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">&nbsp;</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">&times;</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 &mdash; P2P relay backbone for the <a href="https://holepunch.to" target="_blank" rel="noopener noreferrer">Holepunch</a> ecosystem
  &mdash; <a href="/docs">Docs</a>
  &mdash; <a href="/dashboard">Node Dashboard</a>
  &mdash; <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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>