OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / dashboard / leaderboard.html

702 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 Leaderboard</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;
  --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;text-decoration:none}
.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:.25rem;margin-left:1.5rem}
.topbar-nav a{
  font-size:.8rem;color:var(--text-dim);text-decoration:none;
  padding:.35rem .75rem;border-radius:6px;transition:color .2s,background .2s;
}
.topbar-nav a:hover{color:var(--text-bright);background:rgba(255,255,255,.05)}
.topbar-nav a.active{color:#10131b;background:#f5c842;font-weight:600}
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:1rem}
.conn-status{
  display:flex;align-items:center;gap:.4rem;
  font-family:var(--mono);font-size:.75rem;
  padding:.3rem .7rem;border-radius:6px;
}
.conn-status .dot{width:8px;height:8px;border-radius:50%}
.conn-status.live{color:var(--green)}
.conn-status.live .dot{background:var(--green);box-shadow:0 0 6px rgba(63,185,80,.5)}
.conn-status.polling{color:var(--orange)}
.conn-status.polling .dot{background:var(--orange);box-shadow:0 0 6px rgba(210,153,34,.5)}
.conn-status.disconnected{color:var(--red)}
.conn-status.disconnected .dot{background:var(--red);box-shadow:0 0 6px rgba(248,81,73,.5)}

.container{max-width:1400px;margin:0 auto;padding:1.5rem 2rem}

/* Summary Row */
.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)}

/* Table */
.table-wrap{
  background:var(--card);border:1px solid var(--card-border);border-radius:8px;overflow:hidden;
}
.table-header{
  display:flex;align-items:center;justify-content:space-between;
  padding:1rem 1.2rem;border-bottom:1px solid var(--card-border);
}
.table-header h2{font-size:1rem;color:var(--text-bright);font-weight:600}
.table-header .badge{
  font-size:.7rem;font-family:var(--mono);padding:.2rem .6rem;
  border-radius:10px;background:rgba(88,166,255,.1);color:var(--cyan);
}
table{width:100%;border-collapse:collapse}
thead th{
  text-align:left;padding:.75rem 1rem;font-size:.7rem;text-transform:uppercase;
  letter-spacing:1px;color:var(--text-dim);border-bottom:1px solid var(--card-border);
  cursor:pointer;user-select:none;white-space:nowrap;
}
thead th:hover{color:var(--text-bright)}
thead th .sort-arrow{margin-left:.3rem;font-size:.65rem;opacity:.5}
thead th.sorted .sort-arrow{opacity:1;color:var(--cyan)}
tbody tr{border-bottom:1px solid var(--card-border);transition:background .15s}
tbody tr:last-child{border-bottom:none}
tbody tr:hover{background:var(--card-hover)}
tbody td{padding:.7rem 1rem;font-size:.85rem;white-space:nowrap}
td.mono{font-family:var(--mono)}

/* Rank column */
.rank{font-family:var(--mono);font-weight:700;color:var(--text-dim);font-size:.85rem}
.rank-1{color:var(--orange);font-size:1rem}
.rank-2{color:var(--text-bright)}
.rank-3{color:var(--orange);opacity:.7}

/* Pubkey */
.pubkey-cell{display:flex;align-items:center;gap:.5rem}
.pubkey{font-family:var(--mono);font-size:.8rem;color:var(--cyan)}
.copy-btn{
  background:none;border:1px solid var(--card-border);border-radius:4px;
  color:var(--text-dim);cursor:pointer;font-size:.7rem;padding:.15rem .4rem;
  transition:color .2s,border-color .2s;
}
.copy-btn:hover{color:var(--text-bright);border-color:var(--text-dim)}

/* Score colors */
.score-high{color:var(--green);font-weight:700}
.score-mid{color:var(--orange);font-weight:700}
.score-low{color:var(--red);font-weight:700}

