blockyard 0.0.1 → 0.0.9

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