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
+190
View File
@@ -0,0 +1,190 @@
/** Live hashrate for the eom.dev P2Pool node, read from the same-origin summary that
* the monero role's stats sidecar publishes. The summary never contains miner IPs or
* wallets. Standalone public-data client: no credentials, storage, HTML sinks or logging.
*/
export const ENDPOINT = '/p2pool/stats.json';
export const MAX_BYTES = 64 * 1024;
export const REFRESH_MS = 60 * 1000;
const CHAINS = new Set(['main', 'mini', 'nano']);
const UNITS = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s'];
const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const count = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
const epoch = value => Number.isSafeInteger(value) && value > 0 ? value : null;
function section(raw, fields) {
if (!isRecord(raw)) return null;
const out = Object.fromEntries(fields.map(field => [field, count(raw[field])]));
out.updatedAt = epoch(raw.updated_at);
out.stale = raw.stale === true;
return out;
}
/** Copy only known numeric fields; anything unexpected becomes null. */
export function normalizeStats(data) {
if (!isRecord(data) || data.version !== 1) throw new Error('Unsupported stats document');
return {
sidechain: typeof data.sidechain === 'string' && CHAINS.has(data.sidechain) ? data.sidechain : null,
node: section(data.node, ['hashrate_15m', 'hashrate_1h', 'hashrate_24h', 'miners', 'shares_found']),
pool: section(data.pool, ['hashrate', 'miners']),
network: section(data.network, ['hashrate']),
};
}
/** 8123 → "8.12 kH/s"; null → "—". */
export function formatHashrate(value) {
if (count(value) === null) return '—';
let scaled = value;
let unit = 0;
while (scaled >= 1000 && unit < UNITS.length - 1) { scaled /= 1000; unit++; }
const digits = scaled >= 100 || unit === 0 ? 0 : scaled >= 10 ? 1 : 2;
return `${scaled.toFixed(digits)} ${UNITS[unit]}`;
}
export function formatCount(value) {
return count(value) === null ? '—' : Math.round(value).toLocaleString('en-US');
}
const abortError = () => new DOMException('Pool stats request aborted', 'AbortError');
/** Fixed same-origin endpoint. Timer covers headers AND streamed body. */
export async function fetchStats({ fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES } = {}) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 ||
!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
const controller = new AbortController();
let reader;
let rejectAbort;
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
signal?.addEventListener('abort', cancel, { once: true });
const timer = setTimeout(cancel, timeoutMs);
const work = async () => {
if (signal?.aborted) throw abortError();
const response = await fetchImpl(ENDPOINT, { method: 'GET', signal: controller.signal,
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', cache: 'no-store',
headers: { Accept: 'application/json' } });
if (controller.signal.aborted) throw abortError();
if (!response.ok || response.redirected) throw new Error('Pool stats request failed');
if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
throw new Error('Unexpected content type');
}
const length = response.headers.get('content-length');
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
if (!response.body?.getReader) throw new Error('Streaming response required');
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let body = '';
let bytes = 0;
while (true) {
const { done, value } = await reader.read();
if (controller.signal.aborted) throw abortError();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) throw new Error('Response too large');
body += decoder.decode(value, { stream: true });
}
return normalizeStats(JSON.parse(body + decoder.decode()));
};
try { return await Promise.race([work(), aborted]); }
finally {
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
controller.abort();
if (reader) void Promise.resolve(reader.cancel()).catch(() => {});
}
}
const time = seconds => new Date(seconds * 1000).toISOString().slice(11, 16) + ' UTC';
/** Text for each [data-stat] cell. Prefers the 15-minute node average, then 1h, then 24h. */
export function statValues(stats) {
const node = stats.node;
const hashrate = node ? node.hashrate_15m ?? node.hashrate_1h ?? node.hashrate_24h : null;
return {
'node-hashrate': formatHashrate(hashrate),
'node-miners': formatCount(node?.miners),
'node-shares': formatCount(node?.shares_found),
'pool-hashrate': formatHashrate(stats.pool?.hashrate),
'network-hashrate': formatHashrate(stats.network?.hashrate),
};
}
export function statusText(stats) {
const chain = stats.sidechain ? ` · ${stats.sidechain} sidechain` : '';
const node = stats.node;
if (!node) return `Live pool stats unavailable right now${chain}.`;
if (node.stale) {
return `The node last reported stats${node.updatedAt ? ` at ${time(node.updatedAt)}` : ''}; newer figures will appear when it reports again${chain}.`;
}
return `Live${node.updatedAt ? ` · updated ${time(node.updatedAt)}` : ''}${chain} · refreshes every minute.`;
}
/** DOM contract: #p2pool-stats > [data-p2pool-status], [data-p2pool-stats] [data-stat="…"].
* Static "—" placeholders stay in the markup until a valid summary arrives.
*/
export function initP2PoolStats(document = globalThis.document, {
fetchImpl = globalThis.fetch, setIntervalImpl = globalThis.setInterval,
clearIntervalImpl = globalThis.clearInterval, ...requestBounds
} = {}) {
const root = document?.querySelector('#p2pool-stats');
const status = root?.querySelector('[data-p2pool-status]');
const cells = new Map([...(root?.querySelectorAll('[data-p2pool-stats] [data-stat]') ?? [])]
.map(cell => [cell.getAttribute('data-stat'), cell]));
if (!status || !cells.size) return { ready: Promise.resolve(), refresh: async () => {}, stop() {} };
status.setAttribute('role', 'status');
let stopped = false;
let generation = 0;
let pending;
let interval;
let loaded = false;
const invalidate = () => { generation++; pending?.abort(); pending = undefined; };
const refresh = async () => {
if (stopped || document.hidden) return;
invalidate();
const current = generation;
const controller = new AbortController();
pending = controller;
try {
const stats = await fetchStats({ ...requestBounds, fetchImpl, signal: controller.signal });
if (stopped || current !== generation) return;
for (const [key, value] of Object.entries(statValues(stats))) {
const cell = cells.get(key);
if (cell) cell.textContent = value;
}
loaded = true;
status.textContent = statusText(stats);
} catch {
if (!stopped && current === generation) {
status.textContent = loaded ? 'Live pool stats unavailable; showing the last figures received.'
: 'Live pool stats unavailable right now. You can still join with the configuration below.';
}
} finally {
if (current === generation) pending = undefined;
}
};
const pauseTimer = () => { if (interval !== undefined) clearIntervalImpl(interval); interval = undefined; };
const startTimer = () => {
if (!stopped && !document.hidden && interval === undefined) interval = setIntervalImpl(() => {
if (!pending) void refresh();
}, REFRESH_MS);
};
const visibility = () => {
if (stopped) return;
if (document.hidden) { pauseTimer(); invalidate(); }
else { startTimer(); void refresh(); }
};
document.addEventListener('visibilitychange', visibility);
startTimer();
const ready = refresh();
return { ready, refresh, stop() {
if (stopped) return;
stopped = true;
pauseTimer();
invalidate();
document.removeEventListener('visibilitychange', visibility);
} };
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initP2PoolStats(), { once: true });
else initP2PoolStats();
}