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:
@@ -201,6 +201,50 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section featured-section mining-section" id="mining" aria-labelledby="mining-title">
|
||||
<div class="section-heading section-heading-row">
|
||||
<div>
|
||||
<p class="eyebrow">Monero · Decentralized mining with P2Pool</p>
|
||||
<h2 id="mining-title">Mine Monero with us.</h2>
|
||||
<p>I run a Monero node and a P2Pool node on the same servers as this site. Point XMRig at <code>p2pool.eom.dev:3333</code> (TLS) with your own wallet address. There is no pool fee and no account, and payouts go straight from Monero's block reward to your wallet.</p>
|
||||
</div>
|
||||
<a class="text-link" href="https://discourse.eom.dev/t/cryptocurrency/686">Follow the project updates <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
<div id="p2pool-stats">
|
||||
<dl class="mining-stats" data-p2pool-stats>
|
||||
<div><dt>Hashrate on this node</dt><dd data-stat="node-hashrate">—</dd></div>
|
||||
<div><dt>Connected miners</dt><dd data-stat="node-miners">—</dd></div>
|
||||
<div><dt>Shares found</dt><dd data-stat="node-shares">—</dd></div>
|
||||
<div><dt>Sidechain hashrate</dt><dd data-stat="pool-hashrate">—</dd></div>
|
||||
<div><dt>Monero network</dt><dd data-stat="network-hashrate">—</dd></div>
|
||||
</dl>
|
||||
<p class="feed-status" data-p2pool-status role="status" aria-live="polite">Live pool stats load automatically when available.</p>
|
||||
<noscript><p>Enable JavaScript to see live hashrate here. The join instructions below work without it.</p></noscript>
|
||||
</div>
|
||||
<div class="project-grid">
|
||||
<article class="project-card">
|
||||
<div class="project-mark mark-green" aria-hidden="true">⛏</div>
|
||||
<p class="card-kicker">Bring your own CPU</p>
|
||||
<h3>Join the pool</h3>
|
||||
<p>Download the example XMRig configuration, replace the wallet with your own primary address (it starts with 4), and start mining. Every share you find on this node also helps keep Monero's hashrate decentralized.</p>
|
||||
<div class="project-links">
|
||||
<a class="text-link" href="/xmrig.json">Example XMRig configuration <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="https://discourse.eom.dev/t/monetization/345/2">Setup notes</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card">
|
||||
<div class="project-mark mark-blue" aria-hidden="true">≈</div>
|
||||
<p class="card-kicker">Before you start</p>
|
||||
<h3>Is it worth it?</h3>
|
||||
<p>At today's prices, most CPUs spend more on electricity than they earn. Mining can make sense with efficient hardware, cheap power, or heat you'd want in winter anyway. I wrote up the numbers, including what it would take to break even.</p>
|
||||
<div class="project-links">
|
||||
<a class="text-link" href="https://discourse.eom.dev/t/cryptocurrency/686/2">Read the analysis <span aria-hidden="true">→</span></a>
|
||||
<a class="text-link" href="https://gitea.eom.dev/DevOps/ansible-role-monero">How the node is deployed</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section films-section" id="peertube-library" aria-labelledby="library-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Channels & collections · Always something to explore</p>
|
||||
@@ -317,12 +361,14 @@
|
||||
<p>Monero contributions can be sent to this public address:</p>
|
||||
<code class="wallet-address">48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw</code>
|
||||
<p>The <a href="/xmrig.json">XMRig configuration</a> is an example for running your own miner on P2Pool, not a contribution link. Replace the example wallet with your own.</p>
|
||||
<p><a class="text-link" href="#mining">See live pool stats and how to join <span aria-hidden="true">→</span></a></p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/static/peertube-feeds.mjs"></script>
|
||||
<script type="module" src="/static/gitea-projects.mjs"></script>
|
||||
<script type="module" src="/static/p2pool-stats.mjs"></script>
|
||||
<script type="module" src="/static/theme-player.mjs"></script>
|
||||
<!--#include virtual="/common/footer.html" -->
|
||||
</body>
|
||||
|
||||
@@ -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