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
191 lines
8.3 KiB
JavaScript
191 lines
8.3 KiB
JavaScript
/** 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();
|
|
}
|