Homepage: live P2Pool hashrate and "Mine Monero with us" section #1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: '<b>x</b>', 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();
|
||||
});
|
||||
Reference in New Issue
Block a user