OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / dashboard / index.html

1945 lines · html ref:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
/* HiveRelay UI auth shim. When the relay serves this page through a trusted
   reverse proxy (ui.exposeToken), it injects <meta name="hiverelay-ui-token">
   holding a token it will accept as a bearer credential. Attach it to API
   calls so the dashboard works behind a proxy where the localhost check
   can't apply. No meta -> no-op, so direct/localhost use is unchanged. */
(function () {
  try {
    var m = document.querySelector('meta[name="hiverelay-ui-token"]');
    var t = m && m.getAttribute('content');
    if (!t) return;
    var orig = window.fetch.bind(window);
    window.fetch = function (input, init) {
      init = init || {};
      var raw = (typeof input === 'string') ? input : (input && (input.url || input.href)) || String(input || '');
      var u = new URL(raw, window.location.href);
      var h = new Headers((init && init.headers) || (input && input.headers) || {});
      if (u.origin === window.location.origin && !h.has('Authorization')) h.set('Authorization', 'Bearer ' + t);
      init.headers = h;
      return orig(input, init);
    };
  } catch (e) { /* never block page load on the auth shim */ }
})();
</script>
<title>HiveRelay Dashboard</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:#0e1118;--bg-glow:#131a2b;
  --card:#151a26;--card-hi:#1a2132;--card-border:#222b3e;--card-border-hi:#2e3b56;
  --green:#3fb950;--orange:#f0a64a;--cyan:#58a6ff;--red:#f47067;--accent:#f5c842;
  --text:#d6dde8;--text-dim:#8493a8;--text-faint:#5c697e;--text-bright:#fff;
  --mono:'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;
  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
  --radius:12px;--radius-sm:8px;
}
html{font-size:15px}
body{
  background:var(--bg) radial-gradient(1100px 500px at 50% -150px,var(--bg-glow),transparent);
  color:var(--text);font-family:var(--sans);min-height:100vh;padding:1.25rem;
}
a{color:var(--cyan)}
button{font-family:inherit}
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px;border-radius:4px}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}

/* ── Top bar ─────────────────────────────────────────────────────── */
.topbar{
  background:rgba(17,22,34,.88);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  border-bottom:1px solid var(--card-border);
  padding:.8rem 1.5rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
  position:sticky;top:0;z-index:100;margin:-1.25rem -1.25rem 1.5rem -1.25rem;
}
.topbar-logo{display:flex;align-items:center;gap:.7rem;text-decoration:none}
.hive-logo{width:30px;height:30px;flex-shrink:0;filter:drop-shadow(0 0 8px rgba(245,200,66,.35))}
.topbar h1{font-size:1.15rem;color:var(--text-bright);font-weight:650;letter-spacing:.2px}
.topbar-brand{display:flex;flex-direction:column;line-height:1.15}
.topbar-sub{font-size:.56rem;color:var(--text-dim);letter-spacing:.7px;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-right{margin-left:auto;display:flex;align-items:center;gap:.6rem}
.pk-chip{
  display:flex;align-items:center;gap:.45rem;cursor:pointer;border:1px solid var(--card-border);
  background:var(--card);border-radius:999px;padding:.32rem .75rem;
  font-size:.72rem;font-family:var(--mono);color:var(--text-dim);transition:border-color .15s;
}
.pk-chip:hover{border-color:var(--card-border-hi);color:var(--text)}
.pk-chip .copy-ic{opacity:.6;font-size:.75rem}
.uptime-badge{
  display:flex;align-items:center;gap:.42rem;background:rgba(63,185,80,.1);color:var(--green);
  padding:.32rem .8rem;border-radius:999px;font-size:.74rem;font-family:var(--mono);
  border:1px solid rgba(63,185,80,.28);white-space:nowrap;
}
.uptime-badge::before{content:'';width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green)}
.uptime-badge.warn{background:rgba(240,166,74,.1);color:var(--orange);border-color:rgba(240,166,74,.3)}
.uptime-badge.warn::before{background:var(--orange);box-shadow:0 0 6px var(--orange)}

/* ── Cards & stat grid ───────────────────────────────────────────── */
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:.9rem;margin-bottom:1rem}
.stat-card{
  background:linear-gradient(180deg,var(--card-hi),var(--card));
  border:1px solid var(--card-border);border-radius:var(--radius);
  padding:1rem 1.1rem;transition:border-color .15s,transform .15s;
}
.stat-card:hover{border-color:var(--card-border-hi)}
.stat-label{font-size:.66rem;text-transform:uppercase;letter-spacing:1.3px;color:var(--text-dim);margin-bottom:.45rem}
.stat-value{font-size:1.85rem;font-weight:700;font-family:var(--mono);color:var(--text-bright);line-height:1.05}
.stat-sub{font-size:.68rem;color:var(--text-faint);margin-top:.4rem;font-family:var(--mono)}
.stat-value.cyan{color:var(--cyan)}
.stat-value.green{color:var(--green)}
.stat-value.orange{color:var(--orange)}
.stat-value.red{color:var(--red)}
.stat-value.accent{color:var(--accent)}

/* ── Operator data-layer panels (v0/v1) ──────────────────────────── */
.pulse-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.9rem;margin-bottom:1rem}
.pulse-panel{
  background:var(--card);border:1px solid var(--card-border);border-radius:var(--radius);
  padding:1rem 1.1rem;margin-bottom:1rem;
}
.pulse-panel h3{
  font-size:.7rem;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-dim);margin-bottom:.75rem;
}
.pulse-panel .mono-rows{font-family:var(--mono);font-size:.78rem}
.pulse-panel .mono-rows .r{
  display:grid;grid-template-columns:1fr auto auto;gap:.55rem;padding:.4rem 0;
  border-bottom:1px solid rgba(255,255,255,.04);align-items:center;
}
.pulse-panel .mono-rows .r:last-child{border-bottom:0}
.role-pill{
  display:inline-flex;align-items:center;gap:.3rem;font-size:.68rem;font-family:var(--mono);
  padding:.22rem .55rem;border-radius:999px;border:1px solid var(--card-border);color:var(--text-dim);
  margin-right:.35rem;margin-bottom:.25rem;
}
.role-pill.on{color:var(--green);border-color:rgba(63,185,80,.35);background:rgba(63,185,80,.1)}
.role-pill.warn{color:var(--orange);border-color:rgba(240,166,74,.35);background:rgba(240,166,74,.1)}
.role-pill.off{opacity:.55}
.attention-list{list-style:none;margin:0;padding:0}
.attention-list li{
  font-size:.8rem;padding:.4rem 0;border-bottom:1px solid rgba(255,255,255,.04);
  display:flex;justify-content:space-between;gap:.75rem;color:var(--text-dim);
}
.attention-list li:last-child{border-bottom:0}
.attention-list .sev{font-family:var(--mono);font-size:.72rem;color:var(--orange)}
.attention-list .sev.ok{color:var(--green)}
.attention-list .sev.bad{color:var(--red)}

/* ── Earnings panel (subsidy) ────────────────────────────────────── */
.earn-card{
  position:relative;background:linear-gradient(180deg,#1b1d20,#16181d);
  border:1px solid #33301f;border-left:3px solid var(--accent);
  border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;
}
.earn-head{display:flex;align-items:baseline;gap:.6rem;margin-bottom:.85rem}
.earn-title{font-size:.72rem;text-transform:uppercase;letter-spacing:1.4px;color:var(--accent);font-weight:600}
.earn-note{font-size:.66rem;color:var(--text-faint)}
.earn-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1.25rem}
.earn-big{font-size:2rem;font-weight:700;font-family:var(--mono);color:var(--accent);line-height:1.05}
.earn-mid{font-size:1.45rem;font-weight:700;font-family:var(--mono);color:var(--text-bright);line-height:1.1}
.capbar{height:5px;border-radius:3px;background:rgba(255,255,255,.07);margin-top:.5rem;overflow:hidden;max-width:220px}
.capbar>div{height:100%;border-radius:3px;background:linear-gradient(90deg,#c79a2a,var(--accent));width:0;transition:width .5s ease}
.dest-wrap{cursor:pointer;border-radius:var(--radius-sm)}
.dest-chip{
  display:inline-block;font-family:var(--mono);font-size:.82rem;color:var(--cyan);
  background:rgba(88,166,255,.08);border:1px solid rgba(88,166,255,.22);
  padding:.3rem .65rem;border-radius:var(--radius-sm);word-break:break-all;line-height:1.35;
}
.dest-chip.unset{color:var(--text-dim);border-style:dashed;background:transparent}
.dest-edit{font-size:.64rem;color:var(--text-faint);margin-top:.35rem}

/* ── Charts ──────────────────────────────────────────────────────── */
.charts-row{display:grid;grid-template-columns:1fr 1fr;gap:.9rem;margin-bottom:1rem}
.chart-card{
  background:var(--card);border:1px solid var(--card-border);border-radius:var(--radius);
  padding:1rem 1.1rem;overflow:hidden;
}
.chart-card h3{font-size:.7rem;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-dim);margin-bottom:.7rem}
.chart-legend{display:inline-flex;gap:.8rem;margin-left:.6rem;text-transform:none;letter-spacing:0}
.chart-legend span{display:inline-flex;align-items:center;gap:.3rem;font-size:.66rem;color:var(--text-faint)}
.chart-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
.chart-wrap{position:relative;width:100%;height:190px}
.chart-wrap canvas{width:100%!important;height:100%!important;display:block}
.chart-empty{
  position:absolute;inset:0;display:flex;flex-direction:column;gap:.3rem;align-items:center;justify-content:center;
  color:var(--text-faint);font-size:.78rem;text-align:center;padding:0 1rem;
}
.chart-empty b{color:var(--text-dim);font-weight:600}

/* ── Tables ──────────────────────────────────────────────────────── */
.table-card{
  background:var(--card);border:1px solid var(--card-border);border-radius:var(--radius);
  padding:1rem 1.1rem;margin-bottom:1rem;overflow-x:auto;
}
.table-card h3{font-size:.7rem;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-dim);margin-bottom:.7rem}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th{
  text-align:left;padding:.55rem .8rem;border-bottom:1px solid var(--card-border);
  color:var(--text-faint);font-weight:500;cursor:pointer;user-select:none;
  white-space:nowrap;font-size:.66rem;text-transform:uppercase;letter-spacing:.9px;
}
th:hover{color:var(--cyan)}
th .sort-arrow{margin-left:4px;font-size:.6rem;opacity:.5}
th.sorted .sort-arrow{opacity:1;color:var(--cyan)}
td{padding:.55rem .8rem;border-bottom:1px solid rgba(34,43,62,.55);font-family:var(--mono);font-size:.78rem}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(88,166,255,.04)}
.empty-state{text-align:center;padding:1.6rem;color:var(--text-faint);font-style:italic;font-family:var(--sans)}
.badge{
  display:inline-block;font-size:.62rem;font-family:var(--sans);letter-spacing:.4px;
  color:var(--text-faint);border:1px solid var(--card-border);border-radius:999px;padding:.06rem .5rem;
}
.btn{
  border-radius:6px;padding:3px 10px;font-size:.68rem;cursor:pointer;font-family:var(--mono);
  letter-spacing:.5px;transition:filter .15s;background:transparent;
}
.btn:hover{filter:brightness(1.25)}
.btn-green{background:rgba(63,185,80,.14);color:var(--green);border:1px solid rgba(63,185,80,.32)}
.btn-red{background:rgba(244,112,103,.12);color:var(--red);border:1px solid rgba(244,112,103,.3)}

