Add a live P2Pool mining section to the homepage

A new #mining section invites visitors to mine on p2pool.eom.dev and shows
live node/sidechain/network hashrate, connected miners and shares from the
same-origin /p2pool/stats.json summary published by ansible-role-monero.
The stats load progressively: static placeholders and join instructions
remain when the endpoint is unavailable (including in the local preview
and on the onion mirror).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018STDcc5BaYxPMNA9S8Wycs
This commit is contained in:
2026-11-08 03:13:40 +00:00
co-authored by Claude Opus 5.5
parent 849a020cbc
commit 2e81ddf822
5 changed files with 451 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ENDPOINT, MAX_BYTES, REFRESH_MS, normalizeStats, formatHashrate, formatCount,
fetchStats, statValues, statusText, initP2PoolStats,
} from '../htdocs/static/p2pool-stats.mjs';
const SUMMARY = {
version: 1, generated_at: 1794098000, sidechain: 'nano',
node: { hashrate_15m: 8123, hashrate_1h: 7990, hashrate_24h: 8011, miners: 3, shares_found: 42,
shares_failed: 1, updated_at: 1794097990, stale: false },
pool: { hashrate: 25_000_000, miners: 512, updated_at: 1794097990, stale: false },
network: { hashrate: 7_160_416_666.7, difficulty: 859_250_000_000, updated_at: 1794097900, stale: false },
};
function response(body, { status = 200, type = 'application/json', length, redirected = false } = {}) {
const bytes = new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body));
const headers = new Map([['content-type', type]]);
if (length !== undefined) headers.set('content-length', String(length));
return {
ok: status >= 200 && status < 300, status, redirected,
headers: { get: name => headers.get(name.toLowerCase()) ?? null },
body: { getReader() {
let done = false;
return { read: async () => done ? { done: true } : (done = true, { done: false, value: bytes }),
cancel: async () => {} };
} },
};
}
test('normalizeStats copies known numeric fields and rejects unknown documents', () => {
const stats = normalizeStats(SUMMARY);
assert.equal(stats.sidechain, 'nano');
assert.equal(stats.node.hashrate_15m, 8123);
assert.equal(stats.node.miners, 3);
assert.equal(stats.node.updatedAt, 1794097990);
assert.equal(stats.pool.hashrate, 25_000_000);
assert.equal('shares_failed' in stats.node, false);
const odd = normalizeStats({ ...SUMMARY, sidechain: '<b>x</b>', node: { hashrate_15m: '9', miners: -1,
shares_found: Infinity, updated_at: 1.5, stale: 'yes' }, pool: [], network: null });
assert.equal(odd.sidechain, null);
assert.deepEqual([odd.node.hashrate_15m, odd.node.miners, odd.node.shares_found, odd.node.updatedAt, odd.node.stale],
[null, null, null, null, false]);
assert.equal(odd.pool, null);
assert.equal(odd.network, null);
for (const bad of [null, [], 'x', { version: 2 }, {}]) assert.throws(() => normalizeStats(bad));
});
test('hashrates and counts format compactly with placeholders for missing data', () => {
assert.equal(formatHashrate(0), '0 H/s');
assert.equal(formatHashrate(950), '950 H/s');
assert.equal(formatHashrate(8123), '8.12 kH/s');
assert.equal(formatHashrate(25_000_000), '25.0 MH/s');
assert.equal(formatHashrate(7_160_416_666.7), '7.16 GH/s');
assert.equal(formatHashrate(512_000), '512 kH/s');
for (const missing of [null, undefined, NaN, -1, '8']) assert.equal(formatHashrate(missing), '—');
assert.equal(formatCount(1234), '1,234');
assert.equal(formatCount(null), '—');
});
test('statValues prefers the 15-minute node average and falls back to longer windows', () => {
assert.deepEqual(statValues(normalizeStats(SUMMARY)), {
'node-hashrate': '8.12 kH/s', 'node-miners': '3', 'node-shares': '42',
'pool-hashrate': '25.0 MH/s', 'network-hashrate': '7.16 GH/s',
});
const fallback = normalizeStats({ ...SUMMARY, node: { ...SUMMARY.node, hashrate_15m: null, hashrate_1h: null } });
assert.equal(statValues(fallback)['node-hashrate'], '8.01 kH/s');
assert.equal(statValues(normalizeStats({ version: 1 }))['node-hashrate'], '—');
});
test('status text distinguishes live, stale and missing node stats', () => {
assert.match(statusText(normalizeStats(SUMMARY)), /^Live · updated \d\d:\d\d UTC · nano sidechain/u);
assert.match(statusText(normalizeStats({ ...SUMMARY, node: { ...SUMMARY.node, stale: true } })), /last reported stats at/u);
assert.match(statusText(normalizeStats({ version: 1 })), /unavailable/u);
});
test('fetchStats requests only the fixed same-origin endpoint without credentials', async () => {
let seen;
const stats = await fetchStats({ fetchImpl: async (url, init) => { seen = { url, init }; return response(SUMMARY); } });
assert.equal(seen.url, ENDPOINT);
assert.equal(seen.init.credentials, 'omit');
assert.equal(seen.init.redirect, 'error');
assert.equal(seen.init.cache, 'no-store');
assert.equal(stats.node.hashrate_15m, 8123);
});
test('fetchStats rejects errors, wrong types, oversized and malformed bodies', async () => {
const cases = [
response({ version: 1, node: null }, { status: 503 }),
response(SUMMARY, { type: 'text/html' }),
response(SUMMARY, { length: MAX_BYTES + 1 }),
response(SUMMARY, { redirected: true }),
response('x'.repeat(MAX_BYTES + 1)),
response('{not json'),
response({ version: 99 }),
];
for (const reply of cases) await assert.rejects(fetchStats({ fetchImpl: async () => reply }));
await assert.rejects(fetchStats({ timeoutMs: 20, fetchImpl: () => new Promise(() => {}) }), /aborted/u);
await assert.rejects(fetchStats({ timeoutMs: 60_000 }), /bounds/u);
});
function fakeDocument({ hidden = false } = {}) {
const listeners = new Map();
const node = (attrs = {}) => ({ textContent: '—', attrs, setAttribute(name, value) { attrs[name] = value; },
getAttribute(name) { return attrs[name] ?? null; } });
const status = node({ 'data-p2pool-status': '' });
const cells = ['node-hashrate', 'node-miners', 'node-shares', 'pool-hashrate', 'network-hashrate']
.map(key => node({ 'data-stat': key }));
const root = { querySelector: selector => selector === '[data-p2pool-status]' ? status : null,
querySelectorAll: selector => selector === '[data-p2pool-stats] [data-stat]' ? cells : [] };
return {
hidden, status, cells,
querySelector: selector => selector === '#p2pool-stats' ? root : null,
addEventListener: (type, fn) => listeners.set(type, fn),
removeEventListener: type => listeners.delete(type),
fire(type) { listeners.get(type)?.(); },
listeners,
};
}
const timers = () => {
const state = { intervals: new Map(), next: 1 };
return { state, setIntervalImpl: (fn, ms) => { state.intervals.set(state.next, { fn, ms }); return state.next++; },
clearIntervalImpl: id => state.intervals.delete(id) };
};
test('initP2PoolStats fills the stat cells and refreshes every minute while visible', async () => {
const document = fakeDocument();
const clock = timers();
let calls = 0;
const widget = initP2PoolStats(document, { ...clock,
fetchImpl: async () => { calls++; return response(SUMMARY); } });
await widget.ready;
assert.deepEqual(document.cells.map(cell => cell.textContent), ['8.12 kH/s', '3', '42', '25.0 MH/s', '7.16 GH/s']);
assert.match(document.status.textContent, /^Live/u);
assert.equal(document.status.attrs.role, 'status');
const [timer] = clock.state.intervals.values();
assert.equal(timer.ms, REFRESH_MS);
timer.fn();
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls, 2);
document.hidden = true;
document.fire('visibilitychange');
assert.equal(clock.state.intervals.size, 0);
widget.stop();
assert.equal(document.listeners.size, 0);
});
test('initP2PoolStats keeps placeholders or previous figures when the endpoint fails', async () => {
const document = fakeDocument();
let fail = true;
const widget = initP2PoolStats(document, { ...timers(),
fetchImpl: async () => fail ? response({ version: 1 }, { status: 503 }) : response(SUMMARY) });
await widget.ready;
assert.ok(document.cells.every(cell => cell.textContent === '—'));
assert.match(document.status.textContent, /unavailable right now/u);
fail = false;
await widget.refresh();
fail = true;
await widget.refresh();
assert.equal(document.cells[0].textContent, '8.12 kH/s');
assert.match(document.status.textContent, /last figures received/u);
widget.stop();
});
test('initP2PoolStats is inert without its markup', async () => {
const widget = initP2PoolStats({ querySelector: () => null });
await widget.ready;
widget.stop();
});