Skip to content

Commit 2ebcf81

Browse files
committed
Add pool operator admin dashboard + payout worker systemd unit
Dashboard: new /admin (HTML) + /api/admin/summary (JSON) routes on the existing simplepool-dashboard app. Basic auth gated on ADMIN_USER + ADMIN_PASSWORD env vars — both required or the routes return 503 (fail-closed if someone forgets to set them). Both routes render: - Thunder reserve balance (live JSON-RPC probe to THUNDER_RPC_URL/balance, 1.5s timeout so the page doesn't hang if Thunder is down) - PPS ledger totals (accrued / paid / owed / workers with a balance) - Per-worker balances table (name, thunder address, owed, accrued, paid, updated) — sorted by owed desc - In-flight payouts table (empty when clean; non-empty rows need operator reconciliation per payout/README.md) Warning banner fires when totals.owed > reserve.available_sats — that's the "reserve short, payout worker skipping ticks" state. dashboard/lib/admin.js: read-only SQL against the same shares.db the public dashboard reads, plus a minimal Thunder JSON-RPC client for balance(). Same handle pattern as stats.js. deploy/systemd/simplepool-payout.service: template unit for the Node payout worker. Follows the same @user@/@root@ substitution convention as the existing units in deploy/systemd/. Runs as a long-lived service; skips ticks gracefully when the Thunder reserve is short (the current forknet state). deploy/systemd/simplepool-dashboard.service: extended with the new env vars (THUNDER_RPC_URL default, plus commented POOL_THUNDER_ RESERVE_ADDRESS / ADMIN_USER / ADMIN_PASSWORD placeholders) so operators know what to fill in. Verified live on forknet server. Behind basic auth at http://45.33.100.24:8081/admin — currently showing: reserve.available_sats = 0 (no BTC has crossed to Thunder) totals.owed = 127k (PPS accruals for the one ASIC) workers[0] = 3Z6z1hPySN… <— miner's Thunder address inFlight = [] "⚠ Reserve is short" banner shown
1 parent 7320812 commit 2ebcf81

5 files changed

Lines changed: 348 additions & 0 deletions

File tree

dashboard/lib/admin.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// PPS pool operator queries + Thunder reserve balance probe.
2+
//
3+
// All read-only against the same shares.db handle the public dashboard
4+
// uses. Thunder is queried over its JSON-RPC HTTP endpoint (default
5+
// http://127.0.0.1:6009) with a short timeout — we never block a
6+
// request on a slow / down Thunder node.
7+
8+
function unwrap(handle) {
9+
if (typeof handle?.get === 'function') return handle.get();
10+
return handle;
11+
}
12+
13+
/* Pool-wide totals across pps_credits. `owed` is the outstanding sat
14+
* balance the payout worker is responsible for draining. */
15+
export function poolTotals(handle) {
16+
const db = unwrap(handle);
17+
if (!db) return { accrued: 0, paid: 0, owed: 0, workers: 0 };
18+
const r = db.prepare(`
19+
SELECT COALESCE(SUM(accrued_sats), 0) AS accrued,
20+
COALESCE(SUM(paid_sats), 0) AS paid,
21+
COUNT(*) AS workers
22+
FROM pps_credits
23+
`).get();
24+
return {
25+
accrued: Number(r.accrued),
26+
paid: Number(r.paid),
27+
owed: Number(r.accrued) - Number(r.paid),
28+
workers: Number(r.workers),
29+
};
30+
}
31+
32+
/* Per-worker balances, sorted by outstanding first. `thunder_address`
33+
* is what the payout worker will target. */
34+
export function perWorkerBalances(handle) {
35+
const db = unwrap(handle);
36+
if (!db) return [];
37+
return db.prepare(`
38+
SELECT w.id AS worker_id,
39+
w.name AS worker_name,
40+
w.payout_address AS thunder_address,
41+
c.accrued_sats AS accrued,
42+
c.paid_sats AS paid,
43+
(c.accrued_sats - c.paid_sats) AS owed,
44+
c.last_updated AS last_updated
45+
FROM pps_credits c
46+
JOIN workers w ON w.id = c.worker_id
47+
ORDER BY owed DESC
48+
`).all().map(r => ({
49+
...r,
50+
accrued: Number(r.accrued),
51+
paid: Number(r.paid),
52+
owed: Number(r.owed),
53+
last_updated: Number(r.last_updated),
54+
}));
55+
}
56+
57+
/* Current in-flight payouts. A row with an empty txid means the
58+
* broadcast may or may not have gone out (worker crashed between
59+
* INSERT and RPC); a row with a txid means the broadcast succeeded
60+
* but the finalize transaction crashed. Either state requires
61+
* operator attention — see payout/README.md for the reconciliation
62+
* runbook. */
63+
export function inFlight(handle) {
64+
const db = unwrap(handle);
65+
if (!db) return [];
66+
return db.prepare(`
67+
SELECT p.id, p.worker_id, w.name AS worker_name,
68+
w.payout_address AS thunder_address,
69+
p.sats, p.txid, p.started_at
70+
FROM payouts_in_flight p
71+
JOIN workers w ON w.id = p.worker_id
72+
ORDER BY p.started_at ASC
73+
`).all().map(r => ({
74+
...r,
75+
sats: Number(r.sats),
76+
started_at: Number(r.started_at),
77+
}));
78+
}
79+
80+
/* Minimal Thunder JSON-RPC client — we only need `balance()`. Short
81+
* timeout so the admin page renders promptly even when Thunder is
82+
* unreachable. */
83+
export async function thunderBalance(rpcUrl, timeoutMs = 1500) {
84+
const ctrl = new AbortController();
85+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
86+
try {
87+
const res = await fetch(rpcUrl, {
88+
method: 'POST',
89+
headers: { 'content-type': 'application/json' },
90+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'balance', params: [] }),
91+
signal: ctrl.signal,
92+
});
93+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
94+
const body = await res.json();
95+
if (body.error) throw new Error(`${body.error.code} ${body.error.message}`);
96+
return { ok: true, available_sats: Number(body.result?.available_sats ?? 0),
97+
total_sats: Number(body.result?.total_sats ?? 0) };
98+
} catch (e) {
99+
return { ok: false, error: e.message, available_sats: 0, total_sats: 0 };
100+
} finally {
101+
clearTimeout(t);
102+
}
103+
}

