blockyard 0.0.9 → 0.1.1
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 +359 -1
- package/README.md +48 -27
- package/SECURITY.md +2 -2
- package/bin/blockyard.js +2 -1
- package/docs/API.md +17 -15
- package/docs/ARCHITECTURE.md +128 -10
- package/docs/CONFIGURATION.md +39 -30
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +18 -8
- package/docs/INSTALL.md +97 -37
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +58 -22
- package/docs/TROUBLESHOOTING.md +44 -5
- package/docs/USER-GUIDE.md +505 -35
- package/package.json +4 -2
- package/public/404.html +1 -1
- package/public/css/app.css +393 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +353 -109
- package/public/js/agents.js +228 -51
- package/public/js/app.js +131 -16
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +230 -38
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1481 -73
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +8 -2
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +314 -36
- package/public/js/panels.js +41 -28
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +368 -100
- package/public/js/soundcard.js +459 -0
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/check.js +46 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +142 -22
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +7 -6
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +451 -58
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +54 -12
- package/server/collect/network.js +305 -0
- package/server/config.js +53 -22
- package/server/http/api.js +119 -18
- package/server/http/games.js +77 -0
- package/server/http/server.js +30 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +66 -11
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +41 -8
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
package/public/js/mining.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// fees.chunk/chunkweight -- so the block being built is assembled from the mempool the
|
|
17
17
|
// monitor already reads, and costs the node no call at all (server/collect/gbt.js).
|
|
18
18
|
|
|
19
|
-
import { paint, COL } from './charts.js';
|
|
19
|
+
import { paint, COL, lineChart } from './charts.js';
|
|
20
|
+
import { INK } from './theme.js'; // the pie's labels and slice seams follow the theme
|
|
20
21
|
import { blockTreemap, mempoolTreemap, rateColor as rateBucketColor } from './goggles.js';
|
|
21
22
|
import { loadSettings, spaceOptions } from './settings.js';
|
|
22
23
|
// 2026-09-10: the block and the pool now draw as lit solids on a square-packed
|
|
@@ -958,10 +959,10 @@ export function nextHud(nb, { meter = '', fresh = null, mempool = null } = {}, f
|
|
|
958
959
|
const mins = fresh && Number.isFinite(fresh.seconds) ? Math.floor(fresh.seconds / 60) : null;
|
|
959
960
|
const ageSec = nb.at ? Math.max(0, Math.round((Date.now() - nb.at) / 1000)) : null;
|
|
960
961
|
const ec = nb.economy ?? null;
|
|
961
|
-
const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
|
|
962
|
-
return `${head(`<span class="hudk">#${nb.height ?? '?'}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
|
|
962
|
+
const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
|
|
963
|
+
return `${head(`<span class="hudk">#${fmt.esc(nb.height ?? '?')}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
|
|
963
964
|
${meter}
|
|
964
|
-
<div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${ec.marginal.rate}/vB</span>` : '<span class="chip">not full</span>'}</div>
|
|
965
|
+
<div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${fmt.esc(ec.marginal.rate)}/vB</span>` : '<span class="chip">not full</span>'}</div>
|
|
965
966
|
<dl class="hudkv">
|
|
966
967
|
${kv('transactions', nb.txCount != null ? fmt.num(nb.txCount) : '–')}
|
|
967
968
|
${kv('fees', btc(nb.totalFeesSat))}
|
|
@@ -982,7 +983,7 @@ export function tipHud(tipHeight, row, prev, { avgGapSec = null, now = Date.now(
|
|
|
982
983
|
const w = who(row);
|
|
983
984
|
const f = blockFacts(row);
|
|
984
985
|
const gap = prev?.time && row.time ? row.time - prev.time : null;
|
|
985
|
-
const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
|
|
986
|
+
const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
|
|
986
987
|
return `${head}
|
|
987
988
|
<div class="hudpool"><span class="bdot" data-pool="${w.idx}"></span><b data-pool-fg="${w.idx}">${fmt.esc(trunc(w.name, 22))}</b><span class="faint">${agoText(row.at ?? row.seenAt, now) ? `mined ${agoText(row.at ?? row.seenAt, now)}` : ''}</span></div>
|
|
988
989
|
<div class="hudbar" title="${f.capPct != null ? `${f.capPct.toFixed(1)}% of the 4,000,000 WU cap` : 'weight unknown'}"><span data-w="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}"></span></div>
|
|
@@ -1051,44 +1052,321 @@ export function renderMining(s, state, h) {
|
|
|
1051
1052
|
h.nextBlock?.();
|
|
1052
1053
|
blockFlow(document.getElementById('mnFlow'), flowArgs(s, state), h.fmt);
|
|
1053
1054
|
packagesView(document.getElementById('mnPackages'), a?.nextBlock?.packages, h.fmt);
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const mp = state?.mempoolDist ?? s?.mempool ?? {};
|
|
1057
|
-
drawPoolViewer(h.canvas('gnMempoolTreemap'), s, state);
|
|
1058
|
-
const mnote = document.getElementById('gnMempoolNote');
|
|
1059
|
-
if (mnote) {
|
|
1060
|
-
const cells = mp.cells ?? [];
|
|
1061
|
-
const tail = cells.find((c) => c.aggregate) ?? null;
|
|
1062
|
-
const total = mp.totalVsize ?? 0;
|
|
1063
|
-
const drawn = cells.length - (tail ? 1 : 0);
|
|
1064
|
-
if (cells.length) {
|
|
1065
|
-
const parts = [`${fmtNum(drawn, h)} drawn`];
|
|
1066
|
-
if (tail) parts.push(`+ 1 aggregate of ${fmtNum(tail.aggregate, h)} smaller ones`);
|
|
1067
|
-
if (mp.cellCount != null) parts.push(`= ${fmtNum(mp.cellCount, h)} transactions as at the poll`);
|
|
1068
|
-
if (mp.fetchedAt != null) parts.push(`${Math.round((Date.now() - mp.fetchedAt) / 1000)}s ago`);
|
|
1069
|
-
const fits = total <= 1_000_000
|
|
1070
|
-
? '<b>everything waiting fits in a single block</b>'
|
|
1071
|
-
: 'the line sits where one block runs out and everything right of it waits';
|
|
1072
|
-
mnote.innerHTML = `${parts.join(', ')}; ${fmtNum(total, h)} vB waiting. One block is 1,000,000 vB, so ${fits}.`
|
|
1073
|
-
+ ' Ordered by feerate, because that is the order a miner takes them; colour is what each transaction pays.'
|
|
1074
|
-
+ (mp.stale ? ' <span class="warn">Last poll failed; this picture may be old.</span>' : '');
|
|
1075
|
-
} else if (mp.stale) {
|
|
1076
|
-
mnote.innerHTML = '<span class="warn">Detail stale</span> — the last poll of the full pool failed. Anything drawn above is the previous reading.';
|
|
1077
|
-
} else if (mp.count != null && mp.count > 0) {
|
|
1078
|
-
mnote.innerHTML = `<span class="warn">Cells not loaded yet</span> — ${fmtNum(mp.count, h)} transactions are waiting; the full pool is polled on the 20 s tier and this page has not received it.`;
|
|
1079
|
-
} else if (mp.count === 0) {
|
|
1080
|
-
mnote.textContent = 'The mempool is empty: nothing is waiting, so there is nothing to draw.';
|
|
1081
|
-
} else {
|
|
1082
|
-
mnote.textContent = 'Nothing to draw yet — the full pool is polled on the 20 s tier, not every second.';
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1055
|
+
// (the Mempool space viewer left this page on 2026-09-15 -- it is on Overview, Block space and
|
|
1056
|
+
// Mempool -- and with it the full-pool poll this page used to ask for)
|
|
1085
1057
|
feeLandscape(h.canvas('mnFeeLandscape'), a?.nextBlock ?? null, h.fmt);
|
|
1086
1058
|
poolTable(document.getElementById('mnPools'), a, h.fmt);
|
|
1059
|
+
networkPanels(s?.network ?? null, h);
|
|
1060
|
+
const recent = document.getElementById('mnRecent');
|
|
1061
|
+
if (recent) { const html = recentBlocksHtml(a, h.fmt, { limit: 8 }); if (recent.__html !== html) { recent.innerHTML = html; recent.__html = html; } }
|
|
1062
|
+
renderExpand(s, h);
|
|
1087
1063
|
const el = document.getElementById('mnCoverage');
|
|
1088
1064
|
if (el) el.innerHTML = coveragePanel(a, h);
|
|
1089
1065
|
applyMiningStyles(document);
|
|
1090
1066
|
}
|
|
1091
1067
|
|
|
1068
|
+
// THE NETWORK ROW (2026-09-15): reward stats, the difficulty period, a week of pools as a donut,
|
|
1069
|
+
// a year of hashrate, the adjustments table. Everything is from the snapshot's `network`, which
|
|
1070
|
+
// collect/network.js fills from the node alone; a figure it has not gathered yet is a dash and
|
|
1071
|
+
// the note says how far the week of coinbases has got.
|
|
1072
|
+
const DONUT_COLORS = ['#e8306a', '#8b3fd9', '#5b4fd6', '#3a7be0', '#2f9ee6', '#26b8c8', '#22b7a0', '#3cba6c', '#8bc34a', '#c9d02a', '#f0c419', '#f39c1f', '#ee7b2f', '#c7c9d1', '#8d93a1'];
|
|
1073
|
+
function stat(k, v, s = '', cls = '') {
|
|
1074
|
+
return `<div class="ns"><span class="k">${k}</span><span class="v ${cls}">${v}</span>${s ? `<span class="s">${s}</span>` : ''}</div>`;
|
|
1075
|
+
}
|
|
1076
|
+
function signed(pct, dp = 2) {
|
|
1077
|
+
if (pct == null || !Number.isFinite(pct)) return { text: '–', cls: '' };
|
|
1078
|
+
return { text: `${pct >= 0 ? '▴ +' : '▾ '}${pct.toFixed(dp)}%`, cls: pct >= 0 ? 'up' : 'down' };
|
|
1079
|
+
}
|
|
1080
|
+
function shortDate(ms) {
|
|
1081
|
+
if (!Number.isFinite(ms)) return '–';
|
|
1082
|
+
return new Date(ms).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
1083
|
+
}
|
|
1084
|
+
function inWords(sec) {
|
|
1085
|
+
if (!Number.isFinite(sec)) return '–';
|
|
1086
|
+
const d = sec / 86400;
|
|
1087
|
+
if (d < 1) return `~${Math.max(1, Math.round(sec / 3600))} hours`;
|
|
1088
|
+
if (d < 60) return `~${Math.round(d)} days`;
|
|
1089
|
+
const y = Math.floor(d / 365.25), rest = Math.round(d - y * 365.25);
|
|
1090
|
+
return y ? `~${y} year${y > 1 ? 's' : ''}, ${rest} days` : `~${Math.round(d)} days`;
|
|
1091
|
+
}
|
|
1092
|
+
function agoWords(ms, now = Date.now()) {
|
|
1093
|
+
const d = (now - ms) / 86400000;
|
|
1094
|
+
if (d < 1) return `${Math.max(1, Math.round(d * 24))} hours ago`;
|
|
1095
|
+
if (d < 28) return `${Math.round(d)} days ago`;
|
|
1096
|
+
return `${Math.round(d / 7)} weeks ago`;
|
|
1097
|
+
}
|
|
1098
|
+
function tera(d) { return d == null || !Number.isFinite(d) ? '–' : `${(d / 1e12).toFixed(2)}T`; }
|
|
1099
|
+
|
|
1100
|
+
// the spot price for the dollar lines, asked for at most once a minute while the page is
|
|
1101
|
+
// painted, and only ever the cached one (/api/price never starts the exchange polling)
|
|
1102
|
+
const PRICE = { usd: null, at: 0, askedAt: 0, busy: false, off: false };
|
|
1103
|
+
function askPrice(h) {
|
|
1104
|
+
const now = Date.now();
|
|
1105
|
+
if (PRICE.busy || now - PRICE.askedAt < 60_000 || typeof h.api !== 'function') return;
|
|
1106
|
+
PRICE.busy = true; PRICE.askedAt = now;
|
|
1107
|
+
h.api('/api/price').then((d) => { PRICE.usd = d?.usd ?? null; PRICE.at = d?.at ?? now; PRICE.off = d?.polling === false || d?.enabled === false; }).catch(() => {}).finally(() => { PRICE.busy = false; h.render?.(); });
|
|
1108
|
+
}
|
|
1109
|
+
const usd = (v) => (v == null || !Number.isFinite(v) ? '' : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
|
1110
|
+
|
|
1111
|
+
// RECENT BLOCKS, as mempool.space lists them (height, pool, reward, fees): the attributed window
|
|
1112
|
+
// the ledger already carries, the reward being the height's subsidy plus the block's fees
|
|
1113
|
+
const SATS = 100_000_000;
|
|
1114
|
+
export function subsidyAt(height) {
|
|
1115
|
+
if (!Number.isFinite(height) || height < 0) return null;
|
|
1116
|
+
const halvings = Math.floor(height / 210_000);
|
|
1117
|
+
if (halvings >= 64) return 0;
|
|
1118
|
+
let sat = 50 * SATS;
|
|
1119
|
+
for (let i = 0; i < halvings; i++) sat = Math.floor(sat / 2);
|
|
1120
|
+
return sat;
|
|
1121
|
+
}
|
|
1122
|
+
export function recentBlocksHtml(a, F, { limit = 8, now = Date.now() } = {}) {
|
|
1123
|
+
const rows = (a?.recent ?? []).slice(0, limit);
|
|
1124
|
+
if (!rows.length) return '<div class="note tiny faint">no blocks attributed yet</div>';
|
|
1125
|
+
const esc = F.esc;
|
|
1126
|
+
return `<table class="t"><thead><tr><th>Height</th><th>Pool</th><th>Mined</th><th class="r">Reward</th><th class="r">Fees</th></tr></thead><tbody>${rows.map((r) => {
|
|
1127
|
+
const name = r.poolLabel ?? r.poolName ?? r.poolKey ?? '–';
|
|
1128
|
+
const sub = subsidyAt(r.height), reward = sub != null && r.totalfee != null ? sub + r.totalfee : null;
|
|
1129
|
+
return `<tr><td><a href="#explorer/block/${r.height}">#${F.num(r.height)}</a></td><td title="${esc(r.tagText ?? '')}">${esc(String(name).length > 22 ? `${String(name).slice(0, 21)}…` : name)}</td><td>${r.at ? F.ago(r.at, now) : '–'}</td><td class="r">${reward != null ? `${(reward / SATS).toFixed(3)} BTC` : '–'}</td><td class="r">${r.totalfee != null ? `${(r.totalfee / SATS).toFixed(4)} BTC` : '–'}</td></tr>`;
|
|
1130
|
+
}).join('')}</tbody></table>`;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// "VIEW MORE »" (operator, 2026-09-15, of mempool.space: "the 'View more >>' that open up a panel
|
|
1134
|
+
// to see a full screen view of just that panel"): one of four cards, full screen, drawn from the
|
|
1135
|
+
// same snapshot every frame while open. Esc, the scrim or the button close it.
|
|
1136
|
+
const EXPAND = { kind: null, s: null, h: null };
|
|
1137
|
+
const EXPAND_TITLE = { pools: ['Pools', 'the last week’s blocks, by coinbase'], hashrate: ['Hashrate & difficulty', 'a year, one sample a day'], blocks: ['Recent blocks', 'the attributed window, newest first'], adjustments: ['Adjustments', 'each period’s first block against the one before'] };
|
|
1138
|
+
export function openExpand(kind) {
|
|
1139
|
+
const wrap = document.getElementById('expandWrap');
|
|
1140
|
+
if (!wrap || !EXPAND_TITLE[kind]) return;
|
|
1141
|
+
EXPAND.kind = kind;
|
|
1142
|
+
const [t, src] = EXPAND_TITLE[kind];
|
|
1143
|
+
const title = document.getElementById('expandTitle'), s = document.getElementById('expandSrc'), body = document.getElementById('expandBody');
|
|
1144
|
+
if (title) title.textContent = t;
|
|
1145
|
+
if (s) s.textContent = src;
|
|
1146
|
+
if (body) {
|
|
1147
|
+
body.innerHTML = kind === 'pools' ? '<div class="netstats" id="xpStats"></div><canvas class="chart donut" id="xpDonut"></canvas><div id="xpTable"></div>'
|
|
1148
|
+
: kind === 'hashrate' ? '<div class="netstats" id="xpStats"></div><canvas class="chart big" id="xpChart"></canvas>'
|
|
1149
|
+
: '<div id="xpTable"></div>';
|
|
1150
|
+
}
|
|
1151
|
+
wrap.classList.remove('hidden');
|
|
1152
|
+
if (EXPAND.s && EXPAND.h) renderExpand(EXPAND.s, EXPAND.h);
|
|
1153
|
+
}
|
|
1154
|
+
export function closeExpand() {
|
|
1155
|
+
EXPAND.kind = null;
|
|
1156
|
+
document.getElementById('expandWrap')?.classList.add('hidden');
|
|
1157
|
+
}
|
|
1158
|
+
let expandBound = false;
|
|
1159
|
+
function bindExpand() {
|
|
1160
|
+
if (expandBound || typeof document === 'undefined' || typeof document.addEventListener !== 'function' || !document.getElementById('expandWrap')) return;
|
|
1161
|
+
expandBound = true;
|
|
1162
|
+
document.addEventListener('click', (e) => {
|
|
1163
|
+
const a = e.target.closest?.('[data-expand]');
|
|
1164
|
+
if (a) { e.preventDefault(); openExpand(a.dataset.expand); }
|
|
1165
|
+
});
|
|
1166
|
+
document.getElementById('expandClose')?.addEventListener('click', closeExpand);
|
|
1167
|
+
document.getElementById('expandScrim')?.addEventListener('click', closeExpand);
|
|
1168
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && EXPAND.kind) closeExpand(); });
|
|
1169
|
+
}
|
|
1170
|
+
export function renderExpand(s, h) {
|
|
1171
|
+
EXPAND.s = s; EXPAND.h = h;
|
|
1172
|
+
if (!EXPAND.kind) return;
|
|
1173
|
+
const n = s?.network ?? null, F = h.fmt;
|
|
1174
|
+
const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
|
|
1175
|
+
if (EXPAND.kind === 'pools') {
|
|
1176
|
+
const p = n?.pools ?? {};
|
|
1177
|
+
put('xpStats', stat('Pools luck', p.luckPct == null ? '–' : `${p.luckPct.toFixed(2)}%`, p.blocks ? `${p.blocks} found · ${Math.round(p.expected)} expected` : '') + stat('Blocks (1w)', p.blocks ? F.num(p.blocks) : '–') + stat('Pools count', p.count ? String(p.count) : '–'));
|
|
1178
|
+
poolDonut(h.canvas('xpDonut'), p.pools ?? [], F);
|
|
1179
|
+
put('xpTable', (p.pools ?? []).length ? `<table class="t"><thead><tr><th>Pool</th><th class="r">Blocks</th><th class="r">Share</th><th>Labelled</th></tr></thead><tbody>${p.pools.map((x) => `<tr><td>${F.esc(x.name)}</td><td class="r">${F.num(x.blocks)}</td><td class="r">${x.sharePct.toFixed(2)}%</td><td>${x.labelled ? 'curated map' : '<span class="faint">coinbase text</span>'}</td></tr>`).join('')}</tbody></table>` : '');
|
|
1180
|
+
} else if (EXPAND.kind === 'hashrate') {
|
|
1181
|
+
const hr = n?.hashrate ?? {}, a = n?.adjustment;
|
|
1182
|
+
put('xpStats', stat('Hashrate (1w)', hr.networkHashPs != null ? F.eh(hr.networkHashPs / 1e18) : '–', 'getnetworkhashps, 1008 blocks') + stat('Difficulty', tera(n?.difficulty), a ? `period from #${F.num(a.epochStart)}` : '') + stat('Samples', hr.series?.length ? `${hr.series.length}<small>days</small>` : '–', 'one block header a day'));
|
|
1183
|
+
hashrateChart(h.canvas('xpChart'), hr.series ?? [], F);
|
|
1184
|
+
} else if (EXPAND.kind === 'blocks') {
|
|
1185
|
+
put('xpTable', recentBlocksHtml(s?.attribution, F, { limit: 200 }));
|
|
1186
|
+
} else if (EXPAND.kind === 'adjustments') {
|
|
1187
|
+
put('xpTable', adjustmentsHtml(n?.adjustments ?? [], F));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function adjustmentsHtml(rows, F) {
|
|
1191
|
+
if (!rows.length) return '<div class="note tiny faint">reading the periods\' first blocks…</div>';
|
|
1192
|
+
return `<table class="t"><thead><tr><th>Height</th><th>Adjusted</th><th class="r">Difficulty</th><th class="r">Change</th></tr></thead><tbody>${rows.map((x) => { const c = signed(x.changePct); return `<tr><td>#${F.num(x.height)}</td><td>${agoWords(x.time * 1000)}</td><td class="r">${tera(x.difficulty)}</td><td class="r ${c.cls}">${c.text.replace(/^[▴▾] /, '')}</td></tr>`; }).join('')}</tbody></table>`;
|
|
1193
|
+
}
|
|
1194
|
+
function hashrateChart(canvas, series, F) {
|
|
1195
|
+
if (!canvas) return;
|
|
1196
|
+
if (series.length > 2) {
|
|
1197
|
+
const pts = series.map((x) => ({ t: x.t, v: x.hashrate / 1e18 }));
|
|
1198
|
+
const dLo = Math.min(...series.map((x) => x.difficulty / 1e12)), dHi = Math.max(...series.map((x) => x.difficulty / 1e12));
|
|
1199
|
+
const smooth = pts.map((_, i) => { const w = pts.slice(Math.max(0, i - 6), i + 1); return { t: pts[i].t, v: w.reduce((s, q) => s + q.v, 0) / w.length }; });
|
|
1200
|
+
lineChart(canvas, [
|
|
1201
|
+
{ label: 'hashrate, daily', points: pts, color: 'rgba(160,200,80,0.45)', width: 1 },
|
|
1202
|
+
{ label: 'hashrate, 7-day mean', points: smooth, color: '#f0c419', width: 2 },
|
|
1203
|
+
{ label: `difficulty ${dLo.toFixed(0)}T–${dHi.toFixed(0)}T`, points: series.map((x) => ({ t: x.t, v: x.difficulty / 1e12 })), color: '#e8306a', width: 2, axis: 'right' },
|
|
1204
|
+
], {
|
|
1205
|
+
fmtY: (v) => (v >= 1000 ? `${(v / 1000).toFixed(2)}Z` : `${v.toFixed(0)}E`), fmtRight: () => '', left: 40,
|
|
1206
|
+
fmtX: (t) => new Date(t).toLocaleDateString('en-US', { month: 'short' }),
|
|
1207
|
+
min: Math.min(...pts.map((q) => q.v)) * 0.85, max: Math.max(...pts.map((q) => q.v)) * 1.05, zeroBase: false,
|
|
1208
|
+
rightMin: dLo * 0.97, rightMax: dHi * 1.03,
|
|
1209
|
+
});
|
|
1210
|
+
} else paint(canvas, { when: null, draw: () => {}, placeholder: 'reading a year of block headers…' });
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export function networkPanels(n, h) {
|
|
1214
|
+
bindExpand();
|
|
1215
|
+
const F = h.fmt, esc = F.esc;
|
|
1216
|
+
askPrice(h);
|
|
1217
|
+
const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
|
|
1218
|
+
if (!n) {
|
|
1219
|
+
for (const id of ['mnRewards', 'mnAdjust', 'mnPoolsWeek', 'mnHashrate']) put(id, '<div class="note tiny faint">not in this snapshot yet — the node has not been asked</div>');
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
// reward stats
|
|
1223
|
+
const r = n.rewards ?? {};
|
|
1224
|
+
// the dollar lines (operator, 2026-09-15: "Add dollar figures"): under each figure while a
|
|
1225
|
+
// price is at hand; a faint note instead while market polling is off
|
|
1226
|
+
const px = PRICE.usd;
|
|
1227
|
+
const dollars = (sat) => (px != null && sat != null ? `<span class="usd">${usd(sat / 1e8 * px)}</span>` : '');
|
|
1228
|
+
put('mnRewards', r.blocks
|
|
1229
|
+
? stat('Miners reward', `${(r.minersRewardSat / 1e8).toFixed(2)}<small>BTC</small>`, `${dollars(r.minersRewardSat)}${r.blocks} blocks · #${r.from}–#${r.to}`)
|
|
1230
|
+
+ stat('Avg block fees', `${(r.avgBlockFeeSat / 1e8).toFixed(4)}<small>BTC/block</small>`, dollars(r.avgBlockFeeSat))
|
|
1231
|
+
+ stat('Avg tx fee', `${r.avgTxFeeSat == null ? '–' : F.num(r.avgTxFeeSat)}<small>sats/tx</small>`, `${dollars(r.avgTxFeeSat)}${F.num(r.txs)} transactions`)
|
|
1232
|
+
+ (px == null && PRICE.off ? '<div class="note tiny faint usdnote">dollar figures need market polling (Display settings → Markets & Price)</div>' : '')
|
|
1233
|
+
: '<div class="note tiny faint">reading the last 144 blocks…</div>');
|
|
1234
|
+
const src = document.getElementById('mnRewardsSrc'); if (src) src.textContent = `last ${r.blocks || 144} blocks · getblockstats`;
|
|
1235
|
+
// the difficulty period
|
|
1236
|
+
const a = n.adjustment, hv = n.halving;
|
|
1237
|
+
if (a) {
|
|
1238
|
+
const est = signed(a.estimatePct), prev = signed(a.previousPct);
|
|
1239
|
+
put('mnAdjust',
|
|
1240
|
+
stat('Remaining', `${F.num(a.remaining)}<small>blocks</small>`, `in ${inWords(a.etaSec)} · ${a.into} of 2016 mined`)
|
|
1241
|
+
+ stat('Estimate', `<span class="${est.cls}">${est.text}</span>`, `previous: <span class="${prev.cls}">${prev.text}</span>`)
|
|
1242
|
+
+ stat('Next halving', shortDate(hv?.at), hv ? `#${F.num(hv.nextHeight)} · in ${inWords(hv.etaSec)}` : ''));
|
|
1243
|
+
} else put('mnAdjust', '<div class="note tiny faint">reading the period\'s first block…</div>');
|
|
1244
|
+
// pools over the week
|
|
1245
|
+
const p = n.pools ?? {};
|
|
1246
|
+
const luck = p.luckPct == null ? '–' : `${p.luckPct.toFixed(2)}%`;
|
|
1247
|
+
put('mnPoolsWeek', stat('Pools luck', luck, p.blocks ? `${p.blocks} found · ${Math.round(p.expected)} expected` : '')
|
|
1248
|
+
+ stat('Blocks (1w)', p.blocks ? F.num(p.blocks) : '–')
|
|
1249
|
+
+ stat('Pools count', p.count ? String(p.count) : '–'));
|
|
1250
|
+
const note = document.getElementById('mnPoolsWeekNote');
|
|
1251
|
+
if (note) {
|
|
1252
|
+
const left = p.todo ?? 0;
|
|
1253
|
+
note.textContent = left > 0
|
|
1254
|
+
? `Filling in: ${F.num(p.filled ?? 0)} coinbases read so far, ${F.num(left)} to go (eight every few seconds, behind the live polls). Shares are of the blocks read so far.`
|
|
1255
|
+
: p.blocks ? `${F.num(p.blocks)} blocks in the last seven days, every coinbase read from this node; a name appears only where the curated pool map or your alias file says so.` : '';
|
|
1256
|
+
}
|
|
1257
|
+
poolDonut(h.canvas('mnPoolDonut'), p.pools ?? [], F);
|
|
1258
|
+
// hashrate and difficulty
|
|
1259
|
+
const hr = n.hashrate ?? {};
|
|
1260
|
+
put('mnHashrate', stat('Hashrate (1w)', hr.networkHashPs != null ? F.eh(hr.networkHashPs / 1e18) : '–', 'getnetworkhashps, 1008 blocks')
|
|
1261
|
+
+ stat('Difficulty', tera(n.difficulty), a ? `period from #${F.num(a.epochStart)}` : '')
|
|
1262
|
+
+ stat('Samples', hr.series?.length ? `${hr.series.length}<small>days</small>` : '–', 'one block header a day'));
|
|
1263
|
+
hashrateChart(h.canvas('mnHashChart'), hr.series ?? [], F);
|
|
1264
|
+
// the adjustments table
|
|
1265
|
+
put('mnAdjustments', adjustmentsHtml((n.adjustments ?? []).slice(0, 6), F)); // six on the card; every period the server carries under View more
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function poolDonut(canvas, pools, F) {
|
|
1269
|
+
// THE LABELLED PIE (operator, 2026-09-15, with the reference: "I want this view. With pool
|
|
1270
|
+
// names clustered with colored lines linking to their pie slice"): every pool named beside the
|
|
1271
|
+
// pie, the big ones on the side their slice faces, the small ones stacked where there is room,
|
|
1272
|
+
// each name joined to its own slice by a leader in the slice's colour. Slices under half a
|
|
1273
|
+
// percent are gathered into "Other (x.xx%)". Labels on a side are laid out top to bottom at
|
|
1274
|
+
// their slice's height and pushed apart where they would overlap, so the leaders fan out.
|
|
1275
|
+
if (!canvas?.getContext) return;
|
|
1276
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
1277
|
+
const w = canvas.clientWidth || 520, h = canvas.clientHeight || 440;
|
|
1278
|
+
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
|
|
1279
|
+
const ctx = canvas.getContext('2d');
|
|
1280
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1281
|
+
ctx.clearRect(0, 0, w, h);
|
|
1282
|
+
const total = pools.reduce((s, p) => s + p.blocks, 0);
|
|
1283
|
+
const cx = w / 2, cy = h / 2;
|
|
1284
|
+
// THE PIE FITS ITS LABELS (operator, 2026-09-15, of a 975px window: "surely it could look better
|
|
1285
|
+
// at this sizing"): the radius is what is left after the widest label on each side and its
|
|
1286
|
+
// leader, measured, so no name is ever cut at the card's edge; where that leaves too small a
|
|
1287
|
+
// pie the names are shortened and the font drops a size first
|
|
1288
|
+
const gap = 34;
|
|
1289
|
+
const nameOf = (p, max) => (p.other ? p.name : p.name.length > max ? `${p.name.slice(0, max - 1)}…` : p.name);
|
|
1290
|
+
const preview = pools.map((p) => ({ ...p, other: false }));
|
|
1291
|
+
let font = 12.5, maxName = 16, R = 0, withPct = true;
|
|
1292
|
+
for (const [f, m, pc] of [[12.5, 16, true], [11.5, 13, true], [11, 14, false]]) {
|
|
1293
|
+
font = f; maxName = m; withPct = pc;
|
|
1294
|
+
ctx.font = `${f}px system-ui, sans-serif`;
|
|
1295
|
+
const widths = preview.map((p) => ctx.measureText(nameOf(p, m)).width + (pc ? ctx.measureText(' 100.0%').width : 0) + 8);
|
|
1296
|
+
const widest = widths.length ? Math.max(...widths) : 0;
|
|
1297
|
+
R = Math.min(h / 2 - 14, cx - gap - widest - 6);
|
|
1298
|
+
if (R >= 90 || f === 11) break; // the last step drops the shares (the reference has none) for a bigger pie
|
|
1299
|
+
}
|
|
1300
|
+
R = Math.max(50, R);
|
|
1301
|
+
const LH = font + 4, r = R * 0.28;
|
|
1302
|
+
if (!total) { ctx.fillStyle = COL.text; ctx.font = `${font}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.fillText('no blocks read yet', cx, cy); return; }
|
|
1303
|
+
// slices: the named ones, and the rest under a threshold as one "Other". The threshold starts at
|
|
1304
|
+
// half a percent and rises until each side's stack of labels FITS THE CANVAS (operator,
|
|
1305
|
+
// 2026-09-15: "getting cut off on mining screen left bottom" -- fourteen names at 16px need
|
|
1306
|
+
// 230px, and a short card gave them 220), so on a small card the small pools fold into Other
|
|
1307
|
+
// rather than run off the bottom
|
|
1308
|
+
const build = (threshold) => {
|
|
1309
|
+
const small = pools.filter((p) => (p.blocks / total) * 100 < threshold);
|
|
1310
|
+
const big = pools.filter((p) => (p.blocks / total) * 100 >= threshold);
|
|
1311
|
+
const slices = [...big, ...(small.length ? [{ name: `Other (${(small.reduce((s, p) => s + p.blocks, 0) / total * 100).toFixed(2)}%)`, blocks: small.reduce((s, p) => s + p.blocks, 0), other: true }] : [])];
|
|
1312
|
+
let a0 = -Math.PI / 2;
|
|
1313
|
+
return slices.map((p, i) => {
|
|
1314
|
+
const a1 = a0 + (p.blocks / total) * Math.PI * 2, mid = (a0 + a1) / 2;
|
|
1315
|
+
const out = { p, i, a0, a1, mid, color: p.other ? '#8d93a1' : DONUT_COLORS[i % DONUT_COLORS.length] };
|
|
1316
|
+
a0 = a1;
|
|
1317
|
+
return out;
|
|
1318
|
+
});
|
|
1319
|
+
};
|
|
1320
|
+
let laid = build(0.5);
|
|
1321
|
+
for (const threshold of [1, 2, 3, 5, 8, 12]) {
|
|
1322
|
+
const perSide = [1, -1].map((dir) => laid.filter((x) => (Math.cos(x.mid) >= 0 ? 1 : -1) === dir).length);
|
|
1323
|
+
if (Math.max(...perSide) * LH <= h - 6) break;
|
|
1324
|
+
laid = build(threshold);
|
|
1325
|
+
}
|
|
1326
|
+
// the pie
|
|
1327
|
+
for (const s of laid) {
|
|
1328
|
+
ctx.beginPath(); ctx.arc(cx, cy, R, s.a0, s.a1); ctx.arc(cx, cy, r, s.a1, s.a0, true); ctx.closePath();
|
|
1329
|
+
ctx.fillStyle = s.color; ctx.fill();
|
|
1330
|
+
ctx.strokeStyle = INK.panel; ctx.lineWidth = 1; ctx.stroke();
|
|
1331
|
+
}
|
|
1332
|
+
// the labels: each side laid out top to bottom, pushed apart to LH
|
|
1333
|
+
const side = (dir) => {
|
|
1334
|
+
const list = laid.filter((s) => (Math.cos(s.mid) >= 0 ? 1 : -1) === dir).map((s) => ({ s, y: cy + Math.sin(s.mid) * (R + 12), ax: cx + Math.cos(s.mid) * R, ay: cy + Math.sin(s.mid) * R }));
|
|
1335
|
+
list.sort((p, q) => p.y - q.y);
|
|
1336
|
+
for (let k = 1; k < list.length; k++) if (list[k].y < list[k - 1].y + LH) list[k].y = list[k - 1].y + LH;
|
|
1337
|
+
// keep them on the canvas: push the stack up from the bottom, then down from the top
|
|
1338
|
+
const over = list.length ? list[list.length - 1].y - (h - LH / 2) : 0;
|
|
1339
|
+
if (over > 0) for (const l of list) l.y -= over;
|
|
1340
|
+
for (let k = 0; k < list.length; k++) if (list[k].y < LH / 2) list[k].y = LH / 2; else if (k && list[k].y < list[k - 1].y + LH) list[k].y = list[k - 1].y + LH;
|
|
1341
|
+
return list;
|
|
1342
|
+
};
|
|
1343
|
+
ctx.font = `${font}px system-ui, sans-serif`; ctx.textBaseline = 'middle'; ctx.lineWidth = 1.5;
|
|
1344
|
+
for (const dir of [1, -1]) {
|
|
1345
|
+
const labelX = cx + dir * (R + gap), edgeX = cx + dir * (R + gap - 8);
|
|
1346
|
+
ctx.textAlign = dir > 0 ? 'left' : 'right';
|
|
1347
|
+
for (const l of side(dir)) {
|
|
1348
|
+
const name = nameOf(l.s.p, maxName);
|
|
1349
|
+
ctx.strokeStyle = l.s.color;
|
|
1350
|
+
ctx.beginPath(); ctx.moveTo(l.ax, l.ay); ctx.lineTo(edgeX, l.y); ctx.lineTo(labelX - dir * 3, l.y); ctx.stroke();
|
|
1351
|
+
ctx.fillStyle = INK.label;
|
|
1352
|
+
ctx.fillText(name, labelX, l.y);
|
|
1353
|
+
// the share, faint, after the name on the right side and before it on the left
|
|
1354
|
+
if (withPct) {
|
|
1355
|
+
const pct = `${(l.s.p.blocks / total * 100).toFixed(1)}%`;
|
|
1356
|
+
ctx.fillStyle = INK.axisText; ctx.font = `${font - 1.5}px ${'var(--mono), monospace'}`;
|
|
1357
|
+
const nameW = ctx.measureText(name).width;
|
|
1358
|
+
if (dir > 0) ctx.fillText(pct, labelX + nameW + 8, l.y); else ctx.fillText(pct, labelX - nameW - 8, l.y);
|
|
1359
|
+
ctx.font = `${font}px system-ui, sans-serif`;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
ctx.textAlign = 'center';
|
|
1364
|
+
ctx.fillStyle = COL.text; ctx.font = `600 ${font + 1}px system-ui, sans-serif`;
|
|
1365
|
+
ctx.fillText(`${F.num(total)}`, cx, cy - 7);
|
|
1366
|
+
ctx.font = `${font - 2}px system-ui, sans-serif`; ctx.fillStyle = INK.axisText;
|
|
1367
|
+
ctx.fillText('blocks', cx, cy + 8);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1092
1370
|
function coveragePanel(a, h) {
|
|
1093
1371
|
const esc = h.fmt.esc;
|
|
1094
1372
|
const row = (k, v) => `<dt>${esc(k)}</dt><dd>${v}</dd>`;
|
package/public/js/panels.js
CHANGED
|
@@ -30,7 +30,7 @@ export function renderChain(s, state, h) {
|
|
|
30
30
|
placeholder: 'no tip samples yet',
|
|
31
31
|
});
|
|
32
32
|
h.setText('chTipNote', s.tip?.headers != null && s.tip?.height != null
|
|
33
|
-
?
|
|
33
|
+
? `<span title="Headers-first: this reaches 100% only when the last block lands">${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers applied</span>`
|
|
34
34
|
: 'no header count reported yet');
|
|
35
35
|
|
|
36
36
|
const gap = (b.gap ?? []).filter((p) => Number.isFinite(p.v));
|
|
@@ -55,10 +55,11 @@ export function renderChain(s, state, h) {
|
|
|
55
55
|
// reads as "broken panel" unless the panel says otherwise: this figure sat empty
|
|
56
56
|
// for a day because the request asked for statistics the endpoint has never had.
|
|
57
57
|
const sizeRow = (s.blocks?.recent ?? [])[0] ?? null;
|
|
58
|
+
// one line (2026-09-15, the packing pass): the basis sentence is the note's title, the figures its text
|
|
58
59
|
h.setText('chSizeNote', sizeRow?.sizeBasis
|
|
59
|
-
? `<span class="mono"
|
|
60
|
+
? `<span class="mono" title="${fmt.esc(sizeRow.sizeBasis)}">total_size</span>`
|
|
60
61
|
+ (sizeRow.medianTxSize != null && sizeRow.swtotalSize != null && sizeRow.size != null
|
|
61
|
-
? `
|
|
62
|
+
? ` · this block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}`
|
|
62
63
|
: '')
|
|
63
64
|
: (sizeRow?.sizeMissing ?? 'no block stats collected yet — the monitor has not seen a new height since it started'));
|
|
64
65
|
drawFromSeries(h, 'chFeeChart', b.fee, COL.ok, (v) => fmt.short(v), { fmtTip: (v) => `${fmt.sats(v)} sat` });
|
|
@@ -71,10 +72,13 @@ export function renderChain(s, state, h) {
|
|
|
71
72
|
placeholder: 'getchaintxstats has not answered yet',
|
|
72
73
|
});
|
|
73
74
|
const cs = s.chaintxstats;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// ONE WRAPPING LINE (2026-09-15, the packing pass): as a four-column grid these overflowed a
|
|
76
|
+
// 330px card and as two columns they made the card the row's tallest
|
|
77
|
+
const item = (k, v) => `<span><span class="k">${k}</span><b>${v}</b></span>`;
|
|
78
|
+
h.setText('chTxStats', cs
|
|
79
|
+
? item('window', `${fmt.num(cs.window_block_count ?? 0)} blocks`) + item('txs in window', fmt.num(cs.window_tx_count ?? 0))
|
|
80
|
+
+ item('all-time txs', fmt.num(cs.txcount ?? 0)) + item('rate', cs.txrate != null ? `${cs.txrate.toFixed(2)} tx/s` : '–')
|
|
81
|
+
: '<span class="faint">not yet fetched</span>');
|
|
78
82
|
|
|
79
83
|
h.setText('chState', kv([
|
|
80
84
|
['chain', s.chain ?? '–'],
|
|
@@ -89,7 +93,7 @@ export function renderChain(s, state, h) {
|
|
|
89
93
|
['pruned', s.pruned == null ? '–' : String(s.pruned)],
|
|
90
94
|
['chain work', s.chainwork ? fmt.hash(s.chainwork.replace(/^0+/, '') || '0', 6) : '–'],
|
|
91
95
|
['IBD', s.ibd == null ? '–' : String(s.ibd)],
|
|
92
|
-
['
|
|
96
|
+
['uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
|
|
93
97
|
]));
|
|
94
98
|
|
|
95
99
|
const u = s.utxo ?? {};
|
|
@@ -107,8 +111,8 @@ export function renderChain(s, state, h) {
|
|
|
107
111
|
// hundreds of blocks a second is not a hashrate), and the reason travels with it
|
|
108
112
|
// so the dash is an explanation rather than a mystery.
|
|
109
113
|
['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>`]] : []),
|
|
114
|
+
: raw(`<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`)],
|
|
115
|
+
...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', raw(`<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`)]] : []),
|
|
112
116
|
['avg interval', s.avgBlockGapSec != null ? fmt.ageSec(s.avgBlockGapSec) : '–'],
|
|
113
117
|
['reorgs seen', String(s.blocks?.reorgs ?? 0)],
|
|
114
118
|
]));
|
|
@@ -348,7 +352,7 @@ export function peerTableHtml(rows, fmt, { now = Date.now(), tip = null } = {})
|
|
|
348
352
|
const faint = (s) => ` <span class="faint">${fmt.esc(s)}</span>`;
|
|
349
353
|
const body = sorted.map((p) => `<tr>
|
|
350
354
|
<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>
|
|
355
|
+
<td class="addr" title="${fmt.esc(p.addr ?? '')}">${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
|
|
352
356
|
<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
357
|
<td class="r">${Number.isFinite(p.conntime) ? fmt.uptime((now / 1000 - p.conntime) * 1000) : '–'}</td>
|
|
354
358
|
<td class="r">${since(p.lastrecv)}</td>
|
|
@@ -472,7 +476,7 @@ export function renderNode(s, state, h) {
|
|
|
472
476
|
h.setText('ndRpc', kv([
|
|
473
477
|
['endpoint', rpc.url ?? '–'],
|
|
474
478
|
['cookie source', rpc.cookieSource ? fmt.hash(rpc.cookieSource, 14) : '–'],
|
|
475
|
-
['in flight', '1 (by design)'
|
|
479
|
+
['in flight', '1'], // one request at a time is the design (the note under the chart says so); '(by design)' was cut off in the two-column list
|
|
476
480
|
['calls (60 s window)', String(rpc.ratePerSec ?? 0) + '/s'],
|
|
477
481
|
['total calls', fmt.num(rpc.calls)],
|
|
478
482
|
['batches', fmt.num(rpc.batches)],
|
|
@@ -517,7 +521,7 @@ export function renderNode(s, state, h) {
|
|
|
517
521
|
const lh = t.health ?? {};
|
|
518
522
|
h.setText('ndTail', kv([
|
|
519
523
|
['file', t.source === 'disabled'
|
|
520
|
-
? '<span class="faint">disabled — running on RPC only</span>'
|
|
524
|
+
? raw('<span class="faint">disabled — running on RPC only</span>')
|
|
521
525
|
: (t.file ? fmt.hash(t.file, 16) : 'not configured')],
|
|
522
526
|
['exists', t.exists == null ? '–' : String(t.exists)],
|
|
523
527
|
['size', t.size != null ? fmt.bytes(t.size, 1) : '–'],
|
|
@@ -532,10 +536,10 @@ export function renderNode(s, state, h) {
|
|
|
532
536
|
// The two figures that separate "this node is quiet" from "we are tailing the
|
|
533
537
|
// wrong file". A tail that stops moving is invisible in every other panel.
|
|
534
538
|
['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>'],
|
|
539
|
+
? raw(`${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`)
|
|
540
|
+
: raw('<span class="faint">not checked yet</span>')],
|
|
537
541
|
['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>`
|
|
542
|
+
? raw(`${fmt.esc(fmt.ago(lh.lastGrowthAt))} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`)
|
|
539
543
|
: '–'],
|
|
540
544
|
]));
|
|
541
545
|
|
|
@@ -822,8 +826,17 @@ function drawFromSeries(h, id, points, color, fmtY, extra = {}) {
|
|
|
822
826
|
});
|
|
823
827
|
}
|
|
824
828
|
|
|
825
|
-
|
|
826
|
-
|
|
829
|
+
// ESCAPED BY DEFAULT (audit 2026-09-16, L2/L3). kv() used to write every value as markup, and some
|
|
830
|
+
// values are the node's own strings passed straight through -- `chain`, `pruned`, the RPC endpoint,
|
|
831
|
+
// the cookie path. A hostile or misconfigured node returned `chain` with an <img> appended and it
|
|
832
|
+
// became an element on the Chain page (the CSP stopped the script; the element was still there).
|
|
833
|
+
// Now a value is text unless it is wrapped in raw(), which says at the call site "this is markup
|
|
834
|
+
// this file built, with its own dynamic parts already escaped".
|
|
835
|
+
const RAW = Symbol('raw');
|
|
836
|
+
export const raw = (html) => ({ [RAW]: String(html) });
|
|
837
|
+
export function kv(pairs) {
|
|
838
|
+
const cell = (v) => (v && typeof v === 'object' && RAW in v ? v[RAW] : F().esc(v));
|
|
839
|
+
return pairs.map(([k, v]) => `<dt>${F().esc(k)}</dt><dd>${cell(v)}</dd>`).join('');
|
|
827
840
|
}
|
|
828
841
|
|
|
829
842
|
function median(a) {
|
|
@@ -884,8 +897,8 @@ export function initChainDrill(h) {
|
|
|
884
897
|
return;
|
|
885
898
|
}
|
|
886
899
|
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 ${
|
|
900
|
+
['txid', raw(`<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>`)],
|
|
901
|
+
['in', d.inMempool ? raw('<span class="warn">mempool (unconfirmed)</span>') : `block ${d.blockHash ?? '?'}`],
|
|
889
902
|
['confirmations', d.confirmations == null ? '– (in the pool)' : fmt.num(d.confirmations)],
|
|
890
903
|
['size / vsize / weight', [d.size, d.vsize, d.weight].map((v) => (v == null ? '–' : fmt.num(v))).join(' / ')],
|
|
891
904
|
['locktime', d.locktime ?? 0],
|
|
@@ -894,8 +907,8 @@ export function initChainDrill(h) {
|
|
|
894
907
|
['total out', d.totalOutSat != null ? `${fmt.num(d.totalOutSat)} sat` : '–'],
|
|
895
908
|
];
|
|
896
909
|
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('');
|
|
910
|
+
const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${fmt.esc(v.vout ?? '–')}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
|
|
911
|
+
const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${fmt.esc(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
912
|
box.innerHTML = `<div class=\"drill\">
|
|
900
913
|
<dl class=\"kv\">${kv(rows)}</dl>
|
|
901
914
|
${inRows ? io(inRows, `inputs (${d.inputsTotal})`) : ''}
|
|
@@ -932,20 +945,20 @@ export function initChainDrill(h) {
|
|
|
932
945
|
const b = d.header ?? {};
|
|
933
946
|
const st = d.stats ?? {};
|
|
934
947
|
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>' : ''}`],
|
|
948
|
+
['height', b.height == null ? '–' : raw(`<a class="xlink" href="#explorer/block/${encodeURIComponent(Number(b.height))}">${fmt.esc(fmt.num(b.height))}</a> <span class="tiny faint">open in the explorer</span>`)],
|
|
949
|
+
['hash', raw(`<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`)],
|
|
950
|
+
['confirmations', b.confirmations == null ? '–' : raw(`${fmt.esc(fmt.num(b.confirmations))}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`)],
|
|
938
951
|
['time', b.time ? `${new Date(b.time * 1000).toISOString().replace('T', ' ').slice(0, 19)}Z` : '–'],
|
|
939
952
|
['age', b.time ? fmt.ago(b.time * 1000) : '–'],
|
|
940
953
|
['size / weight', `${b.size == null ? '–' : fmt.bytes(b.size, 0)} / ${b.weight == null ? '–' : fmt.num(b.weight)} WU`],
|
|
941
954
|
['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`],
|
|
955
|
+
['fees', st.totalfee == null ? (d.statsError ? raw('<span class=\"warn\">getblockstats failed</span>') : '–') : `${fmt.num(st.totalfee)} sat`],
|
|
943
956
|
['median fee', st.medianfee == null ? '–' : `${fmt.num(st.medianfee)} sat`],
|
|
944
957
|
['fee rate p10/50/90', (st.feerate_percentiles ?? []).length ? st.feerate_percentiles.map((v) => Number(v).toFixed(1)).join(' / ') : '–'],
|
|
945
958
|
['subsidy', st.subsidy == null ? '–' : `${fmt.num(st.subsidy)} sat`],
|
|
946
959
|
['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>`],
|
|
960
|
+
['prev / next', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`)],
|
|
961
|
+
['merkle root', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`)],
|
|
949
962
|
]);
|
|
950
963
|
const ids = d.txids ?? [];
|
|
951
964
|
if (list()) {
|