/* Reliability bar */
.reliability-cell{display:flex;align-items:center;gap:.6rem}
.reliability-bar{width:60px;height:6px;border-radius:3px;background:rgba(255,255,255,.06);overflow:hidden;flex-shrink:0}
.reliability-fill{height:100%;border-radius:3px;transition:width .3s}
.reliability-pct{font-family:var(--mono);font-size:.8rem}

/* Latency colors */
.latency-good{color:var(--green)}
.latency-ok{color:var(--orange)}
.latency-bad{color:var(--red)}

/* Region */
.region-cell{display:flex;align-items:center;gap:.4rem}
.region-flag{font-size:1rem}
.region-code{font-family:var(--mono);font-size:.75rem;color:var(--text-dim)}

/* "YOU" highlight */
tr.this-node{border-left:3px solid var(--cyan);background:rgba(88,166,255,.04)}
tr.this-node:hover{background:rgba(88,166,255,.08)}
.you-badge{
  display:inline-block;font-size:.6rem;font-weight:700;text-transform:uppercase;
  letter-spacing:1px;padding:.1rem .45rem;border-radius:4px;
  background:rgba(88,166,255,.15);color:var(--cyan);margin-left:.5rem;
}

/* Empty state */
.empty-state{
  padding:4rem 2rem;text-align:center;color:var(--text-dim);
}
.empty-state svg{width:64px;height:64px;margin-bottom:1.5rem;opacity:.4}
.empty-state h3{color:var(--text-bright);font-size:1.1rem;margin-bottom:.5rem}
.empty-state p{font-size:.9rem;max-width:480px;margin:0 auto;line-height:1.6}

/* Toast */
.toast{
  position:fixed;bottom:2rem;left:50%;transform:translateX(-50%) translateY(100px);
  background:var(--card);border:1px solid var(--card-border);border-radius:8px;
  padding:.6rem 1.2rem;font-family:var(--mono);font-size:.8rem;color:var(--green);
  opacity:0;transition:transform .3s,opacity .3s;z-index:999;
}
.toast.show{transform:translateX(-50%) translateY(0);opacity:1}

/* Responsive */
@media(max-width:900px){
  .summary-row{grid-template-columns:repeat(2,1fr)}
  .topbar-nav{display:none}
}
@media(max-width:600px){
  .summary-row{grid-template-columns:1fr}
  .container{padding:1rem}
  .topbar{padding:.75rem 1rem}
  table{font-size:.75rem}
  thead th,tbody td{padding:.5rem .6rem}
}
</style>
</head>
<body>

<!-- Top Bar -->
<div class="topbar">
  <a href="/dashboard" 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 Leaderboard</h1><div class="topbar-sub">operator standing and performance</div></div>
  </a>
  <nav class="topbar-nav">
    <a href="/dashboard">Dashboard</a>
    <a href="/network">Network</a>
    <a href="/payments">Payments</a>
    <a href="/calculator">Calculator</a>
    <a href="/leaderboard" class="active">Leaderboard</a>
    <a href="/docs">Docs</a>
  </nav>
  <div class="topbar-right">
    <div id="connStatus" class="conn-status disconnected">
      <span class="dot"></span>
      <span id="connLabel">DISCONNECTED</span>
    </div>
  </div>
</div>

