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,966 @@
1
+ // Page renderers other than Overview. Each reads `state` and paints into the DOM
2
+ // skeleton from index.html. Nothing here fetches on its own except where a
3
+ // payload is deliberately kept out of the live frame (mempool scatter, logs,
4
+ // admin) -- those have their own cadence.
5
+ import { lineChart, histogram, scatter, meter, stackedBars, paint, COL } from './charts.js';
6
+
7
+ const F = () => panelsFmt;
8
+ let panelsFmt = null;
9
+ export function setFmt(mod) { panelsFmt = mod; }
10
+
11
+ // ------------------------------------------------------------------ chain
12
+
13
+ export function renderChain(s, state, h) {
14
+ if (!s) return;
15
+ document.querySelectorAll('[data-sync-hero]').forEach((box) => {
16
+ // The chain page shows the same component; app.js owns the renderer.
17
+ h.renderSyncHero(box, s);
18
+ });
19
+ const fmt = F();
20
+ const ser = state.series ?? {};
21
+ const n = ser.node ?? {};
22
+ const b = ser.blocks ?? {};
23
+
24
+ const tip = n.tip ?? [];
25
+ paint(h.canvas('chTipChart'), {
26
+ when: tip.length > 1,
27
+ draw: (c) => lineChart(c, [
28
+ { label: 'blocks applied', color: COL.accent, points: tip, area: true },
29
+ ], { fmtY: (v) => fmt.short(v), fmtTip: (v) => fmt.num(v), zeroBase: false }),
30
+ placeholder: 'no tip samples yet',
31
+ });
32
+ h.setText('chTipNote', s.tip?.headers != null && s.tip?.height != null
33
+ ? `Applied ${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers. Headers-first means this reaches 100% only when the last block lands.`
34
+ : 'no header count reported yet');
35
+
36
+ const gap = (b.gap ?? []).filter((p) => Number.isFinite(p.v));
37
+ paint(h.canvas('chGapChart'), {
38
+ when: gap.length > 1,
39
+ draw: (c) => lineChart(c, [
40
+ { label: 'seconds between blocks', color: COL.info, points: gap, area: true },
41
+ ], { fmtY: (v) => `${Math.round(v)}s`, fmtTip: (v) => `${(v / 60).toFixed(1)} min`, marker: { v: 600, label: '10 min target', color: COL.ok } }),
42
+ placeholder: 'need two blocks to measure an interval',
43
+ });
44
+ const stats = s.blocks?.recent ?? [];
45
+ const gaps = stats.map((x) => x.gapSec).filter((x) => x != null && x >= 0 && x < 7200);
46
+ h.setText('chGapNote', gaps.length
47
+ ? `${gaps.length} intervals measured · median ${fmt.ageSec(median(gaps))} · ${gaps.filter((g) => g > 1200).length} over 20 min`
48
+ : '');
49
+
50
+ drawFromSeries(h, 'chSizeChart', b.size, COL.purple, (v) => fmt.bytes(v, 0), { fmtTip: (v) => fmt.bytes(v, 0) });
51
+ // What the number *is*, stated where the number is drawn. The figure is
52
+ // getblockstats' total_size -- the sum of transaction sizes -- not the serialized
53
+ // block, and the difference is exactly the kind of thing a reader cannot recover
54
+ // from a chart. When the basis is absent the chart is empty, and an empty chart
55
+ // reads as "broken panel" unless the panel says otherwise: this figure sat empty
56
+ // for a day because the request asked for statistics the endpoint has never had.
57
+ const sizeRow = (s.blocks?.recent ?? [])[0] ?? null;
58
+ h.setText('chSizeNote', sizeRow?.sizeBasis
59
+ ? `<span class="mono">total_size</span> — ${sizeRow.sizeBasis}.`
60
+ + (sizeRow.medianTxSize != null && sizeRow.swtotalSize != null && sizeRow.size != null
61
+ ? ` This block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}.`
62
+ : '')
63
+ : (sizeRow?.sizeMissing ?? 'no block stats collected yet — the monitor has not seen a new height since it started'));
64
+ drawFromSeries(h, 'chFeeChart', b.fee, COL.ok, (v) => fmt.short(v), { fmtTip: (v) => `${fmt.sats(v)} sat` });
65
+ drawFromSeries(h, 'chTxChart', b.txs, COL.cyan, (v) => fmt.short(v));
66
+
67
+ const txr = n.txRate ?? [];
68
+ paint(h.canvas('chTxRateChart'), {
69
+ when: txr.length > 1,
70
+ draw: (c) => lineChart(c, [{ label: 'tx/s', color: COL.accent, points: txr, area: true }], { fmtY: (v) => fmt.short(v) }),
71
+ placeholder: 'getchaintxstats has not answered yet',
72
+ });
73
+ const cs = s.chaintxstats;
74
+ h.setText('chTxStats', cs ? `<dt>window</dt><dd>${fmt.num(cs.window_block_count ?? 0)} blocks</dd>
75
+ <dt>txs in window</dt><dd>${fmt.num(cs.window_tx_count ?? 0)}</dd>
76
+ <dt>all-time txs</dt><dd>${fmt.num(cs.txcount ?? 0)}</dd>
77
+ <dt>rate</dt><dd>${cs.txrate != null ? cs.txrate.toFixed(2) + ' tx/s' : '–'}</dd>` : '<dt>–</dt><dd>not yet fetched</dd>');
78
+
79
+ h.setText('chState', kv([
80
+ ['chain', s.chain ?? '–'],
81
+ ['height', s.tip?.height != null ? fmt.num(s.tip.height) : '–'],
82
+ ['headers', s.tip?.headers != null ? fmt.num(s.tip.headers) : '–'],
83
+ // short forms: the list runs in two columns, and a full date or a 10+10 hash wrapped
84
+ ['best hash', s.tip?.hash ? fmt.hash(s.tip.hash, 6) : '–'],
85
+ ['tip time', s.tip?.time ? fmt.clock(s.tip.time * 1000) : '–'],
86
+ ['tip age', s.tip?.ageSec != null ? fmt.ageSec(s.tip.ageSec) : '–'],
87
+ ['progress', s.progress != null ? (s.progress * 100).toFixed(4) + '%' : '–'],
88
+ ['on disk', s.sizeOnDisk != null ? fmt.bytes(s.sizeOnDisk, 0) : '–'],
89
+ ['pruned', s.pruned == null ? '–' : String(s.pruned)],
90
+ ['chain work', s.chainwork ? fmt.hash(s.chainwork.replace(/^0+/, '') || '0', 6) : '–'],
91
+ ['IBD', s.ibd == null ? '–' : String(s.ibd)],
92
+ ['monitor uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
93
+ ]));
94
+
95
+ const u = s.utxo ?? {};
96
+ h.setText('chUtxo', kv([
97
+ ['coins', u.txouts != null ? fmt.num(u.txouts) : '–'],
98
+ ['height', u.height != null ? fmt.num(u.height) : '–'],
99
+ ['total amount', u.total_amount != null ? u.total_amount.toFixed(4) + ' BTC' : '–'],
100
+ ['muhash', u.muhash ? fmt.hash(u.muhash, 8) : '–'],
101
+ ]));
102
+ drawFromSeries(h, 'chUtxoChart', n.txouts, COL.ok, (v) => fmt.short(v));
103
+
104
+ h.setText('chDiff', kv([
105
+ ['difficulty', s.difficulty != null ? fmt.short(s.difficulty) : '–'],
106
+ // Suppressed during IBD by the monitor (difficulty ÷ a gap measured while applying
107
+ // hundreds of blocks a second is not a hashrate), and the reason travels with it
108
+ // so the dash is an explanation rather than a mystery.
109
+ ['network hash', s.hashrateEstEh != null ? fmt.eh(s.hashrateEstEh)
110
+ : `<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`],
111
+ ...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', `<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`]] : []),
112
+ ['avg interval', s.avgBlockGapSec != null ? fmt.ageSec(s.avgBlockGapSec) : '–'],
113
+ ['reorgs seen', String(s.blocks?.reorgs ?? 0)],
114
+ ]));
115
+ drawFromSeries(h, 'chDiffChart', n.difficulty, COL.warn, (v) => fmt.short(v));
116
+
117
+ const idx = s.indexes ?? {};
118
+ const rows = Object.entries(idx).map(([name, v]) => `<tr><td>${fmt.esc(name)}</td>
119
+ <td class="r">${v?.best_block_height != null ? fmt.num(v.best_block_height) : '–'}</td>
120
+ <td class="${v?.synced ? 'ok' : 'warn'}">${v?.synced ? 'synced' : `behind${v?.best_block_height != null && s.tip?.height != null ? ' ' + fmt.num(s.tip.height - v.best_block_height) : ''}`}</td></tr>`);
121
+ const itb = document.querySelector('#chIndexes tbody');
122
+ if (itb) itb.innerHTML = rows.join('') || '<tr><td colspan="3" class="faint">getindexinfo has not answered</td></tr>';
123
+
124
+ const ttb = document.querySelector('#chTips tbody');
125
+ if (ttb) ttb.innerHTML = (s.tips ?? []).map((t) => `<tr><td>${fmt.num(t.height)}</td>
126
+ <td class="r">${fmt.num(t.branchlen)}</td>
127
+ <td class="${t.status === 'active' ? 'ok' : 'warn'}">${fmt.esc(t.status)}</td></tr>`).join('')
128
+ || '<tr><td colspan="3" class="faint">getchaintips has not answered</td></tr>';
129
+ }
130
+
131
+ // --------------------------------------------------------------- mempool
132
+
133
+ export function renderMempool(s, state, h, detail) {
134
+ if (!s) return;
135
+ const fmt = F();
136
+ const mp = s.mempool ?? {};
137
+ const d = mp.dist ?? null;
138
+ const dd = detail?.dist ?? d;
139
+
140
+ meter(h.canvas('mpMeter'), { value: mp.usage, max: mp.maxUsage, fmt: (v) => fmt.bytes(v, 0) });
141
+ // No Block space viewer on this page any more (operator: "just remove the block space panel from
142
+ // mempool entirely"). `poolViewer` is still exported by mining.js and still used by Overview,
143
+ // Block space, Mining and the Kiosk -- only this page's call is gone, along with its import.
144
+ h.setText('mpLimits', kv([
145
+ ['transactions', mp.count != null ? fmt.num(mp.count) : '–'],
146
+ ['serialized', mp.bytes != null ? fmt.bytes(mp.bytes) : '–'],
147
+ ['memory used', mp.usage != null ? fmt.bytes(mp.usage) : '–'],
148
+ ['limit', mp.maxUsage != null ? fmt.bytes(mp.maxUsage) : '–'],
149
+ ['pool fees', mp.totalFee != null ? mp.totalFee.toFixed(5) + ' BTC' : '–'],
150
+ ['min fee rate', mp.minFee != null ? fmt.satPerVb(mp.minFee) + ' sat/vB' : '–'],
151
+ ['min relay', mp.minRelayFee != null ? fmt.satPerVb(mp.minRelayFee) + ' sat/vB' : '–'],
152
+ ['unbroadcast', mp.unbroadcast ?? '–'],
153
+ ['OP_RETURN max', mp.maxDataCarrier != null ? fmt.num(mp.maxDataCarrier) + ' B' : '–'],
154
+ // COMPUTED ALL ALONG, NEVER SHOWN. server/collect/monitor.js has measured these on every
155
+ // sample since the distribution existed; nothing on the page read them. They cost nothing to
156
+ // draw and they answer the obvious question the byte total does not: how big is a typical
157
+ // transaction in there, and what is the pool worth.
158
+ ['total vsize', dd?.totalVsize != null ? fmt.bytes(dd.totalVsize) : '–'],
159
+ ['average vsize', dd?.avgVsize != null ? fmt.num(dd.avgVsize) + ' vB' : '–'],
160
+ ['fees in pool', dd?.totalFeeSat != null ? fmt.num(dd.totalFeeSat) + ' sat' : '–'],
161
+ ]));
162
+
163
+ const ser = state.series?.mempool ?? {};
164
+ paint(h.canvas('mpSizeChart'), {
165
+ when: (ser.hour ?? []).length > 1,
166
+ draw: (c) => lineChart(c, [
167
+ { label: 'transactions', color: COL.accent, points: ser.hour, area: true },
168
+ { label: 'memory', color: COL.purple, points: ser.usageHour ?? [], axis: 'right' },
169
+ ], { fmtY: (v) => fmt.short(v), fmtRight: (v) => fmt.short(v), fmtTip: (v) => fmt.short(v) }),
170
+ placeholder: 'collecting mempool samples…',
171
+ });
172
+
173
+ paint(h.canvas('mpHistChart'), {
174
+ when: !!dd?.hist?.counts,
175
+ draw: (c) => histogram(c, dd.hist.counts, {
176
+ edges: dd.hist.edges, color: COL.accent, fmtX: (v) => (v >= 1 ? fmt.short(v) : v.toFixed(1)),
177
+ fmtY: (v) => fmt.short(v), axisLabel: 'sat/vB (log)',
178
+ highlight: mp.minFee != null ? mp.minFee * 1e8 / 1000 : null,
179
+ }),
180
+ placeholder: 'waiting for getrawmempool (20 s tier)',
181
+ });
182
+ if (dd?.hist?.counts) {
183
+ h.setText('mpHistNote', `${fmt.num(dd.count ?? 0)} transactions · median ${dd.p50Feerate ?? '–'} sat/vB · p90 ${dd.p90Feerate ?? '–'} · max ${dd.maxFeerate ?? '–'} · green bar = the pool's minimum fee rate`);
184
+ }
185
+
186
+ paint(h.canvas('mpScatter'), {
187
+ when: !!dd?.scatter?.length,
188
+ draw: (c) => scatter(c, dd.scatter, { logY: true, fmtX: (v) => fmt.ageSec(v), fmtY: (v) => fmt.short(v), yLabel: 'sat/vB (log)', color: 'rgba(247,147,26,.55)' }),
189
+ placeholder: 'waiting for the mempool sample (20 s tier)',
190
+ });
191
+ if (dd?.scatter?.length) {
192
+ h.setText('mpScatterNote', `${fmt.num(dd.scatter.length)} of ${fmt.num(dd.count ?? 0)} transactions plotted · dot area ∝ vsize · oldest in pool ${fmt.ageSec(dd.oldestSec)}`
193
+ + (dd.ageUnknown ? ` · ${fmt.num(dd.ageUnknown)} report no entry time (time = 0) and are left out of the age charts` : ''));
194
+ }
195
+
196
+ paint(h.canvas('mpAgeChart'), {
197
+ when: !!dd?.ageHist?.counts,
198
+ draw: (c) => histogram(c, dd.ageHist.counts, {
199
+ edges: dd.ageHist.edges, color: COL.info, fmtX: (v) => fmt.ageSec(v), axisLabel: 'age in pool',
200
+ }),
201
+ placeholder: 'waiting for getrawmempool',
202
+ });
203
+
204
+ const feeS = state.series?.fees ?? {};
205
+ paint(h.canvas('mpFeeChart'), {
206
+ when: (feeS.f6 ?? []).length > 1,
207
+ draw: (c) => lineChart(c, [
208
+ { label: '1 block', color: COL.accent, points: feeS.f1 ?? [] },
209
+ { label: '2', color: COL.cyan, points: feeS.f2 ?? [] },
210
+ { label: '6', color: COL.info, points: feeS.f6 ?? [] },
211
+ { label: '24', color: COL.purple, points: feeS.f24 ?? [] },
212
+ { label: '144', color: COL.ok, points: feeS.f144 ?? [] },
213
+ { label: 'pool min', color: COL.bad, points: feeS.min ?? [] },
214
+ ], { fmtY: (v) => fmt.satPerVb(v, 0), fmtTip: (v) => fmt.satPerVb(v) + ' sat/vB' }),
215
+ placeholder: 'the fee estimator has no data yet',
216
+ });
217
+
218
+ // THESE TWO PANELS COME FROM THE NODE'S LOG, NOT FROM RPC. With the log tail off
219
+ // (BLOCKYARD_LOG_SOURCE=0, which is this deployment) there is no source for any of it, and a
220
+ // column of dashes would read as "the node has no orphans" rather than "we are not watching".
221
+ // Say which it is, the same way the peers page says "not reported by this build".
222
+ const noLog = state.cfg?.log?.enabled === false;
223
+ // ...and when there is no log, the two cards that read it collapse to the sentence that says so,
224
+ // rather than standing a 130px empty chart open under one line of text.
225
+ //
226
+ // BY ID, never by walking up from a child. The DOM stub the tests run against returns null from
227
+ // that traversal, so a class toggled through it is silently skipped under test -- the suite stays
228
+ // green while the browser shows a card that never collapses. web-contract.test.js forbids it
229
+ // outright; `ovFeesCard` is the same lesson already learned once.
230
+ // (And the rule is enforced by scanning this file for the call, so it must not be spelled out
231
+ // here either -- writing it in a comment failed the guard exactly as using it would.)
232
+ for (const id of ['mpAcceptCard', 'mpOrphansCard']) {
233
+ document.getElementById(id)?.classList.toggle('lognone', noLog);
234
+ }
235
+ const acc = mp.rejects;
236
+ if (noLog) {
237
+ h.setText('mpAccept', '<dt>ingest &amp; rejects</dt><dd class="faint">needs the node\'s log; this monitor is running on RPC alone</dd>');
238
+ } else h.setText('mpAccept', kv([
239
+ ['ingest rate', mp.ingestRate != null ? mp.ingestRate.toFixed(2) + ' tx/s' : 'measuring…'],
240
+ ['window', acc?.windowSec != null ? `${acc.windowSec}s` : '–'],
241
+ ['accepted', acc ? fmt.num(acc.windowSec ? Math.round((state.snap?.mempool?.ingestRate ?? 0) * acc.windowSec) : 0) : '–'],
242
+ ['missing inputs', acc ? fmt.num(acc.missingInputs) : '–'],
243
+ ['policy reject', acc ? fmt.num(acc.policy) : '–'],
244
+ ['invalid', acc ? fmt.num(acc.invalid) : '–'],
245
+ ['already confirmed', acc ? fmt.num(acc.alreadyConfirmed) : '–'],
246
+ ['last block drain', mp.lastDrain ? `${fmt.num(mp.lastDrain.removed)} tx at ${fmt.num(mp.lastDrain.height)}` : '–'],
247
+ ]));
248
+ const tf = state.series?.txflow ?? {};
249
+ drawFromSeries(h, 'mpIngestChart', tf.accepted, COL.accent, (v) => fmt.short(v));
250
+
251
+ const or = detail?.log?.orphans;
252
+ const od = detail?.log?.orphanDetail;
253
+ if (noLog) {
254
+ h.setText('mpOrphans', '<dt>orphan pool</dt><dd class="faint">needs the node\'s log; this monitor is running on RPC alone</dd>');
255
+ } else h.setText('mpOrphans', kv([
256
+ ['held', or ? fmt.num(or.held) : '–'],
257
+ ['parked', or ? fmt.num(or.parked) : '–'],
258
+ ['resolved', or ? fmt.num(or.resolved) : '–'],
259
+ ['dropped', or ? fmt.num(or.dropped) : '–'],
260
+ ['1p1c accepted', or?.oneP1C ? fmt.num(or.oneP1C.accepted) : '–'],
261
+ ['parents requested', od ? fmt.num(od.requested) : '–'],
262
+ ['notfound', od ? fmt.num(od.notfound) : '–'],
263
+ ['in flight', od ? fmt.num(od.inFlight) : '–'],
264
+ ['gave up', od ? fmt.num(od.gaveUp) : '–'],
265
+ ]));
266
+ drawFromSeries(h, 'mpOrphanChart', tf.orphansParked, COL.warn, (v) => fmt.short(v));
267
+
268
+ const nr = detail?.notReported ?? [];
269
+ h.setText('mpNotReported', nr.length
270
+ ? `This node's getrawmempool answers ${'<span class="mono">vsize, weight, time, fees.base</span>'} only. These Core fields are therefore <b>not shown rather than shown empty</b>: ${nr.map((x) => `<span class="mono">${fmt.esc(x)}</span>`).join(', ')}.`
271
+ : 'Every field is populated.') ;
272
+ // Keep the page's own refresh loop for the scatter alive.
273
+ h.refreshMempoolDetail?.();
274
+ }
275
+
276
+ // ----------------------------------------------------------------- peers
277
+
278
+ export function renderPeers(s, state, h) {
279
+ if (!s) return;
280
+ const fmt = F();
281
+ const p = s.peers ?? {};
282
+ h.setText('prCount', p.connections == null ? '–' : fmt.num(p.connections));
283
+ h.setText('prSplit', `in ${fmt.num(p.in ?? 0)} · out ${fmt.num(p.out ?? 0)}${p.wanted != null ? ` · wants ${p.wanted}` : ''}`);
284
+ // only what the node reports: on this build every one of these answered "–" or
285
+ // "not reported", seven rows saying nothing above the peer table
286
+ const budget = [
287
+ ['configured max', p.budget?.max], ['outbound budget', p.budget?.outbound], ['full relay', p.budget?.fullRelay],
288
+ ['block-relay', p.budget?.blockRelay], ['feeler', p.budget?.feeler], ['inbound cap', p.budget?.inboundCap],
289
+ ].filter(([, v]) => v != null).map(([k, v]) => [k, fmt.num(v)]);
290
+ if (p.banned != null) budget.push(['banned', `${fmt.num(p.banned)} of ${fmt.num(p.bannedOf ?? 0)}`]);
291
+ h.setText('prBudget', budget.length ? kv(budget) : '<dt>budget &amp; bans</dt><dd class="faint">not reported by this build</dd>');
292
+
293
+ const ser = state.series?.peers ?? {};
294
+ paint(h.canvas('prChart'), {
295
+ when: (ser.connections ?? []).length > 1,
296
+ draw: (c) => lineChart(c, [
297
+ { label: 'connections', color: COL.ok, points: ser.connections, area: false },
298
+ { label: 'inbound', color: COL.info, points: ser.in ?? [] },
299
+ { label: 'outbound', color: COL.accent, points: ser.out ?? [] },
300
+ { label: 'peers relaying', color: COL.purple, points: ser.relay ?? [] },
301
+ ], { fmtY: (v) => fmt.short(v), zeroBase: false, legend: true }),
302
+ placeholder: 'no connection samples yet',
303
+ });
304
+
305
+
306
+ const pr = (id, v) => h.setText(id, v);
307
+ pr('ovPeers', p.connections == null ? '–' : fmt.num(p.connections));
308
+ pr('ovPeersOf', p.wanted != null ? `of ${fmt.num(p.wanted)} wanted` : '');
309
+ pr('ovPeerRelay', p.in != null || p.out != null ? `in ${fmt.num(p.in ?? 0)} · out ${fmt.num(p.out ?? 0)}` : '–');
310
+
311
+ // The peer table getpeerinfo publishes (see peerTableHtml), and a sentence for what
312
+ // it still does not. Earlier builds answered with no rows at all -- the second branch.
313
+ const rows = state.peerRows;
314
+ h.setText('prTable', peerTableHtml(rows, fmt, { tip: s.tip?.height ?? null }));
315
+ pr('prIdentityNote', rows?.length
316
+ ? `${fmt.num(rows.length)} peers, most bytes received first${p.byteCoverage != null ? ` (${(p.byteCoverage * 100).toFixed(1)}% of getnettotals received)` : ''} · rate = change over 15 s · height = reported at connect (synced_* answers -1 here) · no call publishes per-peer relay counts or blocks served`
317
+ : p.rpcRows
318
+ ? 'loading the peer table…'
319
+ : `<span class="warn">getpeerinfo returns no rows</span> while getconnectioncount reports ${fmt.num(p.connections ?? 0)} connections — this build keeps its peer table in the forked download worker and publishes nothing per-peer over RPC. Peer identity, transport, user agent and per-peer bytes are therefore not shown anywhere in this monitor, and are not guessed from any other source.`);
320
+
321
+ h.setText('prNet', '');
322
+
323
+ }
324
+
325
+ // THE PEER TABLE (operator, 2026-09-11: "Doesn't the node's rpc pull more info for peers
326
+ // now?"). It does: getpeerinfo on this build answers one row per connection -- address,
327
+ // network, user agent and protocol version, direction, when it connected, when it last
328
+ // received and sent, the bytes each way (summing to 99.99% of getnettotals, MEASUREMENTS
329
+ // 27), and how far it has synced -- and the server adds a rate from successive samples
330
+ // (withPeerRates). Most bytes received first. The page used to print only the row count.
331
+ export function peerTableHtml(rows, fmt, { now = Date.now(), tip = null } = {}) {
332
+ if (!Array.isArray(rows) || !rows.length) return '';
333
+ const best = tip ?? Math.max(...rows.map((p) => p.synced_headers ?? p.synced_blocks ?? 0));
334
+ const sorted = rows.slice().sort((a, b) => (b.bytesrecv ?? 0) - (a.bytesrecv ?? 0));
335
+ const since = (sec) => (Number.isFinite(sec) && sec > 0 ? fmt.ageSec(Math.max(0, now / 1000 - sec)) : '–');
336
+ const lag = (h) => (!Number.isFinite(h) || h < 0 ? '–' : best - h <= 0 ? 'tip' : `−${fmt.num(best - h)}`);
337
+ // synced_headers / synced_blocks answer -1 on this build (not tracked), so the height a
338
+ // peer reported when it connected stands in, with the blocks the chain has gained since
339
+ const height = (q) => (Number.isFinite(q.synced_blocks) && q.synced_blocks >= 0
340
+ ? lag(q.synced_blocks)
341
+ : Number.isFinite(q.startingheight) && q.startingheight > 0
342
+ ? `${fmt.num(q.startingheight)}${tip != null && tip > q.startingheight ? faint(`+${fmt.num(tip - q.startingheight)}`) : ''}`
343
+ : '–');
344
+ // the services that tell peers apart (every one here is NETWORK + WITNESS)
345
+ const SVC = { P2P_V2: 'v2', COMPACT_FILTERS: 'filters', NETWORK_LIMITED: 'pruned', BLOOM: 'bloom' };
346
+ const svc = (q) => (Array.isArray(q.servicesnames) ? q.servicesnames.map((n) => SVC[n]).filter(Boolean) : []);
347
+ const ua = (s) => String(s ?? '').replace(/^\/|\/$/g, '') || '–';
348
+ const faint = (s) => ` <span class="faint">${fmt.esc(s)}</span>`;
349
+ const body = sorted.map((p) => `<tr>
350
+ <td>${p.inbound ? '<span class="badge muted">in</span>' : '<span class="badge">out</span>'}</td>
351
+ <td>${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
352
+ <td class="w">${fmt.esc(ua(p.subver))}${p.version ? faint(String(p.version)) : ''}${svc(p).map((t) => ` <span class="badge muted">${t}</span>`).join('')}</td>
353
+ <td class="r">${Number.isFinite(p.conntime) ? fmt.uptime((now / 1000 - p.conntime) * 1000) : '–'}</td>
354
+ <td class="r">${since(p.lastrecv)}</td>
355
+ <td class="r">${since(p.lastsend)}</td>
356
+ <td class="r">${fmt.bytes(p.bytesrecv)} <span class="faint">${p.recvRate == null ? '–' : fmt.rate(p.recvRate)}</span></td>
357
+ <td class="r">${fmt.bytes(p.bytessent)} <span class="faint">${p.sentRate == null ? '–' : fmt.rate(p.sentRate)}</span></td>
358
+ <td class="r">${height(p)}</td>
359
+ <td class="r">${Number.isFinite(p.timeoffset) ? `${p.timeoffset}s` : '–'}</td>
360
+ <td class="w">${p.relaytxes === false ? '<span class="faint">blocks only</span>' : 'tx relay'}${Array.isArray(p.permissions) && p.permissions.length ? faint(p.permissions.join(',')) : ''}</td>
361
+ </tr>`).join('');
362
+ return `<table class="t peertbl"><thead><tr><th></th><th>peer</th><th>client</th><th>connected</th><th>last recv</th><th>last send</th><th>received</th><th>sent</th><th>height</th><th>clock</th><th>relay</th></tr></thead><tbody>${body}</tbody></table>`;
363
+ }
364
+
365
+ // --------------------------------------------------------------- network
366
+
367
+ export function renderNetwork(s, state, h) {
368
+ if (!s) return;
369
+ const fmt = F();
370
+ const net = s.net ?? {};
371
+ const ser = state.series?.net ?? {};
372
+ h.setText('ntIn', net.inBps == null ? '–' : fmt.short(net.inBps));
373
+ h.setText('ntInTotals', net.netTotalLog != null ? `${fmt.bytes(net.netTotalLog)} received since the last log tick reset` : 'no totals yet');
374
+ h.setText('ntDisk', net.diskWriteBps == null ? '–' : fmt.short(net.diskWriteBps));
375
+ h.setText('ntDiskTotals', net.diskTotal != null ? `${fmt.bytes(net.diskTotal)} written to the block archive` : 'no totals yet');
376
+
377
+ drawFromSeries(h, 'ntInChart', ser.inHour, COL.cyan, (v) => fmt.short(v) + 'B/s', { fmtTip: (v) => fmt.rate(v) });
378
+ drawFromSeries(h, 'ntDiskChart', ser.diskHour, COL.purple, (v) => fmt.short(v) + 'B/s', { fmtTip: (v) => fmt.rate(v) });
379
+
380
+ // One note covers both directions: the reason is one mechanism (the peer byte
381
+ // counters live in the forked download worker), and the honest sentence differs only
382
+ // per direction. The element id still says "Upload" from when only that half was
383
+ // unmeasurable; renaming it means touching index.html and the id contract test, so
384
+ // the mismatch is commented here rather than silently carried in the prose.
385
+ const dlMeas = !!net.downloadMeasured, upMeas = !!net.uploadMeasured;
386
+ h.setText('ntUploadNote', dlMeas && upMeas
387
+ ? 'The node reports both byte counters, so download and upload are measured directly.'
388
+ : `<b class="warn">${[!dlMeas && 'Download', !upMeas && 'Upload'].filter(Boolean).join(' and ')} is not available.</b>
389
+ <span class="mono">getnettotals</span> answers
390
+ <span class="mono">${[!dlMeas && 'totalbytesrecv: 0', !upMeas && 'totalbytessent: 0'].filter(Boolean).join(', ')}</span>
391
+ in this deployment — the peer byte counters live in the node's forked download
392
+ worker and are not published to the RPC process. Nothing else on this box carries
393
+ these numbers, so no figure is shown here rather than an invented one.`);
394
+ h.setText('ntRpc', kv([
395
+ ['totalbytesrecv (RPC)', net.totalRecvRpc != null ? fmt.num(net.totalRecvRpc) + ' B' : '–'],
396
+ ['totalbytessent (RPC)', net.totalSentRpc != null ? fmt.num(net.totalSentRpc) + ' B' : '–'],
397
+ ['upload target', net.uploadtarget?.target ? fmt.bytes(net.uploadtarget.target, 0) + ' / day' : 'none configured'],
398
+ ['serve historical', net.uploadtarget?.serve_historical_blocks == null ? '–' : String(net.uploadtarget.serve_historical_blocks)],
399
+ ]));
400
+ h.setText('ntAccounting', kv([
401
+ ['received (log)', net.netTotalLog != null ? fmt.bytes(net.netTotalLog) : '–'],
402
+ ['written to disk (log)', net.diskTotal != null ? fmt.bytes(net.diskTotal) : '–'],
403
+ ['average recv since start', net.avgRecv != null ? fmt.rate(net.avgRecv) : '–'],
404
+ ['average write since start', net.avgWrite != null ? fmt.rate(net.avgWrite) : '–'],
405
+ ['dead-weight floor', net.floor != null ? fmt.rate(net.floor) : '–'],
406
+ ['pool median', net.poolMedian != null ? fmt.rate(net.poolMedian) : '–'],
407
+ ['chain size on disk', s.sizeOnDisk != null ? fmt.bytes(s.sizeOnDisk, 0) : '–'],
408
+ ]));
409
+
410
+ const src = state.cfg?.sources ?? [];
411
+ h.setText('ntSources', src.length
412
+ ? `<div class="scroll"><table class="t"><thead><tr><th>panel</th><th>source</th><th>note</th></tr></thead><tbody>${
413
+ src.map((x) => `<tr><td>${fmt.esc(x.panel)}</td><td class="mono tiny">${fmt.esc(x.source)}</td><td class="w tiny faint">${fmt.esc(x.note ?? '')}</td></tr>`).join('')
414
+ }</tbody></table></div>`
415
+ : 'config not loaded');
416
+ }
417
+
418
+ // ----------------------------------------------------------------- logs
419
+
420
+ let logsLoaded = false;
421
+ export function ensureLogsLoaded(state, h) {
422
+ if (logsLoaded) return;
423
+ logsLoaded = true;
424
+ h.api('/api/events?limit=500').then((d) => {
425
+ if (!state.events.length) state.events = d.events ?? [];
426
+ h.render();
427
+ }).catch(() => { logsLoaded = false; });
428
+ }
429
+
430
+ export function renderLogs(state, h) {
431
+ const fmt = F();
432
+ const rows = state.events ?? [];
433
+ const kinds = [...new Set(rows.map((r) => r.kind).filter(Boolean))].slice(0, 40);
434
+ const kindSel = document.getElementById('lgKind');
435
+ if (kindSel && kindSel.options.length <= 1) {
436
+ kindSel.insertAdjacentHTML('beforeend', kinds.map((k) => `<option value="${fmt.esc(k)}">${fmt.esc(k)}</option>`).join(''));
437
+ kindSel.addEventListener('change', () => renderLogs(state, h));
438
+ document.getElementById('lgSev')?.addEventListener('change', () => renderLogs(state, h));
439
+ document.getElementById('lgSearch')?.addEventListener('input', () => renderLogs(state, h));
440
+ document.getElementById('lgClear')?.addEventListener('click', () => { state.events = []; renderLogs(state, h); });
441
+ }
442
+
443
+ const q = (document.getElementById('lgSearch')?.value ?? '').toLowerCase();
444
+ const sev = document.getElementById('lgSev')?.value ?? '';
445
+ const kind = document.getElementById('lgKind')?.value ?? '';
446
+ // Raw node log lines are not shown: the feed is the monitor's own observations.
447
+ const wantRaw = false;
448
+
449
+ let out = rows;
450
+ if (sev) out = out.filter((r) => r.severity === sev);
451
+ if (kind) out = out.filter((r) => r.kind === kind);
452
+ if (!wantRaw) out = out.filter((r) => r.kind !== 'raw');
453
+ if (q) out = out.filter((r) => `${r.text ?? ''} ${r.tag ?? ''} ${r.kind ?? ''} ${r.addr ?? ''}`.toLowerCase().includes(q));
454
+
455
+ const el = document.getElementById('lgFeed');
456
+ const cnt = document.getElementById('lgCount');
457
+ if (cnt) cnt.textContent = `${out.length} of ${rows.length} buffered`;
458
+ if (!el) return;
459
+ el.innerHTML = out.slice(0, 600).map((r) => `<div class="row ${r.severity ?? 'info'}">
460
+ <span class="ts">${fmt.clock(r.ts)}</span>
461
+ <span class="tag">${fmt.esc(r.tagBase ?? r.kind ?? '')}</span>
462
+ <span class="txt">${fmt.esc((r.text ?? '').slice(0, 400))}</span>
463
+ </div>`).join('') || '<div class="row info"><span class="ts"></span><span class="tag"></span><span class="txt faint">nothing matches</span></div>';
464
+ }
465
+
466
+ // ------------------------------------------------------------------ node
467
+
468
+ export function renderNode(s, state, h) {
469
+ if (!s) return;
470
+ const fmt = F();
471
+ const rpc = s.health?.rpc ?? {};
472
+ h.setText('ndRpc', kv([
473
+ ['endpoint', rpc.url ?? '–'],
474
+ ['cookie source', rpc.cookieSource ? fmt.hash(rpc.cookieSource, 14) : '–'],
475
+ ['in flight', '1 (by design)'],
476
+ ['calls (60 s window)', String(rpc.ratePerSec ?? 0) + '/s'],
477
+ ['total calls', fmt.num(rpc.calls)],
478
+ ['batches', fmt.num(rpc.batches)],
479
+ ['methods sent', fmt.num(rpc.methods)],
480
+ ['last latency', rpc.lastLatencyMs != null ? rpc.lastLatencyMs + ' ms' : '–'],
481
+ ['avg latency', rpc.avgLatencyMs != null ? rpc.avgLatencyMs + ' ms' : '–'],
482
+ ['slowest seen', rpc.maxLatencyMs != null ? rpc.maxLatencyMs + ' ms' : '–'],
483
+ ['lane busy', rpc.busyMsPerSec != null ? rpc.busyMsPerSec + ' ms/s' : '–'],
484
+ ['errors / timeouts', `${fmt.num(rpc.errors)} / ${fmt.num(rpc.timeouts)}`],
485
+ ['polls dropped as stale', fmt.num(rpc.staleDropped ?? 0)],
486
+ ['breaker trips', fmt.num(rpc.breakerTrips)],
487
+ ['queued now', fmt.num(rpc.queued)],
488
+ ]));
489
+ const lat = state.series?.rpc?.latency ?? [];
490
+ paint(h.canvas('ndLatChart'), {
491
+ when: lat.length > 1,
492
+ draw: (c) => lineChart(c, [{ label: 'RPC latency ms', color: COL.info, points: lat, area: true }], { fmtY: (v) => `${Math.round(v)}` }),
493
+ placeholder: 'no latency samples yet',
494
+ });
495
+
496
+ const cad = s.health?.cadence ?? {};
497
+ const cb = document.querySelector('#ndCadence tbody');
498
+ if (cb) cb.innerHTML = Object.entries(cad).map(([tier, v]) => `<tr>
499
+ <td>${fmt.esc(tier)}</td>
500
+ <td class="r">${(v.configuredMs / 1000).toFixed(0)}s</td>
501
+ <td class="r ${v.effectiveMs > v.configuredMs ? 'warn' : ''}">${(v.effectiveMs / 1000).toFixed(1)}s</td>
502
+ <td class="r faint">${v.lastRunMs != null ? v.lastRunMs + 'ms' : '–'}</td></tr>`).join('')
503
+ || '<tr><td colspan="4" class="faint">no tiers have run</td></tr>';
504
+ h.setText('ndCadenceNote', s.health?.cadenceStretched
505
+ ? '<span class="warn">Cadence is stretched.</span> The node\'s RPC is slow right now, so these tiers are deliberately polling less often rather than queueing requests behind a single-threaded server. This recovers on its own when the node answers faster.'
506
+ : 'All tiers at their configured cadence.');
507
+
508
+ const q = s.health?.quality ?? [];
509
+ h.setText('ndQuality', q.length
510
+ ? q.map((x) => `<div class="caveat${x.severity === 'warn' ? ' bad' : ''}"><b>${fmt.esc(x.key)}</b> — ${fmt.esc(x.text)} <span class="faint tiny">(${fmt.ago(x.at)})</span></div>`).join('')
511
+ : '<div class="note ok tiny">No quality flags: every panel is backed by a live figure.</div>');
512
+
513
+ h.setText('ndSelf', kv(Object.entries(s.app?.self ?? state.snap?.app?.self ?? {}).map(([k, v]) => [k, typeof v === 'number' ? fmt.short(v) : String(v)])));
514
+ drawSelf(h, s);
515
+
516
+ const t = s.log ?? {};
517
+ const lh = t.health ?? {};
518
+ h.setText('ndTail', kv([
519
+ ['file', t.source === 'disabled'
520
+ ? '<span class="faint">disabled — running on RPC only</span>'
521
+ : (t.file ? fmt.hash(t.file, 16) : 'not configured')],
522
+ ['exists', t.exists == null ? '–' : String(t.exists)],
523
+ ['size', t.size != null ? fmt.bytes(t.size, 1) : '–'],
524
+ ['read to', t.pos != null ? fmt.bytes(t.pos, 1) : '–'],
525
+ ['lag', t.lagBytes != null ? fmt.bytes(t.lagBytes, 1) : '–'],
526
+ ['events parsed', fmt.num(t.events)],
527
+ ['rotations handled', fmt.num(t.rotations)],
528
+ ['truncations handled', fmt.num(t.truncations)],
529
+ ['read errors', fmt.num(t.readErrors)],
530
+ ['last event', t.lastEventAt ? fmt.ago(t.lastEventAt) : '–'],
531
+ ['backfilled', String(t.backfilled ?? false)],
532
+ // The two figures that separate "this node is quiet" from "we are tailing the
533
+ // wrong file". A tail that stops moving is invisible in every other panel.
534
+ ['lines matched', lh.ratio != null
535
+ ? `${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`
536
+ : '<span class="faint">not checked yet</span>'],
537
+ ['last new bytes', lh.lastGrowthAt
538
+ ? `${fmt.ago(lh.lastGrowthAt)} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`
539
+ : '–'],
540
+ ]));
541
+
542
+ const src = state.cfg?.sources ?? [];
543
+ h.setText('ndSources', src.length
544
+ ? `<table class="t"><thead><tr><th>panel</th><th>source</th><th>why</th></tr></thead><tbody>${
545
+ src.map((x) => `<tr><td>${fmt.esc(x.panel)}</td><td class="mono tiny">${fmt.esc(x.source)}</td><td class="w tiny faint">${fmt.esc(x.note ?? '')}</td></tr>`).join('')
546
+ }</tbody></table>`
547
+ : 'config not loaded');
548
+
549
+ bindConsole(h);
550
+ bindConnection(h);
551
+ // PREFILL ONCE, and only where the operator has not typed. This page repaints every second, and
552
+ // rewriting an <input> under the cursor is how a form eats what is being entered. The data
553
+ // directory is not in the snapshot, so it is left blank on purpose -- the server reads a blank
554
+ // datadir as "keep the one already configured", never as "clear it".
555
+ const put = (id, v) => { const e = document.getElementById(id); if (e && !e.value && v) e.value = v; };
556
+ put('cnUrl', rpc.url);
557
+ put('cnLabel', s.label);
558
+ const chainSel = document.getElementById('cnChain');
559
+ if (chainSel && !chainSel.dataset.set && s.chain) {
560
+ chainSel.value = ['main', 'test', 'signet', 'regtest'].includes(s.chain) ? s.chain : 'main';
561
+ chainSel.dataset.set = '1';
562
+ }
563
+ }
564
+
565
+ // THE LAST READING STANDS (operator, 2026-09-13: "This section shows info, then it disappears.
566
+ // Can we not make info go away, and just have it updated?").
567
+ //
568
+ // Every figure here was re-derived from the frame being painted, so ANY frame without `app.self`
569
+ // -- the first paint before the stream has answered, a reconnect, a paused stream, an error frame
570
+ // -- rewrote the whole block as seven dashes. The values did not go stale; they were erased and
571
+ // replaced by placeholders, which reads as the panel breaking rather than as the panel waiting.
572
+ //
573
+ // A figure now only ever changes when there is a NEW figure. `–` survives exactly as long as
574
+ // nothing has ever been read, which is the one time it is honest.
575
+ const SELF_LAST = {};
576
+ function drawSelf(h, s) {
577
+ const fmt = F();
578
+ const t = s.app?.self ?? {};
579
+ // keep(key, value) -- remember it when it is real, otherwise reuse what we last knew
580
+ const keep = (k, v) => {
581
+ if (v != null) SELF_LAST[k] = v;
582
+ return SELF_LAST[k] ?? '–';
583
+ };
584
+ h.setText('ndSelf', kv([
585
+ ['resident', keep('rss', t.rssMb != null ? `${t.rssMb} MB` : null)],
586
+ ['heap', keep('heap', t.heapMb != null ? `${t.heapMb} MB` : null)],
587
+ ['cpu', keep('cpu', t.cpuPct != null ? `${t.cpuPct.toFixed(1)}%` : null)],
588
+ ['sse clients', keep('sse', s.app?.sseClients != null ? String(s.app.sseClients) : null)],
589
+ ['active users', keep('users', t.usersActive != null ? String(t.usersActive) : null)],
590
+ ['events/s', keep('events', t.eventRate != null ? String(t.eventRate) : null)],
591
+ ['app uptime', keep('uptime', s.app?.uptimeSec != null ? fmt.uptime(s.app.uptimeSec * 1000) : null)],
592
+ ]));
593
+
594
+ // THE CHART IS FED BY A RING THE SERVER NEVER SENDS. app.selfRing collects a row every 10 s and
595
+ // caps at 5,000, but nothing exports it -- `s.app.selfHistory` is undefined on every frame ever
596
+ // served, so this branch has always been dead and the panel has always said "no self history
597
+ // yet". Rather than leave a permanent placeholder, the client keeps its own short history from
598
+ // the readings it is already being given, which needs no server change and cannot go stale.
599
+ const rows = (s.app?.selfHistory) ?? SELF_SERIES;
600
+ if (t.rssMb != null && (!SELF_SERIES.length || SELF_SERIES[SELF_SERIES.length - 1].t !== t.t)) {
601
+ SELF_SERIES.push({ t: t.t ?? Date.now(), rssMb: t.rssMb });
602
+ if (SELF_SERIES.length > 480) SELF_SERIES.shift(); // ~8 minutes at one a second
603
+ }
604
+ if (rows && rows.length > 1) {
605
+ lineChart(h.canvas('ndSelfChart'), [{ label: 'rss MB', color: COL.cyan, points: rows.map((r) => ({ t: r.t, v: r.rssMb })), area: true }], { fmtY: (v) => `${Math.round(v)}` });
606
+ } else {
607
+ paint(h.canvas('ndSelfChart'), { when: false, draw: () => {}, placeholder: 'gathering…' });
608
+ }
609
+ }
610
+ const SELF_SERIES = [];
611
+
612
+ let consoleBound = false;
613
+ function bindConsole(h) {
614
+ if (consoleBound) return;
615
+ consoleBound = true;
616
+ const run = async () => {
617
+ const method = (document.getElementById('rpcMethod').value || '').trim();
618
+ const out = document.getElementById('rpcOut');
619
+ if (!method) { out.textContent = 'enter a method'; return; }
620
+ let params = [];
621
+ const raw = (document.getElementById('rpcParams').value || '').trim();
622
+ if (raw) {
623
+ try { params = JSON.parse(raw); } catch (e) { out.textContent = `params is not valid JSON: ${e.message}`; return; }
624
+ if (!Array.isArray(params)) { out.textContent = 'params must be a JSON array, e.g. [6]'; return; }
625
+ }
626
+ out.textContent = '…';
627
+ const t0 = Date.now();
628
+ try {
629
+ const r = await h.api('/api/rpc', { method: 'POST', body: { method, params, node: h.state.node } });
630
+ out.textContent = `${r.method} · ${r.ms ?? Date.now() - t0} ms\n\n${JSON.stringify(r.result, null, 1)}`;
631
+ if (r.note) out.textContent += `\n\nnote: ${r.note}`;
632
+ } catch (err) {
633
+ out.textContent = `${err.message}${err.payload?.error?.code ? `\ncode: ${err.payload.error.code}` : ''}`;
634
+ }
635
+ };
636
+ document.getElementById('rpcRun').addEventListener('click', run);
637
+ ['rpcMethod', 'rpcParams'].forEach((id) => document.getElementById(id).addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }));
638
+ }
639
+
640
+ // ---- the node connection form (index.html, Node & RPC page) --------------------------------
641
+ // TEST FIRST, SAVE SECOND. `save` stays disabled until a test has ANSWERED, and goes back to
642
+ // disabled the moment any field changes -- a configuration nobody reached is not one worth
643
+ // keeping, and a green tick next to an edited field would be a lie about what was tested.
644
+ let connBound = false;
645
+ let connTested = false;
646
+ function bindConnection(h) {
647
+ if (connBound) return;
648
+ connBound = true;
649
+ const el = (id) => document.getElementById(id);
650
+ const out = el('cnOut');
651
+ if (!out) return;
652
+ const fmt = F();
653
+ const body = () => ({
654
+ rpcUrl: (el('cnUrl').value || '').trim(),
655
+ datadir: (el('cnDatadir').value || '').trim(),
656
+ chainHint: el('cnChain').value,
657
+ label: (el('cnLabel').value || '').trim(),
658
+ });
659
+ const invalidate = () => { connTested = false; el('cnSave').disabled = true; };
660
+ for (const id of ['cnUrl', 'cnDatadir', 'cnLabel']) el(id).addEventListener('input', invalidate);
661
+ el('cnChain').addEventListener('change', invalidate);
662
+
663
+ el('cnTest').addEventListener('click', async () => {
664
+ out.textContent = 'testing…';
665
+ try {
666
+ const r = await h.api('/api/config/node/test', { method: 'POST', body: body() });
667
+ if (r.ok) {
668
+ connTested = true;
669
+ el('cnSave').disabled = false;
670
+ out.innerHTML = `<b class="ok">answered in ${r.ms} ms</b> — chain ${fmt.esc(String(r.chain ?? '?'))}, `
671
+ + `${fmt.num(r.blocks ?? 0)} blocks${r.ibd ? ', still in initial block download' : ''}`;
672
+ } else {
673
+ invalidate();
674
+ out.innerHTML = `<b class="bad">no answer</b> — ${fmt.esc(r.error?.message ?? 'unknown')}`;
675
+ }
676
+ } catch (err) { invalidate(); out.textContent = err.message; }
677
+ });
678
+
679
+ el('cnSave').addEventListener('click', async () => {
680
+ if (connTested !== true) return; // belt and braces with the disabled attribute
681
+ out.textContent = 'saving…';
682
+ try {
683
+ const r = await h.api('/api/config/node', { method: 'POST', body: { ...body(), confirm: 'save' } });
684
+ // The environment is applied AFTER the file (server/config.js), so on a box whose unit sets
685
+ // BLOCKYARD_NODE_URL the save is real and still will not take effect. Say so loudly rather
686
+ // than report a success the next restart quietly contradicts.
687
+ out.innerHTML = `<b class="ok">saved to ${fmt.esc(r.file)}</b> — ${fmt.esc(r.note)}`;
688
+ } catch (err) { out.textContent = err.message; }
689
+ });
690
+ }
691
+
692
+ // ---------------------------------------------------------------- admin
693
+
694
+ export async function renderAdmin(s, state, h, force = false) {
695
+ if (!h.state.user) return;
696
+ const fmt = F();
697
+ if (force || !document.querySelector('#adUsers tbody').dataset.loaded) loadAdmin(h, force);
698
+
699
+ const acts = state.actions ?? [];
700
+ h.setText('adActions', acts.length
701
+ ? `<table class="t"><thead><tr><th>action</th><th>role</th><th>state</th></tr></thead><tbody>${
702
+ acts.map((a) => `<tr><td class="w"><b>${fmt.esc(a.label)}</b><div class="tiny faint">${fmt.esc(a.note ?? '')}</div></td>
703
+ <td>${fmt.esc(a.requiredRole)}</td>
704
+ <td class="${a.enabled ? (a.permittedForYou ? 'ok' : 'warn') : 'faint'}">${a.enabled ? (a.permittedForYou ? 'enabled for you' : 'enabled, needs ' + a.requiredRole) : 'disabled'}</td></tr>`).join('')
705
+ }</tbody></table>`
706
+ : '<span class="faint">No actions configured. Node writes stay off unless the operator both enables them and names them, because a monitoring tool that can restart your node is a different kind of accident.</span>');
707
+ }
708
+
709
+ async function loadAdmin(h, force) {
710
+ const fmt = F();
711
+ try {
712
+ if (h.state.user?.role !== 'admin') return;
713
+ const [users, actions, audit] = await Promise.all([h.api('/api/users'), h.api('/api/actions'), h.api('/api/audit?limit=60')]);
714
+ h.state.actions = actions.actions;
715
+ const tb = document.querySelector('#adUsers tbody');
716
+ tb.dataset.loaded = '1';
717
+ tb.innerHTML = users.users.map((u) => `<tr>
718
+ <td>${fmt.esc(u.username)}${u.disabled ? ' <span class="bad">(disabled)</span>' : ''}${u.kdfNeedsUpgrade
719
+ ? ` <span class="warn tiny" title="stored hash uses N=${u.kdf?.N ?? '?'}, r=${u.kdf?.r ?? '?'}, p=${u.kdf?.p ?? '?'}; this server is configured for more. Nothing is broken -- the cost rises for this account the next time it logs in with the correct password">kdf behind</span>`
720
+ : ''}</td>
721
+ <td>${fmt.esc(u.role)}</td>
722
+ <td class="faint">${new Date(u.createdAt).toLocaleDateString()}</td>
723
+ <td class="faint">${u.lastLoginAt ? fmt.ago(u.lastLoginAt) : 'never'}</td>
724
+ <td class="r">
725
+ <button class="btn" data-act="role" data-u="${fmt.esc(u.username)}">role</button>
726
+ <button class="btn" data-act="disable" data-u="${fmt.esc(u.username)}">${u.disabled ? 'enable' : 'disable'}</button>
727
+ </td></tr>`).join('');
728
+ tb.onclick = async (e) => {
729
+ const b = e.target.closest('button[data-act]');
730
+ if (!b) return;
731
+ const u = b.dataset.u;
732
+ try {
733
+ if (b.dataset.act === 'role') {
734
+ const role = prompt(`new role for ${u} (viewer|operator|admin)`);
735
+ if (!role) return;
736
+ await h.api(`/api/users/${encodeURIComponent(u)}/role`, { method: 'POST', body: { role } });
737
+ } else {
738
+ const cur = users.users.find((x) => x.username === u);
739
+ await h.api(`/api/users/${encodeURIComponent(u)}/disabled`, { method: 'POST', body: { disabled: !cur.disabled } });
740
+ }
741
+ h.toast('user updated', 'ok');
742
+ loadAdmin(h, true);
743
+ } catch (err) { h.toast(err.message, 'bad'); }
744
+ };
745
+
746
+ h.setText('adActions', null);
747
+ renderAdminActions(h, actions);
748
+
749
+ const atb = document.querySelector('#adAudit tbody');
750
+ atb.innerHTML = (audit.entries ?? []).map((x) => `<tr>
751
+ <td class="faint">${fmt.clock(x.at)}</td>
752
+ <td class="${/denied|rejected|fail/.test(x.type ?? '') ? 'bad' : ''}">${fmt.esc(x.type)}</td>
753
+ <td>${fmt.esc(x.username ?? '–')}</td>
754
+ <td class="w tiny faint">${fmt.esc([x.method, x.action, x.reason, x.error, x.target].filter(Boolean).join(' · ') || '')}</td></tr>`).join('')
755
+ || '<tr><td colspan="4" class="faint">nothing recorded yet</td></tr>';
756
+
757
+ // The size of the trail is part of the trail's story: it rotates by size now, and
758
+ // a rotation that failed is written here rather than only to stdout.
759
+ const size = document.getElementById('adAuditSize');
760
+ if (size) {
761
+ const g = audit.log ?? {};
762
+ const mb = (n) => `${((n ?? 0) / 1048576).toFixed(2)} MB`;
763
+ size.innerHTML = g.rotationError
764
+ ? `<span class="bad">rotation failed: ${fmt.esc(g.rotationError)}</span> — the file is growing past its ${mb(g.maxBytes)} budget until that succeeds`
765
+ : `${mb(g.currentBytes)} of ${mb(g.maxBytes)} used · ${fmt.num(g.rotations ?? 0)} rotation(s) · keeping ${g.keep ?? '–'} previous file(s) (${(g.files ?? []).length} on disk)`;
766
+ }
767
+
768
+ document.getElementById('btnGen').onclick = async () => {
769
+ const username = document.getElementById('newUser').value.trim();
770
+ const role = document.getElementById('newRole').value;
771
+ if (!username) return h.toast('enter a username', 'bad');
772
+ try {
773
+ const r = await h.api('/api/users/generate', { method: 'POST', body: { username, role } });
774
+ // Shown once, in the DOM, and deliberately never cached anywhere.
775
+ document.getElementById('adGen').innerHTML = `<span class="ok">created ${fmt.esc(r.user.username)}</span> · password
776
+ <span class="mono accent select-all">${fmt.esc(r.password)}</span> — ${fmt.esc(r.warning)}`;
777
+ h.toast('user created', 'ok');
778
+ loadAdmin(h, true);
779
+ } catch (err) { h.toast(err.message, 'bad'); }
780
+ };
781
+ document.getElementById('btnPw').onclick = async () => {
782
+ try {
783
+ const r = await h.api('/api/password', {
784
+ method: 'POST',
785
+ body: { current: document.getElementById('pwCurrent').value, password: document.getElementById('pwNew').value },
786
+ });
787
+ h.toast(r.note ?? 'password changed', 'ok');
788
+ setTimeout(() => { window.location.href = '/login'; }, 900);
789
+ } catch (err) { h.toast(err.message, 'bad'); }
790
+ };
791
+ } catch (err) {
792
+ if (err.status !== 403) h.toast(err.message, 'bad');
793
+ }
794
+ }
795
+
796
+ function renderAdminActions(h, actions) {
797
+ const fmt = F();
798
+ const el = document.getElementById('adActions');
799
+ if (!el) return;
800
+ el.outerHTML = `<div id="adActions">${actions.actions.length
801
+ ? `<table class="t"><thead><tr><th>action</th><th>role</th><th>state</th></tr></thead><tbody>${
802
+ actions.actions.map((a) => `<tr><td class="w"><b>${fmt.esc(a.label)}</b><div class="tiny faint">${fmt.esc(a.note ?? '')}</div></td>
803
+ <td>${fmt.esc(a.requiredRole)}</td>
804
+ <td class="${a.enabled ? (a.permittedForYou ? 'ok' : 'warn') : 'faint'}">${a.enabled ? (a.permittedForYou ? 'enabled for you' : `enabled, needs ${a.requiredRole}`) : 'disabled'}</td></tr>`).join('')
805
+ }</tbody></table><div class="note tiny faint mt-6">Enabled actions: ${actions.allowed.length ? actions.allowed.map(fmt.esc).join(', ') : 'none'} · feature flag ${actions.enabled ? 'on' : 'off'}</div>`
806
+ : '<span class="faint">No actions configured.</span>'}</div>`;
807
+ }
808
+
809
+ // ---------------------------------------------------------------- shared
810
+
811
+ /**
812
+ * The house chart call. Delegates to paint(), which will NOT clear a canvas that
813
+ * already shows data: a gap in sampling costs an amber "no fresh data" pill, not
814
+ * the chart. This is why every panel goes through here rather than calling
815
+ * lineChart/empty directly.
816
+ */
817
+ function drawFromSeries(h, id, points, color, fmtY, extra = {}) {
818
+ paint(h.canvas(id), {
819
+ when: Array.isArray(points) && points.length > 1,
820
+ draw: (c) => lineChart(c, [{ label: '', color, points, area: true }], { fmtY, ...extra }),
821
+ placeholder: extra.placeholder ?? 'waiting for samples',
822
+ });
823
+ }
824
+
825
+ function kv(pairs) {
826
+ return pairs.map(([k, v]) => `<dt>${String(k)}</dt><dd>${String(v)}</dd>`).join('');
827
+ }
828
+
829
+ function median(a) {
830
+ const s = a.slice().sort((x, y) => x - y);
831
+ return s[Math.floor(s.length / 2)];
832
+ }
833
+
834
+ export function init(fmtModule) { panelsFmt = fmtModule; }
835
+
836
+ // ------------------------------------------------------------ block drill-down
837
+
838
+ let drillBound = false;
839
+
840
+ /**
841
+ * Inspect one block, then any transaction inside it.
842
+ *
843
+ * This exists because "which transaction?" previously had exactly one answer in
844
+ * this app: type an RPC into the console. That is a shell, not a view. The shape is
845
+ * deliberately constrained by what the node can afford (see /api/block):
846
+ *
847
+ * * the header and stats come from getblock verbosity=1 + getblockstats, never
848
+ * verbosity=2 — measured 2026-09-08, that costs this node 11 MB of hex per
849
+ * block and still omits Core's fee fields;
850
+ * * txids are listed as buttons rather than dumped, because a block has up to
851
+ * ~6,000 of them and a wall of 64-character strings is not a readable list;
852
+ * * the transaction pane shows what the node decoded, plus `notReported` naming
853
+ * the fee — which is not computable here without N extra turns on a
854
+ * single-threaded RPC server, and is not in this node's reply either.
855
+ */
856
+ export function initChainDrill(h) {
857
+ const runBtn = document.getElementById('bdRun');
858
+ const input = document.getElementById('bdQuery');
859
+ if (!runBtn || !input) return;
860
+ if (drillBound) return;
861
+ drillBound = true;
862
+
863
+ const hdr = () => document.getElementById('bdHeader');
864
+ const list = () => document.getElementById('bdTxids');
865
+ const txBox = () => document.getElementById('bdTx');
866
+
867
+ const showTx = async (txid, blockHash) => {
868
+ const box = txBox();
869
+ if (!box) return;
870
+ const fmt = F();
871
+ box.innerHTML = '<div class="note tiny faint">decoding…</div>';
872
+ const qs = new URLSearchParams({ txid });
873
+ if (blockHash) qs.set('block', blockHash);
874
+ if (h.state.node) qs.set('node', h.state.node);
875
+ let d;
876
+ try {
877
+ d = await h.api(`/api/tx?${qs}`);
878
+ } catch (err) {
879
+ box.innerHTML = `<div class="caveat bad">${fmt.esc(err.message)}</div>`;
880
+ return;
881
+ }
882
+ if (!d.ok) {
883
+ box.innerHTML = `<div class="caveat bad"><b>${fmt.esc(d.error?.message ?? 'the node refused')}</b>${d.hint ? `<div class="tiny">${fmt.esc(d.hint)}</div>` : ''}</div>`;
884
+ return;
885
+ }
886
+ const rows = [
887
+ ['txid', `<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`],
888
+ ['in', d.inMempool ? '<span class="warn">mempool (unconfirmed)</span>' : `block ${fmt.esc(d.blockHash ?? '?')}`],
889
+ ['confirmations', d.confirmations == null ? '– (in the pool)' : fmt.num(d.confirmations)],
890
+ ['size / vsize / weight', [d.size, d.vsize, d.weight].map((v) => (v == null ? '–' : fmt.num(v))).join(' / ')],
891
+ ['locktime', d.locktime ?? 0],
892
+ ['inputs', `${fmt.num(d.inputsTotal)}${d.inputsTotal > (d.inputs?.length ?? 0) ? ` (first ${d.inputs?.length} shown)` : ''}`],
893
+ ['outputs', `${fmt.num(d.outputsTotal)}${d.outputsTotal > (d.outputs?.length ?? 0) ? ` (first ${d.outputs?.length} shown)` : ''}`],
894
+ ['total out', d.totalOutSat != null ? `${fmt.num(d.totalOutSat)} sat` : '–'],
895
+ ];
896
+ const io = (rows2, label) => `<div class="note tiny mt-6"><b>${label}</b></div><table class="t"><tbody>${rows2}</tbody></table>`;
897
+ const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${v.vout ?? '–'}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
898
+ const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${v.n ?? '–'}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
899
+ box.innerHTML = `<div class=\"drill\">
900
+ <dl class=\"kv\">${kv(rows)}</dl>
901
+ ${inRows ? io(inRows, `inputs (${d.inputsTotal})`) : ''}
902
+ ${outRows ? io(outRows, `outputs (${d.outputsTotal})`) : ''}
903
+ ${(d.notReported ?? []).length ? `<div class=\"note tiny faint mt-6\">not reported: ${d.notReported.map(fmt.esc).join(' · ')}</div>` : ''}
904
+ ${(d.notes ?? []).map((n) => `<div class=\"note tiny faint\">${fmt.esc(n)}</div>`).join('')}
905
+ </div>`;
906
+ };
907
+
908
+ const run = async () => {
909
+ const fmt = F();
910
+ const q = (input.value || '').trim();
911
+ const node = h.state.node;
912
+ if (hdr()) hdr().innerHTML = '<dt>loading</dt><dd>…</dd>';
913
+ if (list()) list().innerHTML = '';
914
+ if (txBox()) txBox().innerHTML = '';
915
+ const qs = new URLSearchParams();
916
+ if (/^\d{1,12}$/.test(q)) qs.set('height', q);
917
+ else if (/^[0-9a-fA-F]{64}$/.test(q)) qs.set('hash', q);
918
+ else if (q) { h.toast('enter a block height or a 64-character block hash', 'bad'); return; }
919
+ if (node) qs.set('node', node);
920
+ let d;
921
+ try {
922
+ d = await h.api(`/api/block?${qs}`);
923
+ } catch (err) {
924
+ if (hdr()) hdr().innerHTML = `<dt>failed</dt><dd class=\"bad\">${fmt.esc(err.message)}</dd>`;
925
+ return;
926
+ }
927
+ if (!d.ok) {
928
+ if (hdr()) hdr().innerHTML = `<dt>node refused</dt><dd class=\"bad\">${fmt.esc(d.error?.message ?? 'unknown error')}</dd>`;
929
+ if (txBox()) txBox().innerHTML = d.hint ? `<div class=\"note tiny faint\">${fmt.esc(d.hint)}</div>` : '';
930
+ return;
931
+ }
932
+ const b = d.header ?? {};
933
+ const st = d.stats ?? {};
934
+ if (hdr()) hdr().innerHTML = kv([
935
+ ['height', b.height == null ? '–' : `<a class="xlink" href="#explorer/block/${b.height}">${fmt.num(b.height)}</a> <span class="tiny faint">open in the explorer</span>`],
936
+ ['hash', `<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`],
937
+ ['confirmations', b.confirmations == null ? '–' : `${fmt.num(b.confirmations)}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`],
938
+ ['time', b.time ? `${new Date(b.time * 1000).toISOString().replace('T', ' ').slice(0, 19)}Z` : '–'],
939
+ ['age', b.time ? fmt.ago(b.time * 1000) : '–'],
940
+ ['size / weight', `${b.size == null ? '–' : fmt.bytes(b.size, 0)} / ${b.weight == null ? '–' : fmt.num(b.weight)} WU`],
941
+ ['transactions', fmt.num(b.nTx ?? b.txCount ?? 0)],
942
+ ['fees', st.totalfee == null ? (d.statsError ? '<span class=\"warn\">getblockstats failed</span>' : '–') : `${fmt.num(st.totalfee)} sat`],
943
+ ['median fee', st.medianfee == null ? '–' : `${fmt.num(st.medianfee)} sat`],
944
+ ['fee rate p10/50/90', (st.feerate_percentiles ?? []).length ? st.feerate_percentiles.map((v) => Number(v).toFixed(1)).join(' / ') : '–'],
945
+ ['subsidy', st.subsidy == null ? '–' : `${fmt.num(st.subsidy)} sat`],
946
+ ['utxo delta', st.utxo_increase == null ? '–' : fmt.num(st.utxo_increase)],
947
+ ['prev / next', `<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`],
948
+ ['merkle root', `<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`],
949
+ ]);
950
+ const ids = d.txids ?? [];
951
+ if (list()) {
952
+ list().innerHTML = ids.map((t, i) => `<button data-txid=\"${fmt.esc(t)}\"${b.hash ? ` data-block=\"${fmt.esc(b.hash)}\"` : ''} title=\"${i === 0 ? 'coinbase' : `transaction ${i} of ${d.txidsTotal}`}\">${fmt.esc(t.slice(0, 10))}…</button>`).join('')
953
+ + (d.truncated ? `<span class=\"tiny faint\">+${(d.txidsTotal ?? ids.length) - ids.length} more (list is capped; ask the RPC console)</span>` : '');
954
+ list().onclick = (e) => {
955
+ const btn = e.target.closest('button[data-txid]');
956
+ if (btn) showTx(btn.dataset.txid, btn.dataset.block);
957
+ };
958
+ }
959
+ };
960
+
961
+ runBtn.addEventListener('click', run);
962
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
963
+ // Open on the tip when the page is first visited: an empty box invites nobody to
964
+ // type, and one click on a real block is what shows the shape of the view.
965
+ h.drillRun = run;
966
+ }