dashboard/server.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
44
import { renderFile } from 'ejs';
55
import { openDb } from './lib/db.js';
66
import * as stats from './lib/stats.js';
7+
import * as admin from './lib/admin.js'; /* PPS ADMIN PATCH */
78

89
const __dirname = path.dirname(fileURLToPath(import.meta.url));
910
const PORT = parseInt(process.env.PORT || '8081', 10);
@@ -67,6 +68,56 @@ app.get('/api/blocks', (req, res) => {
6768
});
6869
app.get('/healthz', (req, res) => res.json({ ok: true, db_ready: db.ready() }));
6970

71+
72+
/* PPS ADMIN PATCH — pool operator view. Basic auth via ADMIN_USER /
73+
* ADMIN_PASSWORD env vars; both required or the routes 503 (so we
74+
* fail closed if someone forgets to set them). */
75+
const ADMIN_USER = process.env.ADMIN_USER || '';
76+
const ADMIN_PASS = process.env.ADMIN_PASSWORD || '';
77+
const RESERVE_ADDRESS = process.env.POOL_THUNDER_RESERVE_ADDRESS || '(unset)';
78+
const THUNDER_RPC_URL = process.env.THUNDER_RPC_URL || 'http://127.0.0.1:6009';
79+
80+
function requireAdminAuth(req, res, next) {
81+
if (!ADMIN_USER || !ADMIN_PASS) {
82+
return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD');
83+
}
84+
const h = req.headers.authorization || '';
85+
if (h.startsWith('Basic ')) {
86+
const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
87+
if (u === ADMIN_USER && p === ADMIN_PASS) return next();
88+
}
89+
res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin"');
90+
return res.status(401).send('unauthorised');
91+
}
92+
93+
async function adminSummary() {
94+
const [reserve, totals, workers, inFlight] = await Promise.all([
95+
admin.thunderBalance(THUNDER_RPC_URL),
96+
Promise.resolve(admin.poolTotals(db)),
97+
Promise.resolve(admin.perWorkerBalances(db)),
98+
Promise.resolve(admin.inFlight(db)),
99+
]);
100+
return { reserve, reserveAddress: RESERVE_ADDRESS, totals, workers, inFlight };
101+
}
102+
103+
app.get('/admin', requireAdminAuth, async (req, res) => {
104+
try {
105+
const data = await adminSummary();
106+
res.render('admin', data);
107+
} catch (e) {
108+
res.status(500).send('admin: ' + e.message);
109+
}
110+
});
111+
112+
app.get('/api/admin/summary', requireAdminAuth, async (req, res) => {
113+
try {
114+
res.json(await adminSummary());
115+
} catch (e) {
116+
res.status(500).json({ error: e.message });
117+
}
118+
});
119+
/* END PPS ADMIN PATCH */
120+
70121
app.use((req, res) => res.status(404).render('404', { what: 'page' }));
71122