<!-- Main Content -->
<div class="container">

  <!-- Summary Row -->
  <div class="summary-row">
    <div class="summary-card">
      <div class="summary-label">Total Relays Tracked</div>
      <div id="totalRelays" class="summary-value cyan">--</div>
      <div class="summary-sub">ranked relays</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">Avg Reliability</div>
      <div id="avgReliability" class="summary-value green">--%</div>
      <div class="summary-sub">across all relays</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">Total Bytes Served</div>
      <div id="totalBytes" class="summary-value orange">--</div>
      <div class="summary-sub">network-wide</div>
    </div>
    <div class="summary-card">
      <div class="summary-label">Total Challenges</div>
      <div id="totalChallenges" class="summary-value purple">--</div>
      <div class="summary-sub">proof-of-relay</div>
    </div>
  </div>

  <!-- Leaderboard Table -->
  <div class="table-wrap">
    <div class="table-header">
      <h2>Reputation Leaderboard</h2>
      <span id="relayCount" class="badge">0 relays</span>
    </div>
    <div id="tableContainer">
      <table>
        <thead>
          <tr>
            <th data-col="rank" data-type="num"># <span class="sort-arrow">&#9650;</span></th>
            <th data-col="pubkey" data-type="str">Relay <span class="sort-arrow">&#9650;</span></th>
            <th data-col="score" data-type="num">Score <span class="sort-arrow">&#9650;</span></th>
            <th data-col="reliability" data-type="num">Reliability <span class="sort-arrow">&#9650;</span></th>
            <th data-col="latency" data-type="num">Avg Latency <span class="sort-arrow">&#9650;</span></th>
            <th data-col="uptime" data-type="num">Uptime <span class="sort-arrow">&#9650;</span></th>
            <th data-col="bytes" data-type="num">Bytes Served <span class="sort-arrow">&#9650;</span></th>
            <th data-col="region" data-type="str">Region <span class="sort-arrow">&#9650;</span></th>
            <th data-col="lastActive" data-type="num">Last Active <span class="sort-arrow">&#9650;</span></th>
          </tr>
        </thead>
        <tbody id="leaderboardBody"></tbody>
      </table>
    </div>
    <div id="emptyState" class="empty-state" style="display:none">
      <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
        <defs>
          <linearGradient id="ge" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0%" stop-color="#58a6ff"/>
            <stop offset="100%" stop-color="#3fb950"/>
          </linearGradient>
        </defs>
        <polygon points="50,8 72,20.5 72,45.5 50,58 28,45.5 28,20.5" fill="url(#ge)" opacity=".3"/>
        <polygon points="27,52 49,64.5 49,89.5 27,102 5,89.5 5,64.5" fill="url(#ge)" opacity=".2"/>
        <polygon points="73,52 95,64.5 95,89.5 73,102 51,89.5 51,64.5" fill="url(#ge)" opacity=".2"/>
      </svg>
      <h3>No relays ranked yet</h3>
      <p>Relays need 10+ proof-of-relay challenges to appear on the leaderboard.</p>
    </div>
  </div>
</div>

<!-- Toast -->
<div id="toast" class="toast"></div>

