Skip to content

Commit 5831c18

Browse files
committed
dashboard: split /admin into 5 sub-pages + top-nav + shared partials
Turns the read-only monolithic /admin page into a real operator console. Same functionality, but broken into focused tabs with clear navigation between them, and a matching public-side nav bar. Structure --------- Public (unchanged auth: none): GET / Overview GET /blocks Block history GET /worker/:name Per-worker GET /worker-lookup?name=X Redirects to /worker/X (nav search box) Admin (Basic auth on every route; router lives in lib/admin-router.js): GET /admin Overview (balances + health) GET /admin/workers Per-worker balance table GET /admin/deposits Deposits history + New Deposit form GET /admin/payouts Recent + in-flight + Trigger button GET /admin/tools Nudge mine + Remove-from-mempool GET /admin/worker/:id Per-worker audit (existed, now shares nav) GET /admin/logout 401 clears browser Basic-auth cache GET /admin/api/summary JSON (was /api/admin/summary) GET /admin/api/worker/:id JSON (was /api/admin/worker/:id) POST /admin/action/{nudge-mine,remove-from-mempool,trigger-payout,deposit} Robustness ---------- - Central error middleware inside the admin router — any thrown error becomes a red flash-redirect, never a leaked stack trace. - Action forms carry a hidden `return_to` so each POST redirects back to the page it was fired from; whitelist against a fixed set of admin paths prevents open-redirect abuse. - Writable SQLite handle moved to lib/db-admin.js; server.js opens two distinct handles and only the admin router receives the writable one, so no read path can accidentally trigger a write. - Security headers on every response: X-Content-Type-Options nosniff, X-Frame-Options DENY, Referrer-Policy same-origin, Permissions-Policy interest-cohort=(). - Fmt helpers (fmtN, fmtTs, ago, fmtSats, fmtPct) centralised in lib/fmt.js and dropped onto res.locals via middleware — views no longer duplicate the same 15-line preamble. Shared partials (views/partial/): head.ejs — <head> + optional refresh + title nav-public.ejs — public top nav + worker-name search box nav-admin.ejs — admin top nav (5 tabs + Log out), orange bar flash.ejs — green/red banner after any action redirect CSS additions (public/style.css): .top-admin (distinct color for admin surface) .topnav / .topsearch / .logout / .flash-{ok,err} Old dashboard/views/admin.ejs (381 lines) removed — its sections are distributed across the five new admin-*.ejs views. Not changed ----------- - Auth scheme stays HTTP Basic (per operator ask). /admin/logout is the only new user-visible endpoint that touches auth state. - No JSON API refactor / SPA — every page is still server-rendered EJS. - Public routes and their JSON APIs (/api/*) unchanged. Smoke tested locally: all 7 admin routes render, security headers present, POST actions carry the return_to through the whitelist, /admin/logout returns 401 with a valid ASCII WWW-Authenticate header.
1 parent e983bb9 commit 5831c18

19 files changed

Lines changed: 894 additions & 695 deletions

dashboard/lib/admin-router.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/* Admin router: the 5 sub-pages + the 4 write-action POSTs + the
2+
* per-worker audit page + /admin/logout.
3+
*
4+
* Every route on this router is protected by requireAdminAuth (mounted
5+
* in server.js). Writable DB access is confined to the deposit action.
6+
* All page renders share a single adminSummary() probe to keep the
7+
* chrome (balances, health chips) consistent across tabs.
8+
*/
9+
10+
import express from 'express';
11+
import * as admin from './admin.js';
12+
import * as actions from './actions.js';
13+
import { issueToken, consumeToken } from './csrf.js';
14+
15+
export function createAdminRouter({
16+
db, // read-only handle
17+
dbRw, // writable handle (lazy wrapper)
18+
THUNDER_RPC_URL,
19+
PAYOUT_ADMIN_URL,
20+
ENFORCER_GRPC_ADDR,
21+
GRPCURL_BIN,
22+
THUNDER_SIDECHAIN_ID,
23+
RESERVE_ADDRESS,
24+
PPS_SATS_PER_DIFF,
25+
} = {}) {
26+
const router = express.Router();
27+
const parseAdminForm = express.urlencoded({ extended: false, limit: '4kb' });
28+
29+
/* --- shared probe: called once per admin page render --- */
30+
async function adminSummary() {
31+
const [reserve, enforcer, totals, workers, inFlight, payouts, deposits, blocks] =
32+
await Promise.all([
33+
admin.thunderBalance(THUNDER_RPC_URL),
34+
admin.enforcerBalance(GRPCURL_BIN, ENFORCER_GRPC_ADDR),
35+
Promise.resolve(admin.poolTotals(db)),
36+
Promise.resolve(admin.perWorkerBalances(db)),
37+
Promise.resolve(admin.inFlight(db)),
38+
Promise.resolve(admin.recentPayouts(db, 25)),
39+
Promise.resolve(admin.recentDeposits(db, 25)),
40+
Promise.resolve(admin.recentBlocksFound(db, 15)),
41+
]);
42+
return {
43+
reserve, enforcer,
44+
reserveAddress: RESERVE_ADDRESS,
45+
reserveSidechainId: THUNDER_SIDECHAIN_ID,
46+
payoutAdminConfigured: !!PAYOUT_ADMIN_URL,
47+
totals, workers, inFlight, payouts, deposits, blocks,
48+
};
49+
}
50+
51+
/* --- flash: read from query string on GET, write via redirect on POST --- */
52+
function readFlash(req) {
53+
const msg = typeof req.query.flash === 'string' ? req.query.flash : '';
54+
if (!msg) return null;
55+
return {
56+
ok: req.query.flashOk === '1',
57+
msg,
58+
detail: typeof req.query.flashDetail === 'string' ? req.query.flashDetail : '',
59+
};
60+
}
61+
function flashRedirect(res, result, defaultTarget = '/admin') {
62+
const q = new URLSearchParams();
63+
q.set('flash', result.msg || (result.ok ? 'ok' : 'failed'));
64+
q.set('flashOk', result.ok ? '1' : '0');
65+
if (result.detail) q.set('flashDetail', result.detail);
66+
res.redirect(302, defaultTarget + '?' + q.toString());
67+
}
68+
/* Actions honour a hidden `return_to` form field so each POST goes
69+
* back to the page it was fired from. Whitelist against a small set
70+
* of admin routes so a bad actor can't redirect to elsewhere. */
71+
const RETURN_TO_ALLOW = new Set([
72+
'/admin', '/admin/overview',
73+
'/admin/workers', '/admin/deposits', '/admin/payouts', '/admin/tools',
74+
]);
75+
function returnTarget(req) {
76+
const t = req.body?.return_to;
77+
if (typeof t === 'string' && RETURN_TO_ALLOW.has(t)) return t;
78+
return '/admin';
79+
}
80+
81+
/* --- page helpers --- */
82+
async function renderAdminPage(view, req, res) {
83+
const summary = await adminSummary();
84+
res.render(view, {
85+
...summary,
86+
csrfToken: issueToken(),
87+
flash: readFlash(req),
88+
});
89+
}
90+
91+
/* --- CSRF gate for POST actions --- */
92+
function requireCsrf(req, res, next) {
93+
if (consumeToken(req.body?.csrf)) return next();
94+
flashRedirect(res,
95+
{ ok: false, msg: 'CSRF token missing or already used',
96+
detail: 'refresh the admin page and retry' },
97+
returnTarget(req));
98+
}
99+
100+
/* --- GET page routes --- */
101+
router.get('/', (req, res, next) => renderAdminPage('admin-overview', req, res).catch(next));
102+
router.get('/overview', (req, res) => res.redirect(301, '/admin'));
103+
router.get('/workers', (req, res, next) => renderAdminPage('admin-workers', req, res).catch(next));
104+
router.get('/deposits', (req, res, next) => renderAdminPage('admin-deposits', req, res).catch(next));
105+
router.get('/payouts', (req, res, next) => renderAdminPage('admin-payouts', req, res).catch(next));
106+
router.get('/tools', (req, res, next) => renderAdminPage('admin-tools', req, res).catch(next));
107+
108+
/* --- Per-worker audit (unchanged surface, uses admin.workerAudit) --- */
109+
function workerAuditFor(workerId) {
110+
const audit = admin.workerAudit(db, workerId, { rate: PPS_SATS_PER_DIFF });
111+
if (!audit) return null;
112+
audit.payouts = admin.payoutsForWorker(db, workerId, 100);
113+
return audit;
114+
}
115+
router.get('/worker/:id', (req, res) => {
116+
try {
117+
const audit = workerAuditFor(parseInt(req.params.id, 10));
118+
if (!audit) return res.status(404).render('404', { what: 'worker' });
119+
res.render('admin-worker', { audit });
120+
} catch (e) {
121+
res.status(500).send('admin: ' + e.message);
122+
}
123+
});
124+
125+
/* --- JSON API kept for scripted consumers --- */
126+
router.get('/api/summary', async (req, res) => {
127+
try { res.json(await adminSummary()); }
128+
catch (e) { res.status(500).json({ error: e.message }); }
129+
});
130+
router.get('/api/worker/:id', (req, res) => {
131+
try {
132+
const audit = workerAuditFor(parseInt(req.params.id, 10));
133+
if (!audit) return res.status(404).json({ error: 'unknown worker id' });
134+
res.json(audit);
135+
} catch (e) {
136+
res.status(500).json({ error: e.message });
137+
}
138+
});
139+
140+
/* --- POST write actions --- */
141+
router.post('/action/nudge-mine', parseAdminForm, requireCsrf, async (req, res) => {
142+
const r = await actions.nudgeMine({ thunderRpcUrl: THUNDER_RPC_URL });
143+
flashRedirect(res, r, returnTarget(req));
144+
});
145+
146+
router.post('/action/remove-from-mempool', parseAdminForm, requireCsrf, async (req, res) => {
147+
const r = await actions.removeFromMempool({
148+
thunderRpcUrl: THUNDER_RPC_URL,
149+
txid: (req.body?.txid || '').trim(),
150+
});
151+
flashRedirect(res, r, returnTarget(req));
152+
});
153+
154+
router.post('/action/trigger-payout', parseAdminForm, requireCsrf, async (req, res) => {
155+
const r = await actions.triggerPayout({ payoutAdminUrl: PAYOUT_ADMIN_URL });
156+
flashRedirect(res, r, returnTarget(req));
157+
});
158+
159+
router.post('/action/deposit', parseAdminForm, requireCsrf, async (req, res) => {
160+
const r = await actions.createDeposit({
161+
grpcurlBin: GRPCURL_BIN,
162+
enforcerGrpcAddr: ENFORCER_GRPC_ADDR,
163+
sidechainId: req.body?.sidechain_id ?? THUNDER_SIDECHAIN_ID,
164+
address: (req.body?.address || '').trim(),
165+
valueSats: req.body?.value_sats,
166+
feeSats: req.body?.fee_sats,
167+
db: dbRw?.get() || null,
168+
});
169+
flashRedirect(res, r, returnTarget(req));
170+
});
171+
172+
/* --- /admin/logout: 401 clears the browser's cached Basic creds ---
173+
* Realm string must be ASCII (HTTP headers) — no em dashes here. */
174+
router.get('/logout', (_req, res) => {
175+
res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin - signed out"');
176+
res.status(401).send(
177+
'<!doctype html><meta charset="utf-8">' +
178+
'<title>signed out · simplepool</title>' +
179+
'<link rel="stylesheet" href="/static/style.css">' +
180+
'<main class="wrap"><section class="card">' +
181+
'<h2>Signed out</h2>' +
182+
'<p>Browser Basic-auth cache is cleared. ' +
183+
'<a href="/admin">Sign back in</a> or ' +
184+
'<a href="/">head to the public dashboard</a>.</p>' +
185+
'</section></main>');
186+
});
187+
188+
/* --- central error handler for /admin/* — never leak stack traces --- */
189+
router.use((err, _req, res, _next) => {
190+
console.error('[admin] route error:', err);
191+
flashRedirect(res,
192+
{ ok: false, msg: 'admin route error', detail: err.message || String(err) },
193+
'/admin');
194+
});
195+
196+
return router;
197+
}

dashboard/lib/db-admin.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* Writable SQLite handle used by admin write-actions (only the
2+
* deposit action today).
3+
*
4+
* Kept in its own module so no read-side code (stats.js, admin.js) can
5+
* accidentally reach for the writable handle. server.js imports both
6+
* openDb (read-only) and openAdminDb (writable) explicitly and passes
7+
* each to the routes that need it. */
8+
9+
import { openDb } from './db.js';
10+
11+
export function openAdminDb(dbPath) {
12+
return openDb(dbPath, { readonly: false });
13+
}

dashboard/lib/fmt.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* Formatting helpers shared across views. server.js drops them onto
2+
* res.locals so every EJS render sees them without a per-view
3+
* <% const fmt... %> preamble. */
4+
5+
export const fmtN = (n) =>
6+
new Intl.NumberFormat('en-US').format(n || 0);
7+
8+
export const fmtF = (n, d = 4) =>
9+
(typeof n === 'number' ? n.toFixed(d) : '—');
10+
11+
export const fmtTs = (t) =>
12+
t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—';
13+
14+
export const ago = (t) => {
15+
if (!t) return '—';
16+
const s = Math.max(0, Math.floor(Date.now() / 1000) - t);
17+
if (s < 60) return s + 's ago';
18+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
19+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
20+
return Math.floor(s / 86400) + 'd ago';
21+
};
22+
23+
export const fmtSats = (sats) => {
24+
if (!sats) return '0 sats';
25+
const btc = sats / 1e8;
26+
if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)';
27+
return fmtN(sats) + ' sats';
28+
};
29+
30+
/* Adaptive-precision percent — keeps small miners visible instead of
31+
* rendering them as 0.00%. Used by the public overview. */
32+
export const fmtPct = (p) => {
33+
if (p == null || !isFinite(p)) return '—';
34+
if (p === 0) return '0%';
35+
const abs = Math.abs(p);
36+
if (abs >= 1) return p.toFixed(2) + '%';
37+
if (abs >= 0.01) return p.toFixed(3) + '%';
38+
if (abs >= 0.0001) return p.toFixed(4) + '%';
39+
return p.toPrecision(2) + '%';
40+
};
41+
42+
/* Convenience bundle for res.locals middleware. */
43+
export const all = { fmtN, fmtF, fmtTs, ago, fmtSats, fmtPct };

