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.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
package/public/js/app.js
ADDED
|
@@ -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
|
+
});
|