' +
+ '');
+ });
+
+ /* --- central error handler for /admin/* — never leak stack traces --- */
+ router.use((err, _req, res, _next) => {
+ console.error('[admin] route error:', err);
+ flashRedirect(res,
+ { ok: false, msg: 'admin route error', detail: err.message || String(err) },
+ '/admin');
+ });
+
+ return router;
+}
diff --git a/dashboard/lib/admin.js b/dashboard/lib/admin.js
index 4c12051..5a323a9 100644
--- a/dashboard/lib/admin.js
+++ b/dashboard/lib/admin.js
@@ -254,6 +254,33 @@ export function recentBlocksFound(handle, limit = 15) {
}));
}
+/* Probe the enforcer wallet's BTC balance via gRPC (WalletService.GetBalance).
+ * Shells out to grpcurl — same binary the deposit action uses. Short
+ * timeout so the admin page renders even when the enforcer is slow.
+ * Returns { ok, confirmed_sats, pending_sats, has_synced, error? }. */
+export async function enforcerBalance(grpcurlBin, enforcerGrpcAddr, timeoutMs = 3000) {
+ const { execFile } = await import('node:child_process');
+ const { promisify } = await import('node:util');
+ const execFileAsync = promisify(execFile);
+ try {
+ const { stdout } = await execFileAsync(grpcurlBin, [
+ '-plaintext', '-d', '{}',
+ enforcerGrpcAddr,
+ 'cusf.mainchain.v1.WalletService/GetBalance',
+ ], { timeout: timeoutMs, maxBuffer: 256 * 1024 });
+ const j = JSON.parse(stdout);
+ return {
+ ok: true,
+ confirmed_sats: Number(j.confirmedSats ?? 0),
+ pending_sats: Number(j.pendingSats ?? 0),
+ has_synced: Boolean(j.hasSynced),
+ };
+ } catch (e) {
+ const msg = (e.stderr || '').toString().slice(0, 200) || e.message;
+ return { ok: false, error: msg, confirmed_sats: 0, pending_sats: 0, has_synced: false };
+ }
+}
+
/* Minimal Thunder JSON-RPC client — we only need `balance()`. Short
* timeout so the admin page renders promptly even when Thunder is
* unreachable. */
diff --git a/dashboard/lib/csrf.js b/dashboard/lib/csrf.js
new file mode 100644
index 0000000..b6fb5b6
--- /dev/null
+++ b/dashboard/lib/csrf.js
@@ -0,0 +1,40 @@
+/* Tiny in-memory CSRF token store.
+ *
+ * HTTP Basic auth alone doesn't defend against CSRF: a malicious page can
+ * make the browser POST cross-site with the cached basic-auth header. We
+ * mint a random token when the admin page is rendered, stash it, and
+ * require it back on every POST /admin/action/*. The attacker can't read
+ * the admin page (basic auth blocks the GET), so they can't obtain a
+ * valid token.
+ *
+ * Tokens are single-use and expire after CSRF_TTL_MS. Kept in memory —
+ * fine for a single dashboard process; a multi-worker deployment would
+ * need shared storage. */
+
+import { randomBytes } from 'node:crypto';
+
+const CSRF_TTL_MS = 60 * 60 * 1000; // 1 hour
+
+const tokens = new Map(); // token -> expiresAtMs
+
+function sweep() {
+ const now = Date.now();
+ for (const [t, exp] of tokens) if (exp < now) tokens.delete(t);
+}
+
+export function issueToken() {
+ sweep();
+ const t = randomBytes(24).toString('base64url');
+ tokens.set(t, Date.now() + CSRF_TTL_MS);
+ return t;
+}
+
+/* Consumes the token — a second call with the same token returns false. */
+export function consumeToken(t) {
+ if (!t || typeof t !== 'string') return false;
+ sweep();
+ const exp = tokens.get(t);
+ if (!exp) return false;
+ tokens.delete(t);
+ return exp >= Date.now();
+}
diff --git a/dashboard/lib/db-admin.js b/dashboard/lib/db-admin.js
new file mode 100644
index 0000000..60d0e24
--- /dev/null
+++ b/dashboard/lib/db-admin.js
@@ -0,0 +1,13 @@
+/* Writable SQLite handle used by admin write-actions (only the
+ * deposit action today).
+ *
+ * Kept in its own module so no read-side code (stats.js, admin.js) can
+ * accidentally reach for the writable handle. server.js imports both
+ * openDb (read-only) and openAdminDb (writable) explicitly and passes
+ * each to the routes that need it. */
+
+import { openDb } from './db.js';
+
+export function openAdminDb(dbPath) {
+ return openDb(dbPath, { readonly: false });
+}
diff --git a/dashboard/lib/db.js b/dashboard/lib/db.js
index 62cecc2..e334a0b 100644
--- a/dashboard/lib/db.js
+++ b/dashboard/lib/db.js
@@ -7,7 +7,7 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
-export function openDb(dbPath) {
+export function openDb(dbPath, { readonly = true } = {}) {
let db = null;
let lastTryMs = 0;
@@ -18,7 +18,7 @@ export function openDb(dbPath) {
lastTryMs = now;
if (!fs.existsSync(dbPath)) return null;
try {
- db = new Database(dbPath, { readonly: true, fileMustExist: true });
+ db = new Database(dbPath, { readonly, fileMustExist: true });
// WAL is required for read-consistency while the C proxy writes.
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 2000');
diff --git a/dashboard/lib/fmt.js b/dashboard/lib/fmt.js
new file mode 100644
index 0000000..2d54c0d
--- /dev/null
+++ b/dashboard/lib/fmt.js
@@ -0,0 +1,43 @@
+/* Formatting helpers shared across views. server.js drops them onto
+ * res.locals so every EJS render sees them without a per-view
+ * <% const fmt... %> preamble. */
+
+export const fmtN = (n) =>
+ new Intl.NumberFormat('en-US').format(n || 0);
+
+export const fmtF = (n, d = 4) =>
+ (typeof n === 'number' ? n.toFixed(d) : '—');
+
+export const fmtTs = (t) =>
+ t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—';
+
+export const ago = (t) => {
+ if (!t) return '—';
+ const s = Math.max(0, Math.floor(Date.now() / 1000) - t);
+ if (s < 60) return s + 's ago';
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
+ return Math.floor(s / 86400) + 'd ago';
+};
+
+export const fmtSats = (sats) => {
+ if (!sats) return '0 sats';
+ const btc = sats / 1e8;
+ if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)';
+ return fmtN(sats) + ' sats';
+};
+
+/* Adaptive-precision percent — keeps small miners visible instead of
+ * rendering them as 0.00%. Used by the public overview. */
+export const fmtPct = (p) => {
+ if (p == null || !isFinite(p)) return '—';
+ if (p === 0) return '0%';
+ const abs = Math.abs(p);
+ if (abs >= 1) return p.toFixed(2) + '%';
+ if (abs >= 0.01) return p.toFixed(3) + '%';
+ if (abs >= 0.0001) return p.toFixed(4) + '%';
+ return p.toPrecision(2) + '%';
+};
+
+/* Convenience bundle for res.locals middleware. */
+export const all = { fmtN, fmtF, fmtTs, ago, fmtSats, fmtPct };
diff --git a/dashboard/public/style.css b/dashboard/public/style.css
index 2986e3a..b636d1e 100644
--- a/dashboard/public/style.css
+++ b/dashboard/public/style.css
@@ -31,10 +31,46 @@ a:hover { text-decoration: underline; }
display: flex;
align-items: baseline;
gap: 16px;
+ flex-wrap: wrap;
}
-.brand { font-weight: 700; font-size: 18px; color: var(--fg); }
+.top-admin { border-bottom-color: #c66; background: #c6660d; color: #fff; }
+.top-admin .brand { color: #fff; }
+.top-admin a { color: #fff; }
+.top-admin .tag-admin { color: #fff; background: #922; padding: 2px 8px; border-radius: 3px; }
+.brand { font-weight: 700; font-size: 18px; color: var(--fg); text-decoration: none; }
.tag { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; }
+.topnav {
+ display: flex;
+ gap: 14px;
+ margin-left: 8px;
+ align-items: baseline;
+}
+.topnav a {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 14px;
+ padding: 2px 0;
+ border-bottom: 2px solid transparent;
+}
+.topnav a:hover { color: var(--fg); }
+.topnav a.active { color: var(--fg); border-bottom-color: currentColor; font-weight: 600; }
+.top-admin .topnav a { color: #eee; }
+.top-admin .topnav a.active { color: #fff; border-bottom-color: #fff; }
+
+.topsearch { margin-left: auto; display: flex; gap: 4px; }
+.topsearch input { padding: 4px 8px; font-size: 13px; border: 1px solid var(--border); border-radius: 3px; min-width: 240px; }
+.topsearch button { padding: 4px 10px; font-size: 13px; }
+
+.logout { margin-left: auto; font-size: 13px; opacity: 0.85; }
+.logout:hover { opacity: 1; }
+
+.flash { border-left: 4px solid; margin-top: 12px; }
+.flash-ok { border-left-color: #5b8; }
+.flash-ok h2 { color: #5b8; }
+.flash-err { border-left-color: #c66; }
+.flash-err h2 { color: #c66; }
+
.wrap {
max-width: 1100px;
margin: 0 auto;
diff --git a/dashboard/server.js b/dashboard/server.js
index 91b9c46..478a7be 100644
--- a/dashboard/server.js
+++ b/dashboard/server.js
@@ -1,48 +1,95 @@
+/* simplepool dashboard — two surfaces, one process:
+ *
+ * - Public read-only: /, /worker/:name, /blocks, /api/*
+ * - Admin: /admin/* (Basic auth on every route)
+ *
+ * Admin routes live in lib/admin-router.js; server.js is the assembly:
+ * open DB handles, mount routes, wire middleware.
+ */
+
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderFile } from 'ejs';
import { openDb } from './lib/db.js';
+import { openAdminDb } from './lib/db-admin.js';
import * as stats from './lib/stats.js';
-import * as admin from './lib/admin.js'; /* PPS ADMIN PATCH */
+import * as fmt from './lib/fmt.js';
+import { createAdminRouter } from './lib/admin-router.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const PORT = parseInt(process.env.PORT || '8081', 10);
-// Default to the live SQLite database. WAL mode + readonly handle is safe
-// to point at the file the proxy is actively writing to. The snapshot path
-// (data/shares.snapshot.db) is still supported for users who run the cron
-// `.backup` job documented in the README — set PROXY_DB_PATH to override.
-const DB_PATH = process.env.PROXY_DB_PATH || '../data/shares.db';
+const PORT = parseInt(process.env.PORT || '8081', 10);
+const DB_PATH = process.env.PROXY_DB_PATH || '../data/shares.db';
const app = express();
app.engine('ejs', renderFile);
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.disable('x-powered-by');
+
+/* --- security headers on every response --------------------------------- */
+app.use((_req, res, next) => {
+ /* No cookies here yet, but if any land later they must be same-origin. */
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('X-Frame-Options', 'DENY');
+ res.setHeader('Referrer-Policy', 'same-origin');
+ res.setHeader('Permissions-Policy', 'interest-cohort=()');
+ next();
+});
+
+/* --- fmt helpers on every render ---------------------------------------- */
+app.use((_req, res, next) => {
+ Object.assign(res.locals, fmt.all);
+ next();
+});
+
app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
-const db = openDb(path.resolve(__dirname, DB_PATH));
+/* Two handles: read-only for public + admin read routes, writable one
+ * confined to admin write actions (lives inside admin router). */
+const db = openDb(path.resolve(__dirname, DB_PATH));
+const dbRw = openAdminDb(path.resolve(__dirname, DB_PATH));
-/* Shown on the public "Connect a miner" card. Env-driven so the shipped
- * template doesn't hardcode any particular deployment's host. */
+/* --- public-side config ------------------------------------------------- */
const PUBLIC_STRATUM_URL = process.env.PUBLIC_STRATUM_URL || 'stratum+tcp://:3334';
-/* Read once here so the public per-worker page can render the audit
- * cross-check for the miner without needing admin auth. The admin
- * patch block below also reads this — the constant is shared. */
-const PPS_SATS_PER_DIFF = parseFloat(process.env.POOL_PPS_SATS_PER_DIFF || '1000');
-
-app.get('/', (req, res) => {
- const ov = stats.overview(db);
- const lb = stats.leaderboard(db);
+const PPS_SATS_PER_DIFF = parseFloat(process.env.POOL_PPS_SATS_PER_DIFF || '1000');
+
+/* --- admin-side config -------------------------------------------------- */
+const ADMIN_USER = process.env.ADMIN_USER || '';
+const ADMIN_PASS = process.env.ADMIN_PASSWORD || '';
+const RESERVE_ADDRESS = process.env.POOL_THUNDER_RESERVE_ADDRESS || '(unset)';
+const THUNDER_RPC_URL = process.env.THUNDER_RPC_URL || 'http://127.0.0.1:6009';
+const PAYOUT_ADMIN_URL = process.env.PAYOUT_ADMIN_URL || '';
+const ENFORCER_GRPC_ADDR = process.env.ENFORCER_GRPC_ADDR || '127.0.0.1:50051';
+const GRPCURL_BIN = process.env.GRPCURL_BIN || 'grpcurl';
+const THUNDER_SIDECHAIN_ID = parseInt(process.env.THUNDER_SIDECHAIN_ID || '9', 10);
+
+function requireAdminAuth(req, res, next) {
+ if (!ADMIN_USER || !ADMIN_PASS) {
+ return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD');
+ }
+ const h = req.headers.authorization || '';
+ if (h.startsWith('Basic ')) {
+ const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
+ if (u === ADMIN_USER && p === ADMIN_PASS) return next();
+ }
+ res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin"');
+ return res.status(401).send('unauthorised');
+}
+
+/* ================================ PUBLIC ================================ */
+
+app.get('/', (_req, res) => {
+ const ov = stats.overview(db);
+ const lb = stats.leaderboard(db);
const lbAddr = stats.leaderboardByAddress(db);
const blocks = stats.recentBlocks(db, 5);
- const node = stats.nodeStatus(db);
+ const node = stats.nodeStatus(db);
res.render('index', {
ov, lb, lbAddr, blocks, node,
- stratumUrl: PUBLIC_STRATUM_URL,
+ stratumUrl: PUBLIC_STRATUM_URL,
fmtHashrate: stats.fmtHashrate,
- fmtBtc: stats.fmtBtc,
- fmtPct: stats.fmtPct,
+ fmtBtc: stats.fmtBtc,
});
});
@@ -52,10 +99,19 @@ app.get('/worker/:name', (req, res) => {
res.render('worker', {
...w, name: req.params.name,
fmtHashrate: stats.fmtHashrate,
- fmtPct: stats.fmtPct,
});
});
+/* Search box on the public nav points here (GET /worker-lookup?name=…).
+ * Strip whitespace, cap length, redirect to the canonical URL — or 404
+ * quietly if empty. */
+app.get('/worker-lookup', (req, res) => {
+ const raw = typeof req.query.name === 'string' ? req.query.name.trim() : '';
+ if (!raw) return res.redirect(302, '/');
+ const name = raw.slice(0, 200);
+ return res.redirect(302, '/worker/' + encodeURIComponent(name));
+});
+
app.get('/blocks', (req, res) => {
const beforeTs = req.query.before ? Number(req.query.before) : null;
const page = stats.allBlocks(db, { limit: 50, beforeTs });
@@ -66,10 +122,11 @@ app.get('/blocks', (req, res) => {
});
});
-app.get('/api/overview', (req, res) => res.json(stats.overview(db)));
-app.get('/api/node', (req, res) => res.json(stats.nodeStatus(db) || {}));
-app.get('/api/leaderboard', (req, res) => res.json(stats.leaderboard(db)));
-app.get('/api/leaderboard/by-address', (req, res) => res.json(stats.leaderboardByAddress(db)));
+/* --- JSON API (unchanged) ---------------------------------------------- */
+app.get('/api/overview', (_req, res) => res.json(stats.overview(db)));
+app.get('/api/node', (_req, res) => res.json(stats.nodeStatus(db) || {}));
+app.get('/api/leaderboard', (_req, res) => res.json(stats.leaderboard(db)));
+app.get('/api/leaderboard/by-address',(_req, res) => res.json(stats.leaderboardByAddress(db)));
app.get('/api/worker/:name', (req, res) => {
const w = stats.worker(db, req.params.name);
if (!w.worker) return res.status(404).json({ error: 'unknown worker' });
@@ -80,96 +137,27 @@ app.get('/api/blocks', (req, res) => {
const limit = req.query.limit ? Number(req.query.limit) : 50;
res.json(stats.allBlocks(db, { limit, beforeTs }));
});
-app.get('/healthz', (req, res) => res.json({ ok: true, db_ready: db.ready() }));
-
-
-/* PPS ADMIN PATCH — pool operator view. Basic auth via ADMIN_USER /
- * ADMIN_PASSWORD env vars; both required or the routes 503 (so we
- * fail closed if someone forgets to set them). */
-const ADMIN_USER = process.env.ADMIN_USER || '';
-const ADMIN_PASS = process.env.ADMIN_PASSWORD || '';
-const RESERVE_ADDRESS = process.env.POOL_THUNDER_RESERVE_ADDRESS || '(unset)';
-const THUNDER_RPC_URL = process.env.THUNDER_RPC_URL || 'http://127.0.0.1:6009';
-/* PPS_SATS_PER_DIFF is declared at the top of the file so both the
- * public /worker/:name view and the admin routes can share it. */
-
-function requireAdminAuth(req, res, next) {
- if (!ADMIN_USER || !ADMIN_PASS) {
- return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD');
- }
- const h = req.headers.authorization || '';
- if (h.startsWith('Basic ')) {
- const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
- if (u === ADMIN_USER && p === ADMIN_PASS) return next();
- }
- res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin"');
- return res.status(401).send('unauthorised');
-}
-
-async function adminSummary() {
- const [reserve, totals, workers, inFlight, payouts, deposits, blocks] = await Promise.all([
- admin.thunderBalance(THUNDER_RPC_URL),
- Promise.resolve(admin.poolTotals(db)),
- Promise.resolve(admin.perWorkerBalances(db)),
- Promise.resolve(admin.inFlight(db)),
- Promise.resolve(admin.recentPayouts(db, 25)),
- Promise.resolve(admin.recentDeposits(db, 25)),
- Promise.resolve(admin.recentBlocksFound(db, 15)),
- ]);
- return {
- reserve, reserveAddress: RESERVE_ADDRESS,
- totals, workers, inFlight,
- payouts, deposits, blocks,
- };
-}
-
-app.get('/admin', requireAdminAuth, async (req, res) => {
- try {
- const data = await adminSummary();
- res.render('admin', data);
- } catch (e) {
- res.status(500).send('admin: ' + e.message);
- }
-});
-
-app.get('/api/admin/summary', requireAdminAuth, async (req, res) => {
- try {
- res.json(await adminSummary());
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-
-/* Per-worker audit: "why is my balance N sats?" answered end-to-end. */
-function workerAuditFor(workerId) {
- const audit = admin.workerAudit(db, workerId, { rate: PPS_SATS_PER_DIFF });
- if (!audit) return null;
- audit.payouts = admin.payoutsForWorker(db, workerId, 100);
- return audit;
-}
-
-app.get('/admin/worker/:id', requireAdminAuth, (req, res) => {
- try {
- const audit = workerAuditFor(parseInt(req.params.id, 10));
- if (!audit) return res.status(404).render('404', { what: 'worker' });
- res.render('admin-worker', { audit });
- } catch (e) {
- res.status(500).send('admin: ' + e.message);
- }
-});
-
-app.get('/api/admin/worker/:id', requireAdminAuth, (req, res) => {
- try {
- const audit = workerAuditFor(parseInt(req.params.id, 10));
- if (!audit) return res.status(404).json({ error: 'unknown worker id' });
- res.json(audit);
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-/* END PPS ADMIN PATCH */
-
-app.use((req, res) => res.status(404).render('404', { what: 'page' }));
+app.get('/healthz', (_req, res) => res.json({ ok: true, db_ready: db.ready() }));
+
+/* ================================ ADMIN ================================ */
+
+app.use('/admin',
+ requireAdminAuth,
+ createAdminRouter({
+ db,
+ dbRw,
+ THUNDER_RPC_URL,
+ PAYOUT_ADMIN_URL,
+ ENFORCER_GRPC_ADDR,
+ GRPCURL_BIN,
+ THUNDER_SIDECHAIN_ID,
+ RESERVE_ADDRESS,
+ PPS_SATS_PER_DIFF,
+ }));
+
+/* ================================ 404 =================================== */
+
+app.use((_req, res) => res.status(404).render('404', { what: 'page' }));
app.listen(PORT, () => {
console.log(`simplepool dashboard on :${PORT} (db: ${db.path})`);
diff --git a/dashboard/views/admin-deposits.ejs b/dashboard/views/admin-deposits.ejs
new file mode 100644
index 0000000..9eacf52
--- /dev/null
+++ b/dashboard/views/admin-deposits.ejs
@@ -0,0 +1,95 @@
+
+
+
+<%- include('partial/head', { title: 'admin · deposits · simplepool', refresh: 30 }) %>
+
+
+<%- include('partial/nav-admin', { active: 'deposits' }) %>
+
+ <%- include('partial/flash', { flash }) %>
+
+
+
Deposits (mainchain → Thunder)
+
+ The pool operator batches accumulated BTC into Thunder here.
+ Each deposit spends enforcer-wallet UTXOs into a BIP300
+ sidechain-#<%= reserveSidechainId %> tx; the enforcer signs and
+ broadcasts, the mainchain confirms, and Thunder eventually
+ processes the deposit into a spendable UTXO the payout worker
+ can draw from.
+
+ Live balances and pool-wide totals. Use the nav above for
+ per-page detail (workers, deposits, payouts) and the tools tab
+ for one-off ops like nudging Thunder or clearing stuck txs.
+
+
+
+
+
Thunder reserve
+ <% if (reserve.ok) { %>
+
+
<%= fmtSats(reserve.available_sats) %>
+
<%= fmtSats(reserve.total_sats) %>
+
+
<%= reserveAddress %>
+ <% } else { %>
+
Thunder RPC unreachable: <%= reserve.error %>
+
<%= reserveAddress %>
+ <% } %>
+
+
+
+
Pool BTC (enforcer wallet)
+
+ Spendable balance the Deposit BTC → Thunder
+ action can draw from. Fund it by sending BTC to a fresh
+ enforcer address from your operator wallet.
+
+ ⚠ Reserve is short by
+ <%= fmtSats(totals.owed - reserve.available_sats) %>.
+ The payout worker will keep skipping ticks until this is closed.
+ Head to Deposits to top up the reserve.
+
+ The Thunder payout worker ticks every ~30s and drains any
+ worker whose owed balance ≥ min-payout. Trigger a cycle
+ manually below if you don't want to wait.
+
+
+
+
+
Trigger cycle now
+
+
+
+
+
In-flight
+ <% if (inFlight.length === 0) { %>
+
Nothing in flight — clean.
+ <% } else { %>
+
+ Rows here need operator attention. Empty txid
+ means the Thunder broadcast may not have gone out; a set
+ txid means the broadcast succeeded but the DB
+ finalize crashed. Never auto-resolved.
+
+ One-off ops on Thunder. All CSRF-gated; each redirects back
+ here with a green/red banner. None of these move money —
+ payouts and deposits have their own pages.
+
+
+
+
+
Nudge Thunder mine
+
+
+
+
+
Remove stale Thunder tx
+
+
+
+
+
+
diff --git a/dashboard/views/admin-worker.ejs b/dashboard/views/admin-worker.ejs
index 91509bf..bf57f7c 100644
--- a/dashboard/views/admin-worker.ejs
+++ b/dashboard/views/admin-worker.ejs
@@ -1,21 +1,4 @@
<%
-const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0);
-const fmtF = (n, d = 4) => (typeof n === 'number' ? n.toFixed(d) : '—');
-const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—';
-const ago = (t) => {
- if (!t) return '—';
- const s = Math.max(0, Math.floor(Date.now() / 1000) - t);
- if (s < 60) return s + 's ago';
- if (s < 3600) return Math.floor(s / 60) + 'm ago';
- if (s < 86400) return Math.floor(s / 3600) + 'h ago';
- return Math.floor(s / 86400) + 'd ago';
-};
-const fmtSats = (sats) => {
- if (!sats) return '0 sats';
- const btc = sats / 1e8;
- if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)';
- return fmtN(sats) + ' sats';
-};
/* Precompute a running_accrued column for the recent-shares table.
* The rows come newest-first, so we walk in reverse to accumulate. */
const recentAsc = [...audit.recent].reverse();
@@ -30,19 +13,11 @@ const drift = audit.totals.accrued_computed - naiveAccrued;
-
-
-
- audit · <%= audit.worker.name %> · simplepool
-
+<%- include('partial/head', { title: `audit · ${audit.worker.name} · simplepool`, refresh: 30 }) %>
-
- simplepool
- worker audit
- ← back to admin
-
-
+<%- include('partial/nav-admin', { active: 'worker' }) %>
+
+ Every worker with a non-zero PPS ledger, sorted by
+ outstanding first. Click audit → for the byte-level
+ share breakdown the miner sees on their public page too.
+
- Read-only. Reserve balance is a live probe of Thunder's
- JSON-RPC; everything else is the same SQLite the pool
- writes. Reconciliation is manual — see
- payout/README.md for the runbook.
-
- ⚠ Reserve is short by
- <%= fmtSats(totals.owed - reserve.available_sats) %>.
- The payout worker will keep skipping ticks until this is closed
- (deposit BTC into the reserve via the two-step deposit service).
-
- Rows here need operator attention. Empty txid
- means the Thunder broadcast may not have gone out; a
- set txid means the broadcast succeeded but the
- DB finalize crashed. Never auto-resolved.
-
- Every block's coinbase pays the pool's pool_btc_address (in pps-classic
- mode) for reward. Operator batches accumulated BTC into Thunder via the
- deposits above. To see the coinbase txid on-chain:
- bitcoin-cli getblock <hash> 2.
-