/* ── System / peers ──────────────────────────────────────────────── */
.bottom-row{display:grid;grid-template-columns:1fr 1fr;gap:.9rem;margin-bottom:1rem}
.sys-card,.peers-card{
  background:var(--card);border:1px solid var(--card-border);border-radius:var(--radius);padding:1rem 1.1rem;
}
.sys-card h3,.peers-card h3{font-size:.7rem;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-dim);margin-bottom:.9rem}
.sys-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem}
.sys-row:last-child{margin-bottom:0}
.sys-label{font-size:.76rem;color:var(--text-dim)}
.sys-val{font-family:var(--mono);font-size:.8rem;color:var(--text-bright)}
.progress-wrap{width:100%;margin:.15rem 0 .7rem}
.progress-bar{height:5px;border-radius:3px;background:rgba(255,255,255,.06);overflow:hidden}
.progress-fill{height:100%;border-radius:3px;transition:width .5s ease}
.progress-fill.green{background:linear-gradient(90deg,#2c8c3c,var(--green))}
.progress-fill.cyan{background:linear-gradient(90deg,#3b78c2,var(--cyan))}
.progress-fill.orange{background:linear-gradient(90deg,#b87b2e,var(--orange))}
.progress-fill.red{background:linear-gradient(90deg,#b14a42,var(--red))}
.peers-list{max-height:215px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--card-border) transparent}
.peers-list::-webkit-scrollbar{width:5px}
.peers-list::-webkit-scrollbar-thumb{background:var(--card-border);border-radius:3px}
.peer-item{
  padding:.38rem .55rem;font-family:var(--mono);font-size:.74rem;color:var(--cyan);
  border-bottom:1px solid rgba(34,43,62,.45);display:flex;align-items:center;gap:.5rem;
}
.peer-item:last-child{border-bottom:none}
.peer-item::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--green);flex-shrink:0;box-shadow:0 0 5px rgba(63,185,80,.7)}
.peer-count{font-size:.68rem;color:var(--text-faint);margin-bottom:.45rem;font-family:var(--mono)}

/* ── Registry toggle ─────────────────────────────────────────────── */
.reg-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.8rem;gap:.8rem;flex-wrap:wrap}
.reg-mode{font-size:.68rem;font-family:var(--mono);letter-spacing:.6px}
.switch{position:relative;display:inline-block;width:40px;height:22px;cursor:pointer;flex-shrink:0}
.switch input{opacity:0;width:0;height:0}
.switch-track{position:absolute;inset:0;background:rgba(63,185,80,.2);border-radius:11px;border:1px solid rgba(63,185,80,.35);transition:.25s}
.switch-dot{position:absolute;top:3px;left:3px;width:16px;height:16px;background:#fff;border-radius:50%;transition:.25s}
.sub-head{font-size:.66rem;text-transform:uppercase;letter-spacing:.9px;margin-bottom:.5rem}

/* ── Destination dialog ──────────────────────────────────────────── */
dialog#destDialog{
  background:var(--card-hi);color:var(--text);border:1px solid var(--card-border-hi);
  border-radius:var(--radius);padding:1.4rem;max-width:430px;width:92vw;
}
dialog#destDialog::backdrop{background:rgba(5,8,14,.7);backdrop-filter:blur(2px)}
#destDialog h4{font-size:.95rem;color:var(--text-bright);margin-bottom:.35rem}
#destDialog p{font-size:.76rem;color:var(--text-dim);line-height:1.45;margin-bottom:.9rem}
#destInput{
  width:100%;background:var(--bg);border:1px solid var(--card-border-hi);border-radius:var(--radius-sm);
  color:var(--text-bright);font-family:var(--mono);font-size:.85rem;padding:.6rem .7rem;
}
#destInput:focus{outline:none;border-color:var(--accent)}
#destError{font-size:.72rem;color:var(--red);margin-top:.45rem;min-height:1em}
.dlg-actions{display:flex;justify-content:flex-end;gap:.6rem;margin-top:1rem}
.dlg-btn{border-radius:var(--radius-sm);padding:.45rem 1rem;font-size:.78rem;cursor:pointer;border:1px solid var(--card-border-hi);background:transparent;color:var(--text-dim)}
.dlg-btn.primary{background:var(--accent);border-color:var(--accent);color:#10131b;font-weight:600}

/* ── Footer / connection pill ────────────────────────────────────── */
.foot{display:flex;align-items:center;justify-content:center;gap:.5rem;color:var(--text-faint);font-size:.68rem;padding:1rem 0 .4rem}
.conn-status{
  position:fixed;bottom:1rem;right:1rem;padding:.32rem .7rem;border-radius:999px;
  font-size:.66rem;font-family:var(--mono);z-index:100;
  background:rgba(63,185,80,.1);color:var(--green);border:1px solid rgba(63,185,80,.25);
}
.conn-status.error{background:rgba(244,112,103,.1);color:var(--red);border-color:rgba(244,112,103,.25)}

/* ── Responsive ──────────────────────────────────────────────────── */
@media(max-width:900px){
  .charts-row,.bottom-row{grid-template-columns:1fr}
}
@media(max-width:560px){
  html{font-size:14px}
  body{padding:.8rem}
  .topbar{margin:-.8rem -.8rem 1rem -.8rem;padding:.7rem .9rem;gap:.6rem}
  .topbar-nav{order:3;width:100%;margin-left:0}
  .topbar-right{gap:.4rem}
  .pk-chip{display:none}
  .stat-value{font-size:1.5rem}
  .earn-big{font-size:1.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</h1><div class="topbar-sub">relay dashboard</div></div>
  </a>
  <nav class="topbar-nav">
    <a href="/dashboard" class="active">Dashboard</a>
    <a href="/catalog">Catalog</a>
    <a href="/payments">Payments</a>
    <a href="/calculator">Calculator</a>
    <a href="/network">Network</a>
    <a href="/fleet">Fleet</a>
    <a href="/leaderboard">Leaderboard</a>
    <a href="/docs">Docs</a>
  </nav>
  <div class="topbar-right">
    <button type="button" class="pk-chip" id="headerMeta" title="Copy node public key" aria-label="Copy node public key">Loading...</button>
    <div class="uptime-badge" id="uptimeBadge">--</div>
  </div>
</div>

<!-- Stats Row -->
<div class="stats-row">
  <div class="stat-card">
    <div class="stat-label">Connections</div>
    <div class="stat-value cyan" id="statConnections">--</div>
    <div class="stat-sub" id="statConnectionsSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Apps Kept Alive</div>
    <div class="stat-value green" id="statApps">--</div>
    <div class="stat-sub" id="statAppsSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Data Stored</div>
    <div class="stat-value" id="statStored">--</div>
    <div class="stat-sub" id="statStoredSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Data Relayed</div>
    <div class="stat-value orange" id="statRelayed">--</div>
    <div class="stat-sub" id="statRelayedSub">&nbsp;</div>
  </div>
</div>

<!-- Operator pulse (v0/v1 data layer) -->
<div class="pulse-row" id="operatorPulseRow">
  <div class="stat-card">
    <div class="stat-label">Node health</div>
    <div class="stat-value green" id="statNodeHealth">--</div>
    <div class="stat-sub" id="statNodeHealthSub">disk · api · roles</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Disk</div>
    <div class="stat-value cyan" id="statDisk">--</div>
    <div class="stat-sub" id="statDiskSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Roles</div>
    <div class="stat-value accent" id="statRoles" style="font-size:1.1rem;line-height:1.35">--</div>
    <div class="stat-sub" id="statRolesSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Suppressions</div>
    <div class="stat-value orange" id="statSuppress">--</div>
    <div class="stat-sub" id="statSuppressSub">DO-NOT-SERVE</div>
  </div>
</div>

<div class="stats-row" id="operatorPulseRow2" style="display:none">
  <div class="stat-card">
    <div class="stat-label">OutboxLog writers</div>
    <div class="stat-value cyan" id="statOutboxWriters">--</div>
    <div class="stat-sub" id="statOutboxWritersSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Outbox bytes (all NS)</div>
    <div class="stat-value" id="statOutboxBytes">--</div>
    <div class="stat-sub" id="statOutboxBytesSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Shard rejects</div>
    <div class="stat-value" id="statShardRejected">--</div>
    <div class="stat-sub" id="statShardSub">custody path</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Privacy lane</div>
    <div class="stat-value" id="statPrivacy" style="font-size:1.05rem">--</div>
    <div class="stat-sub" id="statPrivacySub">Tor coarse health</div>
  </div>
</div>

<div class="pulse-panel" id="namespacesPanel" style="display:none">
  <h3>Namespaces · OutboxLog</h3>
  <div class="mono-rows" id="namespacesBody">
    <div class="r"><span style="color:var(--text-faint)">No OutboxLog namespaces reported</span><span></span><span></span></div>
  </div>
</div>

<div class="pulse-panel" id="compliancePanel" style="display:none">
  <h3>Compliance · DO-NOT-SERVE</h3>
  <div class="mono-rows">
    <div class="r"><span>Admin surface</span><span id="compAdminArmed" class="sev">--</span><span id="compAdminNote" style="color:var(--text-faint)"></span></div>
    <div class="r"><span>Suppressed opaque keys</span><span id="compSuppressKeys">--</span><span style="color:var(--text-faint)">serve-time only</span></div>
    <div class="r"><span>Takedown count</span><span id="compTakedownCount">--</span><span style="color:var(--text-faint)">audit list size</span></div>
  </div>
</div>

<!-- Operator data-layer v2: WAL · Gateway · Shard (Prometheus-aligned) -->
<div class="pulse-panel" id="walPanel" style="display:none">
  <h3>WAL · durability pulse</h3>
  <div class="mono-rows">
    <div class="r"><span>Source</span><span id="walSource">--</span><span id="walHealthy" class="sev">--</span></div>
    <div class="r"><span>Frames / s</span><span id="walFrames">--</span><span style="color:var(--text-faint)">blind line</span></div>
    <div class="r"><span>fsync p99</span><span id="walFsync">--</span><span style="color:var(--text-faint)">ms</span></div>
    <div class="r"><span>Pruning</span><span id="walPrune">--</span><span id="walPruneNote" style="color:var(--text-faint)"></span></div>
    <div class="r"><span>SPEND_REPLAY / 24h</span><span id="walSpend">--</span><span style="color:var(--text-faint)">fail-closed if rising</span></div>
    <div class="r"><span>Size / length</span><span id="walSize">--</span><span id="walSizeNote" style="color:var(--text-faint)"></span></div>
  </div>
</div>

<div class="pulse-panel" id="gatewayPanel" style="display:none">
  <h3>T1 HTTPS gateway</h3>
  <div class="mono-rows">
    <div class="r"><span>Enabled</span><span id="gwEnabled" class="sev">--</span><span id="gwEndpoint" style="color:var(--text-faint)"></span></div>
    <div class="r"><span>Total requests</span><span id="gwRequests">--</span><span style="color:var(--text-faint)">lifetime</span></div>
    <div class="r"><span>Bytes served</span><span id="gwBytes">--</span><span style="color:var(--text-faint)">lifetime</span></div>
    <div class="r"><span>Cached drives</span><span id="gwDrives">--</span><span style="color:var(--text-faint)">in-memory</span></div>
    <div class="r"><span>Verify fails</span><span id="gwVerify">--</span><span style="color:var(--text-faint)">exact-byte</span></div>
    <div class="r"><span>Role policy</span><span id="gwRole" class="sev">--</span><span id="gwRoleNote" style="color:var(--text-faint)"></span></div>
  </div>
</div>

<div class="pulse-panel" id="shardPanel" style="display:none">
  <h3>Shard store · custody counters</h3>
  <div class="mono-rows">
    <div class="r"><span>hiverelay_shard_put_total</span><span id="shardPut">--</span><span></span></div>
    <div class="r"><span>get / proof</span><span id="shardGetProof">--</span><span style="color:var(--text-faint)">aggregate</span></div>
    <div class="r"><span>rejected (bad pin)</span><span id="shardRejected" class="sev">--</span><span style="color:var(--text-faint)">no pin payloads</span></div>
    <div class="r"><span>evicted / swept</span><span id="shardEvict">--</span><span style="color:var(--text-faint)"></span></div>
    <div class="r"><span>held / bytes</span><span id="shardHeld">--</span><span style="color:var(--text-faint)">ciphertext only</span></div>
  </div>
</div>

<div class="pulse-panel" id="attentionPanel">
  <h3>Attention</h3>
  <ul class="attention-list" id="attentionList">
    <li><span>Waiting for overview…</span><span class="sev">—</span></li>
  </ul>
</div>

<!-- Earnings (hidden unless the relay has subsidy.enabled) -->
<div class="earn-card" id="subsidyStats" style="display:none">
  <div class="earn-head">
    <span class="earn-title">&#10022; Earnings</span>
    <span class="earn-note">subsidy estimate — verified &amp; paid by the network coordinator</span>
  </div>
  <div class="earn-grid">
    <div>
      <div class="stat-label">Accrued</div>
      <div class="earn-big" id="statSubsidyTotal">--</div>
      <div class="stat-sub">sats total, pending verification</div>
    </div>
    <div>
      <div class="stat-label">Today</div>
      <div class="earn-mid" id="statSubsidyToday">--</div>
      <div class="stat-sub" id="statSubsidyCap">&nbsp;</div>
      <div class="capbar"><div id="subsidyCapFill"></div></div>
    </div>
    <div class="dest-wrap" id="subsidyDestCard" title="Set your payout destination" role="button" tabindex="0">
      <div class="stat-label">Payout Destination</div>
      <span class="dest-chip unset" id="statSubsidyDest">--</span>
      <div class="dest-edit">lightning address &middot; BOLT12 offer &middot; bitcoin address — click to change</div>
    </div>
  </div>
</div>

<!-- Charts -->
<div class="charts-row">
  <div class="chart-card">
    <h3>Connections <span style="text-transform:none;letter-spacing:0">— last hour</span></h3>
    <div class="chart-wrap">
      <canvas id="chartConnections"></canvas>
      <div class="chart-empty" id="chartConnEmpty"><b>No samples yet</b><span>fills in within a couple of minutes</span></div>
    </div>
  </div>
  <div class="chart-card">
    <h3>Traffic
      <span class="chart-legend"><span><i style="background:var(--green)"></i>served</span><span><i style="background:var(--orange)"></i>relayed</span></span>
    </h3>
    <div class="chart-wrap">
      <canvas id="chartTraffic"></canvas>
      <div class="chart-empty" id="chartTrafficEmpty"><b>No traffic yet</b><span>charts fill in as your node serves peers</span></div>
    </div>
  </div>
</div>

<!-- Payment & Credits Stats (hidden until the payment stack reports activity) -->
<div id="paymentsIntro" style="display:none;margin-bottom:.5rem;font-size:.7rem;color:var(--text-faint)">
  All services free by default &middot; 1k welcome credits per app &middot; <a href="/payments" style="text-decoration:none">View payments &rarr;</a>
</div>
<div class="stats-row" id="paymentStats" style="display:none">
  <div class="stat-card">
    <div class="stat-label">Revenue (sats)</div>
    <div class="stat-value orange" id="statRevenue">--</div>
    <div class="stat-sub" id="statRevenueSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Credit Wallets</div>
    <div class="stat-value cyan" id="statWallets">--</div>
    <div class="stat-sub" id="statWalletsSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Service Calls</div>
    <div class="stat-value green" id="statCalls">--</div>
    <div class="stat-sub" id="statCallsSub">&nbsp;</div>
  </div>
  <div class="stat-card">
    <div class="stat-label">Invoices</div>
    <div class="stat-value" id="statInvoices">--</div>
    <div class="stat-sub" id="statInvoicesSub">&nbsp;</div>
  </div>
</div>

<!-- Seeded Apps Table -->
<div class="table-card">
  <h3>Seeded Apps</h3>
  <table id="appsTable">
    <thead>
      <tr>
        <th data-col="name">Name <span class="sort-arrow"></span></th>
        <th data-col="developer">Developer <span class="sort-arrow"></span></th>
        <th data-col="key">App Key <span class="sort-arrow"></span></th>
        <th data-col="started">Started <span class="sort-arrow"></span></th>
        <th data-col="uptime">Uptime <span class="sort-arrow"></span></th>
        <th data-col="bytesServed">Bytes Served <span class="sort-arrow"></span></th>
      </tr>
    </thead>
    <tbody id="appsBody">
      <tr><td colspan="6" class="empty-state">Nothing seeded yet — apps appear here as the network places data on your node</td></tr>
    </tbody>
  </table>
</div>

<!-- Bottom Row -->
<div class="bottom-row">
  <!-- System Info -->
  <div class="sys-card">
    <h3>System</h3>
    <div id="sysInfo">
      <div class="sys-row"><span class="sys-label">Heap Memory</span><span class="sys-val" id="sysHeap">--</span></div>
      <div class="progress-wrap"><div class="progress-bar"><div class="progress-fill cyan" id="sysHeapBar" style="width:0"></div></div></div>
      <div class="sys-row"><span class="sys-label">RSS Memory</span><span class="sys-val" id="sysRss">--</span></div>
      <div class="progress-wrap" id="sysRssWrap" style="display:none"><div class="progress-bar"><div class="progress-fill cyan" id="sysRssBar" style="width:0"></div></div></div>
      <div class="sys-row"><span class="sys-label">Storage</span><span class="sys-val" id="sysStorage">--</span></div>
      <div class="progress-wrap"><div class="progress-bar"><div class="progress-fill green" id="sysStorageBar" style="width:0"></div></div></div>
      <div class="sys-row"><span class="sys-label">Circuits</span><span class="sys-val" id="sysCircuits">--</span></div>
      <div class="sys-row"><span class="sys-label">Errors</span><span class="sys-val" id="sysErrors">--</span></div>
    </div>
  </div>

  <!-- Active Peers -->
  <div class="peers-card">
    <h3>Active Peers</h3>
    <div class="peer-count" id="peerCount">0 connected</div>
    <div class="peers-list" id="peersList"></div>
  </div>
</div>

<!-- Registry Management -->
<div class="table-card" id="registryCard">
  <div class="reg-head">
    <h3 style="margin:0">Seeding Registry</h3>
    <div style="display:flex;align-items:center;gap:.8rem">
      <span id="registryMode" class="reg-mode" style="color:var(--green)">AUTO-ACCEPT</span>
      <label class="switch" aria-label="Toggle auto-accept">
        <input type="checkbox" id="autoAcceptToggle" checked role="switch">
        <span class="switch-track" id="toggleTrack"></span>
        <span class="switch-dot" id="toggleDot" style="left:21px"></span>
      </label>
    </div>
  </div>
  <div id="pendingSection" style="display:none;margin-bottom:1rem">
    <div class="sub-head" style="color:var(--orange)">Pending Approval</div>
    <table style="width:100%">
      <thead><tr>
        <th>App Key</th><th>Publisher</th><th>Replicas</th><th>Geo</th><th>Actions</th>
      </tr></thead>
      <tbody id="pendingBody"><tr><td colspan="5" class="empty-state">No pending requests</td></tr></tbody>
    </table>
  </div>
  <div>
    <div class="sub-head" style="color:var(--text-dim)">Active Seeds</div>
    <table style="width:100%">
      <thead><tr>
        <th>Name</th><th>Developer</th><th>App Key</th><th>Uptime</th><th>Served</th><th>Actions</th>
      </tr></thead>
      <tbody id="seedsBody"><tr><td colspan="6" class="empty-state">No apps seeded</td></tr></tbody>
    </table>
  </div>
</div>

<div class="foot">&#10022; HiveRelay &middot; relay dashboard</div>

<!-- Payout destination dialog -->
<dialog id="destDialog">
  <h4>Payout destination</h4>
  <p>Where the network pays your accrued sats. Use a <b>lightning address</b>, a <b>BOLT12 offer</b>, or a <b>bitcoin address</b> you control — the relay never holds your funds.</p>
  <input id="destInput" type="text" spellcheck="false" autocomplete="off" placeholder="you@your-ln-provider.com">
  <div id="destError"></div>
  <div class="dlg-actions">
    <button class="dlg-btn" id="destCancel" type="button">Cancel</button>
    <button class="dlg-btn primary" id="destSave" type="button">Save</button>
  </div>
</dialog>

<!-- Connection Status -->
<div class="conn-status" id="connStatus">LIVE</div>

<script>
/* ===== Catalog Name Lookup ===== */
var catalogNames = {}; // appKey -> display name
var catalogDevs = {}; // appKey -> { pubkey, displayName, avatar }

function fetchCatalogNames() {
  fetchWithTimeout('/catalog.json').then(function(r) { return r.json(); }).then(function(data) {
    if (data.apps && Array.isArray(data.apps)) {
      data.apps.forEach(function(app) {
        // Catalog entries for metadata-less drives carry the literal
        // placeholder name "Unknown App" — not a real name, skip it so
        // the key-based fallback renders instead.
        if (app.driveKey && app.name && app.name !== 'Unknown App') catalogNames[app.driveKey] = app.name;
        if (app.driveKey && app.developer) catalogDevs[app.driveKey] = app.developer;
      });
      // Re-render tables with names
      renderAppsTable();
      if (window._lastSeedApps) renderSeeds(window._lastSeedApps);
    }
  }).catch(function() {});
}

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);
  node.textContent = '';
}
function appendEl(parent, tag, className, text) {
  var el = document.createElement(tag);
  if (className) el.className = className;
  if (text !== undefined) el.textContent = String(text);
  parent.appendChild(el);
  return el;
}
function safeHttpUrl(value) {
  var raw = String(value || '').trim();
  if (!/^https?:\/\//i.test(raw)) return '';
  try {
    var url = new URL(raw);
    if (url.username || url.password) return '';
    return url.href;
  } catch (e) {
    return '';
  }
}

function renderDevNode(parent, appKey) {
  var dev = catalogDevs[appKey];
  if (!dev) {
    appendEl(parent, 'span', 'badge', 'unlinked');
    return;
  }
  var fallback = dev.pubkey ? String(dev.pubkey).slice(0, 8) + '...' : 'linked';
  var avatarUrl = safeHttpUrl(dev.avatar);
  if (avatarUrl) {
    var avatar = appendEl(parent, 'img');
    avatar.src = avatarUrl;
    avatar.alt = '';
    avatar.referrerPolicy = 'no-referrer';
    avatar.style.width = '16px';
    avatar.style.height = '16px';
    avatar.style.borderRadius = '50%';
    avatar.style.verticalAlign = 'middle';
    avatar.style.marginRight = '4px';
  }
  var name = appendEl(parent, 'span', '', dev.displayName || fallback);
  name.style.color = 'var(--cyan)';
  name.style.fontSize = '.8rem';
}

/* App display name with an honest fallback: when no catalog metadata
   exists, show the truncated key as a dim mono identifier rather than
   pretending "Unknown App" is a name. The API itself reports
   appId "Unknown App" for metadata-less drives — treat that as no name. */
function renderAppNameNode(parent, appKey, appId) {
  var known = catalogNames[appKey] || (appId && appId !== 'Unknown App' ? appId : null);
  if (known) {
    var knownEl = appendEl(parent, 'span', '', known);
    knownEl.style.color = 'var(--text-bright)';
    knownEl.style.fontWeight = '500';
    return;
  }
  var keyEl = appendEl(parent, 'span', '', (appKey || '').slice(0, 12) + '...');
  keyEl.style.color = 'var(--text-dim)';
  keyEl.style.fontFamily = 'var(--mono)';
  parent.appendChild(document.createTextNode(' '));
  appendEl(parent, 'span', 'badge', 'no metadata');
}

/* ===== Helpers ===== */
function formatBytes(b) {
  if (b == null || isNaN(b)) return '--';
  if (b === 0) return '0 B';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
  const v = b / Math.pow(1024, i);
  return (i === 0 ? v : v.toFixed(1)) + ' ' + units[i];
}

function formatUptime(ms) {
  if (ms == null || isNaN(ms) || ms < 0) return '--';
  const s = Math.floor(ms / 1000);
  const d = Math.floor(s / 86400);
  const h = Math.floor((s % 86400) / 3600);
  const m = Math.floor((s % 3600) / 60);
  const parts = [];
  if (d) parts.push(d + 'd');
  if (h || d) parts.push(h + 'h');
  parts.push(m + 'm');
  return parts.join(' ');
}

function truncateKey(hex, len) {
  len = len || 12;
  if (!hex) return '--';
  return hex.length > len ? hex.slice(0, len) + '...' : hex;
}

function renderHeaderMeta(chip, pk, region) {
  pk = String(pk || '');
  region = String(region || '');
  chip.dataset.pk = pk;
  chip.dataset.region = region;
  clearNode(chip);
  appendEl(chip, 'span', '', truncateKey(pk) + (region ? ' · ' + region : ''));
  appendEl(chip, 'span', 'copy-ic', '⧉');
}

function renderHeaderCopyMessage(chip, message) {
  clearNode(chip);
  chip.textContent = message;
  setTimeout(function() {
    renderHeaderMeta(chip, chip.dataset.pk || '', chip.dataset.region || '');
  }, 1200);
}

function renderHeaderCopied(chip) {
  renderHeaderCopyMessage(chip, 'copied ✓');
}

function copyHeaderPubkey(chip) {
  var pk = chip && chip.dataset ? chip.dataset.pk : '';
  if (!pk) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) {
    renderHeaderCopyMessage(chip, 'Clipboard unavailable');
    return;
  }
  navigator.clipboard.writeText(pk).then(function() {
    renderHeaderCopied(chip);
  }).catch(function() {
    renderHeaderCopyMessage(chip, 'Copy failed');
  });
}

function renderPeersList(pl, peers) {
  clearNode(pl);
  if (!peers.length) {
    var empty = appendEl(pl, 'div', 'empty-state', 'No peers right now — the node keeps dialing');
    empty.style.padding = '1rem';
    empty.style.fontSize = '.78rem';
    return;
  }
  peers.forEach(function(p) {
    var key = typeof p === 'string' ? p : (p && (p.publicKey || p.key)) || '';
    appendEl(pl, 'div', 'peer-item', truncateKey(String(key), 16));
  });
}

function formatTime(ts) {
  const d = new Date(ts);
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}

function progressColor(pct) {
  if (pct > 90) return 'red';
  if (pct > 70) return 'orange';
  return 'green';
}

/* ===== Chart Renderer ===== */
function drawChart(canvas, datasets, opts) {
  const dpr = window.devicePixelRatio || 1;
  const rect = canvas.parentElement.getBoundingClientRect();
  const w = rect.width;
  const h = rect.height;
  canvas.width = w * dpr;
  canvas.height = h * dpr;
  canvas.style.width = w + 'px';
  canvas.style.height = h + 'px';

  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  ctx.clearRect(0, 0, w, h);

  const pad = { top: 10, right: 15, bottom: 30, left: 55 };
  const cw = w - pad.left - pad.right;
  const ch = h - pad.top - pad.bottom;

  if (!datasets || datasets.every(function(ds) { return !ds.data || ds.data.length < 2; })) {
    return false; // signal empty
  }

  // Compute global min/max
  var allTimes = [];
  var allVals = [];
  datasets.forEach(function(ds) {
    ds.data.forEach(function(p) {
      allTimes.push(p.t);
      allVals.push(p.v);
    });
  });

  var tMin = Math.min.apply(null, allTimes);
  var tMax = Math.max.apply(null, allTimes);
  var vMin = 0;
  var vMax = Math.max.apply(null, allVals);
  if (vMax === 0) vMax = 1;
  // Add 10% headroom
  vMax *= 1.1;

  if (tMin === tMax) { tMax = tMin + 60000; }

  // Grid
  ctx.strokeStyle = 'rgba(255,255,255,.04)';
  ctx.lineWidth = 1;
  var yTicks = 5;
  for (var i = 0; i <= yTicks; i++) {
    var y = pad.top + ch - (i / yTicks) * ch;
    ctx.beginPath();
    ctx.moveTo(pad.left, y);
    ctx.lineTo(pad.left + cw, y);
    ctx.stroke();
  }

  // Y labels
  ctx.fillStyle = '#5c697e';
  ctx.font = '10px ' + getComputedStyle(document.body).getPropertyValue('--mono');
  ctx.textAlign = 'right';
  ctx.textBaseline = 'middle';
  for (var i = 0; i <= yTicks; i++) {
    var y = pad.top + ch - (i / yTicks) * ch;
    var val = vMin + (i / yTicks) * (vMax - vMin);
    var label = opts && opts.formatY ? opts.formatY(val) : Math.round(val).toString();
    ctx.fillText(label, pad.left - 8, y);
  }

  // X labels
  ctx.textAlign = 'center';
  ctx.textBaseline = 'top';
  var xTicks = Math.min(6, Math.floor(cw / 70));
  for (var i = 0; i <= xTicks; i++) {
    var t = tMin + (i / xTicks) * (tMax - tMin);
    var x = pad.left + (i / xTicks) * cw;
    ctx.fillText(formatTime(t), x, pad.top + ch + 10);
  }

  // Draw lines
  datasets.forEach(function(ds) {
    if (!ds.data || ds.data.length < 2) return;
    var sorted = ds.data.slice().sort(function(a, b) { return a.t - b.t; });

    ctx.beginPath();
    ctx.strokeStyle = ds.color || '#58a6ff';
    ctx.lineWidth = 2;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';

    sorted.forEach(function(p, idx) {
      var x = pad.left + ((p.t - tMin) / (tMax - tMin)) * cw;
      var y = pad.top + ch - ((p.v - vMin) / (vMax - vMin)) * ch;
      if (idx === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    });
    ctx.stroke();

    // Area fill
    var lastPt = sorted[sorted.length - 1];
    var lastX = pad.left + ((lastPt.t - tMin) / (tMax - tMin)) * cw;
    ctx.lineTo(lastX, pad.top + ch);
    var firstPt = sorted[0];
    var firstX = pad.left + ((firstPt.t - tMin) / (tMax - tMin)) * cw;
    ctx.lineTo(firstX, pad.top + ch);
    ctx.closePath();
    ctx.fillStyle = (ds.color || '#58a6ff') + '12';
    ctx.fill();
  });

  return true; // has data
}

/* ===== Sorting ===== */
var sortState = { col: null, asc: true };
var currentApps = [];

function sortApps(col) {
  if (sortState.col === col) sortState.asc = !sortState.asc;
  else { sortState.col = col; sortState.asc = true; }

  document.querySelectorAll('#appsTable th').forEach(function(th) {
    th.classList.remove('sorted');
    th.querySelector('.sort-arrow').textContent = '';
  });
  var activeTh = document.querySelector('#appsTable th[data-col="' + col + '"]');
  if (activeTh) {
    activeTh.classList.add('sorted');
    activeTh.querySelector('.sort-arrow').textContent = sortState.asc ? '▲' : '▼';
  }

  currentApps.sort(function(a, b) {
    var av = a[col], bv = b[col];
    if (typeof av === 'string') return sortState.asc ? av.localeCompare(bv) : bv.localeCompare(av);
    return sortState.asc ? (av - bv) : (bv - av);
  });

  renderAppsTable();
}

function renderAppsTable() {
  var tbody = document.getElementById('appsBody');
  clearNode(tbody);
  if (!currentApps.length) {
    var emptyRow = appendEl(tbody, 'tr');
    var emptyCell = appendEl(emptyRow, 'td', 'empty-state', 'Nothing seeded yet — apps appear here as the network places data on your node');
    emptyCell.colSpan = 6;
    return;
  }
  currentApps.forEach(function(app) {
    var appKey = String(app.appKey || app.key || '');
    var startedAt = app.started ? new Date(app.started).getTime() : NaN;
    var hasStartedAt = !isNaN(startedAt);
    var uptime = hasStartedAt ? Date.now() - startedAt : app.uptime || 0;
    var row = appendEl(tbody, 'tr');
    renderAppNameNode(appendEl(row, 'td'), appKey, app.appId);
    renderDevNode(appendEl(row, 'td'), appKey);
    appendEl(row, 'td', '', truncateKey(appKey));
    appendEl(row, 'td', '', hasStartedAt ? formatTime(app.started) : '--');
    appendEl(row, 'td', '', formatUptime(uptime));
    appendEl(row, 'td', '', formatBytes(app.bytesServed || 0));
  });
}

document.querySelectorAll('#appsTable th').forEach(function(th) {
  th.addEventListener('click', function() { sortApps(th.dataset.col); });
});

function formatSats(s) {
  if (s == null || isNaN(s)) return '--';
  if (s >= 100000000) return (s / 100000000).toFixed(2) + ' BTC';
  if (s >= 1000000) return (s / 1000000).toFixed(1) + 'M';
  if (s >= 1000) return (s / 1000).toFixed(1) + 'k';
  return s.toString();
}

function formatCount(n) {
  if (n == null || isNaN(n)) return '--';
  if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
  if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
  return n.toString();
}

/* ===== Operator data-layer pulse (v0/v1) ===== */
function updateOperatorPulse(overview) {
  if (!overview) return;
  var health = overview.health || {};
  var disk = overview.disk || (health.checks && health.checks.disk) || {};
  var roles = overview.roles || {};
  var compliance = overview.compliance || null;
  var namespaces = overview.namespaces || null;
  var shard = overview.shardStore || null;
  var privacy = Array.isArray(overview.privacyTransports) ? overview.privacyTransports : [];
  var attention = [];

  // Node health
  var healthy = health.healthy !== false && (disk.ok !== false) && (disk.status !== 'critical');
  var healthEl = document.getElementById('statNodeHealth');
  if (healthEl) {
    healthEl.textContent = healthy ? 'OK' : 'ATTN';
    healthEl.className = 'stat-value ' + (healthy ? 'green' : 'orange');
  }
  var healthSub = document.getElementById('statNodeHealthSub');
  if (healthSub) {
    var bits = [];
    if (disk.status) bits.push('disk ' + disk.status);
    else if (disk.usedPct != null) bits.push('disk ' + Math.round(disk.usedPct) + '%');
    if (roles.gateway) bits.push('gateway');
    if (roles.t2) bits.push('custody');
    healthSub.textContent = bits.length ? bits.join(' · ') : 'disk · api · roles';
  }
  if (disk.status === 'critical' || disk.critical === true || (disk.usedPct != null && disk.usedPct >= 90)) {
    attention.push({ text: 'Disk critical / nearly full', sev: 'bad' });
  } else if (disk.usedPct != null && disk.usedPct >= 75) {
    attention.push({ text: 'Disk above 75%', sev: 'warn' });
  }

  // Disk card
  var diskEl = document.getElementById('statDisk');
  var diskSub = document.getElementById('statDiskSub');
  if (diskEl) {
    if (disk.usedPct != null) {
      diskEl.textContent = Math.round(disk.usedPct) + '%';
      diskEl.className = 'stat-value ' + (disk.usedPct >= 90 ? 'red' : disk.usedPct >= 75 ? 'orange' : 'cyan');
    } else diskEl.textContent = '--';
  }
  if (diskSub) {
    diskSub.textContent = disk.freeGB != null
      ? (disk.freeGB + ' GB free' + (disk.status ? ' · ' + disk.status : ''))
      : (disk.status || '');
  }

  // Roles
  var roleParts = [];
  if (roles.t1) roleParts.push('T1');
  if (roles.t2) roleParts.push('T2');
  if (roles.gateway) roleParts.push('GW');
  if (roles.outboxlog) roleParts.push('OL');
  if (roles.witness) roleParts.push('W');
  var rolesEl = document.getElementById('statRoles');
  if (rolesEl) rolesEl.textContent = roleParts.length ? roleParts.join('+') : '--';
  var rolesSub = document.getElementById('statRolesSub');
  if (rolesSub) {
    var notes = [];
    if (roles.gateway && roles.t2) {
      notes.push('check: custody+gateway co-located');
      attention.push({ text: 'T2 custody with gateway enabled — verify role policy', sev: 'warn' });
    }
    if (roles.gateway) notes.push('gateway on');
    else notes.push('gateway off');
    rolesSub.textContent = notes.join(' · ');
  }

  // Suppressions / compliance
  var suppressEl = document.getElementById('statSuppress');
  var suppressSub = document.getElementById('statSuppressSub');
  if (compliance) {
    var sc = compliance.takedownCount != null ? compliance.takedownCount
      : (compliance.suppressedKeys != null ? compliance.suppressedKeys : 0);
    if (suppressEl) suppressEl.textContent = sc;
    if (suppressSub) {
      suppressSub.textContent = compliance.adminArmed
        ? 'admin armed'
        : 'admin unarmed (404)';
    }
    if (!compliance.adminArmed && roles.outboxlog) {
      attention.push({ text: 'DO-NOT-SERVE admin unarmed while OutboxLog is on', sev: 'warn' });
    }
    var compPanel = document.getElementById('compliancePanel');
    if (compPanel) compPanel.style.display = '';
    var armedEl = document.getElementById('compAdminArmed');
    if (armedEl) {
      armedEl.textContent = compliance.adminArmed ? 'ARMED' : 'OFF';
      armedEl.className = compliance.adminArmed ? 'sev ok' : 'sev';
    }
    var armedNote = document.getElementById('compAdminNote');
    if (armedNote) armedNote.textContent = compliance.adminArmed ? 'takedown routes enabled' : 'set outboxlog.adminKey';
    var ck = document.getElementById('compSuppressKeys');
    if (ck) ck.textContent = compliance.suppressedKeys != null ? compliance.suppressedKeys : sc;
    var ct = document.getElementById('compTakedownCount');
    if (ct) ct.textContent = sc;
  } else {
    if (suppressEl) suppressEl.textContent = '—';
    if (suppressSub) suppressSub.textContent = 'auth overview for detail';
    var compPanel2 = document.getElementById('compliancePanel');
    if (compPanel2) compPanel2.style.display = 'none';
  }

  // Namespaces + second pulse row (management fields)
  var row2 = document.getElementById('operatorPulseRow2');
  if (namespaces || shard || privacy.length) {
    if (row2) row2.style.display = '';
    if (namespaces) {
      var writers = 0;
      var list = Array.isArray(namespaces.namespaces) ? namespaces.namespaces : [];
      for (var i = 0; i < list.length; i++) writers += list[i].writers || 0;
      var wEl = document.getElementById('statOutboxWriters');
      if (wEl) wEl.textContent = writers || namespaces.groups || 0;
      var wSub = document.getElementById('statOutboxWritersSub');
      if (wSub) wSub.textContent = list.length + ' namespace(s)';
      var bEl = document.getElementById('statOutboxBytes');
      if (bEl) bEl.textContent = formatBytes(namespaces.totalBytes || 0);
      var bSub = document.getElementById('statOutboxBytesSub');
      if (bSub) {
        var day = 0;
        for (var j = 0; j < list.length; j++) day += list[j].bytes24h || 0;
        bSub.textContent = formatBytes(day) + ' / 24h ingest';
      }
      var nsPanel = document.getElementById('namespacesPanel');
      var nsBody = document.getElementById('namespacesBody');
      if (nsPanel && nsBody) {
        nsPanel.style.display = '';
        if (!list.length) {
          nsBody.innerHTML = '<div class="r"><span style="color:var(--text-faint)">No namespaces configured</span><span></span><span></span></div>';
        } else {
          nsBody.innerHTML = list.map(function (ns) {
            var cap = ns.capBytes24h != null ? formatBytes(ns.capBytes24h) : '∞';
            var pct = (ns.capBytes24h && ns.capBytes24h > 0)
              ? Math.min(100, Math.round((ns.bytes24h || 0) / ns.capBytes24h * 100)) + '%'
              : '';
            if (ns.capBytes24h && ns.bytes24h != null && ns.bytes24h / ns.capBytes24h >= 0.8) {
              attention.push({ text: 'Namespace ' + ns.name + ' near daily byte cap', sev: 'warn' });
            }
            return '<div class="r"><span>' + escapeHtml(ns.name) +
              (ns.blind ? ' <span style="color:var(--accent)">blind</span>' : '') +
              '</span><span>' + (ns.writers || 0) + ' writers</span><span>' +
              formatBytes(ns.bytes24h || 0) + ' / ' + cap + (pct ? ' (' + pct + ')' : '') +
              '</span></div>';
          }).join('');
        }
      }
    }
    if (shard) {
      var sr = document.getElementById('statShardRejected');
      if (sr) {
        sr.textContent = shard.rejected != null ? shard.rejected : '--';
        sr.className = 'stat-value' + (shard.rejected > 0 ? ' orange' : '');
      }
      var ss = document.getElementById('statShardSub');
      if (ss) ss.textContent = 'put ' + (shard.put || 0) + ' · get ' + (shard.get || 0);
    }
  } else if (row2) {
    row2.style.display = 'none';
    var nsPanel2 = document.getElementById('namespacesPanel');
    if (nsPanel2) nsPanel2.style.display = 'none';
  }

  // Privacy transports (always coarse)
  var tor = privacy[0] || null;
  if (!tor && overview.tor) {
    tor = {
      health: overview.tor.health || (overview.tor.running ? 'ready' : 'down'),
      running: overview.tor.running,
      activeConnections: overview.tor.activeConnections,
      negativeProbe: overview.tor.negativeProbe
    };
  }
  var pEl = document.getElementById('statPrivacy');
  var pSub = document.getElementById('statPrivacySub');
  if (pEl) {
    if (!tor) {
      pEl.textContent = 'n/a';
      pEl.className = 'stat-value';
    } else {
      var h = tor.health || (tor.running ? 'ready' : 'down');
      pEl.textContent = h;
      pEl.className = 'stat-value ' + (h === 'ready' || h === 'ok' ? 'green' : h === 'degraded' || h === 'warn' ? 'orange' : 'red');
      if (h === 'degraded' || h === 'down' || h === 'error') {
        attention.push({ text: 'Tor privacy lane: ' + h, sev: h === 'down' || h === 'error' ? 'bad' : 'warn' });
      }
      if (tor.negativeProbe === false) {
        attention.push({ text: 'Tor negative-probe not confirming unauthorized refuse', sev: 'warn' });
      }
    }
  }
  if (pSub) {
    var pbits = [];
    if (tor && tor.activeConnections != null) pbits.push(tor.activeConnections + ' conn');
    pbits.push('no onion in UI');
    if (tor && tor.negativeProbe === true) pbits.push('neg-probe ok');
    else if (tor && tor.negativeProbe === false) pbits.push('neg-probe fail');
    pSub.textContent = pbits.join(' · ');
  }

  // v2: WAL panel
  var wal = overview.wal || null;
  var walPanel = document.getElementById('walPanel');
  if (wal && walPanel) {
    walPanel.style.display = '';
    setText('walSource', wal.source || wal.mode || 'wal');
    var wh = document.getElementById('walHealthy');
    if (wh) {
      if (wal.healthy === true) { wh.textContent = 'OK'; wh.className = 'sev ok'; }
      else if (wal.healthy === false) { wh.textContent = 'BAD'; wh.className = 'sev bad'; }
      else { wh.textContent = '—'; wh.className = 'sev'; }
    }
    setText('walFrames', wal.framesPerSec != null ? wal.framesPerSec : '—');
    setText('walFsync', wal.fsyncP99Ms != null ? wal.fsyncP99Ms + 'ms' : (wal.fsyncP50Ms != null ? '~' + wal.fsyncP50Ms + 'ms p50' : '—'));
    setText('walPrune', wal.pruningSupported === true ? 'ON' : wal.pruningSupported === false ? 'OFF' : '—');
    setText('walPruneNote', wal.pruningSupported === true ? 'walPruningSupported' : (wal.source === 'outboxlog-journal' ? 'journal (no prune bit)' : ''));
    setText('walSpend', wal.spendReplay24h != null ? wal.spendReplay24h : '—');
    var sizeParts = [];
    if (wal.sizeBytes != null) sizeParts.push(formatBytes(wal.sizeBytes));
    if (wal.length != null) sizeParts.push('len ' + wal.length);
    if (wal.opsSinceSnapshot != null) sizeParts.push(wal.opsSinceSnapshot + ' ops/snap');
    if (wal.entries != null) sizeParts.push(wal.entries + ' entries');
    setText('walSize', sizeParts.length ? sizeParts[0] : '—');
    setText('walSizeNote', sizeParts.length > 1 ? sizeParts.slice(1).join(' · ') : '');
    if (wal.healthy === false) attention.push({ text: 'WAL durability unhealthy (' + (wal.source || 'wal') + ')', sev: 'bad' });
    if (wal.spendReplay24h != null && wal.spendReplay24h > 0) {
      attention.push({ text: 'SPEND_REPLAY count ' + wal.spendReplay24h + ' in 24h', sev: 'bad' });
    }
    if (wal.errors != null && wal.errors > 0) {
      attention.push({ text: 'WAL/journal errors: ' + wal.errors, sev: 'warn' });
    }
  } else if (walPanel) {
    walPanel.style.display = 'none';
  }

  // v2: Gateway panel
  var gw = overview.gatewayDetail || null;
  if (!gw && overview.gateway) {
    gw = {
      enabled: true,
      cachedDrives: overview.gateway.cachedDrives,
      totalRequests: overview.gateway.totalRequests,
      totalBytesServed: overview.gateway.totalBytesServed,
      roleConflict: !!(roles.t2 && roles.gateway)
    };
  }
  if (!gw && roles.gateway) {
    gw = { enabled: true, roleConflict: !!roles.t2, cachedDrives: 0, totalRequests: 0, totalBytesServed: 0 };
  }
  var gwPanel = document.getElementById('gatewayPanel');
  if (gw && gwPanel) {
    gwPanel.style.display = '';
    var ge = document.getElementById('gwEnabled');
    if (ge) {
      ge.textContent = gw.enabled ? 'ON' : 'OFF';
      ge.className = gw.enabled ? 'sev ok' : 'sev';
    }
    var ep = [];
    if (gw.host) ep.push(gw.host);
    if (gw.port != null) ep.push(':' + gw.port);
    setText('gwEndpoint', ep.join('') || (gw.enabled ? 'listening' : 'not mounted'));
    setText('gwRequests', gw.totalRequests != null ? formatCount(gw.totalRequests) : '—');
    setText('gwBytes', gw.totalBytesServed != null ? formatBytes(gw.totalBytesServed) : '—');
    setText('gwDrives', gw.cachedDrives != null ? gw.cachedDrives : '—');
    setText('gwVerify', gw.verifyFails != null ? gw.verifyFails : '—');
    var gr = document.getElementById('gwRole');
    if (gr) {
      if (gw.roleConflict) { gr.textContent = 'CONFLICT'; gr.className = 'sev bad'; }
      else if (gw.enabled && roles.t2) { gr.textContent = 'CHECK'; gr.className = 'sev'; }
      else { gr.textContent = 'ok'; gr.className = 'sev ok'; }
    }
    setText('gwRoleNote', gw.roleConflict
      ? 'T2 custody + gateway co-located'
      : (gw.enabled ? 'T1 public surface' : 'gateway off'));
    if (gw.roleConflict) {
      attention.push({ text: 'Gateway enabled with T2 custody — verify role policy', sev: 'bad' });
    }
  } else if (gwPanel) {
    gwPanel.style.display = 'none';
  }

  // v2: Shard Prometheus-style panel
  var shardPanel = document.getElementById('shardPanel');
  if (shard && shardPanel) {
    shardPanel.style.display = '';
    setText('shardPut', shard.put != null ? formatCount(shard.put) : '—');
    setText('shardGetProof', (shard.get != null ? formatCount(shard.get) : '—') + ' / ' + (shard.proof != null ? formatCount(shard.proof) : '—'));
    var srEl = document.getElementById('shardRejected');
    if (srEl) {
      srEl.textContent = shard.rejected != null ? formatCount(shard.rejected) : '—';
      srEl.className = (shard.rejected > 0) ? 'sev' : 'sev ok';
    }
    setText('shardEvict', (shard.evicted != null ? formatCount(shard.evicted) : '0') + ' / ' + (shard.sweep != null ? formatCount(shard.sweep) : '0'));
    var heldBits = [];
    if (shard.held != null) heldBits.push(formatCount(shard.held) + ' shards');
    if (shard.bytes != null) heldBits.push(formatBytes(shard.bytes));
    setText('shardHeld', heldBits.length ? heldBits.join(' · ') : '—');
    if (shard.rejected > 0) {
      attention.push({ text: 'Shard pin rejects: ' + shard.rejected, sev: 'warn' });
    }
  } else if (shardPanel) {
    shardPanel.style.display = 'none';
  }

  if (overview.errors > 0) attention.push({ text: overview.errors + ' recorded errors', sev: 'warn' });
  if (!attention.length) attention.push({ text: 'No operator attention items', sev: 'ok' });

  var att = document.getElementById('attentionList');
  if (att) {
    att.innerHTML = attention.map(function (a) {
      return '<li><span>' + escapeHtml(a.text) + '</span><span class="sev ' +
        (a.sev === 'ok' ? 'ok' : a.sev === 'bad' ? 'bad' : '') + '">' +
        (a.sev === 'ok' ? 'ok' : a.sev === 'bad' ? 'crit' : 'warn') +
        '</span></li>';
    }).join('');
  }
}

function setText(id, value) {
  var el = document.getElementById(id);
  if (el) el.textContent = value == null ? '' : String(value);
}

/* ===== Dashboard Update ===== */
function updateDashboard(overview, history, apps) {
  // Header — pubkey chip (click to copy; full key kept in dataset)
  var pk = overview.publicKey || overview.pubKey || '';
  var region = overview.region || '';
  var chip = document.getElementById('headerMeta');
  renderHeaderMeta(chip, pk, region);

  // Uptime badge
  var badge = document.getElementById('uptimeBadge');
  var ut = overview.uptime || 0;
  badge.textContent = 'UP ' + formatUptime(ut);
  badge.className = 'uptime-badge' + (ut < 60000 ? ' warn' : '');

  // Stats
  document.getElementById('statConnections').textContent = overview.connections != null ? overview.connections : '--';
  document.getElementById('statConnectionsSub').textContent = overview.maxConnections ? 'max ' + overview.maxConnections : '';
  document.getElementById('statApps').textContent = Array.isArray(apps) ? apps.length : (overview.seededApps || '--');
  var storedBytes = overview.bytesStored != null ? overview.bytesStored
    : (overview.dataStored != null ? overview.dataStored
      : (overview.storage && overview.storage.used) || 0);
  var storedMax = overview.storageMax != null ? overview.storageMax
    : (overview.storage && overview.storage.max);
  document.getElementById('statStored').textContent = formatBytes(storedBytes);
  document.getElementById('statStoredSub').textContent = storedMax > 1 ? 'of ' + formatBytes(storedMax) : '';
  document.getElementById('statRelayed').textContent = formatBytes(
    overview.bytesRelayed != null ? overview.bytesRelayed
      : (overview.dataRelayed != null ? overview.dataRelayed
        : (overview.relay && overview.relay.totalBytesRelayed) || 0)
  );

  // Operator data-layer pulse (v0/v1) — roles, disk, compliance, namespaces
  updateOperatorPulse(overview);

  // Charts
  renderCharts(history);

  // Apps table
  currentApps = Array.isArray(apps) ? apps : [];
  if (sortState.col) sortApps(sortState.col);
  else renderAppsTable();

  // System info.
  // Denominators are only trusted when they're real: the old code fell
  // back to `|| 1`, which rendered "31.7 MB / 1 B" and a 100%-red bar
  // whenever a payload (notably the WS overview) lacked heapTotal or
  // storage.max. Unknown total -> show the used value alone, no ratio.
  var mem = overview.memory || {};
  var heapUsed = mem.heapUsed || 0;
  var heapTotal = mem.heapTotal;
  var rss = mem.rss || 0;

  var heapBar = document.getElementById('sysHeapBar');
  if (heapTotal && heapTotal > heapUsed / 100) {
    var heapPct = Math.min(100, Math.round((heapUsed / heapTotal) * 100));
    document.getElementById('sysHeap').textContent = formatBytes(heapUsed) + ' / ' + formatBytes(heapTotal);
    heapBar.parentElement.parentElement.style.display = '';
    heapBar.style.width = heapPct + '%';
    heapBar.className = 'progress-fill ' + progressColor(heapPct);
  } else {
    document.getElementById('sysHeap').textContent = formatBytes(heapUsed);
    heapBar.parentElement.parentElement.style.display = 'none';
  }

  // RSS has no meaningful ceiling exposed by the node — show the value,
  // skip the made-up ratio bar (was rss / heapTotal*2).
  document.getElementById('sysRss').textContent = formatBytes(rss);

  // HTTP /api/overview nests storage as {used,max}; the WS mapping
  // flattens to storageUsed/storageMax. Accept both — the flat-only read
  // was why Storage showed 0 B on the polling path.
  var stUsed = overview.storageUsed != null ? overview.storageUsed : (overview.storage && overview.storage.used) || 0;
  var stMax = overview.storageMax != null ? overview.storageMax : (overview.storage && overview.storage.max);
  var stBar = document.getElementById('sysStorageBar');
  if (stMax && stMax > 1) {
    var stPct = Math.min(100, Math.round((stUsed / stMax) * 100));
    document.getElementById('sysStorage').textContent = formatBytes(stUsed) + ' / ' + formatBytes(stMax);
    stBar.parentElement.parentElement.style.display = '';
    stBar.style.width = stPct + '%';
    stBar.className = 'progress-fill ' + progressColor(stPct);
  } else {
    document.getElementById('sysStorage').textContent = formatBytes(stUsed);
    stBar.parentElement.parentElement.style.display = 'none';
  }

  document.getElementById('sysCircuits').textContent =
    (overview.activeCircuits || 0) + ' active / ' + (overview.totalCircuits || 0) + ' total';
  var errEl = document.getElementById('sysErrors');
  var errCount = overview.errors || 0;
  errEl.textContent = errCount;
  errEl.style.color = errCount > 0 ? 'var(--red)' : 'var(--green)';

  // Payment & Credits stats — only surfaced when the payment stack has
  // something to say. A home node with payments idle shouldn't stare at
  // four "--" cards.
  var credits = overview.credits;
  var metering = overview.metering;
  var invoices = overview.invoices;
  var payment = overview.payment;

  var paymentSignal =
    (payment && payment.enabled) ||
    (metering && ((metering.totalRevenue || 0) > 0 || (metering.totalCalls || 0) > 0)) ||
    (credits && (credits.totalWallets || 0) > 0) ||
    (invoices && (invoices.total || 0) > 0);
  document.getElementById('paymentStats').style.display = paymentSignal ? '' : 'none';
  document.getElementById('paymentsIntro').style.display = paymentSignal ? '' : 'none';

  if (metering) {
    document.getElementById('statRevenue').textContent = formatSats(metering.totalRevenue || 0);
    document.getElementById('statRevenueSub').textContent = (metering.totalApps || 0) + ' apps metered';
    document.getElementById('statCalls').textContent = formatCount(metering.totalCalls || 0);
    document.getElementById('statCallsSub').textContent = 'across all services';
  }
  if (credits) {
    document.getElementById('statWallets').textContent = credits.totalWallets || 0;
    document.getElementById('statWalletsSub').textContent = formatSats(credits.totalBalance || 0) + ' total balance';
  }
  if (invoices) {
    document.getElementById('statInvoices').textContent = (invoices.settled || 0) + '/' + (invoices.total || 0);
    document.getElementById('statInvoicesSub').textContent = formatSats(invoices.totalSettledSats || 0) + ' settled';
  }

  // Peers
  var peers = overview.peers || [];
  document.getElementById('peerCount').textContent = peers.length + ' connected';
  renderPeersList(document.getElementById('peersList'), peers);
}

/* Copy-pubkey chip */
document.getElementById('headerMeta').addEventListener('click', function() {
  copyHeaderPubkey(this);
});

/* ===== Chart Rendering ===== */
var chartAnimFrame = null;
function renderCharts(history) {
  if (chartAnimFrame) cancelAnimationFrame(chartAnimFrame);
  chartAnimFrame = requestAnimationFrame(function() {
    var connData = [];
    var servedData = [];
    var relayedData = [];

    if (history && Array.isArray(history.points)) {
      history.points.forEach(function(p) {
        var t = p.timestamp || p.time || p.t;
        if (p.connections != null) connData.push({ t: t, v: p.connections });
        if (p.bytesServed != null) servedData.push({ t: t, v: p.bytesServed });
        if (p.bytesRelayed != null) relayedData.push({ t: t, v: p.bytesRelayed });
      });
    } else if (history && Array.isArray(history)) {
      history.forEach(function(p) {
        var t = p.timestamp || p.time || p.t;
        if (p.connections != null) connData.push({ t: t, v: p.connections });
        if (p.bytesServed != null) servedData.push({ t: t, v: p.bytesServed });
        if (p.bytesRelayed != null) relayedData.push({ t: t, v: p.bytesRelayed });
      });
    }

    var c1 = document.getElementById('chartConnections');
    var hasConn = drawChart(c1, [{ data: connData, color: '#58a6ff' }]);
    document.getElementById('chartConnEmpty').style.display = hasConn ? 'none' : 'flex';

    var c2 = document.getElementById('chartTraffic');
    var hasTraffic = drawChart(c2, [
      { data: servedData, color: '#3fb950' },
      { data: relayedData, color: '#f0a64a' }
    ], { formatY: function(v) { return formatBytes(v); } });
    document.getElementById('chartTrafficEmpty').style.display = hasTraffic ? 'none' : 'flex';
  });
}

/* ===== Data Fetching & WebSocket ===== */
var connStatusEl = document.getElementById('connStatus');
var fetchErrors = 0;
var wsConnected = false;
var ws = null;
var pollTimer = null;
var dataRefreshBusy = false;
var pendingRefreshBusy = false;
var POLL_INTERVAL_WS = 30000;   // 30s backup when WS is live
var POLL_INTERVAL_HTTP = 5000;  // 5s fallback when WS is down
var REQUEST_TIMEOUT_MS = 10000;

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 updateConnIndicator() {
  if (wsConnected) {
    connStatusEl.textContent = '● WS LIVE';
    connStatusEl.className = 'conn-status';
  } else if (fetchErrors === 0) {
    connStatusEl.textContent = '● POLLING';
    connStatusEl.className = 'conn-status';
  } else {
    connStatusEl.textContent = '● DISCONNECTED';
    connStatusEl.className = 'conn-status error';
  }
}

/* -- HTTP polling (fallback / backup) -- */
async function fetchData() {
  try {
    var results = await Promise.all([
      fetchWithTimeout('/api/overview').then(function(r) { return r.json(); }),
      fetchWithTimeout('/api/history?minutes=60').then(function(r) { return r.json(); }),
      fetchWithTimeout('/api/apps').then(function(r) { return r.json(); })
    ]);
    fetchErrors = 0;
    updateConnIndicator();
    updateDashboard(results[0], results[1], results[2]);
  } catch (e) {
    fetchErrors++;
    updateConnIndicator();
    if (fetchErrors <= 1) console.warn('Dashboard fetch failed:', e);
  }
  fetchSubsidy();
}

function refreshData(force) {
  if (dataRefreshBusy) return;
  if (!canPoll(force)) return;
  dataRefreshBusy = true;
  fetchData().finally(function() {
    dataRefreshBusy = false;
  });
}

/* -- Earnings card (hidden unless the relay has subsidy.enabled) -- */
async function fetchSubsidy() {
  try {
    var r = await fetchWithTimeout('/api/subsidy');
    if (!r.ok) return; // 403 (no auth context) or older relay -> stay hidden
    var s = await r.json();
    if (!s.enabled) return;
    document.getElementById('subsidyStats').style.display = '';
    document.getElementById('statSubsidyTotal').textContent = (s.accruedSatsTotal || 0).toLocaleString();
    document.getElementById('statSubsidyToday').textContent = (s.accruedSatsToday || 0).toLocaleString();
    document.getElementById('statSubsidyCap').textContent = 'of ' + (s.rateSatsPerDay || 0).toLocaleString() + ' sats/day cap';
    var capPct = s.rateSatsPerDay ? Math.min(100, Math.round(((s.accruedSatsToday || 0) / s.rateSatsPerDay) * 100)) : 0;
    document.getElementById('subsidyCapFill').style.width = capPct + '%';
    var destEl = document.getElementById('statSubsidyDest');
    if (s.payoutDestination) {
      destEl.textContent = s.payoutDestination.value;
      destEl.className = 'dest-chip';
      window._destCurrent = s.payoutDestination.value;
    } else {
      destEl.textContent = 'not set — click to add';
      destEl.className = 'dest-chip unset';
      window._destCurrent = '';
    }
  } catch (e) { /* card simply stays hidden */ }
}

/* Destination dialog (replaces the old window.prompt flow) */
var destDialog = document.getElementById('destDialog');
var destInput = document.getElementById('destInput');
var destError = document.getElementById('destError');
var destSaveBusy = false;

function openDestDialog() {
  destInput.value = window._destCurrent || '';
  destError.textContent = '';
  destDialog.showModal();
  destInput.focus();
}
document.getElementById('subsidyDestCard').addEventListener('click', openDestDialog);
document.getElementById('subsidyDestCard').addEventListener('keydown', function(e) {
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDestDialog(); }
});
document.getElementById('destCancel').addEventListener('click', function() { destDialog.close(); });
document.getElementById('destSave').addEventListener('click', async function() {
  if (destSaveBusy) return;
  var v = destInput.value.trim();
  if (!v) { destError.textContent = 'Enter a destination.'; return; }
  var saveBtn = document.getElementById('destSave');
  destSaveBusy = true;
  saveBtn.disabled = true;
  destDialog.setAttribute('aria-busy', 'true');
  try {
    var r = await fetchWithTimeout('/api/subsidy/destination', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ destination: v })
    });
    var out = await r.json();
    if (!r.ok) { destError.textContent = (out.error || 'Could not set destination').replace(/^[A-Z_]+: /, ''); return; }
    destDialog.close();
    fetchSubsidy();
  } catch (e) {
    destError.textContent = 'Request failed — is the node reachable?';
  } finally {
    destSaveBusy = false;
    saveBtn.disabled = false;
    destDialog.setAttribute('aria-busy', 'false');
  }
});

function setPollInterval(ms) {
  if (pollTimer) clearInterval(pollTimer);
  pollTimer = setInterval(function() { refreshData(false); }, ms);
}

/* -- WebSocket connection -- */
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;
    updateConnIndicator();
    // 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.overview) {
        var o = msg.overview;
        // Map the WS overview shape to what updateDashboard expects
        var overview = {
          publicKey: o.publicKey,
          region: o.region,
          uptime: o.uptime ? o.uptime.ms : 0,
          connections: o.connections,
          seededApps: o.seededApps,
          bytesStored: o.storage ? o.storage.used : 0,
          storageUsed: o.storage ? o.storage.used : 0,
          storageMax: o.storage ? o.storage.max : 0,
          bytesRelayed: o.relay ? o.relay.totalBytesRelayed : 0,
          activeCircuits: o.relay ? o.relay.activeCircuits : 0,
          totalCircuits: o.relay ? o.relay.totalCircuitsServed : 0,
          memory: o.memory || {},
          errors: o.errors || 0,
          peers: [],
          credits: o.credits || null,
          metering: o.metering || null,
          invoices: o.invoices || null,
          payment: o.payment || null
        };
        // We don't get history/apps from WS, pass null to preserve last state
        updateDashboard(overview, null, null);
      }
    } catch (e) {
      console.warn('WS message parse error:', e);
    }
  };

  ws.onclose = function() {
    wsConnected = false;
    ws = null;
    updateConnIndicator();
    // 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
  };
}