72123
app.listen(PORT, () => {

dashboard/views/admin.ejs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<%
2+
const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0);
3+
const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '';
4+
const ago = (t) => {
5+
if (!t) return '';
6+
const s = Math.max(0, Math.floor(Date.now() / 1000) - t);
7+
if (s < 60) return s + 's ago';
8+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
9+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
10+
return Math.floor(s / 86400) + 'd ago';
11+
};
12+
const fmtSats = (sats) => {
13+
if (!sats) return '0 sats';
14+
const btc = sats / 1e8;
15+
if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)';
16+
return fmtN(sats) + ' sats';
17+
};
18+
%>
19+
<!doctype html>
20+
<html lang="en">
21+
<head>
22+
<meta charset="utf-8">
23+
<meta name="viewport" content="width=device-width, initial-scale=1">
24+
<meta http-equiv="refresh" content="15">
25+
<title>admin · simplepool</title>
26+
<link rel="stylesheet" href="/static/style.css">
27+
</head>
28+
<body>
29+
<header class="top">
30+
<a href="/" class="brand">simplepool</a>
31+
<span class="tag">pool operator</span>
32+
</header>
33+
<main class="wrap">
34+
<section class="card info">
35+
<h2>Pool operator view</h2>
36+
<p class="muted small">
37+
Read-only. Reserve balance is a live probe of Thunder's
38+
JSON-RPC; everything else is the same SQLite the pool
39+
writes. Reconciliation is manual — see
40+
<code>payout/README.md</code> for the runbook.
41+
</p>
42+
</section>
43+
44+
<section class="card">
45+
<h2>Thunder reserve</h2>
46+
<% if (reserve.ok) { %>
47+
<div class="grid">
48+
<div><label>Available</label><strong><%= fmtSats(reserve.available_sats) %></strong></div>
49+
<div><label>Total</label><strong><%= fmtSats(reserve.total_sats) %></strong></div>
50+
</div>
51+
<p class="mono small muted" style="margin-top:0.5em;word-break:break-all"><%= reserveAddress %></p>
52+
<% } else { %>
53+
<p class="muted">Thunder RPC unreachable: <em><%= reserve.error %></em></p>
54+
<p class="mono small muted" style="margin-top:0.5em;word-break:break-all"><%= reserveAddress %></p>
55+
<% } %>
56+
</section>
57+
58+
<section class="card">
59+
<h2>PPS ledger totals</h2>
60+
<div class="grid">
61+
<div><label>Owed (needs payout)</label><strong><%= fmtSats(totals.owed) %></strong></div>
62+
<div><label>Accrued (all time)</label><strong><%= fmtSats(totals.accrued) %></strong></div>
63+
<div><label>Paid (all time)</label><strong><%= fmtSats(totals.paid) %></strong></div>
64+
<div><label>Workers with a balance</label><strong><%= fmtN(totals.workers) %></strong></div>
65+
</div>
66+
<% if (reserve.ok && totals.owed > reserve.available_sats) { %>
67+
<p class="muted small" style="margin-top:0.75em;color:#c66">
68+
⚠ Reserve is short by
69+
<%= fmtSats(totals.owed - reserve.available_sats) %>.
70+
The payout worker will keep skipping ticks until this is closed
71+
(deposit BTC into the reserve via the two-step deposit service).
72+
</p>
73+
<% } %>
74+
</section>
75+
76+
<section class="card">
77+
<h2>Per-worker balances</h2>
78+
<% if (workers.length === 0) { %>
79+
<p class="muted">No workers have a PPS balance yet.</p>
80+
<% } else { %>
81+
<table>
82+
<thead>
83+
<tr>
84+
<th>Worker</th>
85+
<th>Thunder address</th>
86+
<th>Owed</th>
87+
<th>Accrued</th>
88+
<th>Paid</th>
89+
<th>Updated</th>
90+
</tr>
91+
</thead>
92+
<tbody>
93+
<% workers.forEach(w => { %>
94+
<tr>
95+
<td class="mono small"><%= w.worker_name %></td>
96+
<td class="mono small"><%= w.thunder_address %></td>
97+
<td><strong><%= fmtSats(w.owed) %></strong></td>
98+
<td><%= fmtSats(w.accrued) %></td>
99+
<td><%= fmtSats(w.paid) %></td>
100+
<td class="muted small" title="<%= fmtTs(w.last_updated) %>"><%= ago(w.last_updated) %></td>
101+
</tr>
102+
<% }) %>
103+
</tbody>
104+
</table>
105+
<% } %>
106+
</section>
107+
108+
<section class="card">
109+
<h2>In-flight payouts</h2>
110+
<% if (inFlight.length === 0) { %>
111+
<p class="muted">Nothing in flight — clean.</p>
112+
<% } else { %>
113+
<p class="muted small">
114+
Rows here need operator attention. Empty <em>txid</em>
115+
means the Thunder broadcast may not have gone out; a
116+
set <em>txid</em> means the broadcast succeeded but the
117+
DB finalize crashed. Never auto-resolved.
118+
</p>
119+
<table>
120+
<thead>
121+
<tr>
122+
<th>#</th>
123+
<th>Worker</th>
124+
<th>Amount</th>
125+
<th>Age</th>
126+
<th>txid</th>
127+
<th>State</th>
128+
</tr>
129+
</thead>
130+
<tbody>
131+
<% inFlight.forEach(r => { %>
132+
<tr>
133+
<td><%= r.id %></td>
134+
<td class="mono small"><%= r.worker_name %></td>
135+
<td><%= fmtSats(r.sats) %></td>
136+
<td class="muted small" title="<%= fmtTs(r.started_at) %>"><%= ago(r.started_at) %></td>
137+
<td class="mono small"><%= r.txid || '' %></td>
138+
<td><%= r.txid ? 'broadcast, finalize pending' : 'unbroadcast' %></td>
139+
</tr>
140+
<% }) %>
141+
</tbody>
142+
</table>
143+
<% } %>
144+
</section>
145+
</main>
146+
<footer class="foot">
147+
<span>read-only · auto-refresh 15s</span>
148+
<span class="sep">·</span>
149+
<span>pool operator view</span>
150+
</footer>
151+
</body>
152+
</html>

