blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,1386 @@
1
+ // App core: session, SSE link, routing, the sync hero, and the Overview page.
2
+ // Chart pages live in panels.js.
3
+ // histogram/scatter/meter/stackedBars are named below in the `helpers.charts`
4
+ // object, which is module scope: an identifier that is referenced there but not
5
+ // imported throws while app.js is still evaluating -- before login, before the
6
+ // stream, before a single pixel. The page then shows nothing at all and the server
7
+ // log shows nothing either, because no request was ever made. Import them.
8
+ import { lineChart, histogram, scatter, meter, stackedBars, sparkline, paint, resetCanvas, COL } from './charts.js';
9
+ import * as F from './fmt.js';
10
+ import { renderMiningOverview, renderMining, renderBlockSpace, refreshLabel } from './mining.js';
11
+ import { viewerIdle } from './details3d.js';
12
+ import {
13
+ loadSettings, setSetting, resetSettings, seedSettings, setSettingsPush,
14
+ SETTINGS_KEY, PANEL as SETTINGS_PANEL, formatRangeValue,
15
+ } from './settings.js';
16
+ // APPEARANCE (2026-09-16): the theme goes on <html> before the first paint, from the browser's copy
17
+ // of the settings, and follows every change after (followTheme); the cards' swatches are painted
18
+ // through the CSSOM because the CSP refuses a style attribute in markup
19
+ import { TAB_ROWS } from './settings.js';
20
+ import { followTheme, presetFace, resolveScheme, resolveTheme, PRESETS, BASE_KEYS } from './theme.js';
21
+ import { renderExplorer } from './explorer.js';
22
+ import { renderMarkets, summaryHtml as marketsSummaryHtml, REFRESH_MS as MARKETS_REFRESH_MS } from './markets.js';
23
+ import { renderKiosk } from './kiosk.js';
24
+ import { renderTetrust } from './tetrust.js';
25
+ import { renderBlockout } from './blockout.js';
26
+ import { renderBlockanoid } from './blockanoid.js';
27
+ import { renderWolf3d } from './wolf3d.js';
28
+ import { renderDoom } from './doom.js';
29
+ import { renderQuake } from './quake.js';
30
+ import { renderAbout } from './about.js';
31
+ import { renderChain, renderMempool, renderPeers, renderNetwork, renderLogs, renderNode, renderAdmin, ensureLogsLoaded, init as initPanels, initChainDrill } from './panels.js';
32
+
33
+ followTheme();
34
+
35
+ // panels.js needs the formatters but must not import them from here (circular);
36
+ // they are injected once at module start instead.
37
+ initPanels(F);
38
+
39
+ export const state = {
40
+ snap: null,
41
+ // the Block space viewer's mode (mining.js VIEWER_MODES), remembered per browser
42
+ viewerMode: (() => { try { return globalThis.localStorage?.getItem('blockyard.viewerMode') === '2' ? '2' : '1'; } catch { return '1'; } })(),
43
+ denseBlock: null,
44
+ series: null,
45
+ events: [],
46
+ // Liveness bookkeeping for the stream. Without these a dropped SSE leaves the page
47
+ // looking exactly like a healthy one: frozen numbers, a badge stuck on
48
+ // "reconnecting", and not a single line in any log.
49
+ lastFrameAt: Date.now(),
50
+ startedAt: Date.now(),
51
+ streamFails: 0,
52
+ user: null,
53
+ // Whether the server has accounts at all. Default true so nothing flashes "open
54
+ // access" before /api/me answers; boot() sets it from the server's own report.
55
+ accounts: true,
56
+ caps: null,
57
+ cfg: null,
58
+ nodes: [],
59
+ node: null,
60
+ page: 'overview',
61
+ paused: false,
62
+ pausedHard: false,
63
+ seenSeq: 0,
64
+ charts: {},
65
+ // Per-node cache. Two reasons, both about honesty: switching nodes must never
66
+ // leave the previous node's chart on screen, and a transient gap in the stream
67
+ // must never blank a chart that already has data for the node you are looking
68
+ // at. So data is kept per node, canvases are wiped only on a node switch, and
69
+ // sampling happens in the background.
70
+ byNode: new Map(),
71
+ heroForced: false,
72
+ };
73
+
74
+ const nodeRec = (id) => {
75
+ if (!id) return {};
76
+ if (!state.byNode.has(id)) state.byNode.set(id, {});
77
+ return state.byNode.get(id);
78
+ };
79
+
80
+ /** Every chart canvas currently in the document. */
81
+ const allCanvases = () => [...document.querySelectorAll('canvas.chart')];
82
+
83
+ /**
84
+ * Write data-driven sizes through the CSSOM.
85
+ *
86
+ * CSP `style-src 'self'` refuses `style="width:42%"` inside injected HTML, which is
87
+ * why this app spent a while with a `style-src-attr 'unsafe-inline'` allowance -- an
88
+ * allowance that also permitted any injected markup to style itself however it
89
+ * liked. The narrow fix is not to widen the policy: markup now carries
90
+ * `data-w` / `data-left` / `data-h`, and the value lands on `el.style.width` here,
91
+ * which CSP permits because it is not parsed markup. A string that never reaches an
92
+ * HTML attribute cannot be an injection site.
93
+ *
94
+ * Numbers only. Anything that will not parse as a number is skipped and left at the
95
+ * class default, so a malformed figure degrades to "no bar" rather than to "the
96
+ * first string the node printed became CSS".
97
+ */
98
+ export function applyDataSizes(root = document) {
99
+ const set = (sel, prop, transform) => {
100
+ let nodes = [];
101
+ try { nodes = root.querySelectorAll(sel); } catch { return; } // the DOM stub answers nothing
102
+ for (const el of nodes ?? []) {
103
+ const raw = el.dataset?.[attrOf(sel)];
104
+ if (raw == null || raw === '') continue;
105
+ const n = Number(raw);
106
+ if (!Number.isFinite(n)) continue;
107
+ const v = transform(Math.max(0, Math.min(100, n)));
108
+ try { el.style[prop] = v; } catch { /* a stub without a CSSOM has nothing to set */ }
109
+ }
110
+ };
111
+ set('[data-w]', 'width', (n) => `${n}%`);
112
+ set('[data-left]', 'left', (n) => `${n}%`);
113
+ set('[data-h]', 'height', (n) => `${n}%`);
114
+ return root;
115
+ }
116
+
117
+ const attrOf = (sel) => ({ '[data-w]': 'w', '[data-left]': 'left', '[data-h]': 'h' }[sel] ?? 'w');
118
+
119
+ /**
120
+ * Wipe every chart. Called ONLY when the selected node changes: the pixels on
121
+ * screen belong to a different daemon, and leaving them up while the header
122
+ * names another node would be a straight lie.
123
+ */
124
+ function resetAllCharts() {
125
+ for (const c of allCanvases()) resetCanvas(c);
126
+ }
127
+
128
+ // ------------------------------------------------------------------ api
129
+
130
+ const csrf = () => {
131
+ const m = /(?:^|;\s*)blockyard_csrf=([^;]+)/.exec(document.cookie);
132
+ return m ? decodeURIComponent(m[1]) : null;
133
+ };
134
+
135
+ async function api(path, { method = 'GET', body } = {}) {
136
+ const opt = { method, headers: {}, credentials: 'same-origin' };
137
+ if (body !== undefined) {
138
+ opt.headers['Content-Type'] = 'application/json';
139
+ opt.headers['X-CSRF-Token'] = csrf() ?? '';
140
+ opt.body = JSON.stringify(body);
141
+ }
142
+ const res = await fetch(path, opt);
143
+ if (res.status === 401) {
144
+ window.location.href = '/login';
145
+ throw new Error('signed out');
146
+ }
147
+ let data = null;
148
+ try { data = await res.json(); } catch { data = { error: { message: `HTTP ${res.status}` } }; }
149
+ if (!res.ok) {
150
+ const err = new Error(data?.error?.message ?? `HTTP ${res.status}`);
151
+ err.status = res.status;
152
+ err.payload = data;
153
+ throw err;
154
+ }
155
+ return data;
156
+ }
157
+
158
+ function toast(msg, kind = '') {
159
+ const box = document.getElementById('toast');
160
+ const el = document.createElement('div');
161
+ el.className = kind;
162
+ el.textContent = msg;
163
+ box.appendChild(el);
164
+ setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .4s'; }, kind === 'bad' ? 6500 : 3200);
165
+ setTimeout(() => el.remove(), kind === 'bad' ? 7000 : 3700);
166
+ }
167
+
168
+ // ------------------------------------------------------------- sync hero
169
+
170
+ const STATE_BADGE = {
171
+ synced: ['ok', 'Synced'], ibd: ['sync', 'Initial block download'],
172
+ catching_up: ['sync', 'Catching up'], stalled: ['bad', 'Stalled'],
173
+ reorg: ['warn', 'Reorganising'], unknown: ['muted', 'Unknown'],
174
+ };
175
+
176
+ /**
177
+ * The sync viewer. One bar, two figures, never merged:
178
+ * - the fill is blocks held over announced headers (this node is headers-first,
179
+ * so it only reaches 100% when the last block has landed);
180
+ * - the blue tick is the node's own difficulty-weighted estimate.
181
+ * ETA carries the rate it was computed from and whether that rate is falling,
182
+ * because a decelerating sync's ETA is a number that is about to be wrong.
183
+ */
184
+ /**
185
+ * The sync viewer: one dense instrument row plus a hairline bar, for every state.
186
+ *
187
+ * It used to swap to a ~10-row hero whenever a sync was running, which pushed the
188
+ * charts off screen at exactly the moment you want to see them, and spent ten
189
+ * rows saying "100%" when nothing was running. Both cases now render the same row
190
+ * of the node's own figures; `detail` expands the long-form explanation on demand.
191
+ *
192
+ * The node is named inside the row. "Synced" without saying which daemon is how a
193
+ * monitor looks wrong while telling the truth about a different one.
194
+ */
195
+ export function renderSyncHero(box, s) {
196
+ if (!box) return;
197
+ const sync = s?.sync ?? {};
198
+ const [tone, label] = STATE_BADGE[sync.state] ?? STATE_BADGE.unknown;
199
+ const moving = sync.state === 'ibd' || sync.state === 'catching_up';
200
+ const pct = sync.pct;
201
+ const width = pct == null ? 0 : Math.max(0.4, Math.min(100, pct));
202
+ const vp = sync.verificationProgress;
203
+ const done = sync.state === 'synced';
204
+ const fillCls = sync.state === 'stalled' || sync.state === 'reorg' ? 'stall' : done ? 'done' : '';
205
+ const others = (state.nodes ?? []).filter((n) => n.syncing && n.id !== (sync.node ?? state.node));
206
+ const caveats = sync.caveats ?? [];
207
+
208
+ const facts = (sync.strip ?? []).map((f) => `<span class="sf${f.tone ? ` ${F.esc(f.tone)}` : ''}"${f.title ? ` title="${F.esc(f.title)}"` : ''}><i>${F.esc(f.label)}</i><b>${F.esc(f.value)}</b></span>`).join('');
209
+ // Two figures, never merged: the fill is blocks held over announced headers,
210
+ // the marker is the node's own difficulty-weighted estimate.
211
+ const pctTitle = `blocks held ÷ announced headers. The marker on the bar is the node's own estimate (${vp == null ? 'not reported' : vp.toFixed(3) + '%'}), kept separate because the two measure different things.`;
212
+
213
+ box.className = 'sync';
214
+ box.innerHTML = `
215
+ <div class="strip">
216
+ <span class="badge ${tone} ${moving ? 'pulse' : ''}"><span class="dot"></span>${F.esc(label)}</span>
217
+ <span class="sf node" title="${F.esc(sync.endpoint ?? '')}"><b>${F.esc(sync.nodeLabel ?? s?.label ?? 'node')}</b></span>
218
+ <span class="sf pct-big ${done ? 'ok' : 'accent'}" title="${F.esc(pctTitle)}"><b>${pct == null ? '–' : pct.toFixed(pct >= 99.995 ? 4 : 2)}%</b></span>
219
+ ${facts}
220
+ ${others.length ? others.map((n) => `<button class="sf jump" data-jump-node="${F.esc(n.id)}" title="Switch to this node"><i>also syncing</i><b>${F.esc(n.label)} ${n.pct == null ? '…' : n.pct.toFixed(0) + '%'}</b></button>`).join('') : ''}
221
+ <span class="strip-tail">
222
+ ${caveats.length ? `<span class="sf note-count ${caveats.some((c) => /LONGER|stall|backwards|hide the stall/.test(c)) ? 'bad' : 'warn'}" data-toggle-sync="1" title="${F.esc(caveats.join(' '))}"><i>${caveats.length} note${caveats.length > 1 ? 's' : ''}</i><b>show</b></span>` : ''}
223
+ <button class="btn tiny-btn" data-toggle-hero="1" title="Show the full derivation, legend and notes">${state.heroForced ? 'compact' : 'detail'}</button>
224
+ </span>
225
+ </div>
226
+ <div class="bar" role="progressbar" aria-valuenow="${pct ?? 0}" aria-valuemin="0" aria-valuemax="100" title="${F.esc(pctTitle)}">
227
+ <div class="fill ${fillCls}" data-w="${width}"></div>
228
+ ${vp != null && !done ? `<div class="vp-tick" data-left="${Math.min(100, vp)}"></div>` : ''}
229
+ </div>
230
+ ${state.heroForced ? expanded(s, sync, caveats) : ''}
231
+ ${F.nodeWarnings(s?.warnings).length && !state.heroForced ? `<div class="caveat bad mt-6"><b>Node warnings:</b> ${F.esc(F.nodeWarnings(s.warnings).join('; '))}</div>` : ''}
232
+ `;
233
+ // The bar is drawn from data-w above; without this call the fill is 0 wide and
234
+ // the strip reads as an empty bar on a node that is 96% synced.
235
+ applyDataSizes(box);
236
+ }
237
+
238
+ /** The long-form view behind `detail`: derivation, legend, every caveat in full. */
239
+ function expanded(s, sync, caveats) {
240
+ const ibd = s?.log?.ibd ?? {};
241
+ const rows = [
242
+ ['node', `${sync.nodeLabel ?? s?.label ?? '–'} @ ${sync.endpoint ?? '–'}`],
243
+ ['chain / status', `${s?.chain ?? '–'} · ${sync.state ?? 'unknown'}`],
244
+ ['height', sync.height == null ? '–' : F.num(sync.height)],
245
+ ['announced headers', sync.headers == null ? 'not reported' : F.num(sync.headers)],
246
+ ['behind', sync.behind == null ? '–' : F.num(sync.behind)],
247
+ ['bar fill (blocks ÷ headers)', sync.pct == null ? 'not derivable' : `${sync.pct.toFixed(4)}%`],
248
+ ['node estimate (verificationprogress)', sync.verificationProgress == null ? 'not reported' : `${sync.verificationProgress.toFixed(4)}%`],
249
+ ['throughput', sync.blockRatePerSec == null ? 'measuring…' : `${sync.blockRatePerSec.toFixed(2)} blk/s (${(sync.blockRatePerSec * 60).toFixed(1)}/min)${sync.rateTrend && !['steady', 'unknown'].includes(sync.rateTrend) ? ` · ${sync.rateTrend}` : ''}`],
250
+ ['rate windows', (sync.rateWindows ?? []).length ? sync.rateWindows.map((w) => `${w.blocksPerSec} blk/s over ${w.name}${w.spanSec ? ` (span ${w.spanSec}s)` : ''}`).join(' · ') : 'no window with enough history yet'],
251
+ ['ETA', sync.eta ?? 'not available'],
252
+ ['ETA basis', sync.etaBasis ?? '–'],
253
+ ['ETA range', sync.etaWorst ? `${sync.etaBest} … ${sync.etaWorst} (node downloads in bursts)` : 'single window agrees'],
254
+ ['tip age', sync.tipAgeSec == null ? '–' : F.ageSec(sync.tipAgeSec)],
255
+ ['chain size', sync.sizeOnDisk == null ? '–' : F.bytes(sync.sizeOnDisk, 0)],
256
+ ['utxos', sync.txouts == null ? '–' : F.num(sync.txouts)],
257
+ ['peers', sync.peers == null ? '–' : F.num(sync.peers)],
258
+ // ROWS ONLY WHERE THE FIGURE EXISTS (2026-09-14): these three are read from an experimental
259
+ // node's log; Bitcoin Core prints none of them, and a row saying "not printed by this build"
260
+ // was three lines of nothing on every Core install
261
+ ...(ibd.nodeProgress == null ? [] : [['node\'s own progress ([dlc] ==)',
262
+ `${ibd.nodeProgress.pct == null ? '–' : `${ibd.nodeProgress.pct}% stored`} · ${ibd.nodeProgress.stored ?? '–'}/${ibd.nodeProgress.total ?? '?'} blocks${ibd.nodeProgress.etaText ? ` · their own eta ${ibd.nodeProgress.etaText}` : ''}${ibd.nodeProgress.rateText ? ` · ${ibd.nodeProgress.rateText}` : ''}`]]),
263
+ ...(ibd.nodeCatchup == null ? [] : [['applying thread ([utxo_live])',
264
+ `${ibd.nodeCatchup.pct == null ? '–' : `${ibd.nodeCatchup.pct}%`} caught up${ibd.nodeCatchup.blocksPerSec != null ? ` at ${ibd.nodeCatchup.blocksPerSec} blk/s` : ''}${ibd.nodeCatchup.eta ? ` · their own eta ${ibd.nodeCatchup.eta}` : ''}`]]),
265
+ ...(ibd.applyRate == null ? [] : [['apply rate ([dl] updating utxo)',
266
+ `${ibd.applyRate.perSec ?? '–'} tx/s over ${ibd.applyRate.windowSec ?? '?'}s`]]),
267
+ // Deliberately separate rows: three different threads reporting three
268
+ // different rates is information, and averaging them would be a fabrication
269
+ // (rules 4 and 9). They stay out of the strip for the same reason -- the <=60px
270
+ // budget has no room for a fourth figure, so they live here with their labels.
271
+ ['reorg events seen', String(s?.blocks?.reorgs ?? 0)],
272
+ ];
273
+ return `<div class="expanded">
274
+ <dl class="kv">${rows.map(([k, v]) => `<dt>${F.esc(k)}</dt><dd>${F.esc(v)}</dd>`).join('')}</dl>
275
+ ${(sync.reason && sync.state === 'unknown') ? `<div class="caveat bad mt-8"><b>Why this is unknown:</b> ${F.esc(sync.reason)}</div>` : ''}
276
+ ${caveats.map((c) => `<div class="caveat${/LONGER|stall|cut off|backwards|hide the stall/.test(c) ? ' bad' : ''} mt-5">${F.esc(c)}</div>`).join('')}
277
+ ${F.nodeWarnings(s?.warnings).length ? `<div class="caveat bad mt-5"><b>Node warnings:</b> ${F.esc(F.nodeWarnings(s.warnings).join('; '))}</div>` : ''}
278
+ </div>`;
279
+ }
280
+
281
+ // --------------------------------------------------------------- overview
282
+
283
+ // The last /api/markets reply, for the Overview price strip. Module scope rather than `state`
284
+ // because it is not node state and must not ride the SSE snapshot.
285
+ let overviewMarkets = null;
286
+
287
+ function renderOverview(s) {
288
+ if (!s) return;
289
+ document.querySelectorAll('[data-sync-hero]').forEach((box) => renderSyncHero(box, s));
290
+ renderMiningOverview(s, state, helpers);
291
+
292
+ // THE PRICE STRIP, between the sync hero and Block flow (operator, 2026-09-13: "We really need
293
+ // to squeeze this line into the top of the Overview, between Sync status and Block Flow").
294
+ //
295
+ // Visibility only. NOTHING IS FETCHED HERE: render() runs on every SSE frame, about once a
296
+ // second, and a fetch on that path would poll five exchanges at 1 Hz. The data arrives on its
297
+ // own timer (see marketsStripTimer in boot), which is also where the setting gates the network.
298
+ const stripOn = !!loadSettings().markets?.overviewSummary;
299
+ const strip = document.getElementById('ovMkSummary');
300
+ if (strip) {
301
+ // and nothing to show while the feed is off (the default): the strip stays hidden
302
+ const have = overviewMarkets && overviewMarkets.enabled !== false;
303
+ strip.hidden = !stripOn || !have;
304
+ if (stripOn && have) strip.innerHTML = marketsSummaryHtml(overviewMarkets, F);
305
+ }
306
+
307
+ const mp = s.mempool ?? {};
308
+ setText('ovMpCount', mp.count == null ? '–' : F.num(mp.count));
309
+ setText('ovMpBytes', mp.bytes != null
310
+ ? `${F.bytes(mp.bytes)} · ${F.bytes(mp.usage)} of ${F.bytes(mp.maxUsage)}`
311
+ : 'no byte figure reported');
312
+
313
+ // "How much work is queued, in blocks?" -- the question the mempool number is
314
+ // actually asked for. The divisor is the size of blocks that were really mined in the
315
+ // window, not 1 MB or 4 MWU assumed; when attribution has not run, the card says so
316
+ // instead of dividing by a constant nobody chose.
317
+ const minedRows = (s.attribution?.recent ?? []).filter((r) => r.size != null);
318
+ const avgBlock = minedRows.length ? Math.round(minedRows.reduce((n, r) => n + r.size, 0) / minedRows.length) : null;
319
+ // No second count. `ovMpCount2` printed the same mp.count as `ovMpCount` one card away
320
+ // under the label "waiting", so one measurement read as two. The queue-in-blocks line
321
+ // is the figure that was actually missing, and it stays.
322
+ setText('ovMpVsBlock', mp.bytes != null && avgBlock
323
+ ? `≈ ${(mp.bytes / avgBlock).toFixed(1)} blocks at the ${F.bytes(avgBlock, 0)} average of the last ${minedRows.length} mined`
324
+ : (mp.bytes == null ? 'no byte figure reported' : 'no mined block sizes in the window yet'));
325
+ const avgWeight = minedRows.length ? Math.round(s.attribution.recent.reduce((n, r) => n + (r.weight ?? 0), 0) / minedRows.length) : null;
326
+ setText('ovMpVsBlockKv', `<dl class="kv">
327
+ <dt>queued</dt><dd>${mp.bytes != null ? F.bytes(mp.bytes) : '–'}</dd>
328
+ <dt>avg block mined</dt><dd>${avgBlock != null ? `${F.bytes(avgBlock, 0)} · ${avgWeight != null ? F.num(avgWeight) : '–'} WU` : '<span class="warn">no attributed blocks in the window</span>'}</dd>
329
+ <dt>inbound tx rate</dt><dd>${s.mempool?.ingestRate != null ? `${F.short(s.mempool.ingestRate)} tx/s` : '–'}</dd>
330
+ </dl>`);
331
+
332
+ // Throughput and peer counts live on the Network and Peers pages now, and so do the
333
+ // writers. A card whose element is on another page and whose setText runs in
334
+ // renderOverview only ever updates while you are looking at a different screen --
335
+ // which is how the peer counts sat frozen at their boot values for a whole day.
336
+
337
+ // fees
338
+ const fees = s.fees ?? {};
339
+ const feeRows = [[1, 'f1'], [2, 'f2'], [6, 'f6'], [24, 'f24'], [144, 'f144']]
340
+ .map(([t, k]) => `<dt>in ${t} block${t > 1 ? 's' : ''}</dt><dd class="${fees[k] == null ? 'faint' : ''}">${fees[k] == null ? 'unset' : `${F.satPerVb(fees[k])} <span class="faint">sat/vB</span>`}</dd>`).join('');
341
+ // Rewritten wholesale rather than patched field-by-field: the row count is fixed by
342
+ // the estimator's targets, and this runs once a second.
343
+ //
344
+ // The card is addressed by id, not by walking up from its child. `closest()` is
345
+ // answered `null` by the DOM stub, so that traversal made this card invisible to every
346
+ // test -- it could and did render nothing in the browser while the suite stayed green,
347
+ // because the tested path and the shipped path were not the same code.
348
+ const feeCard = document.getElementById('ovFeesCard');
349
+ if (feeCard) feeCard.innerHTML = `<h3>Fees <span class="sp"></span><span class="src">estimatesmartfee</span></h3>
350
+ <div class="kv" id="ovFees">${feeRows}<dt>pool min</dt><dd>${mp.minFee != null ? `${F.satPerVb(mp.minFee)} <span class="faint">sat/vB</span>` : '–'}</dd></div>
351
+ <canvas class="chart xs" id="ovFeeChart"></canvas>`;
352
+
353
+ // overview mini charts
354
+ const ser = state.series?.mempool ?? {};
355
+ miniLine('ovMpChart', ser.hour, COL.accent);
356
+ const netS = state.series?.net ?? {};
357
+ const inSeries = netS.inHour ?? [];
358
+ paint(canvas('ovNetChart'), {
359
+ when: inSeries.length > 1,
360
+ draw: (c) => lineChart(c, [
361
+ { label: 'network in', color: COL.cyan, points: inSeries, area: true },
362
+ { label: 'disk write', color: COL.purple, points: netS.diskHour ?? [], area: false },
363
+ ], { fmtY: (v) => F.short(v), fmtTip: (v) => F.short(v) + 'B/s' }),
364
+ placeholder: 'no bandwidth ticks in the log yet',
365
+ });
366
+
367
+ const feeS = state.series?.fees ?? {};
368
+ paint(canvas('ovFeeChart'), {
369
+ when: (feeS.f6 ?? []).length > 1 || (feeS.f2 ?? []).length > 1,
370
+ draw: (c) => lineChart(c, [
371
+ { label: 'next block', color: COL.accent, points: feeS.f1 ?? [] },
372
+ { label: '6 blocks', color: COL.info, points: feeS.f6 ?? [] },
373
+ { label: '144 blocks', color: COL.purple, points: feeS.f144 ?? [] },
374
+ ], { fmtY: (v) => F.satPerVb(v, 0), fmtTip: (v) => F.satPerVb(v) + ' sat/vB', legend: true }),
375
+ placeholder: 'the fee estimator has no data yet',
376
+ });
377
+
378
+ const peerS = state.series?.peers ?? {};
379
+ paint(canvas('ovPeerChart'), {
380
+ when: (peerS.connections ?? []).length > 1,
381
+ draw: (c) => lineChart(c, [
382
+ { label: 'connections', color: COL.ok, points: peerS.connections, area: true },
383
+ { label: 'peers relaying', color: COL.info, points: peerS.relay ?? [] },
384
+ ], { fmtY: (v) => F.short(v), zeroBase: false }),
385
+ placeholder: 'no connection samples yet',
386
+ });
387
+
388
+ // blocks table
389
+ const tb = document.querySelector('#ovBlocks tbody');
390
+ if (tb) {
391
+ const rows = (s.blocks?.recent ?? []).slice(0, 14);
392
+ tb.innerHTML = rows.map((b) => `<tr>
393
+ <td><a class="xlink" href="#explorer/block/${b.height}">${F.num(b.height)}</a></td>
394
+ <td class="faint">${F.ageSec(Math.round((Date.now() - b.t) / 1000))}</td>
395
+ <td class="${gapClass(b.gapSec)}">${b.gapSec == null ? '–' : F.ageSec(b.gapSec)}</td>
396
+ <td class="r">${F.num(b.txs)}</td>
397
+ <td class="r">${b.size == null ? '–' : F.bytes(b.size, 0)}</td>
398
+ <td class="r">${b.weight == null ? '–' : F.bytes(Math.round(b.weight / 4), 0)}</td>
399
+ <td class="r">${b.totalfee == null ? '–' : F.btc(b.totalfee)}</td>
400
+ <td class="r">${b.p?.[1] == null ? '–' : b.p[1] + ' s/vB'}</td>
401
+ <td class="w faint tiny">${b.viaPeer ? F.esc(b.viaPeer) : '<span class="faint">–</span>'}</td>
402
+ </tr>`).join('') || `<tr><td colspan="9" class="faint">no block statistics loaded yet</td></tr>`;
403
+ }
404
+
405
+ const cav = document.getElementById('ovCaveats');
406
+ if (cav) {
407
+ const q = (s.health?.quality ?? []);
408
+ cav.innerHTML = `<h3>What this panel cannot tell you <span class="sp"></span><span class="src">stated, not hidden</span></h3>`
409
+ + (q.length ? q.map((x) => `<div class="caveat${x.severity === 'warn' ? ' bad' : ''}"><b>${F.esc(x.key)}</b> — ${F.esc(x.text)}</div>`).join('')
410
+ : '<div class="note ok tiny">No known gaps: every panel is backed by a live figure from the node.</div>');
411
+ }
412
+
413
+ renderFeed('ovFeed', state.events.slice(0, 40));
414
+ }
415
+
416
+ function gapClass(g) {
417
+ if (g == null) return 'faint';
418
+ if (g > 3600) return 'bad';
419
+ if (g > 1200) return 'warn';
420
+ return '';
421
+ }
422
+
423
+ function miniLine(id, points, color, placeholder = 'no samples yet') {
424
+ paint(canvas(id), {
425
+ when: Array.isArray(points) && points.length > 1,
426
+ draw: (c) => sparkline(c, points, { color }),
427
+ placeholder,
428
+ });
429
+ }
430
+
431
+ export function renderFeed(id, rows) { // exported so the harness can exercise the
432
+ // real row builder, not a stub
433
+ const el = document.getElementById(id);
434
+ if (!el) return;
435
+ const want = rows.filter((r) => !(r.kind === 'raw' && r.severity === 'info'));
436
+ el.innerHTML = want.map((r) => `<div class="row ${r.severity ?? 'info'}">
437
+ <span class="ts">${F.clock(r.ts)}</span>
438
+ <span class="tag" title="${F.esc(r.tagBase ?? r.kind ?? '')}">${F.esc(r.tagBase ?? r.kind ?? '')}</span>
439
+ <span class="txt">${F.esc(trimLine(r))}</span>
440
+ </div>`).join('') || '<div class="row info"><span class="ts"></span><span class="tag"></span><span class="txt faint">no events yet</span></div>';
441
+ }
442
+
443
+ function trimLine(r) {
444
+ const t = r.text ?? '';
445
+ return t.length > 220 ? t.slice(0, 220) + '…' : t;
446
+ }
447
+
448
+ function canvas(id) { return document.getElementById(id); }
449
+ export function setText(id, v) {
450
+ const el = document.getElementById(id);
451
+ if (el && !el.classList.contains('no-auto')) el.innerHTML = v;
452
+ }
453
+
454
+ // -------------------------------------------------------------- dispatch
455
+
456
+ // THE HEADER UPTIME HOLDS ITS FIGURE (operator, 2026-09-13: "Uptime keeps blanking out and does
457
+ // not stay drawn. It should stay there until updated").
458
+ //
459
+ // It was not a dropped stream. `app` IS NOT IN AN SSE FRAME AT ALL: wireMonitor pushes
460
+ // `m.snapshot({})`, the bare node snapshot, about once a second, while the `app` block -- version,
461
+ // build, uptime, self telemetry -- is added by fullState, which only runs on the HTTP pull every
462
+ // 20 s. So the uptime was written once per pull and blanked by the very next stream frame a second
463
+ // later. What looked like a flickering value was a figure that is simply absent from 95% of the
464
+ // frames that render the header.
465
+ //
466
+ // Two ways to fix that, and the obvious one is wrong: putting the `app` block on every frame means
467
+ // calling app.selfTelemetry() once a second, and that is not a pure read -- it PUSHES A ROW into
468
+ // app.selfRing, a 5,000-row history sampled every 10 s. Filling it at frame rate would destroy the
469
+ // server's own telemetry history to keep a header field warm.
470
+ //
471
+ // So the value is held here instead: it changes when a new reading arrives and never otherwise, and
472
+ // `–` survives only until the first one. Monitor uptime is a property of THIS MONITOR, not of the
473
+ // node being watched, so holding it across a node switch stays correct -- which is exactly why the
474
+ // rpc latency beside it is left alone. That one is per node, and holding it would show one node's
475
+ // round trip under another node's name. It also never blanks, because health.rpc is in every frame.
476
+ let lastUptime = null;
477
+
478
+ export function render() {
479
+ const s = state.snap;
480
+ document.getElementById('rpcLat').textContent = s?.health?.rpc?.lastLatencyMs != null ? `${s.health.rpc.lastLatencyMs}ms` : '–';
481
+ document.getElementById('rpcLat').className = s?.health?.rpc?.avgLatencyMs > 5000 ? 'bad' : '';
482
+ // `s?.app ?` was also wrong on its own terms: an app block with a null uptimeSec multiplied to 0
483
+ // and rendered "0m" -- a made-up figure rather than a missing one. The reading is the number.
484
+ if (s?.app?.uptimeSec != null) lastUptime = F.uptime(s.app.uptimeSec * 1000);
485
+ document.getElementById('appUp').textContent = lastUptime ?? '–';
486
+ document.getElementById('offline').classList.toggle('hidden', !!s?.online);
487
+ if (s && !s.online) {
488
+ document.getElementById('offline').innerHTML = `<b>${F.esc(s.label)} is not answering RPC.</b> `
489
+ + F.esc(s.health?.rpc?.lastError?.message ?? 'no error detail yet')
490
+ + ' — everything below is the last state we saw.';
491
+ } else if (!s) {
492
+ // No snapshot has ever arrived for the node this page is watching. Before this
493
+ // branch the banner was *shown* (the toggle above opens it whenever online is not
494
+ // truthy) but its text was only written when a snapshot existed -- so a tab left
495
+ // pointing at a node that had been removed from the config displayed a strip of
496
+ // dashes, an open red bar with nothing in it, and no explanation. A blank page
497
+ // that does not say why is the failure this project exists to avoid.
498
+ document.getElementById('offline').innerHTML = `<b>No data from ${F.esc(state.node ?? 'this node')}.</b> `
499
+ + 'Nothing has arrived from it since this page opened, so every figure below is – rather than 0. '
500
+ + 'Either the node is not answering, or it is no longer configured in this monitor.';
501
+ }
502
+
503
+ if (!state.pickerBusy) {
504
+ state.pickerBusy = true;
505
+ // Throttled: this fires on every frame, and the picker only needs to move
506
+ // when a percentage or a state actually changed.
507
+ setTimeout(() => { state.pickerBusy = false; state.refreshPicker?.(); }, 20000);
508
+ }
509
+ switch (state.page) {
510
+ case 'overview': renderOverview(s); break;
511
+ case 'space': renderBlockSpace(s, state, helpers); break;
512
+ case 'chain': renderChain(s, state, helpers); break;
513
+ case 'mining': renderMining(s, state, helpers); break;
514
+ case 'mempool': renderMempool(s, state, helpers); break;
515
+ case 'peers': {
516
+ // The peer table, every 15 s while this page is open (the server reads
517
+ // getpeerinfo on its 15 s tier). The rows live on `state`, not on the
518
+ // snapshot: the stream replaces the snapshot every second, and keying the
519
+ // fetch on "this snapshot has no rows" re-fetched /api/peers on every frame.
520
+ if (!peersFetching && Date.now() - peersFetchedAt >= 15_000) { peersFetching = true; peersDetail(true).then(() => { peersFetching = false; render(); }); }
521
+ renderPeers(s, state, helpers);
522
+ break;
523
+ }
524
+ case 'network': renderNetwork(s, state, helpers); break;
525
+ case 'logs': renderLogs(state, helpers); break;
526
+ case 'node': renderNode(s, state, helpers); break;
527
+ case 'admin': renderAdmin(s, state, helpers); break;
528
+ case 'explorer': renderExplorer(s, state, helpers); break;
529
+ case 'markets': renderMarkets(s, state, helpers); break;
530
+ case 'kiosk': renderKiosk(s, state, helpers); break;
531
+ case 'tetrust': renderTetrust(s, state, helpers); break;
532
+ case 'blockout': renderBlockout(s, state, helpers); break;
533
+ case 'blockanoid': renderBlockanoid(s, state, helpers); break;
534
+ case 'wolf3d': renderWolf3d(s, state, helpers); break;
535
+ case 'doom': renderDoom(s, state, helpers); break;
536
+ case 'quake': renderQuake(s, state, helpers); break;
537
+ case 'about': renderAbout(s, state, helpers); break;
538
+ }
539
+ }
540
+
541
+ const helpers = { api, toast, setText, canvas, renderFeed, state, fmt: F, nextBlock: nextBlockDetail, mempoolDetail: mempoolDetail,
542
+ // paint/resetCanvas are exposed so panels never call charts.empty() directly:
543
+ // a missing sample must mark a chart stale, never erase it.
544
+ charts: { lineChart, histogram, scatter, meter, stackedBars, paint, resetCanvas, COL },
545
+ render, refreshMempoolDetail, renderSyncHero, peersDetail };
546
+
547
+ // getpeerinfo's raw rows are deliberately not in the 1s snapshot (they are either
548
+ // empty on this build or large on others); the peers page pulls them itself.
549
+ let peersFetchedAt = 0;
550
+ async function peersDetail(force = false) {
551
+ if (state.page !== 'peers') return;
552
+ if (!force && Date.now() - peersFetchedAt < 15_000) return state.peerRows;
553
+ peersFetchedAt = Date.now();
554
+ try {
555
+ const d = await api(`/api/peers?node=${encodeURIComponent(state.node ?? '')}`);
556
+ state.peerRows = d.rpcPeers ?? [];
557
+ return state.peerRows;
558
+ } catch { return null; }
559
+ }
560
+
561
+ let peersFetching = false;
562
+
563
+ // The block being built right now. Fetched only while the Mining page is on screen, and
564
+ // only once every 20 s -- which is now OUR cost, not the node's: since 2026-09-13 the server
565
+ // assembles the template from the mempool it already reads (collect/gbt.js) rather than
566
+ // spending 1.3-1.5 s of the node's single RPC thread on getblocktemplate. The cadence stays
567
+ // because re-assembling an unchanged pool would produce an identical block for ~50 ms of CPU.
568
+ // The full pool distribution is 40-50 KB and belongs in neither the snapshot nor the
569
+ // 1 s stream (rule 5), so the Mining page asks for it on its own cadence and the cell
570
+ // list is drawn from wherever the newest answer came from.
571
+ let mempoolFetchedAt = 0;
572
+ // 2026-09-10: 20 s, not 60, and on BOTH pages that draw the pool viewer.
573
+ //
574
+ // Cost, measured on production rather than assumed: getrawmempool verbose
575
+ // answers in 0.08 s for a 2.2 MB payload over 12,555 transactions, against a
576
+ // node with 4 RPC threads. Three calls a minute is ~0.24 s of node RPC time
577
+ // per minute -- a 0.4% duty cycle -- and ~6.6 MB/min of JSON over loopback.
578
+ // The old 60 s gate was not paying for itself: it made the viewer's 5.4 s
579
+ // transition run once a minute, which is what "takes too long to update
580
+ // between animations" was.
581
+ //
582
+ // The page test used to be `!== 'mining'`, which meant the Overview copy of
583
+ // the viewer could never have data at all.
584
+ // 2026-09-11: 20 s -> 60 s. The viewer's transition is now 40 s end to end
585
+ // (operator: "increase the animation time to at least 40 seconds if it's
586
+ // refreshed every 60 seconds"), so a 20 s poll would hand it a new layout
587
+ // halfway through every flight; the renderer also parks a mid-flight layout
588
+ // until the running one lands. A third of the RPC cost, too.
589
+ // 2026-09-11 (again): 60 s -> 30 s ("Faster refresh"), with the server reading the
590
+ // pool every 20 s on its own tier and the transition cut to 20 s end to end, so
591
+ // the board spends as long at rest as moving.
592
+ const MEMPOOL_DETAIL_MS = 30_000;
593
+ const POOL_VIEWER_PAGES = new Set(['mining', 'overview', 'space', 'kiosk']);
594
+ async function mempoolDetail(force = false) {
595
+ if (!POOL_VIEWER_PAGES.has(state.page) || document.hidden) return state.mempoolDist;
596
+ if (!force && Date.now() - mempoolFetchedAt < MEMPOOL_DETAIL_MS) return state.mempoolDist;
597
+ mempoolFetchedAt = Date.now();
598
+ state.poolFetchedAt = mempoolFetchedAt; // the panels count down from here
599
+ try {
600
+ if (state.viewerMode === '2') {
601
+ api(`/api/mempool/dense?node=${encodeURIComponent(state.node ?? '')}`)
602
+ .then((x) => { if (x?.v) { state.denseBlock = { ...x, fetchedAt: Date.now() }; render(); } })
603
+ .catch(() => { /* the mode 1 picture stays up */ });
604
+ }
605
+ const d = await api(`/api/mempool?node=${encodeURIComponent(state.node ?? '')}`);
606
+ if (d?.dist) {
607
+ state.mempoolDist = { ...d.dist, count: d.info?.count ?? d.dist.count, fetchedAt: Date.now(), stale: false };
608
+ render();
609
+ }
610
+ return state.mempoolDist;
611
+ } catch {
612
+ if (state.mempoolDist) state.mempoolDist.stale = true; // keep the picture, mark it
613
+ return state.mempoolDist ?? null;
614
+ }
615
+ }
616
+
617
+ let templateFetchedAt = 0;
618
+ const TEMPLATE_PAGES = new Set(['mining', 'overview', 'space', 'kiosk']);
619
+ async function nextBlockDetail(force = false) {
620
+ // The block being built is asked for on both pages that draw it -- the Overview and
621
+ // Mining -- and nowhere else, and never while the tab is hidden or updates are paused.
622
+ // This used to cost the node 1.3-1.5 s of its single RPC thread per answer, and the
623
+ // cadence was that price. The template is assembled from the mempool now and costs the
624
+ // node nothing, but the cadence stands on its own: the pool tier only refreshes every
625
+ // 20 s, so asking faster would re-assemble an identical block. 20 s on the page whose
626
+ // whole subject is the template, 60 s on the landing page that shows a card of it; the
627
+ // Block space page carries it in its own panel and pays the Mining rate.
628
+ if (!TEMPLATE_PAGES.has(state.page)) return state.snap?.attribution?.nextBlock;
629
+ if (document.hidden || state.paused) return state.snap?.attribution?.nextBlock ?? null;
630
+ const freshMs = state.page === 'overview' ? 60_000 : 20_000;
631
+ if (!force && Date.now() - templateFetchedAt < freshMs) return state.snap?.attribution?.nextBlock;
632
+ templateFetchedAt = Date.now();
633
+ try {
634
+ const d = await api(`/api/nextblock?node=${encodeURIComponent(state.node ?? '')}`);
635
+ if (state.snap?.attribution && d && !d.unavailable) { state.snap.attribution.nextBlock = d; render(); }
636
+ return d;
637
+ } catch { return state.snap?.attribution?.nextBlock ?? null; }
638
+ }
639
+
640
+ // The mempool scatter is deliberately kept out of the 1s snapshot; the mempool
641
+ // page pulls it on its own cadence instead.
642
+ let mpDetailTimer = null;
643
+ async function refreshMempoolDetail(force = false) {
644
+ if (state.page !== 'mempool') return;
645
+ // ...but never makes the viewer wait past its minute: the countdown in its
646
+ // panel promises that moment, so the fetch goes as soon as it is due
647
+ if (!force && mpDetailTimer && Date.now() - mpDetailTimer < 9000 && Date.now() < mempoolFetchedAt + MEMPOOL_DETAIL_MS) return;
648
+ mpDetailTimer = Date.now();
649
+ try {
650
+ const d = await api(`/api/mempool?node=${encodeURIComponent(state.node ?? '')}`);
651
+ state.mempoolDetail = d;
652
+ // the page's own fetch also feeds its Block space viewer, so the Mempool
653
+ // page does not poll /api/mempool twice -- but only once a minute, the
654
+ // same beat as every other page's viewer and the one its countdown shows
655
+ // (a new layout every 9 s landed in the middle of every 40 s flight)
656
+ if (d?.dist && (!state.mempoolDist || Date.now() - mempoolFetchedAt >= MEMPOOL_DETAIL_MS)) {
657
+ mempoolFetchedAt = Date.now();
658
+ state.poolFetchedAt = mempoolFetchedAt;
659
+ state.mempoolDist = { ...d.dist, count: d.info?.count ?? d.dist.count, fetchedAt: Date.now(), stale: false };
660
+ }
661
+ if (state.page === 'mempool') renderMempool(state.snap, state, helpers, d);
662
+ } catch { /* the next tick will try again */ }
663
+ }
664
+
665
+ // ----------------------------------------------------------------- boot
666
+
667
+ function connect() {
668
+ const es = new EventSource(`/api/stream${state.node ? `?node=${encodeURIComponent(state.node)}` : ''}`);
669
+ const badge = document.getElementById('sseState');
670
+ es.onopen = () => { badge.textContent = 'live'; badge.className = 'ok'; };
671
+ es.onerror = () => {
672
+ badge.textContent = 'reconnecting';
673
+ badge.className = 'warn';
674
+ // EventSource reconnects by itself -- but it reconnects to the SAME url. If the
675
+ // node this tab chose has left the configuration, that is a 404 retried forever:
676
+ // a page that never updates and never says why. Two consecutive failures with no
677
+ // frame in between is the signal to re-resolve the node list rather than keep
678
+ // knocking on a door that is gone.
679
+ state.streamFails = (state.streamFails ?? 0) + 1;
680
+ const quietFor = Date.now() - (state.lastFrameAt ?? 0);
681
+ if (state.streamFails >= 2 && quietFor > 20_000) attemptStreamRecovery('the live link kept failing');
682
+ };
683
+ const noteFrame = () => { state.lastFrameAt = Date.now(); state.streamFails = 0; };
684
+ es.addEventListener('snapshot', (ev) => {
685
+ noteFrame();
686
+ if (state.paused) return;
687
+ const s = JSON.parse(ev.data);
688
+ if (s.id && state.node && s.id !== state.node) return;
689
+ const rec = nodeRec(s.id ?? state.node);
690
+ rec.snap = s;
691
+ rec.snapAt = Date.now();
692
+ state.snap = s;
693
+ render();
694
+ });
695
+ es.addEventListener('series', (ev) => {
696
+ if (state.paused) return;
697
+ const p = JSON.parse(ev.data);
698
+ const rec = nodeRec(state.node);
699
+ // Merge, do not replace: a partial push must not drop the series that were
700
+ // not in it, or the panel they feed would lose its chart.
701
+ rec.series = { ...(rec.series ?? {}), ...(p.series ?? p) };
702
+ rec.seriesAt = Date.now();
703
+ state.series = rec.series;
704
+ render();
705
+ });
706
+ es.addEventListener('events', (ev) => {
707
+ noteFrame();
708
+ const rows = JSON.parse(ev.data);
709
+ if (state.pausedHard) return;
710
+ for (const r of rows) {
711
+ state.events.unshift(r);
712
+ // the address index build is the one thing worth interrupting a page for: it takes half an hour
713
+ if (r.kind === 'index') toast(r.text, r.severity === 'warn' ? 'bad' : 'ok');
714
+ }
715
+ // Bounded: an unbounded feed is a slow memory leak with a visible UI.
716
+ if (state.events.length > 1500) state.events.length = 1500;
717
+ if (state.page === 'logs' || state.page === 'overview' || state.page === 'peers') render();
718
+ });
719
+ state.es = es;
720
+ }
721
+
722
+ /**
723
+ * The live link has been useless for a while: work out why, and say it.
724
+ *
725
+ * Two outcomes, and both have to be visible. Either the node this tab chose no longer
726
+ * exists -- then re-resolve the list and switch, which is what recoverMissingNode does
727
+ * -- or the node is still here and the stream is simply down, which is a *staleness*
728
+ * claim and must be shown as one. The failure being designed against is the silent
729
+ * kind: a page of frozen numbers, a badge stuck on "reconnecting", nothing in any log.
730
+ */
731
+ let recovering = false;
732
+ async function attemptStreamRecovery(why) {
733
+ if (recovering) return;
734
+ recovering = true;
735
+ try {
736
+ const nodes = await api('/api/nodes').catch(() => null);
737
+ const known = (nodes?.nodes ?? []).map((n) => n.id);
738
+ if (nodes && state.node && !known.includes(state.node)) {
739
+ await recoverMissingNode();
740
+ return;
741
+ }
742
+ // Node still real: make the silence loud rather than pretty.
743
+ const banner = document.getElementById('offline');
744
+ const since = state.lastFrameAt ? Math.round((Date.now() - state.lastFrameAt) / 1000) : null;
745
+ banner.innerHTML = `<b>${why}.</b> Nothing has arrived for ${since == null ? 'as long as this page has been open' : `${since}s`} `
746
+ + '— the figures below are the last ones received, not current. Check the server log for `sse #` lines if this persists.';
747
+ banner.classList.remove('hidden');
748
+ } finally {
749
+ recovering = false;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Change which node is being watched.
755
+ *
756
+ * The cache means returning to a node you have already looked at is instant and
757
+ * its charts come back immediately. The canvas wipe is not optional: the pixels
758
+ * on screen were measured on the other daemon, and a chart is not labelled with
759
+ * its node the way the header is, so a stale one would silently misattribute
760
+ * data. Anything we do not have for the new node shows a placeholder and fills
761
+ * in from the background pull.
762
+ */
763
+ // THE NODE YOU PICKED IS THE NODE YOU GET BACK (operator, 2026-09-13: "The selected option should
764
+ // stay sticky as the default"). Remembered per browser, like the viewer mode, and written ONLY
765
+ // here: switchNode is the single audited path a deliberate choice goes through (web-contract.test
766
+ // requires the switch be delegated to it), so a storage write anywhere else would be a second
767
+ // owner of the same fact.
768
+ //
769
+ // Wrapped, because storage does not merely return null when it is unavailable -- a private window
770
+ // THROWS on access, and an exception here would take the node switch down with it.
771
+ const NODE_KEY = 'blockyard.node';
772
+ const rememberNode = (id) => { try { globalThis.localStorage?.setItem(NODE_KEY, id); } catch { /* storage refused */ } };
773
+ export const rememberedNode = () => { try { return globalThis.localStorage?.getItem(NODE_KEY) || null; } catch { return null; } };
774
+
775
+ async function switchNode(id) {
776
+ if (!id || id === state.node) return;
777
+ state.node = id;
778
+ rememberNode(id);
779
+ const rec = nodeRec(id);
780
+ resetAllCharts();
781
+ state.snap = rec.snap ?? null;
782
+ state.series = rec.series ?? null;
783
+ state.events = [];
784
+ state.es?.close();
785
+ connect();
786
+ render();
787
+ if (!rec.snap) await backgroundRefresh();
788
+ }
789
+
790
+ /**
791
+ * The background collector.
792
+ *
793
+ * Charts are filled by SSE, but a reconnect, a proxy hiccup or a slow server
794
+ * push must never show up as an empty panel -- so this pulls the full read model
795
+ * (including every chart series) on its own timer, when the page is visible, and
796
+ * merges it into the cache. The foreground renders whatever is known right now
797
+ * and flags it as stale if it is old; it never waits and never clears itself.
798
+ */
799
+ let pulling = false;
800
+ async function backgroundRefresh() {
801
+ if (!state.node || pulling) return;
802
+ pulling = true;
803
+ try {
804
+ const sn = await api(`/api/state?node=${encodeURIComponent(state.node)}`);
805
+ // A successful pull is liveness too, by any definition -- the watchdog below asks
806
+ // "has anything arrived lately", and an HTTP refresh is exactly that.
807
+ state.lastFrameAt = Date.now();
808
+ state.streamFails = 0;
809
+ const rec = nodeRec(state.node);
810
+ rec.snap = sn;
811
+ rec.snapAt = Date.now();
812
+ // Merge rather than replace so a frame missing one series cannot erase a chart.
813
+ rec.series = { ...(rec.series ?? {}), ...(sn.series ?? {}) };
814
+ rec.seriesAt = Date.now();
815
+ state.snap = sn;
816
+ state.series = rec.series;
817
+ render();
818
+ } catch (err) {
819
+ // A node that is no longer configured is not a flaky link: retrying the same
820
+ // request never recovers, and the page would sit showing dashes forever while the
821
+ // header looked merely slow. Re-read the node list and land on a real one.
822
+ // Seen for real when the benchmark node was removed from the config while a tab
823
+ // was open on it: the tab kept asking for a node that no longer existed.
824
+ if (err.status === 404) await recoverMissingNode();
825
+ // Otherwise silence is correct: the next tick retries, and the charts still show
826
+ // what they showed before, marked stale. A toast every 20s on a flaky link would
827
+ // be worse than the gap.
828
+ } finally {
829
+ pulling = false;
830
+ }
831
+ }
832
+
833
+ /**
834
+ * The node this tab is watching has disappeared from the configuration.
835
+ *
836
+ * Re-resolve from the server rather than reloading blindly: the answer might be a
837
+ * renamed node, a different primary, or genuinely nothing -- and "no nodes are
838
+ * configured" must read as that, not as a monitor that is merely quiet.
839
+ *
840
+ * Deliberately does NOT touch state.snap / state.series or wipe canvases itself.
841
+ * Nulling the cache between frames is the bug `test/never-blank.test.js` exists to
842
+ * keep dead, and the canvas wipe belongs to exactly one code path. Switching to the
843
+ * surviving node is a node switch, so it goes through switchNode() and inherits its
844
+ * rules instead of quietly growing a second copy of them.
845
+ */
846
+ async function recoverMissingNode() {
847
+ const lost = state.node;
848
+ try {
849
+ const nodes = await api('/api/nodes');
850
+ state.nodes = nodes.nodes ?? [];
851
+ const next = (nodes.attention && nodes.attention[0]) || nodes.primary;
852
+ if (!next) {
853
+ // No node to switch to: leave whatever is on screen visible and say plainly
854
+ // that nothing is being polled. Blankening it would trade an honest stale view
855
+ // for an unexplained empty one.
856
+ document.getElementById('offline').innerHTML = '<b>This monitor has no nodes configured.</b> '
857
+ + 'Nothing is being polled, so any figure that reads – is – by design rather than by failure. '
858
+ + 'Add a node under <span class="mono">nodes</span> in config/local.json and restart.';
859
+ document.getElementById('offline').classList.remove('hidden');
860
+ toast('no nodes configured in this monitor', 'bad');
861
+ return;
862
+ }
863
+ toast(`${lost} is no longer configured; watching ${next}`, 'warn');
864
+ state.node = null; // switchNode no-ops on the same id; this one differs
865
+ // This also REPLACES the remembered node, because switchNode remembers what it is given -- and
866
+ // that is the behaviour wanted here rather than an accident of call order: the node someone
867
+ // picked is genuinely gone, so keeping its id would make every future boot re-check a node that
868
+ // no longer exists before falling back. The recovery's choice becomes the new default.
869
+ await switchNode(next);
870
+ } catch {
871
+ // Signed out mid-recovery (api() redirects) or the node list is unreachable too.
872
+ // Nothing further to do; the no-snapshot banner already says what is true.
873
+ render();
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Say which build this tab is running, and complain when it is not the current one.
879
+ *
880
+ * The page carries `data-blockyard-build`, stamped per response by the static layer, and
881
+ * asks /api/build whether that string is still current. If it is not, this tab is
882
+ * executing code that has been replaced -- and nothing else on screen would ever
883
+ * reveal that. Every "did the fix land?" on 2026-09-08 cost fifteen minutes for want
884
+ * of exactly this comparison.
885
+ */
886
+ export async function checkBuild() {
887
+ const served = document.documentElement?.dataset?.blockyardBuild ?? null;
888
+ const verEl = document.getElementById('ver');
889
+ const note = document.getElementById('buildNote');
890
+ let info = null;
891
+ try {
892
+ info = await api(`/api/build${served ? `?build=${encodeURIComponent(served)}` : ''}`);
893
+ } catch {
894
+ if (verEl) verEl.textContent = served ? `build ${shortBuild(served)} (server unreachable)` : 'build unknown';
895
+ return { served, current: null };
896
+ }
897
+ const now = shortBuild(info.build);
898
+ if (verEl) verEl.textContent = `v${info.version} · ${now}`;
899
+ const stale = info.matchesClient === false;
900
+ if (note) {
901
+ note.classList.toggle('hidden', !stale);
902
+ const b = note.querySelector?.('b');
903
+ if (b) b.textContent = stale ? 'stale build — reload' : 'build current';
904
+ note.title = stale
905
+ ? `this tab loaded build ${shortBuild(served)} and the server is now running ${now}; reload to execute the code that is actually on disk`
906
+ : `this tab is running build ${now}`;
907
+ if (stale && !state.buildWarned) {
908
+ state.buildWarned = true;
909
+ toast('the monitor was redeployed since this page loaded — reload to pick up the new build', 'bad');
910
+ }
911
+ }
912
+ return { served: info.build, current: !stale };
913
+ }
914
+
915
+ /** `0.1.0-3faef7bb00` -> `3faef7bb00`: the part that distinguishes two builds. */
916
+ function shortBuild(build) {
917
+ const s = String(build ?? '');
918
+ const i = s.indexOf('-');
919
+ return i > 0 ? s.slice(i + 1) : s;
920
+ }
921
+
922
+ // "explorer/tx/<txid>" -> the explorer page, subroute "tx/<txid>". Only the explorer has
923
+ // subroutes; they stay on the URL so every explorer page is a link.
924
+ // The games live behind the Diversions pop-down at the end of the nav; the menu shows as the
925
+ // active tab while one of them is open, since its own button is out of sight inside the popup.
926
+ const DIVERSION_PAGES = ['tetrust', 'blockout', 'blockanoid', 'wolf3d', 'doom', 'quake'];
927
+
928
+ function setPage(route) {
929
+ const [page, ...rest] = String(route).split('/');
930
+ state.xroute = page === 'explorer' ? rest.join('/') : '';
931
+ state.page = page;
932
+ document.querySelectorAll('.page').forEach((el) => el.classList.toggle('on', el.dataset.page === page));
933
+ document.querySelectorAll('nav.pages button').forEach((b) => b.classList.toggle('on', b.dataset.page === page));
934
+ document.getElementById('navDivBtn')?.classList.toggle('on', DIVERSION_PAGES.includes(page));
935
+ const want = `#${page}${state.xroute ? `/${state.xroute}` : ''}`;
936
+ if (location.hash !== want) history.replaceState(null, '', want);
937
+ if (page === 'mempool') refreshMempoolDetail(true);
938
+ // Render the cache immediately, then top it up in the background. The user
939
+ // never waits on a fetch to see a chart that already has data.
940
+ backgroundRefresh();
941
+ if (page === 'logs') ensureLogsLoaded(state, helpers);
942
+ if (page === 'chain') initChainDrill(helpers);
943
+ if (page === 'admin') renderAdmin(state.snap, state, helpers, true);
944
+ render();
945
+ }
946
+
947
+ // Reading localStorage is not merely empty in a locked-down context, it throws, and
948
+ // boot() is not the place to find that out.
949
+ function hasLocalSettings() {
950
+ try { return !!globalThis.localStorage?.getItem(SETTINGS_KEY); } catch { return false; }
951
+ }
952
+
953
+ async function boot() {
954
+ let me = null;
955
+ try {
956
+ me = await api('/api/me');
957
+ } catch {
958
+ // Only reachable when accounts are on: with them off /api/me answers for the
959
+ // anonymous viewer, so a redirect here would be a loop.
960
+ window.location.href = '/login';
961
+ return;
962
+ }
963
+ state.user = me.user;
964
+ state.caps = me.capabilities;
965
+ // `accounts` is reported by the server rather than inferred from a missing button:
966
+ // the posture has to be stateable in words, and "we appear to have no login" is a
967
+ // worse sentence than "this monitor is open to everyone who can reach it".
968
+ state.accounts = me.accounts !== false;
969
+ const open = !state.accounts;
970
+ document.getElementById('whoami').textContent = open
971
+ ? 'no sign-in · read-only'
972
+ : `${me.user.username} · ${me.user.role}`;
973
+ // Sign out with no session is a button that does nothing, which is the kind of UI
974
+ // that makes people reload the page and then report it as broken.
975
+ const out = document.getElementById('btnLogout');
976
+ if (out) out.hidden = open;
977
+ const pill = document.getElementById('accessPill');
978
+ if (pill) {
979
+ pill.hidden = !open;
980
+ const b = pill.querySelector?.('b');
981
+ if (b) b.textContent = 'open access';
982
+ pill.title = 'No account is required: anyone who can reach this monitor reads it as role "viewer" (reads only — user admin, the audit trail and node writes stay closed). Start the server with BLOCKYARD_AUTH=1 to require sign-in.';
983
+ }
984
+ document.getElementById('navAdmin').hidden = me.user.role !== 'admin';
985
+ const [nodes, cfg, saved] = await Promise.all([
986
+ api('/api/nodes'),
987
+ api('/api/config'),
988
+ // Display settings belong to the deployment, not to one browser: this is a server
989
+ // app, so a phone and a desktop pointed at it see the same monitor. A server that
990
+ // cannot answer still boots -- the browser's own settings stand in, which is
991
+ // exactly the behaviour there was before the file existed.
992
+ api('/api/settings').catch(() => null),
993
+ ]);
994
+ state.nodes = nodes.nodes;
995
+ // Land on a node that is doing something. Defaulting to config order meant a
996
+ // fully-synced production node could open at "Synced 100%" and fill the hero
997
+ // while a bench node sat at 72% -- the wrong node, at the wrong size.
998
+ //
999
+ // ...UNLESS SOMEONE HAS CHOSEN ONE (operator, 2026-09-13: "The selected option should stay sticky
1000
+ // as the default"). An explicit pick is stronger evidence of intent than the heuristic, so a
1001
+ // remembered node wins; the attention rule above still decides a FIRST visit, and still decides
1002
+ // it for anyone who has never touched the picker. That keeps "land on the work" for the case it
1003
+ // was written for without overriding someone who has already said otherwise.
1004
+ //
1005
+ // VALIDATED AGAINST THE LIVE LIST, never trusted on its own: a node can be removed from the
1006
+ // config between visits, and a remembered id that no longer exists would open the page on a node
1007
+ // that cannot answer -- a strip of dashes with no explanation, which is the failure this app
1008
+ // exists to avoid. An unknown id simply falls through to the heuristic.
1009
+ const remembered = rememberedNode();
1010
+ const known = (nodes.nodes ?? []).some((n) => n.id === remembered);
1011
+ state.node = (known ? remembered : null) || (nodes.attention && nodes.attention[0]) || nodes.primary;
1012
+ state.cfg = cfg;
1013
+ // Seed before the first paint: every later reader calls loadSettings(), so settings
1014
+ // applied after a render would show this browser's copy and then visibly swap it.
1015
+ if (saved?.stored && saved.settings) seedSettings(saved.settings);
1016
+ else if (hasLocalSettings()) {
1017
+ // A server with no file yet, reached from a browser that already has settings:
1018
+ // hand them up rather than make someone pick them all again. Silent on refusal --
1019
+ // a viewer without write access still gets a working page, just not a saved one.
1020
+ api('/api/settings', { method: 'POST', body: { settings: loadSettings() } }).catch(() => {});
1021
+ }
1022
+ // From here on every save reaches the server too; settings.js debounces the push.
1023
+ setSettingsPush((s) => api('/api/settings', { method: 'POST', body: { settings: s } }));
1024
+ await checkBuild();
1025
+ // Re-check on a timer, because the failure this exists for happens while the tab
1026
+ // is open: the operator deploys, the tab does not reload, and every subsequent
1027
+ // report describes code that is no longer on disk. Five minutes, because a
1028
+ // deploy is slower than that and a reload is cheaper than an hour of doubt.
1029
+ setInterval(() => { if (!document.hidden) checkBuild(); }, 300_000);
1030
+
1031
+ const pick = document.getElementById('nodePick');
1032
+ const STATE_DOT = { synced: 'ok', ibd: 'accent', catching_up: 'accent', stalled: 'bad', reorg: 'warn', unknown: 'faint' };
1033
+ const describe = (n) => `${n.label} — ${n.pct == null ? (n.online ? 'no data yet' : 'offline') : `${n.pct.toFixed(1)}%`}${n.syncing ? ' syncing' : ''}`;
1034
+ const renderPicker = (list) => {
1035
+ if (!list.length) return;
1036
+ if (list.length === 1) {
1037
+ pick.innerHTML = `<span class="${STATE_DOT[list[0].syncState] ?? 'faint'}" title="${F.esc(list[0].label + ' — ' + list[0].rpcUrl)}">${F.esc(list[0].label)}</span>`;
1038
+ return;
1039
+ }
1040
+ // Every node visible with its own state, because picking a node blind is how
1041
+ // you end up watching the one that needs nothing.
1042
+ pick.innerHTML = `<select class="f" id="nodeSel">${list.map((n) => `<option value="${F.esc(n.id)}"${n.id === state.node ? ' selected' : ''}>${F.esc(describe(n))}</option>`).join('')}</select>`;
1043
+ pick.querySelector('select').addEventListener('change', (e) => switchNode(e.target.value));
1044
+ };
1045
+ renderPicker(nodes.nodes);
1046
+ // Keep the picker's percentages honest without a reload: refresh it whenever a
1047
+ // snapshot arrives, but only if anything changed.
1048
+ state.refreshPicker = async () => {
1049
+ try {
1050
+ const n2 = await api('/api/nodes');
1051
+ const sig = JSON.stringify(n2.nodes.map((n) => [n.id, n.syncState, Math.round(n.pct ?? -1)]));
1052
+ if (sig !== state.pickerSig) { state.pickerSig = sig; renderPicker(n2.nodes); }
1053
+ } catch { /* keep the stale list */ }
1054
+ };
1055
+
1056
+ // The compact hero's "detail" button: a user who wants the big view can have
1057
+ // it, without paying for it on every page load.
1058
+ // The hero's expand/collapse control. State lives in one flag and the renderer
1059
+ // re-reads it; the alternative (mutating the DOM in place) would be undone by
1060
+ // the very next snapshot frame, which overwrites this element's innerHTML.
1061
+ document.addEventListener('click', async (e) => {
1062
+ if (e.target.closest('[data-toggle-hero],[data-toggle-sync]')) {
1063
+ state.heroForced = !state.heroForced;
1064
+ render();
1065
+ return;
1066
+ }
1067
+ const jump = e.target.closest('[data-jump-node]');
1068
+ if (jump) await switchNode(jump.dataset.jumpNode);
1069
+ });
1070
+ document.getElementById('nav').addEventListener('click', (e) => {
1071
+ const b = e.target.closest('button[data-page]');
1072
+ if (b) setPage(b.dataset.page);
1073
+ });
1074
+ // The monogram routes like a tab, but it lives OUTSIDE <nav> (it is the brand), and the handler
1075
+ // above is bound to the nav element -- so it needs its own listener rather than inheriting one.
1076
+ document.getElementById('brandAbout')?.addEventListener('click', () => setPage('about'));
1077
+ // the Diversions pop-down: opens on its button, closes on a choice, on a click anywhere else,
1078
+ // and on Escape
1079
+ const divWrap = document.getElementById('navDiv');
1080
+ const divBtn = document.getElementById('navDivBtn');
1081
+ const divPop = document.getElementById('navDivPop');
1082
+ // The panel is position:fixed (see app.css), so it is placed from the button's own rect each
1083
+ // time it opens -- the nav scrolls and the header clips, and a panel positioned inside either of
1084
+ // them cannot be seen at all. Custom properties through the CSSOM, never a style attribute.
1085
+ const placeDiversions = () => {
1086
+ if (!divBtn || !divPop) return;
1087
+ const r = divBtn.getBoundingClientRect();
1088
+ // MEASURED, not transformed. The panel used to be pulled left by translateX(-100%); now its
1089
+ // real left edge is computed so nothing depends on transform behaviour I cannot test here.
1090
+ // It must be measurable to be measured, so it is un-hidden first if it is not already open.
1091
+ const wasHidden = divPop.classList.contains('hidden');
1092
+ if (wasHidden) divPop.classList.remove('hidden');
1093
+ const w = divPop.offsetWidth || 160;
1094
+ if (wasHidden) divPop.classList.add('hidden');
1095
+ // right-aligned to the button, then held inside the viewport on both sides
1096
+ const left = Math.max(6, Math.min(r.right - w, window.innerWidth - w - 6));
1097
+ divPop.style.setProperty('--x', `${Math.round(left)}px`);
1098
+ divPop.style.setProperty('--y', `${Math.round(r.bottom + 6)}px`);
1099
+ };
1100
+ const openDiversions = (open) => {
1101
+ if (open) placeDiversions();
1102
+ divPop?.classList.toggle('hidden', !open);
1103
+ divBtn?.setAttribute('aria-expanded', open ? 'true' : 'false');
1104
+ };
1105
+ divBtn?.addEventListener('click', (e) => { e.stopPropagation(); openDiversions(divPop?.classList.contains('hidden')); });
1106
+ // The panel lives OUTSIDE <nav> now (see index.html: WebKit would not let the button be clicked
1107
+ // inside the scrolling nav), so the delegated handler bound to #nav no longer sees these items.
1108
+ // They route from here instead -- without this the three games become unreachable, which is a
1109
+ // worse fault than the one the move fixes.
1110
+ divPop?.addEventListener('click', (e) => {
1111
+ const b = e.target.closest?.('button[data-page]');
1112
+ if (b) setPage(b.dataset.page);
1113
+ openDiversions(false);
1114
+ });
1115
+ // The panel is no longer inside divWrap (it is a child of <body> now, so the header cannot clip
1116
+ // it), so a click INSIDE the panel is outside the wrapper. Without naming the panel too, opening
1117
+ // the menu and clicking an item would close it before the item's own handler ran.
1118
+ document.addEventListener('click', (e) => {
1119
+ const inside = (divWrap && divWrap.contains(e.target)) || (divPop && divPop.contains(e.target));
1120
+ if (!inside) openDiversions(false);
1121
+ });
1122
+ window.addEventListener('resize', () => { if (divPop && !divPop.classList.contains('hidden')) placeDiversions(); });
1123
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') openDiversions(false); });
1124
+ // DISPLAY SETTINGS (settings.js). The panel is built from PANEL, so a control and its value
1125
+ // cannot drift apart, and every change is saved and applied without a reload: the boards read
1126
+ // loadSettings() on their next paint, and a repaint is asked for immediately.
1127
+ const cfgWrap = document.getElementById('settingsWrap');
1128
+ const cfgBody = document.getElementById('cfgBody');
1129
+ const cfgGear = document.getElementById('btnSettings');
1130
+ // TABS (operator, 2026-09-12: "tabbed section panel in setup"). One tab per PANEL group: the
1131
+ // effects group alone is twenty-six switches, and a single scrolling column buried every other
1132
+ // setting under it. The open tab is remembered for the session, not persisted -- it is where you
1133
+ // were looking, not a preference.
1134
+ let cfgTab = SETTINGS_PANEL[0].group;
1135
+ const drawSettings = () => {
1136
+ const s = loadSettings();
1137
+ if (!SETTINGS_PANEL.some((g) => g.group === cfgTab)) cfgTab = SETTINGS_PANEL[0].group;
1138
+ // one row per TAB_ROWS entry, labelled, the games last (operator: "Diversions go at the very end")
1139
+ const tab = (g) => `<button type="button" class="cfgtab${g.group === cfgTab ? ' on' : ''}" role="tab" aria-selected="${g.group === cfgTab}" data-cfgtab="${g.group}">${g.title}</button>`;
1140
+ const tabs = `<div class="cfgtabs" role="tablist">${TAB_ROWS.map((row) =>
1141
+ `<div class="cfgtabrow"><span class="cfgtablbl">${row.label}</span>${row.groups.map((id) => SETTINGS_PANEL.find((g) => g.group === id)).filter(Boolean).map(tab).join('')}</div>`).join('')}</div>`;
1142
+ cfgBody.innerHTML = tabs + SETTINGS_PANEL.filter((g) => g.group === cfgTab).map((g) => {
1143
+ // ALL / NONE, on the groups that ask for it (the two effects tabs): twenty-eight switches is
1144
+ // a lot of clicking to answer "just show me the quiet board". This was "every row is a
1145
+ // toggle" until the no-repeat slider joined the effects group and silently took the buttons
1146
+ // with it.
1147
+ const bulk = g.bulk
1148
+ ? `<div class="cfgbulk"><button type="button" class="btn" data-cfgall="${g.group}">all on</button><button type="button" class="btn" data-cfgnone="${g.group}">all off</button></div>`
1149
+ : '';
1150
+ // THE SWITCH ROWS IN TWO COLUMNS, HINT AS THE TOOLTIP (2026-09-15, "same treatment on the
1151
+ // settings panel"): on the two effects tabs, thirty-odd switches with a line of hint each ran
1152
+ // to 2,600px of scrolling; as compact rows two across, with the hint on hover, the whole tab
1153
+ // is a screen. The sliders above them keep their hints in view.
1154
+ const compact = !!g.bulk;
1155
+ let open = false;
1156
+ const rowsHtml = g.rows.map((r) => {
1157
+ const v = s[g.group][r.key];
1158
+ const id = `cfg-${g.group}-${r.key}`;
1159
+ // THE APPEARANCE TAB'S OWN CONTROLS: the mode as a segmented row of buttons, the theme as cards
1160
+ // with a swatch strip and a line about each (the shape of every editor's appearance page),
1161
+ // and a "start Custom from this" button under the cards. The swatches' colours are set after
1162
+ // the markup lands (data-swatch), the one way the CSP lets a script colour an element.
1163
+ if (r.kind === 'segment') {
1164
+ const seg = `<span class="cfgseg" role="radiogroup" aria-label="${r.label}">${r.options.map(([val, label]) =>
1165
+ `<button type="button" class="cfgsegbtn${val === v ? ' on' : ''}" role="radio" aria-checked="${val === v}" data-cfgset="${g.group}.${r.key}" data-value="${val}">${label}</button>`).join('')}</span>`;
1166
+ return `<div class="cfgrow"><b><label>${r.label}</label></b><span>${seg}</span><i>${r.hint}</i></div>`;
1167
+ }
1168
+ if (r.kind === 'cards') {
1169
+ const scheme = resolveScheme(s.appearance);
1170
+ const cards = r.options.map(([val, label]) => {
1171
+ const face = val === 'custom' ? resolveTheme(s) : presetFace(val, scheme);
1172
+ const blurb = val === 'custom' ? 'Your own nine colours, below' : PRESETS[val].blurb;
1173
+ const sw = ['bg', 'panel', 'text', 'accent', 'ok', 'warn', 'bad'].map((k) => `<i data-swatch="${face[k]}" title="${k}"></i>`).join('');
1174
+ return `<button type="button" class="thcard${val === v ? ' on' : ''}" role="radio" aria-checked="${val === v}" data-cfgset="${g.group}.${r.key}" data-value="${val}" data-face-bg="${face.bg}" data-face-fg="${face.text}" data-face-line="${face.line}">`
1175
+ + `<span class="thsw">${sw}</span><b>${label}</b><small>${blurb}</small></button>`;
1176
+ }).join('');
1177
+ const from = v === 'custom' ? '' : `<button type="button" class="btn cfgcustomise" data-cfgcustomise="1" title="Copy this theme's colours into the nine pickers below and switch to Custom">customise ${PRESETS[v]?.label ?? 'this'} →</button>`;
1178
+ return `<div class="cfgrow cfgcards"><b><label>${r.label}</label></b><span>${from}</span><span class="thcards" role="radiogroup" aria-label="${r.label}">${cards}</span><i>${r.hint}</i></div>`;
1179
+ }
1180
+ const ctl = r.kind === 'toggle'
1181
+ ? `<input type="checkbox" id="${id}" data-cfg="${g.group}.${r.key}"${v ? ' checked' : ''}>`
1182
+ : r.kind === 'choice'
1183
+ ? `<select id="${id}" data-cfg="${g.group}.${r.key}">${r.options.map(([val, label]) => `<option value="${val}"${val === v ? ' selected' : ''}>${label}</option>`).join('')}</select>`
1184
+ : r.kind === 'colour'
1185
+ ? `<input type="color" id="${id}" data-cfg="${g.group}.${r.key}" value="${v}">`
1186
+ : `<span class="cfgrange"><input type="range" id="${id}" data-cfg="${g.group}.${r.key}" min="${r.min}" max="${r.max}" step="${r.step}" value="${v}"><span class="val" data-val-for="${g.group}.${r.key}">${formatRangeValue(r.step, v)}</span></span>`;
1187
+ // (and the nine custom colours of the Appearance tab the same way: two across, hint as tooltip,
1188
+ // dimmed while another theme is chosen, so the tab is a screen)
1189
+ if ((compact && r.kind === 'toggle') || r.custom) {
1190
+ const start = open ? '' : '<div class="cfgrows2">';
1191
+ open = true;
1192
+ const idle = r.custom && s.appearance.theme !== 'custom' ? ' idle' : '';
1193
+ return `${start}<div class="cfgrow compact${idle}" title="${String(r.hint).replace(/"/g, '&quot;')}"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span></div>`;
1194
+ }
1195
+ const close = open ? '</div>' : '';
1196
+ open = false;
1197
+ return `${close}<div class="cfgrow"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span><i>${r.hint}</i></div>`;
1198
+ }).join('') + (open ? '</div>' : '');
1199
+ return `<div class="cfggroup"><h3>${g.title}</h3><p>${g.note}</p>${bulk}${rowsHtml}</div>`;
1200
+ }).join('');
1201
+ for (const el of cfgBody.querySelectorAll('[data-swatch]')) el.style.setProperty('background', el.dataset.swatch);
1202
+ for (const el of cfgBody.querySelectorAll('.thcard')) {
1203
+ el.style.setProperty('--th-bg', el.dataset.faceBg); el.style.setProperty('--th-fg', el.dataset.faceFg); el.style.setProperty('--th-line', el.dataset.faceLine);
1204
+ }
1205
+ };
1206
+ const openSettings = (open) => {
1207
+ cfgWrap.classList.toggle('hidden', !open);
1208
+ cfgGear.classList.toggle('on', open);
1209
+ cfgGear.setAttribute('aria-expanded', open ? 'true' : 'false');
1210
+ if (open) drawSettings();
1211
+ };
1212
+ cfgGear.addEventListener('click', () => openSettings(cfgWrap.classList.contains('hidden')));
1213
+ document.getElementById('cfgClose').addEventListener('click', () => openSettings(false));
1214
+ document.getElementById('settingsScrim').addEventListener('click', () => openSettings(false));
1215
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !cfgWrap.classList.contains('hidden')) openSettings(false); });
1216
+ document.getElementById('cfgReset').addEventListener('click', () => {
1217
+ resetSettings();
1218
+ drawSettings();
1219
+ render();
1220
+ toast('display settings back to their defaults');
1221
+ });
1222
+ cfgBody.addEventListener('click', (e) => {
1223
+ const tab = e.target.closest?.('[data-cfgtab]');
1224
+ if (tab) { cfgTab = tab.dataset.cfgtab; drawSettings(); return; }
1225
+ // a segment button or a theme card: one value, then the tab redraws so the cards' swatches
1226
+ // show the face the new mode picks
1227
+ const pick = e.target.closest?.('[data-cfgset]');
1228
+ if (pick) { setSetting(loadSettings(), pick.dataset.cfgset, pick.dataset.value); drawSettings(); repaintSoon(); return; }
1229
+ // "customise this": the nine base colours of the theme on screen become the Custom pickers'
1230
+ // values, and Custom is selected, so the pickers start from a look rather than from the shipped one
1231
+ if (e.target.closest?.('[data-cfgcustomise]')) {
1232
+ const face = resolveTheme(loadSettings());
1233
+ let next = loadSettings();
1234
+ const keyOf = { bg: 'customBg', panel: 'customPanel', text: 'customText', muted: 'customMuted', accent: 'customAccent', line: 'customLine', ok: 'customOk', warn: 'customWarn', bad: 'customBad' };
1235
+ for (const k of BASE_KEYS) next = setSetting(next, `appearance.${keyOf[k]}`, face[k]);
1236
+ setSetting(next, 'appearance.theme', 'custom');
1237
+ drawSettings(); repaintSoon();
1238
+ return;
1239
+ }
1240
+ const bulk = e.target.closest?.('[data-cfgall], [data-cfgnone]');
1241
+ if (!bulk) return;
1242
+ const on = bulk.hasAttribute('data-cfgall');
1243
+ const group = bulk.dataset.cfgall ?? bulk.dataset.cfgnone;
1244
+ for (const r of SETTINGS_PANEL.find((g) => g.group === group)?.rows ?? []) {
1245
+ if (r.kind === 'toggle') setSetting(loadSettings(), `${group}.${r.key}`, on);
1246
+ }
1247
+ drawSettings();
1248
+ render();
1249
+ });
1250
+ // ONE REPAINT PER FRAME, not one per input event (operator, 2026-09-12: "the grid intensity
1251
+ // slider jitters when I move it"). `render()` is not cheap -- it rewrites the header, toggles the
1252
+ // offline banner and then repaints the open page's canvases -- and dragging a slider fires an
1253
+ // `input` event per step. The grid intensity control has forty steps across its range AND
1254
+ // recolours a board on each one, so a drag queued forty synchronous repaints and the thumb
1255
+ // visibly stuttered behind the pointer. Every one of the twelve range controls had this; the new
1256
+ // one only made it obvious.
1257
+ // The VALUE is still stored and the readout still updates on every event -- those are cheap, and
1258
+ // the number beside the slider must track the thumb exactly. Only the repaint is coalesced, so at
1259
+ // most one runs per animation frame however fast the pointer moves.
1260
+ let repaintQueued = 0;
1261
+ const repaintSoon = () => {
1262
+ if (repaintQueued) return;
1263
+ repaintQueued = requestAnimationFrame(() => { repaintQueued = 0; render(); });
1264
+ };
1265
+ cfgBody.addEventListener('input', (e) => {
1266
+ const el = e.target.closest?.('[data-cfg]');
1267
+ if (!el) return;
1268
+ const value = el.type === 'checkbox' ? el.checked : el.type === 'range' ? Number(el.value) : el.value;
1269
+ setSetting(loadSettings(), el.dataset.cfg, value);
1270
+ const out = cfgBody.querySelector(`[data-val-for="${el.dataset.cfg}"]`);
1271
+ // fixed decimals from the slider's own step, so the number never changes width (formatRangeValue)
1272
+ if (out) out.textContent = el.type === 'range' ? formatRangeValue(el.step, value) : String(value);
1273
+ repaintSoon(); // the boards pick the new options up on their next paint
1274
+ });
1275
+
1276
+ document.getElementById('btnPause').addEventListener('click', (e) => {
1277
+ state.paused = !state.paused;
1278
+ state.pausedHard = e.shiftKey ? true : state.paused ? state.pausedHard : false;
1279
+ e.target.textContent = state.paused ? 'resume' : 'pause';
1280
+ e.target.classList.toggle('primary', state.paused);
1281
+ toast(state.paused ? `updates frozen${state.pausedHard ? ' including the event feed' : ''}` : 'updates resumed');
1282
+ });
1283
+ document.getElementById('btnLogout').addEventListener('click', async () => {
1284
+ try { await api('/api/logout', { method: 'POST', body: {} }); } catch { /* session may already be gone */ }
1285
+ window.location.href = '/login';
1286
+ });
1287
+
1288
+ // Background collection, not foreground waiting. 20s keeps the charts filling
1289
+ // even if every SSE frame is lost, and only while the tab is actually visible.
1290
+ setInterval(() => { if (!document.hidden && !state.paused) backgroundRefresh(); }, 20_000);
1291
+ // THE OVERVIEW PRICE STRIP'S OWN TIMER, and the one place its network cost is decided.
1292
+ //
1293
+ // GET /api/markets calls markets.touch() on the server, which is what starts the five-exchange
1294
+ // polling -- so this must not run unless the operator switched the strip on. Turning it off
1295
+ // stops asking, the server's feed parks itself after idleAfterMs, and the promise in
1296
+ // docs/SECURITY.md holds again. Same cadence as the Markets page, and only while the tab is
1297
+ // visible: a backgrounded tab has nobody reading the price.
1298
+ const pullMarketsStrip = () => {
1299
+ if (document.hidden || state.paused) return;
1300
+ if (!loadSettings().markets?.overviewSummary) return;
1301
+ if (state.page !== 'overview') return; // Markets and Kiosk fetch their own
1302
+ api('/api/markets').then((d) => { overviewMarkets = d; if (state.page === 'overview') render(); }).catch(() => {});
1303
+ };
1304
+ setInterval(pullMarketsStrip, MARKETS_REFRESH_MS);
1305
+ pullMarketsStrip();
1306
+ // "Trigger Refresh Now" (operator, 2026-09-11: "a button for 'Trigger Refresh
1307
+ // Now' that is only enabled when the animation is idle"). A click makes the
1308
+ // viewer due at once and fetches; the ticker below keeps each button enabled
1309
+ // only while its own board is at rest (viewerIdle), updates are not paused
1310
+ // and no refresh is already under way. Checked again on click, since the
1311
+ // ticker runs once a second.
1312
+ let poolRefreshing = false;
1313
+ // a refresh button's board: through its control bar, which lives in the panel's heading now
1314
+ const viewerCanvasFor = (b) => b.closest('.viewer-ctl')?.__canvas ?? b.closest('.treemapwrap')?.querySelector('canvas') ?? null;
1315
+ document.addEventListener('click', async (e) => {
1316
+ const b = e.target.closest?.('[data-refresh-now]');
1317
+ if (!b || b.disabled || poolRefreshing || state.paused) return;
1318
+ if (!viewerIdle(viewerCanvasFor(b))) return;
1319
+ poolRefreshing = true;
1320
+ b.disabled = true;
1321
+ mempoolFetchedAt = 0; // due now
1322
+ try { if (state.page === 'mempool') await refreshMempoolDetail(true); else await mempoolDetail(true); }
1323
+ finally { poolRefreshing = false; }
1324
+ });
1325
+ // The pool viewers' countdown to their next refresh (see refreshLabel), once
1326
+ // a second. Text and a CSS variable only -- nothing is re-rendered.
1327
+ setInterval(() => {
1328
+ if (document.hidden) return;
1329
+ const { text, frac } = refreshLabel(state.poolFetchedAt ? state.poolFetchedAt + MEMPOOL_DETAIL_MS : NaN, Date.now(), { paused: state.paused, period: MEMPOOL_DETAIL_MS });
1330
+ for (const el of document.querySelectorAll('[data-refresh]')) {
1331
+ if (el.textContent !== text) el.textContent = text;
1332
+ el.style.setProperty('--p', `${Math.round(frac * 100)}%`);
1333
+ }
1334
+ for (const b of document.querySelectorAll('[data-refresh-now]')) {
1335
+ const ok = !state.paused && !poolRefreshing && viewerIdle(viewerCanvasFor(b));
1336
+ if (b.disabled === ok) b.disabled = !ok;
1337
+ b.title = ok ? 'refresh the pool now' : state.paused ? 'updates are paused' : 'waits for the board to come to rest';
1338
+ }
1339
+ }, 1000);
1340
+ // Watchdog. A page can lose its stream without anything else noticing: the badge
1341
+ // says "reconnecting", the numbers stop moving, and no log line is written anywhere.
1342
+ // So every 15s, ask the blunt question -- has any frame arrived in the last 90s?
1343
+ setInterval(() => {
1344
+ if (document.hidden || state.pausedHard) return;
1345
+ const quietFor = Date.now() - (state.lastFrameAt ?? state.startedAt);
1346
+ if (quietFor > 90_000) attemptStreamRecovery('no live data has arrived');
1347
+ else if (state.lastFrameAt) {
1348
+ const rec = state.byNode.get(state.node);
1349
+ const age = Date.now() - (rec?.snapAt ?? 0);
1350
+ // Stale must be VISIBLY stale (rule 8): numbers that stopped moving look
1351
+ // identical to numbers that are current, which is the whole trap.
1352
+ const badge = document.getElementById('sseState');
1353
+ if (age > 90_000 && badge.textContent === 'live') { badge.textContent = 'stale'; badge.className = 'warn'; }
1354
+ }
1355
+ }, 15_000);
1356
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) backgroundRefresh(); });
1357
+ window.addEventListener('resize', () => { clearTimeout(state.rt); state.rt = setTimeout(render, 220); });
1358
+ // VIEWER MODES: the switch in every Block space viewer's control bar (mining.js VIEWER_MODES)
1359
+ document.addEventListener('click', (e) => {
1360
+ const b = e.target.closest?.('[data-vmode]');
1361
+ if (!b) return;
1362
+ state.viewerMode = b.dataset.vmode;
1363
+ try { localStorage.setItem('blockyard.viewerMode', state.viewerMode); } catch { /* storage refused */ }
1364
+ mempoolDetail(true);
1365
+ render();
1366
+ });
1367
+ window.addEventListener('hashchange', () => {
1368
+ const p = location.hash.slice(1);
1369
+ if (p && p !== `${state.page}${state.xroute ? `/${state.xroute}` : ''}`) setPage(p);
1370
+ });
1371
+
1372
+ // The node has to be on the URL. /api/state falls back to app.primary, so
1373
+ // selecting the attention node in `state.node` and then fetching without it
1374
+ // meant the page still opened on the synced production node -- the picker
1375
+ // looked right and the data underneath it was a different daemon.
1376
+ await backgroundRefresh();
1377
+ const ev = await api('/api/events?limit=200');
1378
+ state.events = ev.events ?? [];
1379
+ connect();
1380
+ setPage(location.hash.slice(1) || 'overview');
1381
+ }
1382
+
1383
+ boot().catch((err) => {
1384
+ console.error(err);
1385
+ toast(`startup failed: ${err.message}`, 'bad');
1386
+ });