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:
@@ -1038,6 +1038,37 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.mining-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.mining-stats > div {
|
||||
padding: 1rem 1.15rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.mining-stats dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.mining-stats dd {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--text);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mining-section .project-grid {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user