<script>
(function() {
  'use strict';

  // --- State ---
  var relays = [];
  var myPubkey = null;
  var sortCol = 'rank';
  var sortAsc = true;
  var ws = null;
  var refreshTimer = null;
  var leaderboardRefreshBusy = false;
  var REQUEST_TIMEOUT_MS = 10000;

  // --- Helpers ---
  function formatBytes(b) {
    if (b == null || isNaN(b)) return '--';
    if (b < 1024) return b + ' B';
    if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
    if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
    if (b < 1099511627776) return (b / 1073741824).toFixed(2) + ' GB';
    return (b / 1099511627776).toFixed(2) + ' TB';
  }

  function formatUptime(hours) {
    if (hours == null || isNaN(hours)) return '--';
    var h = Math.floor(hours);
    if (h < 24) return h + 'h';
    var d = Math.floor(h / 24);
    var rem = h % 24;
    return d + 'd ' + rem + 'h';
  }

  function formatRelativeTime(ts) {
    if (!ts) return '--';
    var now = Date.now();
    var t = typeof ts === 'number' ? ts : new Date(ts).getTime();
    var diff = Math.max(0, Math.floor((now - t) / 1000));
    if (diff < 60) return diff + 's ago';
    if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
    if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
    return Math.floor(diff / 86400) + 'd ago';
  }

  function truncatePubkey(pk) {
    if (!pk || pk.length < 16) return pk || '--';
    return pk.slice(0, 8) + '...' + pk.slice(-8);
  }

  function metricNumber(value) {
    if (typeof value === 'string') {
      var trimmed = value.trim();
      if (trimmed.endsWith('%')) trimmed = trimmed.slice(0, -1);
      value = trimmed;
    }
    var n = Number(value);
    return Number.isFinite(n) && n >= 0 ? n : 0;
  }

  function clampPercent(value) {
    return Math.min(100, metricNumber(value));
  }

  var regionFlags = {
    'NA': '\u{1F1FA}\u{1F1F8}', 'EU': '\u{1F1EA}\u{1F1FA}',
    'AS': '\u{1F30F}', 'SA': '\u{1F30E}', 'AF': '\u{1F30D}', 'OC': '\u{1F30F}'
  };

  function regionFlag(r) {
    return regionFlags[r] || '\u{1F310}';
  }

  function scoreClass(s) {
    if (s >= 70) return 'score-high';
    if (s >= 40) return 'score-mid';
    return 'score-low';
  }

  function latencyClass(ms) {
    if (ms < 500) return 'latency-good';
    if (ms < 2000) return 'latency-ok';
    return 'latency-bad';
  }

  function reliabilityColor(pct) {
    if (pct >= 90) return 'var(--green)';
    if (pct >= 60) return 'var(--orange)';
    return 'var(--red)';
  }

  function showToast(msg) {
    var t = document.getElementById('toast');
    t.textContent = msg;
    t.classList.add('show');
    setTimeout(function() { t.classList.remove('show'); }, 2000);
  }

  function writeClipboard(text, successMessage) {
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) {
      showToast('Clipboard unavailable');
      return;
    }
    navigator.clipboard.writeText(text).then(function() {
      showToast(successMessage || 'Copied!');
    }).catch(function() {
      showToast('Copy failed');
    });
  }

  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 copyText(text) {
    writeClipboard(text, 'Copied!');
  }

  function bindLeaderboardActions(tbody) {
    if (tbody.dataset.leaderboardActionsBound) return;
    tbody.dataset.leaderboardActionsBound = '1';
    tbody.addEventListener('click', function(e) {
      var btn = e.target.closest('[data-copy-pubkey]');
      if (!btn || !tbody.contains(btn)) return;
      e.stopPropagation();
      copyText(btn.dataset.copyPubkey || '');
    });
  }

  function normalizeRelay(r) {
    return {
      pubkey: String(r.relay || r.pubkey || ''),
      score: metricNumber(r.score),
      reliability: clampPercent(r.reliability),
      avgLatency: metricNumber(r.avgLatencyMs || r.avg_latency || r.avgLatency),
      uptimeHours: metricNumber(r.uptimeHours || r.uptime_hours),
      bytesServed: metricNumber(r.bytesServed || r.bytes_served),
      region: String(r.region || 'unknown'),
      lastActive: r.lastActivity || r.last_active || r.lastActive || null,
      challenges: metricNumber(r.totalChallenges || r.challenges || r.challenge_count)
    };
  }

  // --- Connection Status ---
  function setConnStatus(state, label) {
    var el = document.getElementById('connStatus');
    el.className = 'conn-status ' + state;
    document.getElementById('connLabel').textContent = label;
  }

  // --- Sorting ---
  function getValue(relay, col) {
    switch (col) {
      case 'rank': return relay._rank;
      case 'pubkey': return relay.pubkey || '';
      case 'score': return relay.score || 0;
      case 'reliability': return relay.reliability || 0;
      case 'latency': return relay.avgLatency || 0;
      case 'uptime': return relay.uptimeHours || 0;
      case 'bytes': return relay.bytesServed || 0;
      case 'region': return relay.region || '';
      case 'lastActive': return relay.lastActive ? new Date(relay.lastActive).getTime() : 0;
      default: return 0;
    }
  }

  function sortRelays() {
    relays.sort(function(a, b) {
      var va = getValue(a, sortCol);
      var vb = getValue(b, sortCol);
      if (typeof va === 'string') {
        var cmp = va.localeCompare(vb);
        return sortAsc ? cmp : -cmp;
      }
      return sortAsc ? va - vb : vb - va;
    });
  }

  function renderRelayRow(r) {
    var isMe = myPubkey && r.pubkey === myPubkey;
    var score = r.score || 0;
    var rel = r.reliability || 0;
    var lat = r.avgLatency || 0;
    var up = r.uptimeHours || 0;
    var bytes = r.bytesServed || 0;
    var region = String(r.region || '--');
    var lastActive = r.lastActive || null;
    var pubkey = String(r.pubkey || '');
    var rankCls = r._rank === 1 ? 'rank rank-1' : (r._rank === 2 ? 'rank rank-2' : (r._rank === 3 ? 'rank rank-3' : 'rank'));

    var row = makeEl('tr', isMe ? 'this-node' : null);
    appendEl(row, 'td', 'mono ' + rankCls, r._rank);

    var pubCell = appendEl(row, 'td');
    var pubWrap = appendEl(pubCell, 'div', 'pubkey-cell');
    appendEl(pubWrap, 'span', 'pubkey', truncatePubkey(pubkey));
    var copy = appendEl(pubWrap, 'button', 'copy-btn', '\u2398');
    copy.type = 'button';
    copy.dataset.copyPubkey = pubkey;
    if (isMe) appendEl(pubWrap, 'span', 'you-badge', 'YOU');

    appendEl(row, 'td', 'mono ' + scoreClass(score), score.toFixed(1));

    var relCell = appendEl(row, 'td');
    var relWrap = appendEl(relCell, 'div', 'reliability-cell');
    var relBar = appendEl(relWrap, 'div', 'reliability-bar');
    var relFill = appendEl(relBar, 'div', 'reliability-fill');
    relFill.style.width = rel + '%';
    relFill.style.background = reliabilityColor(rel);
    var relPct = appendEl(relWrap, 'span', 'reliability-pct mono', rel.toFixed(1) + '%');
    relPct.style.color = reliabilityColor(rel);

    appendEl(row, 'td', 'mono ' + latencyClass(lat), Math.round(lat) + ' ms');
    appendEl(row, 'td', 'mono', formatUptime(up));
    appendEl(row, 'td', 'mono', formatBytes(bytes));

    var regionCell = appendEl(row, 'td');
    var regionWrap = appendEl(regionCell, 'div', 'region-cell');
    appendEl(regionWrap, 'span', 'region-flag', regionFlag(region));
    appendEl(regionWrap, 'span', 'region-code', region);

    var activeCell = appendEl(row, 'td', 'mono', formatRelativeTime(lastActive));
    activeCell.style.color = 'var(--text-dim)';
    return row;
  }

  // --- Render ---
  function render() {
    var tbody = document.getElementById('leaderboardBody');
    bindLeaderboardActions(tbody);
    var empty = document.getElementById('emptyState');
    var table = document.querySelector('#tableContainer table');

    if (relays.length === 0) {
      table.style.display = 'none';
      empty.style.display = 'block';
      document.getElementById('relayCount').textContent = '0 relays';
      return;
    }
    table.style.display = '';
    empty.style.display = 'none';
    document.getElementById('relayCount').textContent = relays.length + ' relay' + (relays.length !== 1 ? 's' : '');

    // Assign ranks based on score before sorting for display
    var ranked = relays.slice().sort(function(a, b) { return (b.score || 0) - (a.score || 0); });
    for (var i = 0; i < ranked.length; i++) ranked[i]._rank = i + 1;

    sortRelays();

    clearNode(tbody);
    for (var j = 0; j < relays.length; j++) {
      tbody.appendChild(renderRelayRow(relays[j]));
    }

    // Update sort indicators
    var ths = document.querySelectorAll('thead th');
    for (var k = 0; k < ths.length; k++) {
      var th = ths[k];
      if (th.dataset.col === sortCol) {
        th.classList.add('sorted');
        th.querySelector('.sort-arrow').textContent = sortAsc ? '\u25B2' : '\u25BC';
      } else {
        th.classList.remove('sorted');
        th.querySelector('.sort-arrow').textContent = '\u25B2';
      }
    }
  }

  function updateSummary() {
    var totalRelays = relays.length;
    var totalBytes = 0;
    var totalChallenges = 0;
    var sumRel = 0;

    for (var i = 0; i < relays.length; i++) {
      totalBytes += relays[i].bytesServed || 0;
      totalChallenges += relays[i].challenges || 0;
      sumRel += relays[i].reliability || 0;
    }

    document.getElementById('totalRelays').textContent = totalRelays.toLocaleString();
    document.getElementById('avgReliability').textContent = totalRelays > 0 ? (sumRel / totalRelays).toFixed(1) + '%' : '--%';
    document.getElementById('totalBytes').textContent = formatBytes(totalBytes);
    document.getElementById('totalChallenges').textContent = totalChallenges.toLocaleString();
  }

  // --- Data Fetching ---
  function fetchLeaderboard() {
    return fetchWithTimeout('/api/reputation')
      .then(function(res) { return res.json(); })
      .then(function(data) {
        var list = Array.isArray(data) ? data : (data.relays || data.leaderboard || []);
        relays = list.map(normalizeRelay);
        updateSummary();
        render();
        setConnStatus('polling', 'POLLING');
      })
      .catch(function(err) {
        console.error('Failed to fetch leaderboard:', err);
        setConnStatus('disconnected', 'DISCONNECTED');
      });
  }

  function fetchMyPubkey() {
    return fetchWithTimeout('/api/overview')
      .then(function(res) { return res.json(); })
      .then(function(data) {
        myPubkey = data.pubkey || data.public_key || data.publicKey || null;
      })
      .catch(function() {});
  }

  function canPoll(force) {
    return force === true || document.hidden !== true;
  }

  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);
    });
  }

  function refreshLeaderboardState(force) {
    if (leaderboardRefreshBusy) return;
    if (!canPoll(force)) return;
    leaderboardRefreshBusy = true;
    Promise.all([fetchLeaderboard(), fetchMyPubkey()]).finally(function() {
      leaderboardRefreshBusy = false;
    });
  }

  // --- WebSocket ---
  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;
    try {
      var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
      ws = new WebSocket(proto + '//' + location.host + '/ws');
      ws.onopen = function() {
        sendWsAuth(ws);
        setConnStatus('live', 'WS LIVE');
      };
      ws.onmessage = function(evt) {
        try {
          var msg = JSON.parse(evt.data);
          if (msg.type === 'reputation' || msg.type === 'leaderboard') {
            var list = Array.isArray(msg.data) ? msg.data : (msg.data.relays || msg.data.leaderboard || []);
            relays = list.map(normalizeRelay);
            updateSummary();
            render();
          }
        } catch (e) {}
      };
      ws.onclose = function() {
        setConnStatus('polling', 'POLLING');
        ws = null;
        if (document.hidden !== true) setTimeout(connectWS, 5000);
      };
      ws.onerror = function() {
        ws.close();
      };
    } catch (e) {
      setConnStatus('polling', 'POLLING');
    }
  }

  document.addEventListener('visibilitychange', function() {
    if (document.hidden === true) return;
    refreshLeaderboardState(true);
    if (!ws) connectWS();
  });

  // --- Sort Click Handlers ---
  document.querySelectorAll('thead th').forEach(function(th) {
    th.addEventListener('click', function() {
      var col = th.dataset.col;
      if (!col) return;
      if (sortCol === col) {
        sortAsc = !sortAsc;
      } else {
        sortCol = col;
        sortAsc = col === 'rank' || col === 'region' || col === 'pubkey';
      }
      render();
    });
  });

  // --- Init ---
  refreshLeaderboardState(true);
  connectWS();
  refreshTimer = setInterval(function() {
    refreshLeaderboardState(false);
  }, 10000);

})();
</script>
</body>
</html>