/* -- Adjust updateDashboard to handle null history/apps gracefully -- */
var _origUpdateDashboard = updateDashboard;
updateDashboard = function(overview, history, apps) {
  // Keep previous apps/history when WS sends only overview
  if (history == null) history = window._lastHistory || [];
  else window._lastHistory = history;
  if (apps == null) apps = window._lastApps || [];
  else window._lastApps = apps;
  _origUpdateDashboard(overview, history, apps);
};

/* ===== Registry Management ===== */
var autoAcceptToggle = document.getElementById('autoAcceptToggle');
var registryModeEl = document.getElementById('registryMode');
var toggleTrack = document.getElementById('toggleTrack');
var toggleDot = document.getElementById('toggleDot');
var pendingSection = document.getElementById('pendingSection');

function postJson(path, body) {
  return fetchWithTimeout(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
}

function updateToggleUI(on) {
  if (on) {
    registryModeEl.textContent = 'AUTO-ACCEPT';
    registryModeEl.style.color = 'var(--green)';
    toggleTrack.style.background = 'rgba(63,185,80,.2)';
    toggleTrack.style.borderColor = 'rgba(63,185,80,.35)';
    toggleDot.style.left = '21px';
    pendingSection.style.display = 'none';
  } else {
    registryModeEl.textContent = 'APPROVAL MODE';
    registryModeEl.style.color = 'var(--orange)';
    toggleTrack.style.background = 'rgba(240,166,74,.2)';
    toggleTrack.style.borderColor = 'rgba(240,166,74,.35)';
    toggleDot.style.left = '3px';
    pendingSection.style.display = 'block';
  }
}

autoAcceptToggle.addEventListener('change', function() {
  var enabled = autoAcceptToggle.checked;
  var previous = !enabled;
  autoAcceptToggle.disabled = true;
  updateToggleUI(enabled);
  postJson('/registry/auto-accept', { enabled: enabled })
    .catch(function(e) {
      autoAcceptToggle.checked = previous;
      updateToggleUI(previous);
      console.warn('Toggle failed:', e);
    })
    .finally(function() {
      autoAcceptToggle.disabled = false;
    });
});

function setRegistryActionBusy(btn, busy) {
  if (!btn) return;
  btn.disabled = busy;
  btn.setAttribute('aria-busy', busy ? 'true' : 'false');
}

function runRegistryAction(btn, path, body, onSuccess, label) {
  if (btn && btn.disabled) return Promise.resolve();
  setRegistryActionBusy(btn, true);
  return postJson(path, body)
    .then(function() {
      if (onSuccess) onSuccess();
    })
    .catch(function(e) {
      console.warn(label + ' failed:', e);
    })
    .finally(function() {
      setRegistryActionBusy(btn, false);
    });
}

function unseedApp(appKey, btn) {
  if (!confirm('Stop seeding ' + appKey.slice(0, 12) + '...?')) return;
  return runRegistryAction(btn, '/unseed', { appKey: appKey }, function() {
    refreshData(true);
  }, 'Unseed');
}

function approveRequest(appKey, btn) {
  return runRegistryAction(btn, '/registry/approve', { appKey: appKey }, function() {
    fetchPending();
    refreshData(true);
  }, 'Approve');
}

function rejectRequest(appKey, btn) {
  return runRegistryAction(btn, '/registry/reject', { appKey: appKey }, function() {
    fetchPending();
  }, 'Reject');
}

function bindRegistryActionTable(tbody) {
  if (tbody.dataset.registryActionsBound) return;
  tbody.dataset.registryActionsBound = '1';
  tbody.addEventListener('click', function(e) {
    var btn = e.target.closest('[data-registry-action]');
    if (!btn || !tbody.contains(btn)) return;
    if (btn.disabled) return;
    var appKey = btn.dataset.appKey || '';
    if (btn.dataset.registryAction === 'unseed') unseedApp(appKey, btn);
    else if (btn.dataset.registryAction === 'approve') approveRequest(appKey, btn);
    else if (btn.dataset.registryAction === 'reject') rejectRequest(appKey, btn);
  });
}

function appendRegistryButton(parent, action, appKey, className, label) {
  var btn = appendEl(parent, 'button', className, label);
  btn.type = 'button';
  btn.dataset.registryAction = action;
  btn.dataset.appKey = String(appKey || '');
  return btn;
}

function renderSeeds(apps) {
  window._lastSeedApps = apps;
  var tbody = document.getElementById('seedsBody');
  bindRegistryActionTable(tbody);
  clearNode(tbody);
  if (!apps || !apps.length) {
    var emptyRow = appendEl(tbody, 'tr');
    var emptyCell = appendEl(emptyRow, 'td', 'empty-state', 'No apps seeded');
    emptyCell.colSpan = 6;
    return;
  }
  apps.forEach(function(app) {
    var uptime = app.startedAt ? Date.now() - app.startedAt : 0;
    var appKey = String(app.appKey || '');
    var row = appendEl(tbody, 'tr');
    renderAppNameNode(appendEl(row, 'td'), appKey, app.appId);
    renderDevNode(appendEl(row, 'td'), appKey);
    appendEl(row, 'td', '', truncateKey(appKey));
    appendEl(row, 'td', '', formatUptime(uptime));
    appendEl(row, 'td', '', formatBytes(app.bytesServed || 0));
    var actions = appendEl(row, 'td');
    appendRegistryButton(actions, 'unseed', appKey, 'btn btn-red', 'UNSEED');
  });
}

function renderPendingRequests(requests) {
  var tbody = document.getElementById('pendingBody');
  bindRegistryActionTable(tbody);
  clearNode(tbody);
  if (!requests || !requests.length) {
    var emptyRow = appendEl(tbody, 'tr');
    var emptyCell = appendEl(emptyRow, 'td', 'empty-state', 'No pending requests');
    emptyCell.colSpan = 5;
    return;
  }
  requests.forEach(function(req) {
    var appKey = String(req.appKey || '');
    var publisher = String(req.publisherPubkey || '');
    var replication = Number(req.replicationFactor);
    var replicationText = Number.isFinite(replication) && replication > 0 ? String(Math.floor(replication)) : '3';
    var geoPreference = Array.isArray(req.geoPreference) && req.geoPreference.length
      ? req.geoPreference.map(function(region) { return String(region); }).join(', ')
      : 'any';
    var row = appendEl(tbody, 'tr');
    appendEl(row, 'td', '', truncateKey(appKey));
    appendEl(row, 'td', '', truncateKey(publisher));
    appendEl(row, 'td', '', replicationText);
    appendEl(row, 'td', '', geoPreference);
    var actions = appendEl(row, 'td');
    actions.style.display = 'flex';
    actions.style.gap = '4px';
    appendRegistryButton(actions, 'approve', appKey, 'btn btn-green', 'APPROVE');
    appendRegistryButton(actions, 'reject', appKey, 'btn btn-red', 'REJECT');
  });
}

function fetchPending() {
  return fetchWithTimeout('/api/registry/pending').then(function(r) { return r.json(); }).then(function(data) {
    renderPendingRequests(data.requests || []);
  }).catch(function() {});
}

function refreshPending(force) {
  if (pendingRefreshBusy) return;
  if (!canPoll(force)) return;
  if (autoAcceptToggle.checked) return;
  pendingRefreshBusy = true;
  fetchPending().finally(function() {
    pendingRefreshBusy = false;
  });
}

// Fetch registry state on load and periodically
function fetchRegistryState() {
  fetchWithTimeout('/api/overview').then(function(r) { return r.json(); }).then(function(data) {
    if (data.registry) {
      var isAuto = data.registry.autoAccept !== false;
      autoAcceptToggle.checked = isAuto;
      updateToggleUI(isAuto);
    }
  }).catch(function() {});
  fetchWithTimeout('/api/apps').then(function(r) { return r.json(); }).then(function(apps) {
    renderSeeds(apps);
  }).catch(function() {});
  if (!autoAcceptToggle.checked) fetchPending();
}

// Override updateDashboard to also update seeds table
var _prevUpdateDashboard = updateDashboard;
updateDashboard = function(overview, history, apps) {
  _prevUpdateDashboard(overview, history, apps);
  if (apps && apps.length !== undefined) renderSeeds(apps);
  if (overview && overview.registry) {
    var isAuto = overview.registry.autoAccept !== false;
    autoAcceptToggle.checked = isAuto;
    updateToggleUI(isAuto);
  }
};

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

setInterval(function() { refreshPending(false); }, 10000);
fetchRegistryState();

// Boot: start HTTP fetch immediately, then try WS
refreshData(true);
fetchCatalogNames();
setPollInterval(POLL_INTERVAL_HTTP);
connectWS();

/* Resize handler for charts */
var resizeTimer;
window.addEventListener('resize', function() {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(function() {
    renderCharts(null);
  }, 200);
});
</script>
</body>
</html>