<!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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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">✦ Earnings</span>
<span class="earn-note">subsidy estimate — verified & 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"> </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 · BOLT12 offer · 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 · 1k welcome credits per app · <a href="/payments" style="text-decoration:none">View payments →</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"> </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"> </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"> </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"> </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">✦ HiveRelay · 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>