diff --git a/dashboard/README.md b/dashboard/README.md index 8ba28fe..3fc19e5 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -67,6 +67,7 @@ snapshot cron — SQLite's WAL mode makes that safe too, just less isolated. | `/api/status` | Everything at once: pool, node, health, versions | | `/healthz` | `{ ok: true, db_ready: bool }` | | `/health` | Full hard-failure detail; 503 when a check is failing | +| `/slipstream` | Slipstream fees and recent txs (only with `SLIPSTREAM_API_URL`) | `/api/status` is the one URL to poll if you want a single document: pool totals and hashrate, mainchain tip, the health checks, and which commit of @@ -74,6 +75,18 @@ each component is running. It always returns 200 — it is a report, and a report that a check is failing was still produced successfully. Watch `health.ok` for the condition and `/health` for a status code to alert on. +## Slipstream + +Set `SLIPSTREAM_API_URL` (where this process reaches the [slipstream +service](../slipstream/README.md), e.g. `http://127.0.0.1:8124`) and the +public nav gains a **Slipstream** page: the minimum submission rate, the +current mineable rate, how to submit, and each recent tx with where it +stands. `PUBLIC_SLIPSTREAM_URL` is the address shown to submitters. + +The page reads the service's API, never `slipstream.db`, so the service stays +the only owner of its schema; if it is down, the page says so and the rest of +the dashboard is unaffected. + ## Network difficulty The node-tip card on `/` — and `/api/node` — report the chain's current diff --git a/dashboard/lib/slipstream.js b/dashboard/lib/slipstream.js new file mode 100644 index 0000000..b9ab645 --- /dev/null +++ b/dashboard/lib/slipstream.js @@ -0,0 +1,39 @@ +/* The slipstream service, as the dashboard shows it. + * + * Read through the service's own API rather than its database: the service + * owns slipstream.db, and a second reader of its schema is a second place to + * keep in step with it. Every call degrades to { ok: false, error } so a + * service that is down costs the page one card, not the page. + */ + +const TIMEOUT_MS = 3000; + +async function getJson(url) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(t); + } +} + +/* Fees and recent txs. `statuses` narrows the list, e.g. to what is still + * open for the admin page. */ +export async function fetchSlipstream(apiUrl, { limit = 50, statuses = null } = {}) { + if (!apiUrl) return { configured: false, ok: false, fees: null, txs: [] }; + const base = apiUrl.replace(/\/+$/, ''); + try { + const lists = statuses + ? statuses.map(s => getJson(`${base}/api/txs?status=${encodeURIComponent(s)}&limit=${limit}`)) + : [getJson(`${base}/api/txs?limit=${limit}`)]; + const [fees, ...pages] = await Promise.all([getJson(`${base}/api/fees`), ...lists]); + const txs = pages.flatMap(p => p.txs ?? []) + .sort((a, b) => b.submitted_at - a.submitted_at); + return { configured: true, ok: true, fees, txs }; + } catch (e) { + return { configured: true, ok: false, fees: null, txs: [], error: e.message }; + } +} diff --git a/dashboard/server.js b/dashboard/server.js index f216cb1..39c1420 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -19,6 +19,7 @@ import { startHealthMonitor, currentHealth } from './lib/health.js'; import { versions } from './lib/versions.js'; import * as fmt from './lib/fmt.js'; import { createAdminRouter } from './lib/admin-router.js'; +import { fetchSlipstream } from './lib/slipstream.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || '8081', 10); @@ -55,6 +56,8 @@ app.use((_req, res, next) => { * rather than being threaded through one render call. */ res.locals.stratumUrl = PUBLIC_STRATUM_URL; res.locals.sidechainId = THUNDER_SIDECHAIN_ID; + /* The Slipstream nav link appears only on a pool that runs it. */ + res.locals.slipstreamEnabled = !!SLIPSTREAM_API_URL; next(); }); @@ -75,6 +78,12 @@ startHealthMonitor(db, { intervalMs: HEALTH_INTERVAL_MS }); /* --- public-side config ------------------------------------------------- */ const PUBLIC_STRATUM_URL = process.env.PUBLIC_STRATUM_URL || 'stratum+tcp://:3334'; +/* Slipstream (optional). SLIPSTREAM_API_URL is where this process reaches + * the service, e.g. http://127.0.0.1:8124; PUBLIC_SLIPSTREAM_URL is where + * submitters do. */ +const SLIPSTREAM_API_URL = process.env.SLIPSTREAM_API_URL || ''; +const PUBLIC_SLIPSTREAM_URL = process.env.PUBLIC_SLIPSTREAM_URL || ''; + /* The PPS rate is NOT configured here. It is read from pool_meta, which the * proxy writes on every template change, so the dashboard always reports the * rate that was actually applied rather than a second copy of the config @@ -199,6 +208,18 @@ app.get('/templates', (req, res) => { }); }); +/* Slipstream: its fees and what has been submitted. 404 on a pool that + * does not run it, rather than a page explaining the absence of a feature. */ +app.get('/slipstream', async (_req, res, next) => { + if (!SLIPSTREAM_API_URL) return res.status(404).render('404', { what: 'page' }); + try { + res.render('slipstream', { + slip: await fetchSlipstream(SLIPSTREAM_API_URL, { limit: 50 }), + submitUrl: PUBLIC_SLIPSTREAM_URL || null, + }); + } catch (e) { next(e); } +}); + /* --- 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) || {})); diff --git a/dashboard/test/slipstream.test.js b/dashboard/test/slipstream.test.js new file mode 100644 index 0000000..7702d47 --- /dev/null +++ b/dashboard/test/slipstream.test.js @@ -0,0 +1,102 @@ +/* The slipstream page and its nav link. + * + * The page reads the slipstream service's API, never its database, and has + * to render when the service is down: a pool whose slipstream service fell + * over should lose one card, not the page. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; +import ejs from 'ejs'; + +import * as fmt from '../lib/fmt.js'; +import { fetchSlipstream } from '../lib/slipstream.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const VIEWS = path.resolve(__dirname, '../views'); + +const TXID = 'ab'.repeat(32); +const FEES = { min_submission_rate: 1, mineable_rate: 3.5, required_rate: 3.5, + template_height: 101, updated_at: Math.floor(Date.now() / 1000) }; +const TXS = [ + { txid: TXID, fee_rate: 7.09, status: 'confirmed', status_reason: null, + mined_block_hash: 'cd'.repeat(32), mined_height: 100, mined_by_pool: 1, + submitted_at: Math.floor(Date.now() / 1000) - 600 }, + { txid: 'ef'.repeat(32), fee_rate: 2, status: 'dropped', status_reason: 'conflict_mined', + mined_block_hash: null, mined_height: null, mined_by_pool: null, + submitted_at: Math.floor(Date.now() / 1000) - 60 }, +]; + +const render = (view, locals) => ejs.renderFile(path.join(VIEWS, view), { + ...fmt.all, pool: null, health: null, stratumUrl: 'stratum+tcp://pool.example:3334', + sidechainId: 9, slipstreamEnabled: true, ...locals, +}, { views: [VIEWS] }); + +test('the page shows both rates, how to submit, and each tx where it stands', async () => { + const html = await render('slipstream.ejs', { + slip: { configured: true, ok: true, fees: FEES, txs: TXS }, + submitUrl: 'https://slipstream.example/', + }); + assert.match(html, /Minimum submission rate<\/label>1\.00 sat\/vB/); + assert.match(html, /Current mineable rate<\/label>3\.50 sat\/vB/); + assert.match(html, /curl -X POST --data-binary "\$RAW_TX_HEX" https:\/\/slipstream\.example\/api\/tx/); + assert.match(html, /href="https:\/\/slipstream\.example\/info\.json"/); + assert.match(html, /confirmed/); + assert.match(html, /\(ours\)/); + assert.match(html, /dropped<\/span>\s*\(conflict_mined\)/); + assert.match(html, /href="\/slipstream" class="active">Slipstream/); +}); + +test('a service that is down costs the page its content, not the page', async () => { + const html = await render('slipstream.ejs', { + slip: { configured: true, ok: false, fees: null, txs: [], error: 'HTTP 502' }, + submitUrl: null, + }); + assert.match(html, /not answering right now \(HTTP 502\)/); + assert.doesNotMatch(html, /Minimum submission rate/); +}); + +test('the nav links to slipstream only on a pool that runs it', async () => { + const nav = (slipstreamEnabled) => render('partial/nav-public.ejs', { slipstreamEnabled }); + assert.match(await nav(true), /href="\/slipstream"/); + assert.doesNotMatch(await nav(false), /href="\/slipstream"/); +}); + +/* A stub HTTP server answering one handler. */ +async function stub(handler) { + const calls = []; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + calls.push({ url: req.url, body: body ? JSON.parse(body) : null }); + const { status = 200, json } = handler(req.url, body ? JSON.parse(body) : null); + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(json)); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return { url: `http://127.0.0.1:${server.address().port}`, calls, + close: () => new Promise(resolve => server.close(resolve)) }; +} + +test('fetchSlipstream reads fees and txs from the service API', async () => { + const s = await stub((url) => url.startsWith('/api/fees') + ? { json: FEES } + : { json: { txs: url.includes('status=pending') ? [TXS[1]] : [TXS[0]] } }); + try { + const all = await fetchSlipstream(s.url); + assert.equal(all.ok, true); + assert.equal(all.fees.mineable_rate, 3.5); + assert.equal(all.txs[0].txid, TXID); + const open = await fetchSlipstream(s.url + '/', { statuses: ['pending', 'in_template'] }); + assert.ok(s.calls.some(c => c.url.startsWith('/api/txs?status=in_template'))); + assert.equal(open.txs.length, 2); + } finally { + await s.close(); + } + assert.equal((await fetchSlipstream('')).configured, false); + const down = await fetchSlipstream('http://127.0.0.1:1'); + assert.equal(down.ok, false); +}); diff --git a/dashboard/views/partial/nav-public.ejs b/dashboard/views/partial/nav-public.ejs index 0cf1189..a874423 100644 --- a/dashboard/views/partial/nav-public.ejs +++ b/dashboard/views/partial/nav-public.ejs @@ -1,5 +1,5 @@ <%# Top nav for the public (read-only) dashboard. - Locals: active ('overview'|'blocks'|'templates'|'worker'). %> + Locals: active ('overview'|'blocks'|'templates'|'slipstream'|'worker'). %> <% const _pactive = (typeof active === 'string') ? active : ''; %> <%# The chip used to read "stratum stats", which every page already looks like. It now carries the two facts that change what the numbers below @@ -16,6 +16,9 @@ Overview Blocks Templates + <% if (typeof slipstreamEnabled !== 'undefined' && slipstreamEnabled) { %> + Slipstream + <% } %>