#!/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=): // 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'; const recState = JSON.parse(fs.readFileSync(path.join(here, 'recorded/state.json'), 'utf8')); 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); 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 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/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));