deploy/systemd/simplepool-dashboard.service

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ Group=@USER@
1111
WorkingDirectory=@ROOT@/dashboard
1212
Environment=PORT=8081
1313
Environment=PROXY_DB_PATH=@ROOT@/data/shares.db
14+
# --- PPS admin panel — /admin route ---
15+
# When ADMIN_USER + ADMIN_PASSWORD are both set, /admin serves the pool
16+
# operator view (reserve balance, ledger totals, per-worker owed,
17+
# in-flight payouts). Without both, /admin returns 503 (fail-closed).
18+
Environment=THUNDER_RPC_URL=http://127.0.0.1:6009
19+
# Environment=POOL_THUNDER_RESERVE_ADDRESS=<paste from proxy.conf>
20+
# Environment=ADMIN_USER=admin
21+
# Environment=ADMIN_PASSWORD=<generate with `openssl rand -base64 21`>
1422
ExecStart=/usr/bin/node @ROOT@/dashboard/server.js
1523
Restart=on-failure
1624
RestartSec=3
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[Unit]
2+
Description=simplepool Thunder payout worker
3+
Documentation=https://github.com/rsantacroce/simplepool
4+
After=network-online.target
5+
Wants=network-online.target
6+
7+
[Service]
8+
Type=simple
9+
User=@USER@
10+
Group=@USER@
11+
WorkingDirectory=@ROOT@/payout
12+
# All required env vars must be set here — the worker exits if any is
13+
# missing. THUNDER_FROM_ADDRESS must equal pool_thunder_reserve_address
14+
# in proxy.conf so payouts drain from the same Thunder wallet the
15+
# coinbase is depositing into.
16+
Environment=PAYOUT_DB_PATH=@ROOT@/data/shares.db
17+
Environment=THUNDER_RPC_URL=http://127.0.0.1:6009
18+
# Environment=THUNDER_FROM_ADDRESS=<paste from proxy.conf>
19+
Environment=PAYOUT_MIN_SATS=10000
20+
Environment=PAYOUT_INTERVAL_MS=30000
21+
ExecStart=/usr/bin/node @ROOT@/payout/index.js
22+
Restart=on-failure
23+
RestartSec=5
24+
StandardOutput=journal
25+
StandardError=journal
26+
27+
NoNewPrivileges=true
28+
PrivateTmp=true
29+
ProtectSystem=full
30+
ProtectHome=read-only
31+
ReadWritePaths=@ROOT@/data
32+
33+
[Install]
34+
WantedBy=multi-user.target

0 commit comments

Comments
 (0)