igneum/app/igneum-app/ui/app.js
igneum-labs 057f0ec8bf app: Power control setting (default off): the app never asks for administrator rights on its own
the project lead, 5 October 2026: "if we don't have to ask then don't ask". The NVIDIA power cap and the efficiency sweep need
administrator rights (one UAC prompt); PC 1 raised that prompt for cmd.exe at every app start and every sweep attempt
(17:00, 17:30, 18:12, 19:04 UTC today, each cancelled unanswered after 2 minutes; the "Windows Command Processor"
the project lead saw).

- config.rs: `power_control` (default OFF on every machine); `sweep` default becomes off and is implied by it (an
  install carrying sweep = true without power_control is migrated to off on load).
- engine.rs: `elevation_allowed(power_control, sweep_only)` gates the power cap (`power_cap_plan` builds nothing when
  off, the card note says so), the sweep scheduler, Sweep now, the sweep helper; no prompt on quit (the limits reset at
  the next reboot); no second prompt through PowerShell when the window host's prompt goes unanswered.
  Cmd::PowerControl(on): on = ONE prompt at that moment (every NVIDIA cap in one step), off = nothing asks;
  `power_control_after_prompt` turns a refused, cancelled or unanswered prompt into "power control off:
  administrator rights were not given" (switch back off, sweep off, no retries). Unit tests: off builds no elevated
  command; on + refusal gives the notice; rights given keeps it on.
- platform.rs: `elevated_failure` maps the launcher's exit 251 and the "canceled" wording to the prompt, any other
  code to the step itself.
- server.rs: POST /api/power/control {on}. ui: the Power control switch with the line "Windows asks for administrator
  rights once; the cap and the sweep need them", the note beside it, the sweep switch disabled while it is off.
