From 2e81ddf8220173b32f4481d46cd9933e81fbbb2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 03:13:40 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018STDcc5BaYxPMNA9S8Wycs --- README.md | 14 +++ htdocs/index.html | 46 ++++++++ htdocs/static/home.css | 31 ++++++ htdocs/static/p2pool-stats.mjs | 190 +++++++++++++++++++++++++++++++++ tests/p2pool-stats.test.mjs | 170 +++++++++++++++++++++++++++++ 5 files changed, 451 insertions(+) create mode 100644 htdocs/static/p2pool-stats.mjs create mode 100644 tests/p2pool-stats.test.mjs diff --git a/README.md b/README.md index 7cfcfeb..fde4293 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,20 @@ email addresses and other author metadata are not displayed. Gitea requests are anonymous, HTTPS-only, size-limited to 2 MiB, and time out after 10 seconds. The cards refresh every 15 minutes while visible, retain clearly labeled fallback/previous content on failure, and link to the full public activity page. + +### Live mining stats + +The **Mine Monero with us** section (`#mining`) uses +[htdocs/static/p2pool-stats.mjs](htdocs/static/p2pool-stats.mjs) to read +`/p2pool/stats.json`. That same-origin path is not part of this repository: the +[ansible-role-monero](https://gitea.eom.dev/DevOps/ansible-role-monero) stats +sidecar publishes it through an Ingress on the website host. The summary carries +only aggregate numbers (node, sidechain and network hashrate, connected miners, +shares) and never miner IPs or wallets. The module accepts only those numeric +fields, writes plain text into fixed `[data-stat]` cells, limits responses to +64 KiB with a 10-second timeout, and refreshes every minute while the page is +visible. When the endpoint is missing (local preview, onion mirror, P2Pool +restarting) the `—` placeholders and join instructions stay in place. No Creator Launch project or service offer is involved. [htdocs/static/peertube-feeds.mjs](htdocs/static/peertube-feeds.mjs) loads public diff --git a/htdocs/index.html b/htdocs/index.html index a00ad36..b6d7d9e 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -201,6 +201,50 @@ + +

Channels & collections · Always something to explore

@@ -317,12 +361,14 @@

Monero contributions can be sent to this public address:

48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw

The XMRig configuration is an example for running your own miner on P2Pool, not a contribution link. Replace the example wallet with your own.

+

See live pool stats and how to join

+ diff --git a/htdocs/static/home.css b/htdocs/static/home.css index 7a9cd47..73c40ed 100755 --- a/htdocs/static/home.css +++ b/htdocs/static/home.css @@ -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, diff --git a/htdocs/static/p2pool-stats.mjs b/htdocs/static/p2pool-stats.mjs new file mode 100644 index 0000000..138fd2f --- /dev/null +++ b/htdocs/static/p2pool-stats.mjs @@ -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(); +} diff --git a/tests/p2pool-stats.test.mjs b/tests/p2pool-stats.test.mjs new file mode 100644 index 0000000..9aecbea --- /dev/null +++ b/tests/p2pool-stats.test.mjs @@ -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: 'x', 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(); +});