igneum/tools/ui-mock/server.mjs

215 lines
17 KiB
JavaScript

#!/usr/bin/env node
// A stand-in for the Igneum Miner engine, for working on app/igneum-app/ui without touching a running miner.
// Serves the UI files at /t/mock/ (the same path shape as the engine) and replays the recorded API responses in
// recorded/ with every timestamp shifted to now. POSTs are logged and answered {ok:true}; a few (pause, resume,
// settings) are applied to the in-memory state so the dashboard reacts. Nothing here reaches the real engine.
//
// node tools/ui-mock/server.mjs [port] default 4310
// open http://127.0.0.1:4310/t/mock/?scenario=live
//
// Scenarios (set with ?scenario= on the page URL, or GET /mock/scenario?set=<name>):
// live the recorded state as it was (Apple M5 Max mining, node synced)
// syncing node syncing, 41,000 headers, blocks climbing at a steady rate (the ETA)
// nodedown node failed and restarting, miner waiting
// nocards no GPU found (cards screen with ?screen=cards, and the dashboard)
// integrated one integrated card only, off by default
// clock clock skew that blocks mining
// paused miner paused
// biglog 12,000 log lines with errors and program swaps (virtualised list)
// fresh setup not done (welcome, cards, address screens)
// job a remote job running
// The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const uiDir = path.resolve(here, '../../app/igneum-app/ui');
const coin = path.resolve(here, '../../brand/igneum-coin-1024.png');
const port = parseInt(process.argv[2] || '4310', 10);
const PREFIX = '/t/mock';
// IGNEUM_MOCK_STATE=<file> replays a state captured from a real machine (relay/playbooks/app-state-grab.ps1) instead of
// the recorded one; IGNEUM_MOCK_LIVE=<file> serves that machine's /api/live reply as it was (timestamps shifted to now)
const recState = JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_STATE || path.join(here, 'recorded/state.json'), 'utf8'));
const recLive = process.env.IGNEUM_MOCK_LIVE ? JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_LIVE, 'utf8')) : null;
const recLog = JSON.parse(fs.readFileSync(path.join(here, 'recorded/log.json'), 'utf8')).lines;
const t0 = Date.now() / 1000; // server start
const shift = t0 - recState.now; // recorded clock -> live clock
let scenario = 'live';
let paused = false;
let seq = 0;
let log = [];
const posts = [];
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function now() { return Date.now() / 1000; }
function seedLog() {
seq = 0; log = [];
for (const l of recLog) log.push({ seq: ++seq, t: l.t + shift, src: l.src, err: l.err, text: l.text });
if (scenario === 'biglog') {
const base = log[log.length - 1].t - 3600 * 2;
const srcs = ['node', 'node', 'node', 'miner1', 'watch', 'app'];
for (let i = 0; i < 12000; i++) {
const src = srcs[i % srcs.length];
const t = base + i * 0.6;
let text, err = false;
if (i % 1500 === 1499) { src === 'app'; text = '[build] hourly program changed: new program ' + i; }
else if (i % 977 === 500) { err = true; text = 'worker: kernel launch failed (code 702) on identity mac-d937c69d-1; restarting in 5 s'; }
else if (src === 'node') text = '2026-10-04 18:00:00.000+01:00 [INFO ] PoW accepted ' + (i * 2654435761 >>> 0).toString(16).padStart(8, '0') + 'c954de57e97dc514cd8a92d by igneum-lottery-v2-bound (daa ' + (30000 + i) + ', nonce 0x' + (i * 97).toString(16) + ')';
else if (src === 'miner1') text = (t).toFixed(3) + ' ACCEPTED block nonce=0x' + (i * 31).toString(16) + ' (gpu worker, cpu re-check ok, identity mac-d937c69d-1)';
else if (src === 'watch') text = t.toFixed(3) + ' node1 blocks=' + (30000 + i) + ' headers=' + (30000 + i) + ' daa=' + (30000 + i) + ' tips=1 peers=4 synced=true';
else text = 'status: accepted ' + (5000 + i / 6 | 0) + ' blocks, 17.0 MH/s, mining | node ' + (30000 + i) + ' blocks, 4 peers, synced';
log.push({ seq: ++seq, t, src: i % 1500 === 1499 ? 'app' : src, err, text });
}
log.sort((a, b) => a.t - b.t); log.forEach((l, i) => { l.seq = i + 1; }); seq = log.length;
}
}
// a trickle of new lines so the drawer has something to follow
let tick = 0;
function growLog() {
const t = now(); tick++;
const h = (t * 1000 | 0).toString(16);
log.push({ seq: ++seq, t, src: 'node', err: false, text: new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 23) + ' [INFO ] PoW accepted ' + h + 'c954de57e97dc514cd8a92d by igneum-lottery-v2-bound (daa ' + (39571 + tick) + ')' });
if (tick % 4 === 0) log.push({ seq: ++seq, t, src: 'miner1', err: false, text: t.toFixed(3) + ' ACCEPTED block nonce=0x' + h + ' (gpu worker, cpu re-check ok, identity mac-d937c69d-1)' });
if (tick % 10 === 0) log.push({ seq: ++seq, t, src: 'watch', err: false, text: t.toFixed(3) + ' node1 blocks=' + (39571 + tick) + ' headers=' + (39571 + tick) + ' tips=1 peers=4 synced=true' });
if (tick % 45 === 0) log.push({ seq: ++seq, t, src: 'app', err: true, text: '[error] mock: a sample error line so "last error" has somewhere to jump' });
if (tick % 120 === 60) log.push({ seq: ++seq, t, src: 'app', err: false, text: '[build] hourly program changed: new program (mock swap)' });
while (log.length > 40000) log.shift();
}
setInterval(growLog, 1000);
// /api/live for the embedded DAG scene (site/live-dag.js): a synthetic chain at one block a second, four miner lanes
// ("you" is this machine), parents into the last two seconds, the selected chain through "you" and "miner-2", blue
// and red colouring, proofs filling two blocks in three after 20 s, a checkpoint band every 30 s locked after 10 s.
const LIVE_T0 = Math.floor(Date.now() / 1000) - 400;
function liveNow(windowS) {
const t = Math.floor(now()), from = t - Math.min(300, Math.max(30, windowS)) - 25, blocks = [];
const lanes = ['you', 'miner-2', 'miner-3', 'miner-4'];
let prev = [];
for (let s = from; s <= t - 2; s++) {
const seed = ((s % 100000) * 7919 + ((s >> 3) % 97) * 131) % 1000;
const miner = lanes[seed % 4], hash = 'b' + s.toString(16).padStart(10, '0');
const chain = miner === 'you' || miner === 'miner-2' ? (seed % 7 !== 3) : (seed % 5 === 0);
const color = seed % 11 === 0 ? 'red' : (s < t - 4 ? 'blue' : 'pending');
const proven = color === 'blue' && s < t - 20 && seed % 3 !== 0;
const shards = proven ? [{ state: 'paid' }] : color === 'blue' && s < t - 8 ? [{ state: 'proving' }] : [];
const parents = prev.slice(-2).map(b => b.hash);
const locked = (s - LIVE_T0) % 30 === 0 && s < t - 10;
const b = { hash, ts: s * 1000, blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), parents, chain, color, miner, locked, final: s < t - 40, shards, proven };
blocks.push(b); prev.push(b); if (prev.length > 3) prev.shift();
}
const checkpoints = [];
for (let s = from; s <= t - 2; s++) if ((s - LIVE_T0) % 30 === 0) checkpoints.push({ index: Math.floor((s - LIVE_T0) / 30), hash: 'c' + s.toString(16), blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), state: s < t - 10 ? 'locked' : 'pending', fraction_total: s < t - 10 ? 0.71 : 0.44 });
return { ok: true, now: t, state: { stale: false, age_s: 1, height: 198000 + (t - LIVE_T0) }, blocks, finality: { checkpoints } };
}
function stateNow() {
const s = clone(recState);
const t = now();
s.now = t;
s.uptime_s = recState.uptime_s + Math.floor(t - t0);
s.mining.found = recState.mining.found.map(x => x + shift).filter(x => t - x < 3600);
s.events = recState.events.map(e => ({ ...e, t: e.t + shift }));
s.finality.last_lock_at += shift; s.finality.age_s = t - s.finality.last_lock_at;
s.update.checked_at += shift; s.clock.checked_at += shift; s.jobs.checked_at += shift;
s.program.eta_s = Math.max(0, Math.round(3600 - ((t - t0) % 3600)));
s.node.blocks = recState.node.blocks + Math.floor(t - t0); s.node.headers = s.node.blocks; s.node.daa = s.node.blocks;
s.node.last_reading_age_s = (t - t0) % 10;
// a captured state (IGNEUM_MOCK_STATE) is replayed as it was: the machine's own name, cards, rate and numbers
if (process.env.IGNEUM_MOCK_STATE) { s.node.blocks = recState.node.blocks; s.node.headers = recState.node.headers; s.node.daa = recState.node.daa; return s; }
// a live-looking hash rate
const wob = Math.sin((t - t0) / 7) * 1.4;
s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total;
// a fresh block every ~9 s on average
const extra = []; for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4);
s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b);
s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length;
if (extra.length) s.events = [{ kind: 'block', t: extra[extra.length - 1], text: 'block accepted by the node (Apple M5 Max)' }].concat(s.events).slice(0, 40);
s.display_name = 'studio-mac-pro-with-a-long-name';
s.host = s.display_name;
if (paused || scenario === 'paused') { s.mining.paused = true; s.mining.state = 'paused'; s.mining.cards[0].state = 'off'; s.mining.hash_total = 0; s.mining.cards[0].hash_now = 0; }
if (scenario === 'syncing') {
const rate = 38; // blocks per second
s.node.state = 'syncing'; s.node.synced = false; s.node.headers = 412000; s.node.blocks = Math.min(412000, 30000 + Math.floor((t - t0) * rate)); s.node.daa = s.node.blocks;
s.node.message = 'downloading blocks'; s.node.peers = 2; s.node.tips = 0;
s.mining.state = 'waiting'; s.mining.hash_total = 0; s.mining.cards[0].state = 'waiting'; s.mining.cards[0].hash_now = 0; s.mining.found = []; s.program.eta_s = -1; s.program.message = '';
s.finality = { age_s: 0, last_lock: 0, last_lock_at: 0, message: 'Finality starts after the node is synced.', votes: 0 };
}
if (scenario === 'nodedown') {
s.node.state = 'restarting'; s.node.synced = false; s.node.restart_in_s = 7 - Math.floor(t - t0) % 8; s.node.restarts = 3; s.node.peers = 0; s.node.message = 'the node exited (code 101)';
s.mining.state = 'waiting'; s.mining.hash_total = 0; s.mining.cards[0].state = 'waiting'; s.mining.cards[0].hash_now = 0; s.mining.found = []; s.program.eta_s = -1;
s.events = [{ kind: 'error', t: t - 12, text: 'node exited with code 101; restarting in 8 s' }].concat(s.events).slice(0, 40);
}
if (scenario === 'nocards') { s.mining.cards = []; s.mining.state = 'idle'; s.mining.hash_total = 0; s.mining.found = []; s.detect_message = 'No Metal, CUDA or OpenCL device answered.'; s.detecting = false; }
if (scenario === 'integrated') {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'intel::Intel Iris Xe', name: 'Intel Iris Xe Graphics', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '96 EUs, shared memory', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default, around 0.3 MH/s and it slows the desktop', identities: 1, prepared: false }];
s.mining.state = 'idle'; s.mining.hash_total = 0; s.mining.found = [];
}
if (scenario === 'clock') { s.clock = { ...s.clock, severity: 'block', skew_s: -412, message: 'This machine\'s clock is 6 min 52 s behind the network. Blocks it finds are rejected until the clock is right.', hint: 'System Settings > General > Date & Time: turn on "Set time and date automatically"' }; s.mining.state = 'waiting'; s.mining.hash_total = 0; }
if (scenario === 'fresh') { s.setup_done = false; s.phase = 'welcome'; s.address = { display: '', key_saved: false, source: '', value: '', wallet_file: '' }; }
if (scenario === 'job') { s.jobs = { ...s.jobs, active: true, id: 'job-2026-10-04-17', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', started_at: t - 400, results: ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], message: '' }; }
if (scenario === 'nvidia') {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 441, temp_gpu: 71, temp_mem: 92, telemetry_at: t, hash_now: 412.5, hash_avg: 405.2 },
{ ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: false, power_note: 'nvidia-smi: wanted 160 W, card reports 200 W', power_limit_w: 200, power_w: 188, temp_gpu: 64, temp_mem: 0, telemetry_at: t, hash_now: 98.1, hash_avg: 97.4, state: 'restarting', restart_in_s: 4 },
{ ...recState.mining.cards[0], key: 'intel::Intel UHD 770', name: 'Intel UHD Graphics 770', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '32 EUs', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default', identities: 1 }];
s.mining.hash_total = 510.6; s.display_name = 'rig-01';
}
return s;
}
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
function send(res, code, type, body, cache) {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': cache ? 'public, max-age=86400' : 'no-store', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function json(res, v) { send(res, 200, 'application/json; charset=utf-8', JSON.stringify(v)); }
seedLog();
http.createServer((req, res) => {
const u = new URL(req.url, 'http://127.0.0.1');
if (u.pathname === '/mock/scenario') {
if (u.searchParams.get('set')) { scenario = u.searchParams.get('set'); paused = false; seedLog(); }
return json(res, { scenario, lines: log.length, posts: posts.slice(-20) });
}
if (u.pathname === '/mock/posts') return json(res, posts);
if (!u.pathname.startsWith(PREFIX)) return send(res, 404, 'text/plain', 'not found');
let rest = u.pathname.slice(PREFIX.length) || '/';
if (rest === '/' || rest === '/index.html') {
if (u.searchParams.get('scenario') && u.searchParams.get('scenario') !== scenario) { scenario = u.searchParams.get('scenario'); paused = false; seedLog(); }
return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html')));
}
if (rest === '/api/state' && req.method === 'GET') return json(res, stateNow());
if (rest === '/api/live' && req.method === 'GET') {
if (recLive && recLive.blocks) {
// replayed with its newest block 3 s behind now, so the module's window shows it as it was captured
const newest = Math.max(...recLive.blocks.map(b => b.ts || 0)), dt = Date.now() - 3000 - newest;
const d = JSON.parse(JSON.stringify(recLive)); d.blocks.forEach(b => { b.ts = (b.ts || newest) + dt; if (!b.miner) b.miner = 'unknown'; }); d.now = Math.floor(Date.now() / 1000); return json(res, d);
}
return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
}
if (rest === '/api/log' && req.method === 'GET') {
const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10));
let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit);
return json(res, { lines: out });
}
if (req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; }); req.on('end', () => {
posts.push({ t: now(), path: rest, body: body.slice(0, 300) }); console.log('POST', rest, body.slice(0, 200));
if (rest === '/api/pause') paused = true; if (rest === '/api/resume') paused = false;
if (rest === '/api/key/reveal') return json(res, { ok: true, private_key: '0x' + 'ab'.repeat(32) });
if (rest === '/api/setup') return json(res, { ok: true, address: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8', private_key: '0x' + 'cd'.repeat(32), wallet_file: '/Users/you/Library/Application Support/Igneum/app/wallet.json' });
json(res, { ok: true });
});
return;
}
if (rest === '/coin.png') return send(res, 200, types['.png'], fs.readFileSync(coin), true);
const file = path.normalize(path.join(uiDir, rest));
if (!file.startsWith(uiDir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
send(res, 200, types[path.extname(file)] || 'application/octet-stream', fs.readFileSync(file), rest.startsWith('/fonts/'));
}).listen(port, '127.0.0.1', () => console.log('ui-mock on http://127.0.0.1:' + port + PREFIX + '/ scenario=' + scenario));