- The clock-sync prompt stays behind the Sync clock button only (unchanged).
- tools/windows/power-prompts-off.ps1: the 0.3.9 job that switched PC 1's sweep off through the API it has
  (run-20261005-192313: sweep True -> False; the 0.3.9 cap has no off switch, it asks at an app start only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 19:25:12 +00:00

1121 lines
88 KiB
JavaScript

/* Igneum Miner dashboard. Talks to the engine on the same origin: api/state every second, api/log while the
drawer is open. Screens: welcome, cards, address (plus the one-time key sheet), dashboard. ?screen=<name> forces
a screen (screenshots). Everything is relative to the token path the page was opened on. */
/* ---------- notices: what the one strip under the header says (pure; notices.test.mjs loads this block) ----------
The state (src/state.rs) becomes at most one notice at a time, the most important first. Each notice has a key;
closing it hides that key until the state moves on (a new status, version or job). Levels, lowest number first:
0 update installing, urgent or failed 1 job failed 2 clock 3 job running
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new
version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job
notice is for the dashboard. */
var Notices = (function () {
'use strict';
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; }
function notice(kind, key, text, extra) {
var n = { kind: kind, level: LEVEL[kind], key: key, text: text, tone: '', detail: '', actions: [], progress: -1, title: '' };
if (extra) for (var k in extra) n[k] = extra[k];
return n;
}
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'close', label: 'Later' };
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
// u = state.update; s = { version, uptime_s, quitting }
function updateNotice(u, s) {
if (!u) return null;
s = s || {};
var ver = u.version || '', v = 'Igneum Miner ' + ver, pct = u.progress > 0 ? Math.round(u.progress * 100) : 0;
if (u.status === 'error') {
if (/no update manifest configured/.test(u.error || '')) return null; // a build without a manifest: nothing to try, Settings says so
var text = u.rolled_back ? (v + ' did not stay up and was rolled back.') : ('The update' + (ver ? ' to ' + ver : '') + ' failed.');
return notice('update-failed', 'update:error:' + ver, text, { tone: 'bad', detail: firstLine(u.error), actions: [RETRY], title: u.error || '' });
}
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
var t = s.quitting ? 'Installing ' + v + '. The app restarts itself in a moment.' : 'Installing ' + v + '. The app restarts itself. Mining continues until then.';
return notice('update-installing', 'update:applying:' + ver, t, { title: u.wait || '' });
}
if (u.urgent && u.urgent_text) {
var dl = u.status === 'downloading';
return notice('update-urgent', 'update:urgent:' + ver, u.urgent_text + (dl ? ' Downloading' + (pct ? ': ' + pct + '%' : '') + '.' : ''), { tone: 'bad', progress: dl ? (u.progress || 0) : -1 });
}
switch (u.status) {
case 'available': return notice('update-available', 'update:' + ver + ':pending', v + ' is available.', { actions: [INSTALL, LATER] });
case 'downloading': return notice('update-available', 'update:' + ver + ':pending', 'Downloading ' + v + (pct ? ': ' + pct + '%' : '') + '.', { progress: u.progress || 0, actions: [INSTALL, LATER], title: u.size ? Math.round(u.size / 1e6) + ' MB' : '' });
case 'staging': return notice('update-available', 'update:' + ver + ':pending', 'Checking ' + v + '.', { actions: [LATER] });
case 'ready':
var why = /failed to install before/.test(u.wait || '') ? ' It failed to install before.' : u.auto ? ' It installs by itself at a quiet moment.' : '';
return notice('update-available', 'update:' + ver + ':ready', v + ' is ready.' + why, { actions: [INSTALL, LATER], title: u.wait || '' });
case 'deferred': return notice('update-available', 'update:' + ver + ':deferred', v + ' is waiting for permission. It installs the next time someone is at this PC. Mining continues.', { actions: [INSTALL, LATER] });
case 'manual': return notice('update-available', 'update:' + ver + ':manual', v + ' is downloaded. Open it and drag the app over the old one.', { actions: [OPEN, LATER], title: u.wait || '' });
}
if (u.updated_from && s.uptime_s >= 0 && s.uptime_s < INSTALLED_S) {
var now = s.version || ver;
return notice('update-installed', 'update:installed:' + now, 'Updated to Igneum Miner ' + now + ' from ' + u.updated_from + '.');
}
return null;
}
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (mining continues)' : j.kind); }
function firstErrorLine(results, summary) {
for (var i = 0; i < results.length; i++) if (/BUILD FAILED|error|failed|panic/i.test(results[i])) return firstLine(results[i]);
return firstLine(summary);
}
// j = state.jobs; now = state.now (unix seconds)
function jobNotice(j, now) {
if (!j) return null;
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
var results = (j.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; });
return notice('job-running', 'job:run:' + j.id, 'Job: ' + jobTitle(j) + ' running, ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: results.length ? results[results.length - 1] : '', title: j.message || '' });
}
var l = j.last;
if (!l || !l.id) return null;
var mins = Math.max(0, Math.round((l.finished_at - l.started_at) / 60)), t = l.title || l.kind;
var report = l.uploaded ? 'Report uploaded.' : 'Report not uploaded.';
if (l.status === 'done') {
if (l.finished_at && now - l.finished_at > JOB_DONE_S) return null;
return notice('job-done', 'job:done:' + l.id, 'Job: ' + t + ' done after ' + mins + ' min. ' + report, { title: l.summary || '' });
}
var word = l.status === 'timeout' ? 'hit its time cap' : l.status === 'aborted' ? 'was stopped' : 'failed';
return notice('job-failed', 'job:failed:' + l.id, 'Job: ' + t + ' ' + word + ' after ' + mins + ' min, exit ' + l.exit + '. ' + report, { tone: 'bad', detail: firstErrorLine(j.last_results || [], l.summary), title: l.summary || '' });
}
// c = state.clock: the engine's words, Sync clock, the manual hint underneath
function clockNotice(c) {
if (!c || !(c.severity === 'block' || c.severity === 'warn')) return null;
return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] });
}
// every notice the state carries for this screen; pick() chooses the one to show
function gather(s, where) {
where = where || {};
var out = [], u = updateNotice(s.update, s);
if (u) out.push(u);
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
return out;
}
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
function pick(list, dismissed) {
var best = null;
for (var i = 0; i < list.length; i++) {
var n = list[i];
if (!n || (dismissed && dismissed[n.key])) continue;
if (!best || n.level < best.level) best = n;
}
return best;
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
A centred card over the window for one update: the mark with a progress ring, "Igneum Ember 0.3.7", one line
(is available, is downloading, is ready to install, Installing, did not install), up to three lines of release
notes with the rest behind "What changed", the size, Install now and Later. The same card, the same rules and the
same words sit in the wallet (app/igneum-wallet/ui/update-card.js).
model() turns state.update into what the card says. decide() says whether it shows now, given the window
(ctx) and what the card already did (mem: open, later, seen):
deferred while a job runs, in the engine's first 60 s, while the key sheet is up, while the app quits
later Later, Escape or the backdrop hides this version at this stage; the strip (Notices) keeps it
back a newer version, or the download ready while automatic updates are off (with them on it installs
by itself, so a dismissed download stays dismissed); an open card follows its update to the end
installing and failed show only on an open card (Install now was pressed here); the strip carries the rest */
var UpdateCard = (function () {
'use strict';
var NAME = 'Igneum Ember', LINE_MAX = 70, LINES = 3, FIRST_S = 60;
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'later', label: 'Later' };
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '…' : t; }
// the manifest's notes as sentences: split on line breaks, then after . ! ? ; (no lookbehind: the Mac WebView)
function sentences(text) {
var out = [], parts = String(text || '').split(/\n+/);
for (var i = 0; i < parts.length; i++) {
var p = parts[i], at = 0;
for (var j = 0; j < p.length; j++) {
var ch = p.charAt(j), next = p.charAt(j + 1);
if ((ch === '.' || ch === '!' || ch === '?' || ch === ';') && (next === ' ' || next === '')) { push(p.slice(at, j + 1)); at = j + 1; }
}
push(p.slice(at));
}
function push(s) { s = s.trim().replace(/[;:,]+$/, ''); if (s) out.push(cap(s)); }
return out;
}
// at most LINES lines, each under LINE_MAX characters (cut at a word); more = something is left for "What changed"
function splitNotes(text) {
var all = sentences(text), lines = [], more = all.length > LINES;
for (var i = 0; i < all.length && lines.length < LINES; i++) {
var s = all[i].replace(/\.$/, '');
if (s.length >= LINE_MAX) { var cut = s.lastIndexOf(' ', LINE_MAX - 2); s = s.slice(0, cut > 20 ? cut : LINE_MAX - 2).replace(/[,;:]$/, '') + '…'; more = true; }
lines.push(s);
}
return { lines: lines, more: more, all: all };
}
function size(bytes) { if (!(bytes > 0)) return ''; return bytes < 1e6 ? (bytes / 1e6).toFixed(1) + ' MB' : Math.round(bytes / 1e6) + ' MB'; }
function card(stage, u, over) {
var ver = u.version || '';
var m = { stage: stage, version: ver, key: 'update:' + ver + ':' + (stage === 'available' || stage === 'downloading' || stage === 'staging' ? 'pending' : stage), name: NAME + ' ' + ver, line: '', note: '', cause: '', size: size(u.size), pct: -1, ring: 'none', actions: [], dismissable: true, auto: !!u.auto };
var n = splitNotes(u.notes); m.lines = n.lines; m.more = n.more; m.all = n.all;
if (over) for (var k in over) m[k] = over[k];
return m;
}
// u = state.update; s = { version, uptime_s, quitting }
function model(u, s) {
if (!u || !u.version) return null;
s = s || {};
var pct = u.progress > 0 ? Math.min(100, Math.round(u.progress * 100)) : 0;
if (u.status === 'error') {
if (/no update manifest configured/.test(u.error || '')) return null;
return card('failed', u, { line: u.rolled_back ? 'did not stay up and was rolled back.' : 'did not install.', cause: firstLine(u.error, 120), ring: 'failed', actions: [RETRY, LATER], lines: [], more: false });
}
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
return card('installing', u, { line: 'Installing. The app restarts itself.', note: s.quitting ? 'A moment.' : 'Mining continues until then.', ring: 'busy', dismissable: false, lines: [], more: false });
}
var m = null;
switch (u.status) {
case 'available': m = card('available', u, { line: 'is available.', actions: [INSTALL, LATER] }); break;
case 'downloading': m = card('downloading', u, { line: 'is downloading.', pct: pct, ring: 'progress', actions: [INSTALL, LATER] }); break;
case 'staging': m = card('staging', u, { line: 'is being checked.', pct: 100, ring: 'progress', actions: [INSTALL, LATER] }); break;
case 'ready':
var why = /failed to install before/.test(u.wait || '') ? 'It failed to install before.' : u.auto ? 'It installs by itself at a quiet moment.' : '';
m = card('ready', u, { line: 'is ready to install.', note: why, ring: 'full', actions: [INSTALL, LATER] }); break;
case 'deferred': m = card('deferred', u, { line: 'is waiting for permission.', note: 'It installs the next time someone is at this PC. Mining continues.', ring: 'full', actions: [INSTALL, LATER] }); break;
case 'manual': m = card('manual', u, { line: 'is downloaded.', note: 'Open the disk image and drag the app over the old one.', ring: 'full', actions: [OPEN, LATER] }); break;
}
if (!m) return null;
if (u.urgent && u.urgent_text) { m.note = u.urgent_text; m.dismissable = false; m.actions = m.actions.filter(function (a) { return a.act !== 'later'; }); }
return m;
}
// why the card may not open now: ctx = { jobActive, uptime_s, quitting, sheetOpen }
function blocked(ctx) {
ctx = ctx || {};
if (ctx.quitting) return 'the app is quitting';
if (ctx.sheetOpen) return 'the key sheet is open';
if (ctx.jobActive) return 'a job is running';
if (ctx.uptime_s >= 0 && ctx.uptime_s < FIRST_S) return 'the engine started under a minute ago';
return '';
}
// mem = { open: the card is up, later: the key Later was pressed on, seen: the version the card was shown for }
function decide(m, ctx, mem) {
mem = mem || {};
if (!m) return { show: false, why: 'none' };
var b = blocked(ctx);
if (b && m.stage !== 'installing') return { show: false, why: 'deferred', reason: b };
if (m.stage === 'installing' || m.stage === 'failed') return mem.open ? { show: true, why: 'open' } : { show: false, why: 'strip' };
if (!m.dismissable) return { show: true, why: 'urgent' };
if (mem.later === m.key) return { show: false, why: 'later' };
if (mem.open) return { show: true, why: 'open' };
if (m.stage === 'ready' && m.auto && mem.seen === m.version) return { show: false, why: 'auto' };
return { show: true, why: m.stage === 'ready' ? 'ready' : 'new' };
}
return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, FIRST_S: FIRST_S, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; }
if (typeof document !== 'undefined') (function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var dpr = Math.min(2, window.devicePixelRatio || 1);
var state = null, phase = 'welcome', forced = null, cardsSeen = false, keyShownThisRun = false;
var params = new URLSearchParams(location.search);
if (params.get('screen')) forced = params.get('screen');
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
var forcedJob = params.get('job'); // a sample remote job for screenshots: running|done|failed
if (params.get('host') === 'mac') document.body.classList.add('mac');
// ?debug=1 prints a budget line every 5 s (ms of JS per second in the canvas, the state render and the log) and
// exposes the counters as window.__igneumPerf
var debug = params.get('debug') === '1';
// the hosts hide the window to the menu bar or the tray: nothing draws or polls fast while document.hidden.
// ?visible=1 ignores that (screenshots from a hidden pane).
var forceVisible = params.get('visible') === '1';
function hiddenNow() { return document.hidden && !forceVisible; }
var perf = { frames: 0, draw: 0, render: 0, log: 0, poll: 0, polls: 0, since: performance.now() };
if (debug) {
window.__igneumPerf = perf;
setInterval(function () {
var s = (performance.now() - perf.since) / 1000;
console.log('[perf] ' + (hiddenNow() ? 'hidden' : 'visible') + ' ' + (perf.frames / s).toFixed(1) + ' fps · js ms/s: canvas ' + (perf.draw / s).toFixed(2) + ' render ' + (perf.render / s).toFixed(2) + ' log ' + (perf.log / s).toFixed(2) + ' poll ' + (perf.poll / s).toFixed(2) + ' · ' + (perf.polls / s).toFixed(2) + ' polls/s · total ' + ((perf.draw + perf.render + perf.log + perf.poll) / s / 10).toFixed(2) + '% of one core');
perf.frames = 0; perf.draw = 0; perf.render = 0; perf.log = 0; perf.poll = 0; perf.polls = 0; perf.since = performance.now();
}, 5000);
}
// ---------- helpers ----------
function api(path, body) {
var opt = body === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
return fetch(path, opt).then(function (r) { return r.json(); });
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); }
function withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; }
function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }
function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; }
function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); }
var toastT = null;
function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); }
function copyText(text, msg) {
msg = msg || 'Copied';
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast(msg); }, function () { fallbackCopy(text, msg); }); }
else fallbackCopy(text, msg);
}
function fallbackCopy(text, msg) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast(msg || 'Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); }
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-copy]'); if (!b) return;
copyText($(b.getAttribute('data-copy')).textContent);
});
// ---------- screens ----------
function show(p) {
phase = p;
document.body.setAttribute('data-phase', p);
var dash = p === 'dashboard';
$('bottom').hidden = !dash;
$('btn-settings').hidden = !dash;
document.body.classList.toggle('has-bottom', dash);
if (!dash && drawerOpen()) setDrawer(false);
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
api('api/phase', { phase: p });
}
$('btn-begin').addEventListener('click', function () { show('cards'); });
$('btn-cards-next').addEventListener('click', function () {
var choices = readCardRows($('cards-list'));
if (choices.length) api('api/cards', { cards: choices }).then(function () { show('address'); }); else show('address');
});
$('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); });
$('btn-address-back').addEventListener('click', function () { show('cards'); });
// address options
var opts = document.querySelectorAll('.option');
function pickOption(el) { opts.forEach(function (o) { o.classList.toggle('on', o === el); }); el.querySelector('input').checked = true; if (el.id === 'opt-paste') $('addr-input').focus(); }
opts.forEach(function (o) { o.addEventListener('click', function (e) { if (e.target.tagName !== 'INPUT' || e.target.type === 'radio') pickOption(o); }); });
$('addr-input').addEventListener('focus', function () { pickOption($('opt-paste')); });
function validAddr(a) { return /^0x[0-9a-fA-F]{40}$/.test((a || '').trim()); }
$('btn-start').addEventListener('click', function () {
var mode = document.querySelector('input[name=mode]:checked').value;
var body = { mode: mode };
if (mode === 'paste') {
var a = $('addr-input').value.trim();
if (!validAddr(a)) { $('addr-err').hidden = false; return; }
$('addr-err').hidden = true; body.address = a;
}
$('btn-start').disabled = true;
api('api/setup', body).then(function (r) {
$('btn-start').disabled = false;
if (!r.ok) { $('addr-err').textContent = r.error || 'could not save'; $('addr-err').hidden = false; return; }
if (mode === 'generate' && r.private_key) { showKeySheet(r); }
else { api('api/start', {}).then(function () { show('dashboard'); }); }
}).catch(function () { $('btn-start').disabled = false; toast('The engine did not answer'); });
});
function showKeySheet(r) {
$('key-address').textContent = r.display || r.address;
$('key-private').textContent = r.private_key;
$('key-file').textContent = r.wallet_file || '';
$('key-ack').checked = false; $('btn-key-done').disabled = true;
$('sheet-key').hidden = false; keyShownThisRun = true;
}
$('key-ack').addEventListener('change', function () { $('btn-key-done').disabled = !this.checked; });
$('btn-key-done').addEventListener('click', function () {
api('api/key/saved', {}).then(function () { return api('api/start', {}); }).then(function () { $('sheet-key').hidden = true; $('key-private').textContent = ''; show('dashboard'); });
});
// ---------- settings ----------
$('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; });
$('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; });
$('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); });
function fillSettings() {
if (!state) return;
$('s-address').textContent = state.address.display || 'not set';
renderCardRows($('s-cards'), state.mining.cards);
state.mining.cards.forEach(function (cd) {
if (cd.vendor !== 'nvidia' || !(cd.power_default_w > 0)) return;
var row = $('s-cards').querySelector('.gpu-row[data-key="' + cd.key.replace(/"/g, '\\"') + '"] .info');
if (!row) return;
var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100);
var d = document.createElement('div'); d.className = cd.power_applied ? 'msg ok' : 'msg hot';
d.innerHTML = cd.power_applied ? 'power cap: ' + want + ' W applied' : 'power cap NOT applied (needs the administrator prompt) <button class="btn tiny" data-power-retry="1">Retry</button>';
row.appendChild(d);
});
$('s-name').value = state.display_name || '';
$('s-mid').textContent = (state.machine_id || '').slice(0, 8);
$('s-vote').checked = state.settings.vote;
$('s-devfee').checked = state.settings.dev_fee;
$('s-devfee-text').textContent = devFeeText(state);
$('s-login').checked = state.settings.start_at_login;
$('s-version').textContent = 'Igneum Miner ' + state.version;
$('s-node-dir').textContent = 'chain data: ' + state.node_dir;
$('s-log-dir').textContent = 'logs: ' + state.log_dir;
$('s-reveal').hidden = state.address.source !== 'generated';
$('s-live').hidden = !state.live_page;
var u = state.update;
$('s-auto-update').checked = !!state.settings.auto_update;
fillJobsSettings(state.jobs || {});
$('s-install').hidden = !((u.ready || u.downloaded || u.status === 'error') && !u.applying);
$('s-update-note').textContent = settingsUpdateNote(u);
}
$('s-address-save').addEventListener('click', function () {
var a = $('s-address-input').value.trim();
if (!validAddr(a)) { $('s-address-err').textContent = 'An address is 0x followed by 40 hex characters.'; $('s-address-err').hidden = false; return; }
api('api/settings', { address: a }).then(function (r) { if (r.ok) { $('s-address-err').hidden = true; $('s-address-input').value = ''; toast('Rewards address changed'); } else { $('s-address-err').textContent = r.error; $('s-address-err').hidden = false; } });
});
$('s-reveal').addEventListener('click', function () {
api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; } else toast(r.error || 'no key'); });
});
$('s-name-save').addEventListener('click', function () {
api('api/settings', { display_name: $('s-name').value }).then(function (r) { if (r.ok) toast('Renamed'); });
});
$('s-cards-save').addEventListener('click', function () {
var choices = readCardRows($('s-cards'));
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast('Applied; only the changed workers restart'); });
});
$('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
$('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
// the miner's own start line, next to the rewards address: "dev fee 1% (1 block in 100) to 0x...; --dev-fee 0 turns it off"
function devFeeText(s) {
var d = s.dev_fee || {};
var addr = d.address ? ' to ' + d.address.slice(0, 8) + '\u2026' + d.address.slice(-4) : '';
if (!s.settings.dev_fee) return 'Dev fee off (--dev-fee 0); the default is 1% (1 block in 100)' + addr;
if (d.line && !d.on && !d.address) return 'Dev fee: ' + d.line;
return 'Dev fee ' + (d.percent || 1) + '% (1 block in 100)' + addr + (d.address ? '' : '; the miner names the address when it starts');
}
$('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); });
$('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
$('s-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); });
$('s-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); });
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('s-prove').addEventListener('change', function () { api('api/prove', { on: this.checked }).then(function (r) { if (r.ok) toast(r.ok && $('s-prove').checked ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); }); });
$('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); setTimeout(fillSettings, 800); });
$('s-power-control').addEventListener('change', function () { var on = this.checked; api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on: Windows asks for administrator rights once' : 'Power control off: nothing asks for administrator rights'); else { toast(r.error || 'could not change'); $('s-power-control').checked = !on; } setTimeout(fillSettings, 1500); }); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); });
$('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); $('s-jobs-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
// ---------- bottom bar ----------
$('btn-pause').addEventListener('click', function () {
if (!state) return;
api(state.mining.paused ? 'api/resume' : 'api/pause', {});
});
$('btn-logs').addEventListener('click', function () { setDrawer(!document.body.classList.contains('drawer-open')); });
$('btn-quit').addEventListener('click', function () {
if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return;
api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true;
});
// ---------- log drawer ----------
// logLines: everything fetched this session (newest last, capped at LOG_CAP). view: the lines after the source chip
// and the search box. The list is virtualised: rows have a fixed height (RH) and only the rows in the viewport are in
// the DOM, so 20,000 lines cost the same as 20. Wrap mode renders the whole view with wrapped lines when it is small
// enough. Polling runs only while the drawer is open and the window is visible.
var LOG_CAP = 20000, WRAP_MAX = 5000, RH = 19, OVERSCAN = 8;
var logAfter = 0, logFilter = '', logQuery = '', logLines = [], view = [], viewDirty = true, wrap = false, follow = true;
var logView = $('log-view'), logPre = $('log'), logPad = $('log-pad');
var winStart = -1, winEnd = -1, autoScroll = false, scrollRaf = 0, rulerAt = 0, rulerCount = -1, searchT = null, logTimer = null;
var ERR_RE = /\[error\]|\berror\b|\bfailed\b|\bpanic\b|exited with code/i, SWAP_RE = /hourly program changed|program changed|seed change|hot swap|new program|exit-on-seed-change/i;
function isErr(l) { return l.err || ERR_RE.test(l.text); }
function isSwap(l) { return SWAP_RE.test(l.text); }
function drawerOpen() { return document.body.classList.contains('drawer-open'); }
function setDrawer(open) {
document.body.classList.toggle('drawer-open', open);
var b = $('btn-logs'); b.classList.toggle('on', open); b.setAttribute('aria-expanded', open ? 'true' : 'false');
$('btn-logs-text').textContent = open ? 'Close logs' : 'Logs';
if (open) { applyDrawerHeight(); pollLog(); scheduleLogPoll(); viewDirty = true; renderLog(true); }
else if (logTimer) { clearTimeout(logTimer); logTimer = null; }
}
// height: dragged by the grip, clamped to [140, 70% of the window]; a short window gets a smaller default
var drawerH = 0;
function defaultDrawerH() { return window.innerHeight < 700 ? 180 : 260; }
function applyDrawerHeight() {
var max = Math.floor(window.innerHeight * 0.7), min = 140;
if (!drawerH) { try { drawerH = parseInt(localStorage.getItem('igneum.drawer-h'), 10) || 0; } catch (e) { } }
var h = Math.max(min, Math.min(max, drawerH || defaultDrawerH()));
document.body.style.setProperty('--drawer-h', h + 'px');
if (drawerOpen()) renderLog(true);
}
(function () {
var grip = $('drawer-grip'), startY = 0, startH = 0;
grip.addEventListener('pointerdown', function (e) {
startY = e.clientY; startH = parseInt(getComputedStyle(document.body).getPropertyValue('--drawer-h'), 10) || defaultDrawerH();
grip.setPointerCapture(e.pointerId); document.body.classList.add('drawer-drag'); e.preventDefault();
});
grip.addEventListener('pointermove', function (e) {
if (!document.body.classList.contains('drawer-drag')) return;
drawerH = startH + (startY - e.clientY); applyDrawerHeight();
});
function end(e) { if (!document.body.classList.contains('drawer-drag')) return; document.body.classList.remove('drawer-drag'); try { localStorage.setItem('igneum.drawer-h', String(parseInt(getComputedStyle(document.body).getPropertyValue('--drawer-h'), 10))); } catch (x) { } }
grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end);
grip.addEventListener('dblclick', function () { drawerH = defaultDrawerH(); applyDrawerHeight(); try { localStorage.removeItem('igneum.drawer-h'); } catch (x) { } });
})();
window.addEventListener('resize', function () { if (drawerOpen()) applyDrawerHeight(); });
document.querySelectorAll('#log-chips .chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('#log-chips .chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); viewDirty = true; renderLog(true); }); });
$('log-search').addEventListener('input', function () { clearTimeout(searchT); var v = this.value; searchT = setTimeout(function () { logQuery = v.trim().toLowerCase(); viewDirty = true; renderLog(true); }, 120); });
$('log-search').addEventListener('keydown', function (e) { if (e.key === 'Escape') { this.value = ''; logQuery = ''; viewDirty = true; renderLog(true); } });
$('log-wrap').addEventListener('click', function () { wrap = !wrap; this.classList.toggle('on', wrap); logView.classList.toggle('wrap', wrap); renderLog(true); });
$('log-follow').addEventListener('change', function () { setFollow(this.checked); if (follow) renderLog(true); });
$('log-jump').addEventListener('click', function () { setFollow(true); renderLog(true); });
$('log-copy').addEventListener('click', function () {
var r = visibleRange(), rows = view.slice(r[0], r[1]);
if (!rows.length) { toast('Nothing to copy'); return; }
copyText(rows.map(function (l) { return clock(l.t) + ' ' + l.src.padEnd(6) + ' ' + l.text; }).join('\n'), 'Copied ' + rows.length + ' line' + (rows.length === 1 ? '' : 's'));
});
$('log-last-error').addEventListener('click', function () { jumpToLast(isErr, 'No error in the log'); });
$('log-last-swap').addEventListener('click', function () { jumpToLast(isSwap, 'No program swap in the log yet'); });
$('log-time').addEventListener('keydown', function (e) { if (e.key === 'Enter') { jumpToTime(this.value); } });
$('log-time').addEventListener('change', function () { jumpToTime(this.value); });
$('log-ruler').addEventListener('click', function (e) {
if (!view.length) return;
var r = this.getBoundingClientRect(), f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
var t0 = view[0].t, t1 = view[view.length - 1].t;
jumpToIndex(lowerBound(view, t0 + f * (t1 - t0)));
});
function setFollow(on) { follow = on; $('log-follow').checked = on; $('log-jump').hidden = on; }
function lowerBound(arr, t) { var lo = 0, hi = arr.length; while (lo < hi) { var mid = (lo + hi) >> 1; if (arr[mid].t < t) lo = mid + 1; else hi = mid; } return lo; }
function jumpToTime(v) {
var m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec((v || '').trim());
if (!m) { toast('Type a time as HH:MM:SS'); return; }
if (!view.length) { toast('Nothing in the log'); return; }
var last = new Date(view[view.length - 1].t * 1000), d = new Date(last.getTime());
d.setHours(+m[1], +m[2], +(m[3] || 0), 0);
if (d.getTime() > last.getTime() + 1000) d.setDate(d.getDate() - 1); // a time later than the newest line means yesterday
var target = d.getTime() / 1000;
if (target < view[0].t - 1) { toast('The log starts at ' + clock(view[0].t)); return; }
jumpToIndex(lowerBound(view, target));
}
function jumpToLast(test, missing) {
for (var i = view.length - 1; i >= 0; i--) if (test(view[i])) { jumpToIndex(i); return; }
toast(missing);
}
var hitSeq = 0, hitT = 0;
function jumpToIndex(i) {
if (!view.length) return;
i = Math.max(0, Math.min(view.length - 1, i));
setFollow(false);
hitSeq = view[i].seq; hitT = performance.now();
autoScroll = true;
logView.scrollTop = Math.max(0, rowTop(i) - Math.floor(logView.clientHeight / 3));
renderLog(true);
setTimeout(function () { hitSeq = 0; renderLog(true); }, 1600);
}
function rowTop(i) {
if (!wrap) return i * RH;
var kids = logPre.children; if (i < kids.length) return kids[i].offsetTop; return logPre.scrollHeight;
}
function visibleRange() {
if (!view.length) return [0, 0];
if (wrap) { // find by offsetTop (the rows are all in the DOM)
var kids = logPre.children, top = logView.scrollTop, bottom = top + logView.clientHeight, a = -1, b = kids.length;
for (var k = 0; k < kids.length; k++) { var y = kids[k].offsetTop; if (a < 0 && y + kids[k].offsetHeight > top) a = k; if (y >= bottom) { b = k; break; } }
return [Math.max(0, a), b];
}
var s = Math.floor(logView.scrollTop / RH), n = Math.ceil(logView.clientHeight / RH) + 1;
return [Math.max(0, s), Math.min(view.length, s + n)];
}
function rebuildView() {
var q = logQuery;
view = logLines.filter(function (l) { return (!logFilter || l.src.indexOf(logFilter) === 0) && (!q || l.text.toLowerCase().indexOf(q) >= 0 || l.src.indexOf(q) >= 0); });
viewDirty = false; winStart = winEnd = -1;
}
function appendView(lines) {
var q = logQuery;
for (var i = 0; i < lines.length; i++) { var l = lines[i]; if ((!logFilter || l.src.indexOf(logFilter) === 0) && (!q || l.text.toLowerCase().indexOf(q) >= 0 || l.src.indexOf(q) >= 0)) view.push(l); }
}
function rowHtml(l) {
var text = esc(l.text), q = logQuery;
if (q) { // highlight every match (case-insensitive, on the escaped text; the query itself is escaped too)
var eq = esc(q), lower = text.toLowerCase(), out = '', at = 0, idx;
while ((idx = lower.indexOf(eq, at)) >= 0) { out += text.slice(at, idx) + '<mark>' + text.slice(idx, idx + eq.length) + '</mark>'; at = idx + eq.length; }
text = out + text.slice(at);
}
return '<div class="ln' + (isErr(l) ? ' e' : '') + (hitSeq && l.seq === hitSeq ? ' hit' : '') + '" title="' + clock(l.t) + '"><span class="src ' + esc(l.src) + '">' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + '</span> ' + text + '</div>';
}
function renderLog(force) {
if (!drawerOpen()) return;
var a = performance.now();
if (viewDirty) rebuildView();
var n = view.length;
$('log-empty').hidden = n > 0;
$('log-empty').textContent = logLines.length ? 'No line matches.' : 'Nothing logged yet. Lines appear as the node and the miner write them.';
$('log-count').textContent = (logQuery || logFilter) ? (n + ' of ' + logLines.length) : (n ? String(n) : '');
var canWrap = n <= WRAP_MAX; $('log-wrap').disabled = !canWrap; $('log-wrap').title = canWrap ? 'Wrap long lines' : 'Wrap is off above 5,000 lines; filter or search first';
if (wrap && canWrap) {
logPad.style.height = ''; logPre.style.transform = '';
if (force || winStart !== 0 || winEnd !== n) { logPre.innerHTML = view.map(rowHtml).join(''); winStart = 0; winEnd = n; }
} else {
if (wrap && !canWrap) { wrap = false; $('log-wrap').classList.remove('on'); logView.classList.remove('wrap'); }
logPad.style.height = (n * RH) + 'px';
if (follow) { autoScroll = true; logView.scrollTop = Math.max(0, n * RH - logView.clientHeight); }
else if (logView.scrollTop > n * RH) { autoScroll = true; logView.scrollTop = Math.max(0, n * RH - logView.clientHeight); }
var r = visibleRange(), s = Math.max(0, r[0] - OVERSCAN), e = Math.min(n, r[1] + OVERSCAN);
if (force || s !== winStart || e !== winEnd) {
var h = ''; for (var i = s; i < e; i++) h += rowHtml(view[i]);
logPre.innerHTML = h; logPre.style.transform = 'translateY(' + (s * RH) + 'px)'; winStart = s; winEnd = e;
}
}
if (follow && wrap) { autoScroll = true; logView.scrollTop = logView.scrollHeight; }
updateAt(); updateRuler(force);
perf.log += performance.now() - a;
}
function updateAt() {
var r = visibleRange();
$('log-at').textContent = view.length ? clock(view[Math.min(view.length - 1, r[0])].t) : '';
}
// the ruler: the span of the view, error (ember) and swap (molten) marks, and the window in view; rebuilt when the
// count changes, at most every 2 s, from a sample of at most 4,000 lines
function updateRuler(force) {
var ruler = $('log-ruler'), n = view.length;
if (!n) { ruler.classList.add('empty'); return; }
ruler.classList.remove('empty');
var t0 = view[0].t, t1 = view[n - 1].t, span = Math.max(1, t1 - t0);
var r = visibleRange(), win = $('log-ruler-win'), i0 = Math.min(n - 1, r[0]), i1 = Math.max(i0, Math.min(n - 1, r[1] - 1));
win.style.left = ((view[i0].t - t0) / span * 100) + '%';
win.style.width = Math.max(0.5, (view[i1].t - view[i0].t) / span * 100) + '%';
var now = performance.now();
if (n === rulerCount && now - rulerAt < 2000) return;
rulerAt = now; rulerCount = n;
$('log-ruler-0').textContent = clock(t0); $('log-ruler-1').textContent = clock(t1);
ruler.querySelectorAll('.mk').forEach(function (m) { m.remove(); });
var step = Math.max(1, Math.floor(n / 4000)), h = '';
for (var i = 0; i < n; i += step) { var l = view[i]; var sw = isSwap(l); if (sw || isErr(l)) h += '<i class="mk ' + (sw ? 'swap' : 'err') + '" style="left:' + ((l.t - t0) / span * 100).toFixed(2) + '%" title="' + clock(l.t) + (sw ? ' program swap' : ' error') + '"></i>'; }
win.insertAdjacentHTML('beforebegin', h);
}
logView.addEventListener('scroll', function () {
if (autoScroll) { autoScroll = false; return; }
var atEnd = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - RH;
if (atEnd !== follow) setFollow(atEnd);
if (!scrollRaf) scrollRaf = requestAnimationFrame(function () { scrollRaf = 0; renderLog(false); });
});
function pollLog() {
if (!drawerOpen() || hiddenNow()) return;
api('api/log?after=' + logAfter + '&limit=' + (logAfter ? 400 : 5000)).then(function (r) {
perf.polls++;
if (r.lines && r.lines.length) {
logLines = logLines.concat(r.lines); logAfter = r.lines[r.lines.length - 1].seq;
if (logLines.length > LOG_CAP) { logLines = logLines.slice(-LOG_CAP); viewDirty = true; } else if (!viewDirty) appendView(r.lines);
var a = performance.now(); renderLog(false); perf.poll += performance.now() - a;
}
}).catch(function () { });
}
function scheduleLogPoll() { clearTimeout(logTimer); logTimer = setTimeout(function () { pollLog(); if (drawerOpen()) scheduleLogPoll(); }, 1500); }
// ---------- the blocks strip (your blocks over the last 10 minutes) ----------
// The minute grid never moves (it hangs off "now", which is always the right edge), so it is drawn once per size
// into an offscreen layer. A frame is clear + the layer + the rail + the blocks. Blocks drift 1.7 px/s, so the
// strip redraws twice a second; while a block flashes (1.2 s after it appears) it runs at the display rate. Nothing
// draws while the window is hidden (the hosts hide it to the menu bar or the tray).
var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, born = {}, grid = document.createElement('canvas'), gx = grid.getContext('2d');
var SPAN = 600, S = 18, PAD = 10, RIGHT_PAD = 26;
function size() {
W = c.clientWidth; H = c.clientHeight; if (!W || !H) return;
c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0);
grid.width = W * dpr; grid.height = H * dpr; gx.setTransform(dpr, 0, 0, dpr, 0, 0);
gx.clearRect(0, 0, W, H);
var right = W - RIGHT_PAD, left = PAD, pxs = (right - left) / SPAN;
gx.font = '500 10px IBM Plex Mono, monospace'; gx.textAlign = 'center';
for (var m = 0; m <= 10; m++) {
var px = right - m * 60 * pxs;
gx.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; gx.lineWidth = 1; gx.setLineDash(m === 0 ? [] : [3, 6]);
gx.beginPath(); gx.moveTo(px, 14); gx.lineTo(px, H - 18); gx.stroke(); gx.setLineDash([]);
if (px > 30) { gx.fillStyle = 'rgba(154,154,158,0.8)'; gx.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); }
}
var rail = H * 0.5;
gx.strokeStyle = 'rgba(58,58,66,0.9)'; gx.lineWidth = 1; gx.beginPath(); gx.moveTo(left, rail); gx.lineTo(right, rail); gx.stroke();
requestDraw();
}
window.addEventListener('resize', function () { size(); });
function rrect(px, py, w, h, r) { x.beginPath(); x.moveTo(px + r, py); x.lineTo(px + w - r, py); x.quadraticCurveTo(px + w, py, px + w, py + r); x.lineTo(px + w, py + h - r); x.quadraticCurveTo(px + w, py + h, px + w - r, py + h); x.lineTo(px + r, py + h); x.quadraticCurveTo(px, py + h, px, py + h - r); x.lineTo(px, py + r); x.quadraticCurveTo(px, py, px + r, py); x.closePath(); }
// returns true while something is animating (a flash), so the caller keeps the fast rate
function draw(now) {
if (!W || !H) size();
if (!W) return false;
x.clearRect(0, 0, W, H);
x.drawImage(grid, 0, 0, W, H);
var right = W - RIGHT_PAD, pxs = (right - PAD) / SPAN;
var nowU = state ? (state.now + (performance.now() - stateAt) / 1000) : Date.now() / 1000;
var found = state ? state.mining.found : [];
var rail = H * 0.5, shown = 0, animating = false;
for (var i = 0; i < found.length; i++) {
var t = found[i], age = nowU - t; if (age < 0 || age > SPAN) continue; shown++;
var key = String(t);
if (!born[key]) born[key] = age < 5 ? now : now - 2000; // only a block that just arrived flashes, not the whole strip on first paint
var fl = Math.max(0, 1 - (now - born[key]) / 1200);
if (fl > 0) animating = true;
var px = right - age * pxs, py = rail + ((i % 3) - 1) * 22;
if (fl > 0) { var g = x.createRadialGradient(px, py, 0, px, py, S * 2.2); g.addColorStop(0, 'rgba(255,179,92,' + (0.6 * fl) + ')'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.beginPath(); x.arc(px, py, S * 2.2, 0, Math.PI * 2); x.fill(); }
x.fillStyle = '#F2541B'; rrect(px - S / 2, py - S / 2, S, S, 5); x.fill();
if (fl > 0) { x.strokeStyle = 'rgba(255,179,92,' + fl + ')'; x.lineWidth = 2; rrect(px - S / 2 - 2, py - S / 2 - 2, S + 4, S + 4, 6); x.stroke(); }
}
// forget flashes older than the strip, so the map does not grow for a day-long run
if (found.length && Object.keys(born).length > found.length + 50) { var keep = {}; for (var k = 0; k < found.length; k++) if (born[String(found[k])]) keep[String(found[k])] = born[String(found[k])]; born = keep; }
if (!shown) {
x.fillStyle = 'rgba(154,154,158,0.9)'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center';
var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: your blocks appear here as the node accepts them' : state.mining.state === 'paused' ? 'paused' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync';
x.fillText(msg, W / 2, rail - 14);
}
return animating && !reduce;
}
var stateAt = performance.now(), drawTimer = null, drawRaf = 0;
function frame(now) {
drawRaf = 0; clearTimeout(drawTimer); drawTimer = null;
if (hiddenNow()) return;
var fast = false;
if (phase === 'dashboard') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
if (fast) drawRaf = requestAnimationFrame(frame);
else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500);
}
function requestDraw() { if (hiddenNow() || drawRaf) return; clearTimeout(drawTimer); drawTimer = null; drawRaf = requestAnimationFrame(frame); }
document.addEventListener('visibilitychange', function () {
if (hiddenNow()) { clearTimeout(drawTimer); drawTimer = null; if (drawRaf) { cancelAnimationFrame(drawRaf); drawRaf = 0; } clearTimeout(logTimer); logTimer = null; }
else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } }
});
requestDraw();
// ---------- render ----------
// ---------- GPU rows: one per card, with a switch and an identities stepper ----------
function badgeHtml(cd) {
if (cd.vendor === 'apple') return '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU';
}
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); }
function renderCardRows(container, cards) {
container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); });
cards.forEach(function (cd) {
var d = document.createElement('div');
d.className = 'gpu-row' + (cd.enabled ? '' : ' off');
d.dataset.key = cd.key;
var meta = '<span>worker <b>' + esc(cd.worker) + '</b></span>' + (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : '');
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
(cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '<div class="power"><span class="k">power cap</span><input type="range" min="50" max="100" step="5" value="' + (cd.power_pct || 80) + '"><span class="pv mono">' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W</span>' + (cd.pinned ? '<span class="pin" title="set by hand; the sweep records but does not change it">pinned</span>' : cd.sweep_pct ? '<span class="pin" title="chosen by the efficiency sweep">sweep ' + cd.sweep_pct + '%</span>' : '') + '</div>' : '') +
'<div class="ids"><span class="k">identities</span><button type="button" data-d="-1" aria-label="fewer">&minus;</button><input type="number" min="1" max="64" value="' + cd.identities + '"><button type="button" data-d="1" aria-label="more">+</button></div>' +
'<label class="switch" title="' + (cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled ? ' checked' : '') + (cd.kind === 'unknown' ? ' disabled' : '') + '><span class="track"></span></label>';
container.appendChild(d);
});
}
function readCardRows(container) {
var out = [];
container.querySelectorAll('.gpu-row').forEach(function (r) {
var n = parseInt(r.querySelector('.ids input').value, 10); if (!(n >= 1)) n = 1; if (n > 64) n = 64;
var pr = r.querySelector('.power input'), item = { key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n };
if (pr) item.power_pct = parseInt(pr.value, 10);
out.push(item);
});
return out;
}
document.addEventListener('click', function (e) {
var b = e.target.closest('.ids button'); if (!b) return;
var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1;
v = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); inp.value = v;
});
document.addEventListener('change', function (e) {
if (e.target.matches('.gpu-row .switch input')) { var r = e.target.closest('.gpu-row'); r.classList.toggle('off', !e.target.checked); }
});
document.addEventListener('input', function (e) {
if (!e.target.matches('.power input')) return;
var row = e.target.closest('.gpu-row'), cd = state && state.mining.cards.filter(function (c) { return c.key === row.dataset.key; })[0];
var pct = parseInt(e.target.value, 10);
row.querySelector('.pv').textContent = pct + '%' + (cd && cd.power_default_w ? ' · ' + Math.round(cd.power_default_w * pct / 100) + ' W' : '');
});
var cardsRendered = '';
function renderCards(s) {
var list = $('cards-list'), det = $('cards-detecting');
if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; }
det.hidden = true;
var cards = s.mining.cards;
var sig = cards.map(function (c) { return c.key; }).join('|');
if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); }
if (!cards.length) {
$('cards-sub').textContent = 'No GPU this app can drive was found.';
$('cards-note').textContent = (s.detect_message || '') + ' The node can still run on its own; mining needs a supported card.';
$('cards-note').hidden = false; $('cards-help').hidden = true; $('btn-cards-retry').hidden = false; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue without mining';
return;
}
var on = cards.filter(function (c) { return c.enabled; }).length;
$('cards-sub').textContent = cards.length === 1 ? 'One card found. Switch it on to mine with it.' : cards.length + ' GPUs found. Switch on the ones you want to mine with; each gets its own worker.';
$('cards-note').hidden = !s.detect_message; $('cards-note').textContent = s.detect_message || '';
$('cards-help').hidden = false;
var nv = cards.filter(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0; }).length;
$('cards-power').hidden = !nv;
$('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = on ? 'Continue' : 'Continue';
}
// power draw, the cap and the temperatures (NVIDIA); memory over 90 C amber, over 95 C red
function telemetryHtml(cd) {
if (cd.vendor !== 'nvidia' || !(cd.telemetry_at > 0 || cd.power_default_w > 0)) return '';
var memCls = cd.temp_mem > 95 ? 'hot' : cd.temp_mem > 90 ? 'warm' : '';
var h = '<div class="m tele"><span>draw <b>' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '</b>' + (cd.power_limit_w ? ' <span class="dim">/ cap ' + Math.round(cd.power_limit_w) + ' W</span>' : '') + '</span>' +
'<span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span>' +
'<span class="' + memCls + '">memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span>' +
'<span>eff <b>' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '</b></span></div>';
if (memCls) h += '<div class="msg ' + memCls + '">memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk</div>';
if (cd.power_default_w > 0) {
var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100);
if (cd.power_applied) h += '<div class="msg ok">power cap: ' + want + ' W (' + (cd.power_pct || 80) + '%)' + (cd.pinned ? ' pinned' : cd.sweep_pct === (cd.power_pct || 80) && cd.sweep_pct ? ' chosen by the sweep' : '') + '</div>';
else h += '<div class="msg hot">power cap NOT applied (needs the administrator prompt)' + (cd.power_note && cd.power_note.indexOf('wanted') > 0 ? ' · ' + esc(cd.power_note.replace(/^.*: card reports/, 'card reports')) : '') + ' <button class="btn tiny" data-power-retry="1">Retry</button></div>';
}
return h + sweepHtml(cd);
}
// the efficiency sweep line: running (the phase), the last result (best cap and MH/W), or why it cannot run
function sweepHtml(cd) {
if (!cd.sweep_supported) return cd.sweep_note ? '<div class="msg dim sweep">' + esc(cd.sweep_note) + '</div>' : '';
var running = cd.sweep_state === 'running';
var t = '';
if (running) t = esc(cd.sweep_note || 'sweep: running');
else if (cd.sweep_pct) t = 'sweep: best <b>' + cd.sweep_pct + '%</b> · <b>' + cd.sweep_eff.toFixed(3) + ' MH/W</b> (' + cd.sweep_mhs.toFixed(1) + ' MH/s at ' + Math.round(cd.sweep_watts) + ' W' + (cd.sweep_at ? ', ' + rel(state.now - cd.sweep_at) + ' ago' : '') + ')' + (cd.sweep_note && cd.sweep_note.indexOf('stopped') === 0 ? ' · ' + esc(cd.sweep_note) : '');
else t = esc(cd.sweep_note || 'sweep: not run yet');
var btn = running ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Sweep now</button>';
if (!running && cd.pinned) btn += ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0" title="let the sweep choose the cap again">Unpin</button>';
return '<div class="msg sweep' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>';
}
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-sweep-start],[data-sweep-stop],[data-sweep-pin]'); if (!b) return;
if (b.dataset.sweepStart) api('api/sweep/start', { key: b.dataset.sweepStart }).then(function () { toast('Sweep queued: 100% down to 50%, 75 s a step'); });
else if (b.dataset.sweepStop) api('api/sweep/stop', {}).then(function () { toast('Sweep stopped; cap restored'); });
else api('api/sweep/pin', { key: b.dataset.sweepPin, pinned: b.dataset.pinned === '1' }).then(function () { toast(b.dataset.pinned === '1' ? 'Cap pinned' : 'The sweep chooses the cap again'); });
});
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-power-retry]'); if (!b) return;
api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); });
});
var lastEventsKey = '', lastFoot = '';
function skewWord(sk) { sk = Math.round(Math.abs(sk)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; }
// sync ETA from the block rate over the last minute of readings: "about 2 h 10 min left"
var syncSamples = [];
function syncEta(n, now) {
if (n.state !== 'syncing' || !(n.headers > n.blocks)) { syncSamples = []; return ''; }
if (!syncSamples.length || now - syncSamples[syncSamples.length - 1].t >= 1) syncSamples.push({ t: now, b: n.blocks });
while (syncSamples.length > 2 && now - syncSamples[0].t > 90) syncSamples.shift();
var a = syncSamples[0], b = syncSamples[syncSamples.length - 1];
if (b.t - a.t < 5 || b.b <= a.b) return syncSamples.length > 15 ? 'no progress in the last minute' : 'measuring the sync rate';
var rate = (b.b - a.b) / (b.t - a.t), left = (n.headers - n.blocks) / rate;
var word = left < 60 ? 'under a minute' : left < 3600 ? 'about ' + Math.round(left / 60) + ' min' : left < 86400 ? 'about ' + Math.floor(left / 3600) + ' h ' + Math.round(left % 3600 / 60) + ' min' : 'over a day';
return word + ' left at ' + Math.round(rate) + ' blocks/s';
}
function renderDashboard(s) {
var m = s.mining, n = s.node, p = s.program, f = s.finality;
$('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1);
var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length;
$('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state;
$('d-blocks').textContent = withCommas(m.accepted_total);
$('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '');
var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state;
$('d-node').textContent = nodeWord;
// the live numbers always show, so a stuck label is visible as stuck
var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's');
var eta = syncEta(n, s.now);
var syncing = n.state === 'syncing' && n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + Math.floor(n.blocks / n.headers * 100) + '%)' : '';
$('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + skewWord(s.clock.skew_s) + ' ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': sync it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : syncing || (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live;
$('d-node-sub').title = $('d-node-sub').textContent + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : '');
$('d-hash-sub').title = $('d-hash-sub').textContent; $('d-blocks-sub').title = $('d-blocks-sub').textContent;
var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block'));
if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); }
else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'; }
$('d-eta-sub').title = $('d-eta-sub').textContent;
var nowU = s.now;
$('d-found-10').textContent = m.found.filter(function (t) { return nowU - t <= 600; }).length;
$('d-found-60').textContent = m.found.length;
$('d-fee').textContent = withCommas(m.fee_total || 0);
$('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee';
$('d-chain-blocks').textContent = compact(n.blocks);
// per-card tiles (enabled cards), the rest in one muted line
var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; });
$('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : '');
var tb = $('d-cards');
if (!on.length) tb.innerHTML = '<div class="empty">' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
else tb.innerHTML = on.map(function (cd) {
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : '';
var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state;
var extra = cd.ids.length ? '<span>id <b>' + esc(cd.ids.join(' ')) + '</b></span>' : '';
return '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
'<div class="h">' + cd.hash_now.toFixed(1) + '<span class="unit">MH/s</span></div>' +
'<div class="m"><span>blocks <b>' + cd.accepted + '</b>' + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + ' rejected</span>' : '') + '</span><span>avg <b>' + cd.hash_avg.toFixed(1) + '</b></span><span>identities <b>' + cd.identities + '</b></span>' + extra + (cd.faults ? '<span class="dim">worker faults <b>' + cd.faults + '</b></span>' : '') + (cd.mismatched ? '<span class="dim">re-check misses <b>' + cd.mismatched + '</b></span>' : '') + '</div>' +
telemetryHtml(cd) +
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
}).join('');
$('d-cards-off').hidden = !off.length;
$('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. Settings switches them on.' : '';
// node card
$('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : '');
$('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers;
$('n-daa').textContent = withCommas(n.daa); $('n-diff').textContent = compact(n.difficulty); $('n-tips').textContent = n.tips;
var consensus = n.consensus_switch_daa > 0 ? ' Consensus switch at DAA ' + withCommas(n.consensus_switch_daa) + (n.daa > 0 && n.consensus_switch_daa > n.daa ? ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : '.') + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '') : '';
$('n-note').textContent = (n.state === 'synced' ? ('Reading every 10 s' + (n.last_reading_age_s >= 0 ? ', last ' + Math.round(n.last_reading_age_s) + ' s ago' : '') + '.') : (n.message ? n.message + '.' : '')) + consensus;
// finality
if (f.last_lock > 0) { $('f-lock').textContent = '#' + withCommas(f.last_lock); $('f-age').textContent = rel(f.age_s); $('f-note').textContent = 'Checkpoints lock at 2/3 of the 30-day weight. Votes are sent by this miner.'; }
else { $('f-lock').textContent = 'none yet'; $('f-age').textContent = 'n/a'; $('f-note').textContent = f.message || 'Locks appear once the miner votes on checkpoints.'; }
// proving
var pv = s.proving || {};
var pvWord = { off: 'off', setup: 'needs setup', waiting: 'waiting', idle: 'idle', proving: 'proving', submitted: 'submitted' }[pv.status] || (pv.status || 'off');
$('pv-state').textContent = pvWord + (pv.status === 'proving' && pv.current ? ' ' + pv.current : '') + (pv.backend && pv.enabled && pv.available ? ' (' + pv.backend.toUpperCase() + ')' : '');
$('pv-assigned').textContent = String(pv.assigned || 0);
$('pv-submitted').textContent = String(pv.submitted || 0);
$('pv-paid').textContent = String(pv.paid || 0) + (pv.paid_wei ? ' (' + (Number(pv.paid_wei) / 1e18).toFixed(4) + ' IGN)' : '');
$('pv-note').textContent = pv.enabled ? (pv.message || '') : 'Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.';
// the node's proof verifier (spec 7.7 item 4): a node without one relays records and never includes them
var vm = pv.verifier_mode || 'unknown';
var vWord = { command: 'verifying', trust: 'trust (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm;
$('pv-verifier').textContent = vWord + (vm === 'command' && pv.pool_entries ? ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '') : '');
$('pv-verifier').classList.toggle('warn', vm === 'off' || vm === 'trust');
$('pv-verifier-note').textContent = pv.verifier_note || '';
$('pv-verifier-note').hidden = !pv.verifier_note;
$('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || (vm === 'off' && /WSL2 prover is not installed/.test(pv.verifier_reason || '')));
$('f-votes').textContent = withCommas(f.votes);
// events
var key = s.events.length ? s.events[0].t + ':' + s.events.length : '';
if (key !== lastEventsKey) {
lastEventsKey = key;
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 14).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(e.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet.</div>';
}
// bottom
$('btn-pause').textContent = m.paused ? 'Resume' : 'Pause';
// the bottom line: the machine name truncates first, the address is always short, the rest never wraps
var foot = s.quitting ? '<span class="pc">stopping: miners first, then the node</span>' :
(s.display_name ? '<span class="pc nm" title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</span>' : '') +
'<span class="pc">' + esc(m.state === 'mining' ? 'mining' : m.state) + '</span><span class="pc">node ' + esc(nodeWord) + '</span><span class="pc up">up ' + uptime(s.uptime_s) + '</span>' +
(s.address.display ? '<span class="pc ad" title="rewards to ' + esc(s.address.display) + '">rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) + '</span>' : '');
if (foot !== lastFoot) { lastFoot = foot; $('foot-status').innerHTML = foot; }
$('foot-version').textContent = 'v' + s.version;
}
function renderPill(s) {
var pill = $('pill'), text;
pill.classList.remove('on', 'warn');
if (s.quitting) text = 'stopping';
else if (!s.setup_done) text = s.detecting ? 'detecting' : 'set up';
else if (s.mining.state === 'mining') { text = s.mining.hash_total.toFixed(1) + ' MH/s'; pill.classList.add('on'); }
else if (s.mining.state === 'paused') text = 'paused';
else if (s.node.state === 'syncing' || s.node.state === 'starting') text = 'node ' + s.node.state;
else if (s.node.state === 'failed' || s.node.state === 'restarting') { text = 'node ' + s.node.state; pill.classList.add('warn'); }
else if (s.mining.state === 'idle' && s.node.state === 'synced') { text = 'node synced'; pill.classList.add('on'); }
else text = s.mining.state;
$('pill-text').textContent = text;
}
// the clock on the dashboard: the node card (the strip carries it on the setup screens, see Notices)
function renderClock(s) {
var c = s.clock || { severity: 'none' }, bad = c.severity === 'block', warn = c.severity === 'warn';
var card = $('n-clock');
card.hidden = !(bad || warn);
card.classList.toggle('warn', warn);
$('n-clock-msg').textContent = c.message || '';
$('n-clock-hint').textContent = c.hint ? 'By hand: ' + c.hint : '';
$('n-clock-result').textContent = c.sync_result || '';
$('n-clock-sync').disabled = !!c.syncing; $('n-clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock';
$('btn-start').disabled = bad;
$('start-blocked').hidden = !bad;
$('start-blocked').textContent = bad ? c.message + ' Use "Sync clock" above.' : '';
$('btn-key-done').disabled = bad || !$('key-ack').checked;
}
$('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
// ---------- the notice strip (Notices, top of this file): one notice, its actions, the close control ----------
// dismissed: the keys closed this session; a notice with a closed key stays hidden until its key changes
var dismissed = {}, noticeSig = '', stripH = 0;
function renderNotices(s) {
var n = Notices.pick(Notices.gather(s, { dashboard: phase === 'dashboard' }), dismissed);
var strip = $('notices'), el = $('notice');
if (!n) {
if (noticeSig) { noticeSig = ''; strip.hidden = true; layoutStrip(); }
return;
}
var sig = [n.key, n.kind, n.tone, n.text, n.detail, n.progress >= 0 ? 'p' : '', JSON.stringify(n.actions)].join('|');
if (sig !== noticeSig) {
noticeSig = sig;
el.className = 'notice ' + n.kind + (n.tone ? ' ' + n.tone : '');
el.dataset.key = n.key;
$('notice-text').textContent = n.text; $('notice-text').title = n.title || '';
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = !n.detail; $('notice-detail').title = n.detail;
$('notice-actions').innerHTML = n.actions.map(function (a) { return '<button class="btn small ' + (a.primary ? 'primary' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('notice-prog').hidden = !(n.progress >= 0);
strip.hidden = false;
}
if (n.progress >= 0) $('notice-prog').firstElementChild.style.width = Math.round(n.progress * 100) + '%';
layoutStrip();
}
// main sits under the strip: its top is set once per height change and moves with the 150 ms transition in the
// CSS; an empty strip reserves nothing
function layoutStrip() {
var strip = $('notices'), h = strip.hidden ? 0 : strip.offsetHeight;
if (h === stripH) return;
stripH = h;
var top = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--top'), 10) || 60;
$('main').style.top = h ? (top + h) + 'px' : '';
}
window.addEventListener('resize', layoutStrip);
$('notice').addEventListener('click', function (e) {
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, key = $('notice').dataset.key;
if (act === 'close') { dismissed[key] = true; renderNotices(state || {}); }
else if (act === 'install') { api('api/update/install', {}); toast('Installing at once'); }
else if (act === 'retry') { api('api/update/install', {}); toast('Trying the update again'); }
else if (act === 'open') api('api/update/open', {});
else if (act === 'sync') api('api/clock/sync', {});
});
// ---------- the update card (UpdateCard, top of this file): one update, centred, over everything ----------
// mem: open (the card is up), later (the key Later was pressed on), seen (the version it was shown for);
// asked: Install now was pressed on this card and the engine has not flipped to applying yet
var updMem = { open: false, later: '', seen: '' }, updSig = '', updAsked = '', updMore = false;
if (forcedUpdate && params.get('card') === '1') updMem.open = true; // screenshots: installing and failed only show on an open card
function cardCtx(s) { return { jobActive: !!(s.jobs && s.jobs.active), uptime_s: s.uptime_s, quitting: !!s.quitting, sheetOpen: !$('sheet-key').hidden }; }
function renderUpdateCard(s) {
var m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd');
if (!d.show) {
if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; }
return;
}
if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; }
var asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed';
var sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001');
if (sig !== updSig) {
updSig = sig;
$('upd-name').textContent = m.name;
$('upd-line').textContent = m.line;
$('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause;
$('upd-notes').innerHTML = m.lines.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('');
$('upd-all').innerHTML = m.all.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('');
$('upd-more').hidden = !m.more; if (!m.more) updMore = false;
$('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false');
$('upd-all').hidden = !updMore;
$('upd-mark').className = 'upd-mark ' + m.ring;
var acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions;
$('upd-actions').innerHTML = acts.map(function (a) { return '<button class="btn ' + (a.primary ? 'primary big' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('upd').dataset.dismiss = m.dismissable ? '1' : '';
}
// the ring and the percent move every poll; the words above do not
if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; }
else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; }
var meta = [];
if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size);
else if (m.size && (m.stage === 'available')) meta.push(m.size + ' download');
else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded');
if (m.note) meta.push(m.note);
$('upd-meta').textContent = meta.join(' · ');
if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); }
}
function cardLater() {
if (!updMem.open || !$('upd').dataset.dismiss) return;
var m = UpdateCard.model(state && state.update, state || {});
updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = '';
}
$('upd').addEventListener('click', function (e) {
if (e.target === $('upd')) { cardLater(); return; }
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {});
if (act === 'later') cardLater();
else if (act === 'install' || act === 'retry') { api('api/update/install', {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'Trying the update again' : m && m.stage === 'ready' ? 'Installing at once' : 'Installs as soon as it is downloaded'); if (state) renderUpdateCard(state); }
else if (act === 'open') api('api/update/open', {});
});
$('upd-more').addEventListener('click', function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } });
// ---------- over-the-air updates (src/ota.rs): the settings note; the strip is Notices.updateNotice ----------
function settingsUpdateNote(u) {
var n = Notices.updateNotice(u, state || {}), parts = [];
if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.');
if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.');
if (n && n.kind !== 'update-installed' && !(u.rolled_back && u.status === 'error')) parts.push(n.text + (n.detail ? ' ' + Notices.endDot(n.detail) : ''));
else if (u.status === 'checking') parts.push('Checking.');
else if (u.status === 'current') parts.push('This is the latest version' + (u.checked_at ? ' (checked ' + rel(state.now - u.checked_at) + ')' : '') + '.');
else if (u.error) parts.push(u.error);
if (u.activation_height && !u.urgent) parts.push('Consensus upgrade at height ' + withCommas(u.activation_height) + '.');
return parts.join(' ');
}
function sampleUpdate(kind) {
// ?update=<kind>[&auto=0]: the card and the strip for each state, without an engine
var u = { available: true, version: '0.3.7', notes: 'The update card: one centred card, Install now and Later. Worker restarts no longer drop the hourly program. The log drawer wraps long lines and jumps to the last error. Windows Hello is written but untested; Touch ID confirms every send on the Mac.', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: params.get('auto') !== '0', wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' };
if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)';
if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; }
if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); }
if (kind === 'staging') { u.status = 'staging'; u.ready = false; u.progress = 1; }
if (kind === 'waiting') { u.wait = 'hourly program boundary in 97 s; installing after it'; }
if (kind === 'applying') { u.status = 'applying'; u.applying = true; }
if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission); mining continues'; }
if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.7 now.'; }
if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; }
if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; }
if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.6'; u.version = '0.3.7'; }
return u;
}
// ---------- remote jobs (src/jobrun.rs): the settings block; the strip is Notices.jobNotice ----------
var jobTitle = Notices.jobTitle;
function sampleJob(kind, now) {
var j = { allowed: true, key_fingerprint: '8f186e37', url_set: true, checked_at: now - 30, error: '', queued: 0, account: '', active: false, id: '', kind: '', title: '', stage: '', message: '', started_at: 0, run_id: '', results: [], last: {}, last_results: [], history: [] };
if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; }
else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; }
else if (kind === 'failed') { j.last = { id: 'job-2026-10-05-01', kind: 'build', title: 'build', status: 'failed', started_at: now - 2000, finished_at: now - 900, exit: 1, run_id: 'r0', uploaded: false, summary: 'build exited with code 1' }; j.last_results = ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo` in this scope (src/engine.rs:2082)']; }
return j;
}
function fillJobsSettings(j) {
$('s-jobs-allow').checked = !!j.allowed;
$('s-prove').checked = !!(state.settings && state.settings.prove);
$('s-trust').checked = !!(state.settings && state.settings.proof_verify_trust);
var pc = !!(state.settings && state.settings.power_control);
$('s-power-control').checked = pc;
$('s-power-note').textContent = (state.settings && state.settings.power_note) || '';
$('s-sweep').checked = !!(state.settings && state.settings.sweep);
$('s-sweep').disabled = !pc;
$('s-jobs-key').textContent = j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : '';
var parts = [];
if (j.account) parts.push(j.account);
if (!j.allowed) parts.push('Off: nothing runs here until it is switched on.');
else if (!j.url_set) parts.push('No jobs address in this build.');
else if (j.error) parts.push(j.error + '.');
else if (j.active) parts.push('Running ' + jobTitle(j) + ' (' + j.id + ').');
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((state.now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.');
$('s-jobs-note').textContent = parts.join(' ');
var h = j.history || [];
$('s-jobs-history').innerHTML = h.length ? '<table><thead><tr><th>job</th><th>kind</th><th>started</th><th>exit</th><th>report</th></tr></thead><tbody>' + h.map(function (r) {
return '<tr class="' + esc(r.status) + '"><td class="mono" title="' + esc(r.summary || '') + '">' + esc(r.id) + '</td><td>' + esc(r.kind) + '</td><td>' + (r.started_at ? clock(r.started_at) : '') + '</td><td>' + esc(r.status) + (r.status !== 'running' ? ' (' + r.exit + ')' : '') + '</td><td>' + (r.uploaded ? 'uploaded' : (r.status === 'running' ? 'pending' : 'not uploaded')) + '</td></tr>';
}).join('') + '</tbody></table>' : '<p class="note">No job has run on this machine yet.</p>';
}
function render(s) {
state = s; stateAt = performance.now();
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.7'; s.uptime_s = 20; } }
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
if (params.get('uptime')) s.uptime_s = parseInt(params.get('uptime'), 10) || 0; // the card's first-minute rule, for screenshots
renderPill(s);
renderClock(s);
renderNotices(s);
renderUpdateCard(s);
if (phase === 'cards') renderCards(s);
if (phase === 'dashboard') renderDashboard(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }
}
var failures = 0, first = true, pollTick = 0;
function poll() {
// hidden windows poll every 5 s (the pill and the tray text still need a recent state), visible ones every second
if (hiddenNow() && (pollTick++ % 5)) return;
api('api/state').then(function (s) {
var a = performance.now();
failures = 0;
if (first) {
first = false;
var start = forced || (s.setup_done ? 'dashboard' : 'welcome');
if (start === 'settings') { show('dashboard'); setTimeout(function () { fillSettings(); $('settings').hidden = false; }, 300); }
else if (start === 'key') { show('address'); showKeySheet({ address: s.address.value || '0x0000000000000000000000000000000000000000', display: s.address.display || '0x0000000000000000000000000000000000000000', private_key: '0x' + '0'.repeat(64), wallet_file: s.address.wallet_file || (s.node_dir.replace(/[^\/\\]+$/, '') + 'app/wallet.json') }); }
else show(start);
if (start === 'cards') renderCards(s);
}
render(s);
perf.render += performance.now() - a; perf.polls++;
if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); }
}).catch(function () {
failures++;
if (failures >= 4) { $('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; if (phase === 'dashboard') { $('d-node').textContent = 'no answer'; $('d-node-sub').textContent = 'the engine is not answering; the window reconnects by itself'; $('d-node-cell').classList.add('bad'); } }
});
}
poll(); setInterval(poll, 1000);
size();
// debug: time N synchronous calls of each hot path (ms per call), independent of the host's frame rate
if (debug) window.__igneumBench = function (n) {
n = n || 100; var out = {}, a, i;
a = performance.now(); for (i = 0; i < n; i++) draw(performance.now()); out.drawMsPerFrame = +((performance.now() - a) / n).toFixed(3);
if (state) { a = performance.now(); for (i = 0; i < n; i++) render(state); out.renderMsPerPoll = +((performance.now() - a) / n).toFixed(3); }
if (drawerOpen()) { a = performance.now(); for (i = 0; i < n; i++) renderLog(true); out.logMsPerRender = +((performance.now() - a) / n).toFixed(3); }
return out;
};
})();