dashboard/public/style.css

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,46 @@ a:hover { text-decoration: underline; }
3131
display: flex;
3232
align-items: baseline;
3333
gap: 16px;
34+
flex-wrap: wrap;
3435
}
35-
.brand { font-weight: 700; font-size: 18px; color: var(--fg); }
36+
.top-admin { border-bottom-color: #c66; background: #c6660d; color: #fff; }
37+
.top-admin .brand { color: #fff; }
38+
.top-admin a { color: #fff; }
39+
.top-admin .tag-admin { color: #fff; background: #922; padding: 2px 8px; border-radius: 3px; }
40+
.brand { font-weight: 700; font-size: 18px; color: var(--fg); text-decoration: none; }
3641
.tag { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; }
3742

43+
.topnav {
44+
display: flex;
45+
gap: 14px;
46+
margin-left: 8px;
47+
align-items: baseline;
48+
}
49+
.topnav a {
50+
color: var(--muted);
51+
text-decoration: none;
52+
font-size: 14px;
53+
padding: 2px 0;
54+
border-bottom: 2px solid transparent;
55+
}
56+
.topnav a:hover { color: var(--fg); }
57+
.topnav a.active { color: var(--fg); border-bottom-color: currentColor; font-weight: 600; }
58+
.top-admin .topnav a { color: #eee; }
59+
.top-admin .topnav a.active { color: #fff; border-bottom-color: #fff; }
60+
61+
.topsearch { margin-left: auto; display: flex; gap: 4px; }
62+
.topsearch input { padding: 4px 8px; font-size: 13px; border: 1px solid var(--border); border-radius: 3px; min-width: 240px; }
63+
.topsearch button { padding: 4px 10px; font-size: 13px; }
64+
65+
.logout { margin-left: auto; font-size: 13px; opacity: 0.85; }
66+
.logout:hover { opacity: 1; }
67+
68+
.flash { border-left: 4px solid; margin-top: 12px; }
69+
.flash-ok { border-left-color: #5b8; }
70+
.flash-ok h2 { color: #5b8; }
71+
.flash-err { border-left-color: #c66; }
72+
.flash-err h2 { color: #c66; }
73+
3874
.wrap {
3975
max-width: 1100px;
4076
margin: 0 auto;

0 commit comments

Comments
 (0)