2843 lines
264 KiB
JavaScript
2843 lines
264 KiB
JavaScript
/* Igneum Miner dashboard (miner-ui-3). 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), then the dashboard with four
|
||
pages behind a rail (Mine, Earnings, Prove, Settings). ?screen=<name> forces a screen, ?page=<name> a page, and
|
||
?update=, ?job=, ?tune= show sample states (screenshots). Everything is relative to the token path the page was
|
||
opened on. The pure blocks (Notices, UpdateCard, View, TuneLine) run under node for the tests. */
|
||
|
||
var Notices = (function () {
|
||
'use strict';
|
||
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, tuning: 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
|
||
var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300;
|
||
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;
|
||
var at = s.now > 0 && s.uptime_s >= 0 ? ' at ' + hhmm(s.now - s.uptime_s) : '';
|
||
return notice('update-installed', 'update:installed:' + now, 'Updated to ' + now + at + '.', { title: '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); }
|
||
// the job in a user's words (miner-ui-3 polish, 6 October 2026): from its kind, else from its id's prefix
|
||
function jobWord(kind, id) {
|
||
var k = String(kind || ''), i = String(id || '');
|
||
var t = k || i;
|
||
if (/^update-now/.test(t) || /^update-now/.test(i)) return 'update check';
|
||
if (/^restart/.test(t) || /^restart/.test(i)) return 'restart';
|
||
if (/^collect/.test(t) || /^collect/.test(i)) return 'log collection';
|
||
if (/shard-benchmark|^shard/.test(t) || /shard/.test(i)) return 'shard benchmark';
|
||
if (/^build/.test(t) || /^build/.test(i)) return 'build';
|
||
if (/ember|tune|sweep/.test(t) || /ember|tune|sweep/.test(i)) return 'tuning check';
|
||
if (/script|playbook|run/.test(t)) return 'a script';
|
||
return k || 'a task';
|
||
}
|
||
function hhmm(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); }
|
||
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; });
|
||
var tech = [jobTitle(j), results.length ? results[results.length - 1] : ''].filter(Boolean).join(' · ');
|
||
return notice('job-running', 'job:run:' + j.id, 'A job from the team is running: ' + jobWord(j.kind, j.id) + ', ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: tech, 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 ? '' : ' The report did not upload.', word = jobWord(l.kind, l.id), tech = t + (l.summary ? ': ' + l.summary : '');
|
||
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, 'A job from the team ran: ' + word + ', ' + mins + ' min.' + report, { detail: tech, title: l.summary || '' });
|
||
}
|
||
var verb = l.status === 'timeout' ? 'ran out of time' : l.status === 'aborted' ? 'was stopped' : 'failed';
|
||
var cause = firstErrorLine(j.last_results || [], l.summary);
|
||
return notice('job-failed', 'job:failed:' + l.id, 'A job from the team ' + verb + ': ' + word + ', after ' + mins + ' min.' + report, { tone: 'bad', detail: [tech, cause !== l.summary ? cause : ''].filter(Boolean).join(' · ') + ' · exit ' + l.exit, 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 }] });
|
||
}
|
||
|
||
// cards = state.mining.cards; now = state.now: every card a re-detection added or lost in the last 5 minutes
|
||
// (added_at / removed_at are 0 for the cards found at start). The newest first, so pick() shows it.
|
||
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'eGPU' : 'Unknown'; }
|
||
function cardNotices(cards, now) {
|
||
var out = [];
|
||
(cards || []).forEach(function (c) {
|
||
if (c.removed_at > 0) {
|
||
if (now - c.removed_at <= CARD_S) out.push(notice('card-removed', 'card:removed:' + c.key + ':' + Math.floor(c.removed_at), 'Card removed: ' + c.name + '. Its worker stopped.', { tone: 'warn', at: c.removed_at }));
|
||
return;
|
||
}
|
||
if (!(c.added_at > 0) || now - c.added_at > CARD_S) return;
|
||
// a new card with a problem, or a card that was mining and now reports one (both carry added_at): the row's words
|
||
if (c.problem) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), c.name + ': not usable (' + c.problem + '). No worker runs on it.', { tone: 'warn', detail: c.reason || '', at: c.added_at }));
|
||
else if (c.enabled) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', mining.', { at: c.added_at }));
|
||
else out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', off (' + kindWord(c.kind).toLowerCase() + '). Settings switches it on.', { at: c.added_at }));
|
||
});
|
||
out.sort(function (a, b) { return b.at - a.at; });
|
||
return out;
|
||
}
|
||
|
||
// 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); }
|
||
if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0));
|
||
var tn = tuneNotice(s.mining && s.mining.cards);
|
||
if (tn) out.push(tn);
|
||
return out;
|
||
}
|
||
// the one line while a card is being tuned: which card, which plan, the step, when mining is back
|
||
function tuneNotice(cards) {
|
||
var l = (cards || []).filter(function (c) { return c.state === 'tuning'; });
|
||
if (!l.length) return null;
|
||
var c = l[0], plan = c.tune_plan === 'confirm' ? 'confirming the fleet prior' : c.tune_plan === 'baseline' ? 'measuring as it runs' : 'the full tune';
|
||
var step = c.tune_steps > 0 ? ', step ' + (c.tune_step || 1) + ' of ' + c.tune_steps : '';
|
||
var eta = c.tune_eta_s > 0 ? ' Mining again in ' + (c.tune_eta_s < 60 ? 'under a minute' : 'about ' + Math.round(c.tune_eta_s / 60) + ' min') + '.' : '';
|
||
return notice('tuning', 'tuning:' + c.key, 'Tuning ' + c.name + ': ' + plan + step + '.' + eta, { tone: 'info', title: 'Ember Tune' });
|
||
}
|
||
// 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 { tuneNotice: tuneNotice, jobWord: jobWord, hhmm: hhmm, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
|
||
})();
|
||
|
||
/* ---------- 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 Miner 0.3.14", 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 Miner', 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 };
|
||
})();
|
||
/* ---------- the pages' words (pure; view.test.mjs loads this block) ----------
|
||
What each section says for a given state: the card row (every state word, the numbers, the money, the tune line),
|
||
the big start/stop button, the node line, the prove tier sentence per card, the goal's consequence, the dev-fee
|
||
line, the confirmations' words. No DOM, no time of day: the caller passes `now`. The sections (miner-ui-3): Mine,
|
||
Earnings, Prove, Settings. */
|
||
var View = (function () {
|
||
'use strict';
|
||
var PAGES = [
|
||
{ id: 'overview', title: 'Overview', sub: 'your rate, what it costs, the chain as it grows' },
|
||
{ id: 'cards', title: 'Cards', sub: 'every card, tuned by Ember' },
|
||
{ id: 'earnings', title: 'Earnings', sub: 'what you made, where it goes' },
|
||
{ id: 'prove', title: 'Prove', sub: 'prove blocks, earn IGN' },
|
||
{ id: 'settings', title: 'Settings', sub: 'tuning, this machine, updates, logs' }
|
||
];
|
||
var DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
var SHARD_GB = 20.4, PROVE_GB = 24, PROVE_MIN_GB = 12; // PROVE_MIN_GB: main's rule, 7 October 2026 (src/provedefault.rs MIN_VRAM_MB_PROVE_ANY)
|
||
function page(id) { for (var i = 0; i < PAGES.length; i++) if (PAGES[i].id === id) return PAGES[i]; return PAGES[0]; }
|
||
function withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||
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 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'; }
|
||
// a coarser "ago" for a line that is read, not measured: "just now", "12 min ago", "2 h ago", "3 d ago"
|
||
function ago(s) { s = Math.max(0, Math.round(s)); if (s < 60) return 'just now'; if (s < 3600) return Math.floor(s / 60) + ' min ago'; if (s < 86400) return Math.floor(s / 3600) + ' h ago'; return Math.floor(s / 86400) + ' d ago'; }
|
||
function shortHex(h, head, tail) { h = String(h || ''); head = head || 8; tail = tail || 6; return h.length > head + tail + 2 ? h.slice(0, head) + '…' + h.slice(-tail) : h; }
|
||
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
|
||
var kindWord = Notices.kindWord;
|
||
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
|
||
// The list is ordered by performance (the project lead, 6 October 2026): usable cards first, then by the measured rate since the
|
||
// start (5 MH/s buckets so the order does not flicker), then discrete, external and Apple before integrated, then
|
||
// memory; removed and unusable cards last. Ties keep the detection order.
|
||
function perfRank(c) {
|
||
var usable = !(c.removed_at > 0) && !c.problem ? 0 : 1;
|
||
var integrated = c.kind === 'integrated' ? 1 : 0;
|
||
var bucket = -Math.floor(((c.enabled ? (c.hash_avg || c.hash_now || 0) : 0)) / 5);
|
||
return [usable, integrated, bucket, -(c.vram_mb || 0)];
|
||
}
|
||
function shownCards(cards) {
|
||
return (cards || []).map(function (c, i) { return { c: c, i: i, r: perfRank(c) }; }).sort(function (a, b) {
|
||
for (var k = 0; k < a.r.length; k++) { if (a.r[k] !== b.r[k]) return a.r[k] - b.r[k]; }
|
||
return a.i - b.i;
|
||
}).map(function (x) { return x.c; }).filter(function (c) { return !c.gone; });
|
||
}
|
||
// listed, driver fine, not unplugged: a worker can run on it
|
||
function present(c) { return !(c.removed_at > 0) && !c.problem; }
|
||
// the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address
|
||
function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== undefined && cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); }
|
||
|
||
// ---------- the network step (0.3.23) ----------
|
||
// Two cards. A fresh install selects the manifest's default_network (Devnet 3 until the go); the testnet card reads
|
||
// "not yet open" and is refused until the manifest names it open; an existing install keeps the network it runs.
|
||
var NETWORKS = [
|
||
{ id: 'devnet-3', label: 'Devnet 3', name: 'igneum-devnet-3', chain: 4463, sub: 'staging: the chain may reset, coins have no value' },
|
||
{ id: 'testnet-1', label: 'igneum-testnet-1', name: 'igneum-testnet-1', chain: 4462, sub: 'the public test chain: coins have no value, resets are announced' }
|
||
];
|
||
function networkIdOf(name) { for (var i = 0; i < NETWORKS.length; i++) if (NETWORKS[i].name === name) return NETWORKS[i].id; return 'devnet-3'; }
|
||
function networkWords(s) {
|
||
s = s || {}; var st = s.settings || {}, u = s.update || {}, open = !!u.testnet_open, current = networkIdOf(s.network_name || ''), pending = s.network_pending || '';
|
||
var chosen = st.network || '', def = (u.default_network === 'testnet-1' && open) ? 'testnet-1' : 'devnet-3';
|
||
var selected = s.setup_done ? (pending || current) : (chosen || def);
|
||
var cards = NETWORKS.map(function (n) { var isOpen = n.id !== 'testnet-1' || open; return { id: n.id, label: n.label, name: n.name, line: n.id === 'devnet-3' ? 'igneum-devnet-3, chain id 4463' : (open ? 'chain id 4462' : 'chain id 4462 · not yet open'), sub: n.sub, open: isOpen, selected: n.id === selected, current: !!s.setup_done && n.id === current }; });
|
||
var pend = NETWORKS.filter(function (n) { return n.id === pending; })[0];
|
||
var line = pend ? 'Switching to ' + pend.name + ' at the next start: quit Igneum Miner and open it again.' : s.setup_done ? 'This machine runs ' + (s.network_name || 'igneum-devnet-3') + '.' : '';
|
||
return { cards: cards, selected: selected, current: current, pending: pending, line: line, error: s.network_error || '' };
|
||
}
|
||
function networkAsk(want, runningName) { var n = NETWORKS.filter(function (x) { return x.id === want; })[0]; return 'Switch this machine to ' + (n ? n.name : want) + '? The node’s data and the mining state on ' + runningName + ' reset at the next start. Your key and your address stay.'; }
|
||
|
||
// ---------- vendor marks (gpu-logos, 7 October 2026) ----------
|
||
// One simplified monochrome glyph per GPU vendor, drawn by hand from the public mark's geometry (never a copied logo
|
||
// file, never a raster): a nominative use that names the hardware. Each glyph is under 2 KB and takes currentColor,
|
||
// so the per-vendor token tints it; the ember accent is reserved for state and never tints a brand. The light hex of
|
||
// every vendor reads at 3:1 or better on its well (the vendor colour at WELL_ALPHA_LIGHT over the graphite card);
|
||
// view.test.mjs computes the ratio and checks app.css carries the same hex values.
|
||
var WELL_ALPHA_LIGHT = 0.10, WELL_ALPHA_DARK = 0.14;
|
||
var VENDORS = {
|
||
nvidia: { label: 'NVIDIA', dark: '#8BE37A', light: '#2F8A22' },
|
||
amd: { label: 'AMD Radeon', dark: '#FF5A5A', light: '#C41E2A' },
|
||
intel: { label: 'Intel', dark: '#7CC4FF', light: '#1C6FD6' },
|
||
apple: { label: 'Apple', dark: '#E6E3DD', light: '#4A4A50' },
|
||
gpu: { label: 'GPU', dark: '#9A9A9E', light: '#6B6B70' }
|
||
};
|
||
var SVG_OPEN = '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"';
|
||
var MARKS = {
|
||
// the eye: a lens with a three-quarter swirl and its centre
|
||
nvidia: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z"/><path d="M15.6 12A3.6 3.6 0 1 0 12 15.6"/><circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none"/></svg>',
|
||
// the arrow: the corner bracket pointing up and right, the bracket below it
|
||
amd: SVG_OPEN + ' fill="currentColor"><path d="M8 3h13v13l-4-4V7h-5z"/><path d="M3 8l4 4v5h5l4 4H3z"/></svg>',
|
||
// the swoosh: an open tilted ring around the dot of the i
|
||
intel: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3"/><path d="M11.2 9.6v6.2"/><circle cx="11.2" cy="6.6" r="1.1" fill="currentColor" stroke="none"/></svg>',
|
||
// the apple: the bitten fruit with its leaf
|
||
apple: SVG_OPEN + ' fill="currentColor"><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>',
|
||
// the neutral fallback: a chip with its pins
|
||
gpu: SVG_OPEN + ' fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="5.5" y="5.5" width="13" height="13" rx="2.2"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3"/></svg>'
|
||
};
|
||
function escH(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||
// the vendor a row is drawn as: the engine's word when it is one of the four, else the name decides (an OpenCL
|
||
// integrated row arrives as vendor "other" with "Intel UHD Graphics 770"), else the neutral chip
|
||
function vendorOf(cd) {
|
||
var v = (cd && cd.vendor || '').toLowerCase();
|
||
if (VENDORS[v] && v !== 'gpu') return v;
|
||
var n = (cd && cd.name || '') + ' ' + (cd && cd.code || '');
|
||
if (/nvidia|geforce|quadro|\bRTX\b|\bGTX\b/i.test(n)) return 'nvidia';
|
||
if (/\bamd\b|radeon|\bgfx\d/i.test(n)) return 'amd';
|
||
if (/intel|\barc\b|iris|\buhd\b/i.test(n)) return 'intel';
|
||
if (/apple|\bM\d (max|pro|ultra)\b/i.test(n)) return 'apple';
|
||
return 'gpu';
|
||
}
|
||
// the series line under the name, in mono: "RTX 50 series · Blackwell", "RX 9000 series · RDNA 4", "Arc B series ·
|
||
// Battlemage", "M5 series · integrated"; an integrated row always ends in "integrated" (the same glyph, never a second mark)
|
||
function seriesLine(cd) {
|
||
var v = vendorOf(cd), n = cd.name || '', code = cd.code || '', m, out = '';
|
||
if (v === 'nvidia') {
|
||
if ((m = /\b(RTX|GTX)\s*(\d)(\d)(\d{2})\b/i.exec(n))) { var tens = m[2] + m[3], fam = m[1].toUpperCase(), gen = { RTX50: 'Blackwell', RTX40: 'Ada Lovelace', RTX30: 'Ampere', RTX20: 'Turing', GTX16: 'Turing', GTX10: 'Pascal', GTX90: 'Maxwell' }[fam + tens]; out = fam + ' ' + tens + ' series' + (gen ? ' · ' + gen : ''); }
|
||
else if (/\b(H100|H200|A100|B200|B100|L40|L4|A30|A10)\b/i.test(n)) out = 'data centre';
|
||
else if (/\bRTX\b|quadro/i.test(n)) out = 'RTX workstation';
|
||
else out = 'GeForce';
|
||
} else if (v === 'amd') {
|
||
if ((m = /\bRX\s*(\d)(\d{3})\b/i.exec(n))) { var g = { 9: 'RDNA 4', 7: 'RDNA 3', 6: 'RDNA 2', 5: 'RDNA' }[m[1]]; out = 'RX ' + m[1] + '000 series' + (g ? ' · ' + g : ''); }
|
||
else if (/\bgfx12/i.test(code + n)) out = 'RDNA 4'; else if (/\bgfx11/i.test(code + n)) out = 'RDNA 3'; else if (/\bgfx103/i.test(code + n)) out = 'RDNA 2'; else if (/\bgfx10/i.test(code + n)) out = 'RDNA';
|
||
else if (/\bMI\d{2,3}/i.test(n)) out = 'Instinct · data centre';
|
||
else out = 'Radeon';
|
||
} else if (v === 'intel') {
|
||
if ((m = /\bArc\b.*?\b([ABC])(\d{3})\b/i.exec(n)) || (m = /\b([ABC])(\d{3})\b.*\bArc\b/i.exec(n))) { var L = m[1].toUpperCase(); out = 'Arc ' + L + ' series · ' + ({ A: 'Alchemist', B: 'Battlemage', C: 'Celestial' })[L]; }
|
||
else if (/iris/i.test(n)) out = 'Iris Xe'; else if (/\buhd\b/i.test(n)) out = 'UHD Graphics'; else out = 'Intel graphics';
|
||
} else if (v === 'apple') {
|
||
out = (m = /\bM(\d)\b/.exec(n)) ? 'M' + m[1] + ' series' : 'Apple silicon';
|
||
} else out = cd.worker ? String(cd.worker) : '';
|
||
if (cd.kind === 'integrated' || cd.kind === 'apple') out = (out ? out + ' · ' : '') + 'integrated';
|
||
return out;
|
||
}
|
||
// the mark for a row: the vendor word, the glyph, the label for the title, the series line
|
||
function vendorMark(cd) { var v = vendorOf(cd); return { vendor: v, svg: MARKS[v], label: VENDORS[v].label, series: seriesLine(cd) }; }
|
||
function markHtml(cd, size) { var mk = vendorMark(cd); return '<div class="badge ' + (size ? size + ' ' : '') + mk.vendor + '" data-mark="' + mk.vendor + '" title="' + escH(mk.label) + '">' + mk.svg + '</div>'; }
|
||
// the name block beside the mark: the name, the kind pill, the series line; shared by the first-run rows and the Cards page
|
||
function nameHtml(cd, extra) { var mk = vendorMark(cd); return '<div class="name" title="' + escH(cardTitle(cd)) + '">' + escH(cd.name) + (extra || '') + ' <span class="kind ' + escH(cd.kind) + '">' + escH(kindWord(cd.kind)) + '</span></div>' + (mk.series ? '<div class="gen">' + escH(mk.series) + '</div>' : ''); }
|
||
// the two together: one mark, one name block
|
||
function rowHead(cd, extra) { return markHtml(cd) + nameHtml(cd, extra); }
|
||
// sRGB relative luminance and the WCAG contrast ratio, for the test and for anyone picking a new vendor hex
|
||
function hexRgb(h) { h = h.replace('#', ''); return [0, 2, 4].map(function (i) { return parseInt(h.substr(i, 2), 16); }); }
|
||
function luminance(rgb) { var c = rgb.map(function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; }
|
||
function contrast(a, b) { var la = luminance(a), lb = luminance(b), hi = Math.max(la, lb), lo = Math.min(la, lb); return (hi + 0.05) / (lo + 0.05); }
|
||
// the well's colour: the vendor hex at alpha over the card ground (graphite)
|
||
function wellRgb(hex, alpha, ground) { var v = hexRgb(hex), g = hexRgb(ground); return v.map(function (c, i) { return Math.round(c * alpha + g[i] * (1 - alpha)); }); }
|
||
function vendorContrast(v, theme) { var hex = VENDORS[v][theme], ground = theme === 'light' ? '#FFFFFF' : '#16161A', alpha = theme === 'light' ? WELL_ALPHA_LIGHT : WELL_ALPHA_DARK; return contrast(hexRgb(hex), wellRgb(hex, alpha, ground)); }
|
||
|
||
// one GPU row on the Mine page: cd = state.mining.cards[i]
|
||
function cardRow(cd) {
|
||
var removed = cd.removed_at > 0, unusable = !!cd.problem;
|
||
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
|
||
// the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word
|
||
var tuning = st === 'tuning', held = st === 'held';
|
||
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'paused for a job from the team') : st === 'resting' ? 'resting (heat mode)' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
|
||
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'restarting') ? 'bad' : '';
|
||
var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
|
||
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
|
||
var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : '';
|
||
var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : '';
|
||
var cap = cd.power_default_w > 0 ? Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) : 0;
|
||
var sub = unusable ? (cd.reason || '') : removed ? 'unplugged. Its row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || '';
|
||
var meta = [];
|
||
if (cd.vram_mb) { var gb = cd.vram_mb / 1024; meta.push((gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : '')); }
|
||
if (on && cd.accepted) meta.push(cd.accepted + ' block' + (cd.accepted === 1 ? '' : 's'));
|
||
if (on && cd.prepared) meta.push('next program ready');
|
||
return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) };
|
||
}
|
||
|
||
// driver-check (7 October 2026): the row's driver strip. {kind: none|fine|offer|note|running|reboot|error, text, button, sub}
|
||
// offer = a missing or old driver with an Install button (Windows); note = the macOS, Linux or HiveOS sentence, no
|
||
// button; running = this card's vendor is installing (the progress line); reboot = installed, Restart now;
|
||
// error = the last install failed, Install again. The install is one at a time, so a second card of the same vendor
|
||
// reads the same running line.
|
||
function driverWords(cd, s) {
|
||
var o = cd.driver_offer, d = (s && s.drivers) || {};
|
||
if (!o) return { kind: 'none', text: '' };
|
||
var mine = d.vendor && d.vendor === o.vendor, ext = cd.kind === 'external';
|
||
// PC 2, 7 October 2026: every card of the vendor has its worker stopped for the install (0.3.22; the Arc in the Razer
|
||
// Core X V2 read as discrete, so the kind alone missed it), and the installer's display reset can take a machine with an
|
||
// enclosure; the row says both before the click and while it runs
|
||
var extRun = '. The ' + vendorWord(o.vendor) + ' workers are stopped until the installer ends; the display may reset' + (ext ? ', and this card\u2019s enclosure can take the machine for a minute' : '') + '.';
|
||
if (mine && (d.status === 'downloading' || d.status === 'verifying' || d.status === 'installing')) {
|
||
var pct = d.status === 'downloading' ? Math.round((d.progress || 0) * 100) + '%' : '';
|
||
return { kind: 'running', text: (d.status === 'downloading' ? 'Downloading the ' : d.status === 'verifying' ? 'Checking the ' : 'Installing the ') + vendorWord(o.vendor) + ' driver ' + d.version + (pct ? ' · ' + pct : ''), sub: (d.message || '') + extRun, progress: d.progress || 0, dry: !!d.dry_run, external: ext };
|
||
}
|
||
if (mine && d.status === 'reboot') return { kind: 'reboot', text: vendorWord(o.vendor) + ' driver ' + d.version + ' installed. Restart Windows to finish.', button: 'restart', sub: 'Mining continues until you restart.' };
|
||
if (mine && d.status === 'error') return { kind: 'error', text: 'The ' + vendorWord(o.vendor) + ' driver did not install: ' + (d.error || d.message || 'unknown error'), button: o.installable ? 'install' : '', sub: o.hash_source ? 'Checked against ' + o.hash_source : '' };
|
||
if (o.status === 'fine') return { kind: 'fine', text: o.text };
|
||
if (o.status === 'none') return { kind: 'note', text: o.text };
|
||
if (!o.installable) return { kind: 'note', text: o.text };
|
||
var sub = 'Downloaded from ' + hostOf(o.url) + ', checked against the vendor\u2019s published hash and signature, then installed with one administrator prompt. ';
|
||
sub += 'The worker on every ' + vendorWord(o.vendor) + ' card stops for the install and comes back after it; the other cards keep mining. The display resets during the install' + (ext ? ', and a card in an external enclosure can take the machine for a minute and may need a restart' : '') + ': save your work first.';
|
||
return { kind: 'offer', text: o.text, button: 'install', sub: sub, external: ext };
|
||
}
|
||
function vendorWord(v) { return v === 'nvidia' ? 'NVIDIA' : v === 'amd' ? 'AMD' : v === 'intel' ? 'Intel Arc' : v === 'apple' ? 'Apple' : v; }
|
||
function hostOf(u) { var m = /^https?:\/\/([^\/]+)/.exec(u || ''); return m ? m[1] : 'the vendor'; }
|
||
|
||
// "tuning: step 7 of 9 · measuring 220 W" (the live draw; the plan's words when there is no draw yet)
|
||
function tuneWord(cd) {
|
||
var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : '';
|
||
var w = cd.power_w > 0 ? 'measuring ' + Math.round(cd.power_w) + ' W' : '';
|
||
var rest = [step, w].filter(Boolean).join(' · ');
|
||
return rest ? 'tuning: ' + rest : (cd.sweep_note || 'tuning');
|
||
}
|
||
// "about 12 min" / "under a minute" from a tune's seconds left
|
||
function tuneEta(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? 'under a minute' : 'about ' + Math.round(s / 60) + ' min'; }
|
||
// the card a tune is running on, or null
|
||
function tuningCard(cards) { var l = (cards || []).filter(function (c) { return c.state === 'tuning' || c.sweep_state === 'running'; }); return l.length ? l[0] : null; }
|
||
|
||
// ---- money (miner-ui-3): £ a day from watts at pence per kWh; null when either is unknown ----
|
||
function poundsPerDay(watts, pence) { return watts > 0 && pence > 0 ? watts * 24 / 1000 * pence / 100 : null; }
|
||
// the currency symbol follows the region (Ember Heat, setRegion); £ until a region is chosen
|
||
var currency = { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 }, locale = 'en-GB', override = '', regionCode = '';
|
||
// the ONE money formatter (currency-21, 7 October 2026): Intl places the symbol as the locale does; whole units from 100
|
||
function fmtMoney(v, cur, loc) {
|
||
if (v === null || v === undefined || isNaN(v)) return '';
|
||
var d = v >= 100 ? 0 : minorOf(cur).digits;
|
||
try { return new Intl.NumberFormat(loc || locale, { style: 'currency', currency: cur, currencyDisplay: 'narrowSymbol', minimumFractionDigits: d, maximumFractionDigits: d }).format(v).replace(/[\u00a0\u202f]/g, ' '); }
|
||
catch (e) { return (CURRENCIES[cur] || CURRENCIES.GBP).symbol + (d ? v.toFixed(d) : String(Math.round(v))); }
|
||
}
|
||
function money(v) { return fmtMoney(v, currency.code, locale); }
|
||
// MH per watt: the engine's live figure, else rate over draw
|
||
function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; }
|
||
function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; }
|
||
// the watts drawn now by the cards that are mining or tuning (an idle card's standby draw is not the fleet's cost;
|
||
// the engine's mining.watts_total counts the same way)
|
||
function wattsTotal(cards) { return (cards || []).filter(function (c) { return present(c) && (c.state === 'mining' || c.state === 'tuning'); }).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); }
|
||
// why a row has no power or temperature cell: one sentence, once, instead of three n/a
|
||
function noReading(cd) {
|
||
if (cd.power_w > 0 || cd.temp_gpu > 0) return '';
|
||
if (cd.vendor === 'apple') return 'No power or temperature reading on Apple silicon';
|
||
if (cd.kind === 'integrated') return 'No power or temperature reading on an integrated GPU';
|
||
if (cd.vendor === 'intel') return 'No power or temperature reading on Intel Arc yet: the driver exposes them through IGCL, not OpenCL';
|
||
return '';
|
||
}
|
||
// the row's cells: rate, watts with MH/W, £ a day, temperature; a cell with no number is left out, never n/a
|
||
function cells(cd, pence) {
|
||
var r = cardRow(cd), out = {};
|
||
out.hash = r.hash; out.power = r.power; out.temp = r.temp; out.tempTone = r.tempTone;
|
||
out.eff = effText(effOf(cd));
|
||
var ppd = poundsPerDay(cd.power_w, pence);
|
||
out.money = ppd === null ? '' : money(ppd);
|
||
out.needsPrice = cd.power_w > 0 && !(pence > 0);
|
||
out.noReading = noReading(cd);
|
||
return out;
|
||
}
|
||
|
||
// ---- the tune line under a row (miner-ui-3 section 4): cd, s = state, now ----
|
||
function nextCheck(at, period, now) {
|
||
if (!(at > 0) || !now) return '';
|
||
period = period > 0 ? period : 604800;
|
||
var next = at + period, d = next - now;
|
||
if (d <= 0) return 'next check due now';
|
||
if (d < 86400) return 'next check within a day';
|
||
var dt = new Date(next * 1000);
|
||
if (d < 6 * 86400) return 'next check ' + DAYS[dt.getDay()];
|
||
return 'next check ' + dt.getDate() + ' ' + MONTHS[dt.getMonth()];
|
||
}
|
||
function tunePoint(cd) {
|
||
var pct = cd.sweep_pct || cd.power_pct || 0;
|
||
if (cd.tune_clock_mhz > 0) return cd.tune_clock_mhz + ' MHz at ' + pct + '%';
|
||
return pct ? pct + '%, clock unlocked' : '';
|
||
}
|
||
function reasonOf(note) { return cap(String(note || '').replace(/^measure only /, 'measured only ')); }
|
||
function tunable(cd) { return (cd.vendor === 'nvidia' || cd.vendor === 'amd' || cd.vendor === 'apple' || !!cd.sweep_supported) && present(cd) && cd.kind !== 'integrated'; }
|
||
// {kind: none|paused|running|measured|tuned|stopped|idle, text, note, button: ''|'tune'|'stop', progress: -1 or 0..1}
|
||
function tuneWords(cd, s, now) {
|
||
cd = cd || {}; var st = (s && s.settings) || {};
|
||
if (!tunable(cd)) return { kind: 'none', text: '', note: '', button: '', progress: -1 };
|
||
if (st.tuning_off) return { kind: 'paused', text: st.tuning_note || 'Tuning paused fleet-wide by the signed manifest', note: '', button: '', progress: -1 };
|
||
if (cd.state === 'tuning' || cd.sweep_state === 'running') {
|
||
var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : (cd.sweep_note ? cd.sweep_note.replace(/^tuning:\s*/, '') : 'running');
|
||
var eta = cd.tune_eta_s > 0 ? ' · ' + tuneEta(cd.tune_eta_s) + ' left' : '';
|
||
var prog = cd.tune_steps > 0 ? Math.min(1, Math.max(0, ((cd.tune_step || 1) - 1) / cd.tune_steps)) : 0;
|
||
return { kind: 'running', text: 'Tuning: ' + step + eta, note: '', button: 'stop', progress: prog };
|
||
}
|
||
var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
|
||
var nc = nextCheck(cd.sweep_at, st.tune_period_s, now);
|
||
if (cd.tune_line) {
|
||
var nums = String(cd.tune_line).replace(/^(Tuned|Measured):\s*/, '');
|
||
if (cd.tune_source === 'baseline' || !cd.tune_control) return { kind: 'measured', text: 'Measured ' + nums + ' as it runs' + (when ? ', ' + when : ''), note: reasonOf(cd.sweep_note), button: 'tune', progress: -1 };
|
||
var src = cd.tune_source === 'confirm' ? ' from what other miners found, confirmed' : '';
|
||
var pin = cd.pinned ? 'Your cap stays pinned at ' + (cd.power_pct || 0) + '% · ' : '';
|
||
var pt = tunePoint(cd);
|
||
var stopped = cd.sweep_note && /^tuning stopped/i.test(cd.sweep_note) ? ' · ' + cap(cd.sweep_note) : '';
|
||
return { kind: 'tuned', text: pin + 'Tuned ' + (when || 'before') + src + (pt ? ' · ' + pt : '') + (nc ? ' · ' + nc : '') + stopped, note: nums, button: 'tune', progress: -1 };
|
||
}
|
||
if (cd.sweep_note && /^tuning stopped/i.test(cd.sweep_note)) return { kind: 'stopped', text: cap(cd.sweep_note), note: '', button: 'tune', progress: -1 };
|
||
var why = !cd.tune_control && cd.sweep_note ? reasonOf(cd.sweep_note) : '';
|
||
var text = st.sweep === false ? 'Not tuned yet: Ember Tune is off in Settings' : 'Not tuned yet: starts after 2 min of steady mining';
|
||
return { kind: 'idle', text: text, note: why, button: 'tune', progress: -1 };
|
||
}
|
||
// the Tuning card's one line: what runs, what ran last, what comes next
|
||
function schedule(cards, s, now) {
|
||
var st = (s && s.settings) || {};
|
||
if (st.tuning_off) return st.tuning_note || 'Tuning paused fleet-wide by the signed manifest.';
|
||
var list = shownCards(cards).filter(tunable), tc = tuningCard(list);
|
||
if (tc) { var step = tc.tune_steps > 0 ? ', step ' + (tc.tune_step || 1) + ' of ' + tc.tune_steps : ''; return 'Tuning now: ' + tc.name + step + (tc.tune_eta_s > 0 ? ', mining again in ' + tuneEta(tc.tune_eta_s) : '') + '.'; }
|
||
var last = null;
|
||
list.forEach(function (c) { if (c.sweep_at > 0 && (!last || c.sweep_at > last.sweep_at)) last = c; });
|
||
if (last) return 'Last tune ' + ago(now - last.sweep_at) + ' (' + last.name + ') · ' + nextCheck(last.sweep_at, st.tune_period_s, now) + '.';
|
||
if (!list.length) return 'No card here can be tuned.';
|
||
return st.sweep === false ? 'Ember Tune is off. Tune and Tune all still work when you press them.' : 'Not tuned yet: the first tune starts 2 min into steady mining.';
|
||
}
|
||
// the goal control's consequence: goal = efficiency|balanced|rate, cards, pence
|
||
var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' };
|
||
var GOAL_NAMES = { efficiency: 'Efficiency', balanced: 'Balanced', rate: 'Maximum' };
|
||
function goalWords(goal, cards, pence) {
|
||
goal = GOALS[goal] ? goal : 'balanced';
|
||
var list = (cards || []).filter(present), w = 0;
|
||
list.forEach(function (c) {
|
||
if (goal === 'efficiency') w += c.sweep_watts > 0 ? c.sweep_watts : (c.power_w > 0 ? c.power_w * 0.9 : 0);
|
||
else if (goal === 'rate') w += c.power_default_w > 0 ? c.power_default_w : (c.power_w || 0);
|
||
else w += c.power_w > 0 ? c.power_w : 0;
|
||
});
|
||
var ppd = poundsPerDay(w, pence);
|
||
return { goal: goal, label: GOALS[goal], watts: Math.round(w), consequence: ppd === null ? (w > 0 ? 'about ' + Math.round(w) + ' W' : '') : 'about ' + money(ppd) + ' a day at ' + Math.round(w) + ' W' };
|
||
}
|
||
|
||
// the big button on Mine: m = state.mining, n = state.node, s = { quitting }
|
||
function toggle(m, n, s) {
|
||
var cards = shownCards(m.cards).filter(present);
|
||
var on = cards.filter(function (c) { return c.enabled; }).length;
|
||
var mining = cards.filter(function (c) { return c.state === 'mining'; }).length;
|
||
if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' };
|
||
var tc = tuningCard(cards);
|
||
if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' };
|
||
var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length;
|
||
if (heldCards && !mining) return { label: 'Held', sub: 'a job from the team is using the cards. Mining restarts when it ends', cls: 'stop', disabled: true, act: '' };
|
||
if (s && s.heat && s.heat.on && s.heat.phase === 'resting' && !mining && !m.paused) return { label: 'Stop mining', sub: 'resting · heat mode holds ' + s.heat.set_c.toFixed(1) + ' °C, heats again in ' + (s.heat.until_s < 45 ? 'under a minute' : Math.round(s.heat.until_s / 60) + ' min'), cls: 'stop', disabled: false, act: 'pause' };
|
||
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', cls: '', disabled: true, act: '' };
|
||
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
|
||
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
|
||
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : n && n.state === 'behind' ? 'waiting: the node is behind the chain' : n && n.state === 'no peers' ? 'waiting: no peers on our chain' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
|
||
return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' };
|
||
}
|
||
// the row's state word while the miner is paused: the card is on, the miner is not
|
||
function rowWord(r, m) { return r.on && m && m.paused && (r.word === 'off' || r.word === 'ready' || r.word === 'waiting for the node') ? 'paused' : r.word; }
|
||
|
||
// the Node page's plain words: n = state.node, clock = state.clock, eta = the sync ETA sentence (may be '')
|
||
function nodeWords(n, clock, eta) {
|
||
var bad = clock && clock.severity === 'block';
|
||
if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' };
|
||
switch (n.state) {
|
||
case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.tip_age_s >= 0 ? ' Last block ' + Math.round(n.tip_age_s) + ' s ago.' : n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' };
|
||
// N4 (6 October 2026): a tip that stopped moving is never "synced"; the node says why
|
||
case 'behind':
|
||
// 7 October 2026: a node whose blocks never merge into the network (src/merge.rs) is behind with a tip that still moves
|
||
if (n.sync_cause === 'not merging') return { word: 'behind', line: 'Your blocks are not merging into the network: every other node is more than the merge depth ahead. Mining pauses until this node merges again; a slow link is the usual cause.', tone: 'bad' };
|
||
return { word: 'behind', line: (n.tip_age_s >= 0 ? 'No new block for ' + tipAge(n.tip_age_s) + '. ' : '') + 'The chain may have moved on without this node. Mining pauses until it catches up; the node re-dials its peers by itself.', tone: 'bad' };
|
||
case 'no peers': return { word: 'no peers', line: 'No other node on our chain is connected. The node keeps dialling the seed list; mining waits.', tone: 'bad' };
|
||
case 'syncing':
|
||
var pct = n.headers > n.blocks && n.headers > 0 ? Math.floor(n.blocks / n.headers * 100) : 0;
|
||
var line = n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + pct + '%)' : (n.message || 'caught up, waiting for the next block');
|
||
return { word: 'syncing', line: line, tone: '' };
|
||
case 'starting': return { word: 'starting', line: n.message || 'opening the database', tone: '' };
|
||
case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' };
|
||
case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' };
|
||
case 'stopped': return { word: n.source === 'none' ? 'not started' : 'stopped', line: n.message || 'the node is not running', tone: 'bad' };
|
||
case 'stub': return { word: 'stub engine', line: 'This node was built without the mining engine, so it refuses every real block. Reinstall the app.', tone: 'bad' };
|
||
}
|
||
return { word: n.state || 'starting', line: n.message || '', tone: '' };
|
||
}
|
||
// "12 s" / "4 min" / "53 min" / "2 h" for a tip age
|
||
function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; }
|
||
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago"
|
||
function nodeLine(n, clock, eta) {
|
||
var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word];
|
||
if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's'));
|
||
if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks');
|
||
if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago');
|
||
return { text: parts.join(' · '), sub: w.line, tone: w.tone };
|
||
}
|
||
function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); 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'; }
|
||
function peersLine(n) { return n.peers === 0 ? 'none yet: looking for the seed node' : n.peers === 1 ? 'one other node this one talks to' : 'other nodes this one talks to'; }
|
||
function heightLine(n) { return n.headers > n.blocks + 2 ? 'of ' + withCommas(n.headers) + ' headers seen' : 'blocks this node holds'; }
|
||
|
||
// the next planned rule change: switches = state.node.consensus_switches, daa = state.node.daa
|
||
function nextSwitch(switches, daa) {
|
||
var list = (switches || []).slice().sort(function (a, b) { return a.daa - b.daa; });
|
||
for (var i = 0; i < list.length; i++) if (list[i].daa > daa) return { name: list[i].name, daa: list[i].daa, away: list[i].daa - daa, key: list[i].key };
|
||
return null;
|
||
}
|
||
function switchLine(sw, daa) {
|
||
if (!sw) return (daa > 0 ? 'Every planned rule change is behind this node. ' : '') + 'A rule change is planned ahead. The node applies it by itself when the chain reaches that height.';
|
||
var blocks = daa > 0 ? withCommas(sw.away) + ' blocks away, about ' + (sw.away < 3600 ? Math.max(1, Math.round(sw.away / 60)) + ' min' : sw.away < 86400 ? Math.floor(sw.away / 3600) + ' h ' + Math.round(sw.away % 3600 / 60) + ' min' : Math.floor(sw.away / 86400) + ' d') + ' at one block a second.' : 'the node applies it by itself at that height.';
|
||
return sw.name + ' at block ' + withCommas(sw.daa) + ': ' + blocks;
|
||
}
|
||
|
||
// the Prove page: pv = state.proving, enabled = state.settings.prove, synced = state.node.synced
|
||
// the "Prove instead of mining" row: shown only on a machine whose every NVIDIA card is under 12 GB (pv.under_12gb),
|
||
// the switch's state from settings.prove_instead; the sentence names the choice in force
|
||
function proveInsteadWords(pv, settings) {
|
||
pv = pv || {}; settings = settings || {};
|
||
if (!pv.under_12gb) return { show: false, on: false, help: '' };
|
||
if (settings.prove_instead) return { show: true, on: true, help: 'On: the miner is held off on the small card while it proves; switch off to mine again.' };
|
||
return { show: true, on: false, help: 'A card under 12 GB holds the prover or the miner, never both. On, the miner stops on that card while it proves and comes back when proving stops.' };
|
||
}
|
||
function proveWords(pv, enabled, synced) {
|
||
pv = pv || {};
|
||
if (!enabled) return { word: 'off', sub: 'not proving', tone: 'off', note: 'Off. Switch it on and this machine proves the shards it is given.' };
|
||
if (pv.status === 'setup' || (pv.enabled && !pv.available && pv.setup_hint)) return { word: 'needs setup', sub: 'one-time install', tone: 'bad', note: pv.setup_hint || pv.message || 'The prover is not installed yet.' };
|
||
if (!synced || pv.status === 'waiting') return { word: 'waiting', sub: 'for the node to sync', tone: '', note: pv.message || 'Proving starts once the node has every block.' };
|
||
if (pv.status === 'proving') return { word: 'proving', sub: pv.current || 'a shard', tone: 'on', note: pv.message || 'A proof takes minutes on a CPU and seconds on an NVIDIA card.' };
|
||
if (pv.status === 'submitted') return { word: 'submitted', sub: 'waiting to be paid', tone: 'on', note: pv.message || 'The node checks the proof, then the chain pays it.' };
|
||
if (pv.status === 'idle') return { word: 'idle', sub: 'nothing assigned', tone: 'ok', note: pv.message || 'On. Nothing to prove right now. The next shard arrives within a minute.' };
|
||
return { word: pv.status || 'on', sub: pv.backend ? pv.backend.toUpperCase() : '', tone: '', note: pv.message || '' };
|
||
}
|
||
function verifierWords(pv) {
|
||
var vm = (pv && pv.verifier_mode) || 'unknown';
|
||
var word = { command: 'verifying', trust: 'trusting without verifying (devnet only)', off: 'off, passing proofs along', unknown: 'not read yet' }[vm] || vm;
|
||
if (vm === 'command' && pv.pool_entries) word += ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '');
|
||
return { word: word, tone: vm === 'command' ? 'ok' : (vm === 'off' || vm === 'trust') ? 'warn' : 'dim', needsSetup: vm === 'off' && /WSL2 prover is not installed/.test((pv && pv.verifier_reason) || '') };
|
||
}
|
||
// the tier sentence per card: can this card prove, and how (the 24 GB rule and the 20.4 GB shard are the app's own
|
||
// words since miner-ui-2; the engine's proving.available wins when it disagrees)
|
||
function proveTier(cd) {
|
||
var gb = cd.vram_mb ? Math.round(cd.vram_mb / 1024) : 0;
|
||
if (cd.vendor === 'apple') return cd.name + ': proves on the CPU, slowly.';
|
||
if (cd.vendor === 'nvidia' && gb > 0 && gb < PROVE_MIN_GB) return cd.name + ': proving needs a 12 GB card; mining continues (' + gb + ' GB).';
|
||
if (cd.vendor === 'nvidia') return gb >= PROVE_GB ? cd.name + ': proves while it mines (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' : cd.name + ': cannot prove a full shard (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).';
|
||
return cd.name + ': cannot prove yet (proving needs an NVIDIA card).';
|
||
}
|
||
// the counts in one sentence: "idle · nothing assigned · 0 assigned · 0 proven · 0 paid · 0.0000 IGN"
|
||
function proveLine(pv, enabled, synced) {
|
||
pv = pv || {};
|
||
var w = proveWords(pv, enabled, synced), ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000';
|
||
return cap(w.word) + (w.sub ? ' · ' + w.sub : '') + ' · ' + (pv.assigned || 0) + ' assigned · ' + (pv.submitted || 0) + ' proven · ' + (pv.paid || 0) + ' paid · ' + ign + ' IGN';
|
||
}
|
||
|
||
// the miner's own dev-fee line, next to the rewards address
|
||
function devFeeText(s) {
|
||
var d = s.dev_fee || {};
|
||
var addr = d.address ? ' to ' + d.address.slice(0, 8) + '…' + 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');
|
||
}
|
||
function devFeeLine(s) {
|
||
return s.settings.dev_fee ? 'One block in 100 pays the miner software’s dev fee' + (s.mining.fee_total ? ' (' + withCommas(s.mining.fee_total) + ' so far)' : '') + '. The switch above turns it off.' : 'The dev fee is off. Every block pays this address.';
|
||
}
|
||
|
||
// the remote-jobs line on Settings: j = state.jobs, now = state.now
|
||
function jobsNote(j, now, jobTitle) {
|
||
var parts = [];
|
||
if (j.account) parts.push(j.account);
|
||
if (!j.allowed) parts.push('Off. Nothing runs here until the switch above allows jobs from the team.');
|
||
else if (!j.url_set) parts.push('This build has no jobs address.');
|
||
else if (j.error) parts.push(j.error + '.');
|
||
else if (j.active) parts.push('Running: ' + jobTitle(j) + '.');
|
||
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + ((j.history || []).length ? '; ' + j.history.length + ' job' + (j.history.length === 1 ? '' : 's') + ' ran here' : '') + '.');
|
||
return parts.join(' ');
|
||
}
|
||
// an activity line for a user: a JSON tail goes to the log, a long line is cut at a word
|
||
function eventLine(text, max) {
|
||
text = String(text || ''); max = max || 140;
|
||
var j = text.indexOf('{'); if (j > 0) text = text.slice(0, j).replace(/[:\s]+$/, '') + ' (details in the log)';
|
||
if (text.length > max) { var cut = text.lastIndexOf(' ', max - 2); text = text.slice(0, cut > 40 ? cut : max - 2) + '…'; }
|
||
return text;
|
||
}
|
||
// the engine's event line in a user's voice (src/engine.rs event strings, 6 October 2026): {text, tech}; tech is
|
||
// the engine's own line when it differs, for the tooltip. Unknown lines keep their words with a JSON tail cut.
|
||
var EVENT_MAP = [
|
||
[/^updated to Igneum Miner (\S+) from (\S+)/, function (m) { return 'Updated to ' + m[1]; }],
|
||
[/^Igneum Miner (\S+) is available: downloading/, function (m) { return 'Downloading ' + m[1]; }],
|
||
[/^Igneum Miner (\S+) is ready/, function (m) { return m[1] + ' is ready to install'; }],
|
||
[/^installing Igneum Miner (\S+)/, function (m) { return 'Installing ' + m[1]; }],
|
||
[/^update to (\S+) failed/, function (m) { return 'The update to ' + m[1] + ' failed'; }],
|
||
[/^the update to (\S+) did not take/, function (m) { return 'The update to ' + m[1] + ' did not take'; }],
|
||
[/^Igneum Miner (\S+) did not stay up twice; restoring (\S+)/, function (m) { return m[1] + ' did not stay up; going back to ' + m[2]; }],
|
||
[/^update downloaded/, function () { return 'Update downloaded'; }],
|
||
[/^OTA: administrator approval not given/, function () { return 'The update waits for the next time someone is at this PC'; }],
|
||
[/^job (\S+) \((.+)\) starts: /, function (m) { return 'A job from the team started: ' + Notices.jobWord(m[2], m[1]); }],
|
||
[/^job (\S+) \((.+)\) (done|failed|timeout|aborted) after (\d+) min/, function (m) { return 'A job from the team ' + (m[3] === 'done' ? 'ran' : m[3] === 'timeout' ? 'ran out of time' : m[3] === 'aborted' ? 'was stopped' : 'failed') + ': ' + Notices.jobWord('', m[1]) + ', ' + m[4] + ' min'; }],
|
||
[/^(\d+) remote jobs? received from Igneum/, function (m) { return m[1] + ' job' + (m[1] === '1' ? '' : 's') + ' from the team received'; }],
|
||
[/^remote job: update check now/, function () { return 'The team asked for an update check'; }],
|
||
[/^remote job: the app restarts/, function () { return 'The team asked the app to restart'; }],
|
||
[/^remote job asked for a node restart, but the node is external/, function () { return 'The team asked for a node restart. This node is not the app’s, so nothing was done'; }],
|
||
[/^remote jobs allowed/, function () { return 'Jobs from the team can run here'; }],
|
||
[/^remote jobs switched off/, function () { return 'Jobs from the team are off'; }],
|
||
[/^job finished; the miners restart/, function () { return 'The job finished; mining restarts'; }],
|
||
[/^node synced: (\d+) blocks, (\d+) peer/, function (m) { return 'Node synced: ' + withCommas(+m[1]) + ' blocks, ' + m[2] + ' peer' + (m[2] === '1' ? '' : 's'); }],
|
||
[/^a node already answers on/, function () { return 'Using the node already running on this machine'; }],
|
||
[/^consensus parameters from the signed manifest/, function () { return 'Rule settings read from the team'; }],
|
||
[/^this node build does not understand the consensus parameters/, function () { return 'This node is too old for the team’s rule settings. An app update carries the newer node'; }],
|
||
[/^restarting the node with the new consensus parameters/, function () { return 'Restarting the node with the new rule settings'; }],
|
||
[/^kernel tuning from the signed manifest/, function () { return 'Tuning starting points read from the team'; }],
|
||
[/^block accepted by the node \((.+)\)/, function (m) { return 'Block found by ' + m[1]; }],
|
||
[/^first block: found by (.+) \(block card\)/, function (m) { return 'Your first block, found by ' + m[1]; }],
|
||
[/^block ([\d,]+) of this machine: found by (.+) \(block card\)/, function (m) { return 'Block ' + m[1] + ' of this machine, found by ' + m[2]; }],
|
||
[/^first block on (.+) \(block card\)/, function (m) { return 'First block on ' + m[1]; }],
|
||
[/^public profile on:/, function () { return 'Your address page is public'; }],
|
||
[/^public profile off:/, function () { return 'Your address page is unlisted'; }],
|
||
[/^igneumd exited with code (\d+) after (\d+) s; restarting/, function (m) { return 'The node stopped (code ' + m[1] + '). Restarting'; }],
|
||
[/^igneumd exited at once \(code (\d+)\)/, function (m) { return 'The node stopped at once (code ' + m[1] + ')'; }],
|
||
[/^igneumd could not start/, function () { return 'The node could not start'; }],
|
||
[/^miner (\S+) exited with code (\d+); restarting in (\d+) s/, function (m) { return 'A card stopped (code ' + m[2] + '). Restarting in ' + m[3] + ' s'; }],
|
||
[/^no reading from the node for (\d+) s/, function (m) { return 'No reading from the node for ' + m[1] + ' s. Restarting it'; }],
|
||
[/^no peer after 90 s/, function () { return 'No other node found after 90 s. Still looking'; }],
|
||
[/^no payout address/, function () { return 'No rewards address yet: finish the setup'; }],
|
||
[/^mining paused; the node keeps running/, function () { return 'Mining paused. The node keeps running'; }],
|
||
[/^mining resumed/, function () { return 'Mining resumed'; }],
|
||
[/^GPU power cap in force: (.+)/, function (m) { return 'Power cap in force: ' + m[1]; }],
|
||
[/^GPU power cap NOT applied/, function () { return 'Power cap not set. The administrator prompt was refused (Retry on the card)'; }],
|
||
[/^power control on:/, function () { return 'Power control on'; }],
|
||
[/^rewards go to (\S+)/, function (m) { return 'Rewards go to ' + shortHex(m[1], 6, 4); }],
|
||
[/^settings changed \((.+)\); the miners restart/, function (m) { return 'Settings changed (' + m[1] + '). Mining restarts'; }],
|
||
[/^(.+): tune queued; it starts once/, function (m) { return m[1] + ': tune queued'; }],
|
||
[/^(.+): tuning started: /, function (m) { return m[1] + ': tuning started'; }],
|
||
[/^(.+): best point (.+?) \((.+?)\); your setting stays pinned/, function (m) { return m[1] + ': best point ' + m[2] + ' (' + m[3] + '). Your cap stays pinned'; }],
|
||
[/^(.+): measured as it runs: (.+)/, function (m) { return m[1] + ': measured ' + m[2]; }],
|
||
[/^(.+): no tune: (.+)/, function (m) { return m[1] + ': no tune (' + m[2] + ')'; }],
|
||
[/^(.+): worker fault: (.+); the miner restarts the worker/, function (m) { return m[1] + ': an error. Restarting it'; }],
|
||
[/^(.+): the worker could not compile the next program/, function (m) { return m[1] + ': the next program is built at the top of the hour instead'; }],
|
||
[/^(.+) worker ready/, function (m) { return m[1] + ': ready'; }],
|
||
[/^(.+) kernel race: (.+?) at ([\d.]+) MH\/s/, function (m) { return m[1] + ': fastest program chosen, ' + m[3] + ' MH/s'; }],
|
||
[/^(.+): switched on, (\d+) identit/, function (m) { return m[1] + ': on'; }],
|
||
[/^(.+): switched off/, function (m) { return m[1] + ': off'; }],
|
||
[/^(.+): (\d+) identit(y|ies); its worker restarts/, function (m) { return m[1] + ': ' + m[2] + ' miner' + (m[2] === '1' ? '' : 's') + ' on it. The card restarts'; }],
|
||
[/^next hourly program compiled and ready/, function () { return 'Next hourly program ready'; }],
|
||
[/^the hourly program changed; the worker restarts/, function () { return 'New hourly program. The card restarts on it'; }],
|
||
[/^hourly program changed: /, function () { return 'New hourly program'; }],
|
||
[/^program pack out of date, rebuilding/, function () { return 'Rebuilding the mining programs'; }],
|
||
[/^GPU worker built from source/, function () { return 'Mining program built'; }],
|
||
[/^block (\d+) shard (\d+) paid (.+) IGN/, function (m) { return 'Proof paid: ' + m[3] + ' IGN'; }],
|
||
[/^block (\d+) shard (\d+) proven and submitted in (\d+) s/, function (m) { return 'Proof sent in ' + m[3] + ' s'; }],
|
||
[/^segment .+ paid (.+) IGN to the aggregator/, function (m) { return 'Segment paid: ' + m[1] + ' IGN'; }],
|
||
[/^segment (\S+)\.\.(\S+) aggregated and submitted in (\d+) s/, function (m) { return 'Segment proven in ' + m[3] + ' s'; }],
|
||
[/^segment (\S+)\.\.(\S+): (\d+) shards proven, aggregated and submitted in (\d+) s/, function (m) { return 'Segment proven: ' + m[3] + ' shards in ' + m[4] + ' s'; }],
|
||
[/^segment (\S+)\.\.(\S+): (\d+) shards proven and submitted in (\d+) s; the segment record is held/, function (m) { return 'Segment proven: ' + m[3] + ' shards in ' + m[4] + ' s. Its record waits for the chain'; }],
|
||
[/^segment (\S+)\.\.(\S+) record accepted on retry/, function () { return 'Segment record accepted'; }],
|
||
[/^WSL2 prover setup started/, function () { return 'Prover setup started in its own window'; }],
|
||
[/^Card removed: (.+); its worker stopped/, function (m) { return 'Card removed: ' + m[1]; }],
|
||
[/^(.+): not usable \((.+?)\); its worker stopped/, function (m) { return m[1] + ': not usable (' + m[2] + ')'; }],
|
||
[/^clock agrees with the network again/, function () { return 'Clock agrees with the network again'; }],
|
||
[/^asking the system to set its clock/, function () { return 'Setting the clock from a time server'; }],
|
||
[/^clock: (.+)/, function (m) { return 'Clock: ' + m[1]; }],
|
||
[/^devnet only: the node trusts proof records/, function () { return 'Devnet trust mode: proofs are not checked (Settings)'; }],
|
||
[/^a sweep is setting the caps right now/, function () { return 'A tune is setting the caps now. Retry when it is done'; }],
|
||
[/^updates are off for this engine/, function () { return 'Updates are off for this copy (a measurement run)'; }],
|
||
[/^(.+); the node restarts$/, function (m) { return cap(m[1]) + '; the node restarts'; }],
|
||
[/^(.+); the node is external, so the app cannot restart it/, function (m) { return cap(m[1]) + '; the node is not the app’s, so it is not restarted'; }]
|
||
];
|
||
function plainEvent(text) {
|
||
text = String(text || '');
|
||
for (var i = 0; i < EVENT_MAP.length; i++) { var m = EVENT_MAP[i][0].exec(text); if (m) { var out = EVENT_MAP[i][1](m); return { text: out, tech: out === text ? '' : text }; } }
|
||
var cut = eventLine(text);
|
||
return { text: cap(cut), tech: cut === text ? '' : text };
|
||
}
|
||
// the pill in the top bar: a verb a user understands, and its tone (on = molten, bad = ember, '' = grey)
|
||
function pill(s) {
|
||
if (s.quitting) return { text: 'Stopping', tone: '' };
|
||
if (!s.setup_done) return { text: s.detecting ? 'Detecting cards' : 'Setting up', tone: '' };
|
||
var m = s.mining || {}, n = s.node || {};
|
||
if (tuningCard(m.cards)) return { text: 'Tuning', tone: 'on' };
|
||
if (m.state === 'mining') return { text: 'Mining · ' + (m.hash_total >= 100 ? m.hash_total.toFixed(0) : (m.hash_total || 0).toFixed(1)) + ' MH/s', tone: 'on' };
|
||
if (m.state === 'paused') return { text: 'Paused', tone: '' };
|
||
if (s.heat && s.heat.on && s.heat.phase === 'resting') return { text: 'Resting · heat mode', tone: '' };
|
||
if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' };
|
||
if (n.state === 'starting') return { text: 'Starting the node', tone: '' };
|
||
if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' };
|
||
if (n.state === 'behind') return { text: 'Node behind', tone: 'bad' };
|
||
if (n.state === 'no peers') return { text: 'No peers', tone: 'bad' };
|
||
if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' };
|
||
if (n.state === 'stopped') return { text: n.source === 'none' ? 'Node not started' : 'Node stopped', tone: 'bad' };
|
||
if (n.state === 'stub') return { text: 'Node fault', tone: 'bad' };
|
||
if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' };
|
||
if (m.state === 'idle') return { text: 'Not mining', tone: '' };
|
||
return { text: cap(m.state || 'starting'), tone: '' };
|
||
}
|
||
// ---- Ember on the card row (miner-ui-4, 6 October 2026): the flame mark's fill, the before -> after watts, the saving,
|
||
// the sparkline points and the plain label. cd = the card, s = state, now = unix s ----
|
||
// the "before" point (Ember, 6 October 2026): card.tune_before_watts / tune_before_mhs, step 0 of the last FULL plan,
|
||
// kept across confirm plans; until a build carries them, the full plan's step 0 in tune_curve (unlocked, 100%); a
|
||
// confirm or baseline plan without the fields has no before, so no saving is claimed
|
||
function beforeOf(cd) {
|
||
if (cd.tune_before_watts > 0) return { watts: cd.tune_before_watts, mhs: cd.tune_before_mhs || 0 };
|
||
if (cd.tune_before_watts !== undefined) return null;
|
||
var r = (cd.tune_curve || [])[0];
|
||
if (cd.tune_source === 'full' && r && r.watts > 0 && (r.power_pct || 0) >= 100 && !(r.clock_mhz > 0)) return { watts: r.watts, mhs: r.mhs || 0 };
|
||
return null;
|
||
}
|
||
// {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points}
|
||
function ember(cd, s, now) {
|
||
var tw = tuneWords(cd, s, now), out = { kind: tw.kind, fill: 0, label: '', sub: '', before: 0, after: 0, savingW: 0, savingPct: 0, savingText: '', next: '', points: [] };
|
||
if (tw.kind === 'none') return out;
|
||
var st = (s && s.settings) || {};
|
||
out.next = nextCheck(cd.sweep_at, st.tune_period_s, now).replace(/^next check /, '');
|
||
out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05 }; });
|
||
if (cd.tune_floor) out.floor = true;
|
||
out.heat = heatRowWords(s && s.heat);
|
||
if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''; return out; }
|
||
if (tw.kind === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; }
|
||
if (tw.kind === 'stopped') { out.label = tw.text; return out; }
|
||
if (tw.kind === 'measured') { out.fill = 0.5; out.label = 'Measured, not tuned'; out.sub = /Power control/i.test(cd.sweep_note || '') ? 'Power control is off' : cd.vendor === 'apple' ? 'Apple silicon sets its own power' : (tw.note || '').replace(/^Measured only /, ''); return out; }
|
||
if (tw.kind === 'tuned') {
|
||
out.fill = 1; var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
|
||
out.label = 'Tuned' + (when ? ' ' + when : '') + (cd.tune_source === 'confirm' ? ' from what other miners found' : '');
|
||
out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the lowest clock Ember will set)' : '') + (out.next ? ' · next check ' + out.next : '');
|
||
var b = beforeOf(cd); out.after = cd.sweep_watts > 0 ? cd.sweep_watts : 0; out.before = b ? b.watts : 0;
|
||
if (out.before > 0 && out.after > 0 && out.before > out.after) {
|
||
out.savingW = Math.round(out.before - out.after);
|
||
out.savingPct = b.mhs > 0 && cd.sweep_mhs > 0 ? Math.max(0, (b.mhs - cd.sweep_mhs) / b.mhs * 100) : 0;
|
||
out.savingText = 'saves ' + out.savingW + ' W' + (b.mhs > 0 ? ', ' + (out.savingPct < 0.05 ? 'no' : out.savingPct.toFixed(2) + '% of') + ' rate' : '');
|
||
}
|
||
return out;
|
||
}
|
||
out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = st.sweep === false ? 'Tune starts one by hand' : 'starts after 2 min of steady mining'; return out;
|
||
}
|
||
// the Tuning strip's fleet saving: watts and £ a day summed over the tuned cards
|
||
function fleetSaving(cards, s, now, pence) {
|
||
var w = 0, n = 0;
|
||
(cards || []).forEach(function (c) { if (!present(c)) return; var e = ember(c, s, now); if (e.kind === 'tuned' && e.savingW > 0) { w += e.savingW; n++; } });
|
||
if (!n) return '';
|
||
var ppd = poundsPerDay(w, pence);
|
||
return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's');
|
||
}
|
||
// ---- Ember Heat (mission item 7, 7 October 2026; docs/plans/ember-heat.md): the region table and the price prompt,
|
||
// the restricted line, the cost-against-rent line, the heat words ----
|
||
// The prices are the public table's rows (docs/analysis/mission/future.md 4.1: Ofgem, Eurostat, EIA), typed into the
|
||
// app as defaults the miner edits; nothing is fetched. The dollar rates are the ones future.md 3.4 implies (USD 1.32 per
|
||
// pound, 1.08 per euro), approximate. The restricted entries are future.md 4.4 and 8.3 item 7: Russia (ten regions, the
|
||
// Moscow region, the seasonal three) and China. Nothing is added to that list here.
|
||
var RESTRICTED_SENTENCE = 'Mining may be restricted where you are. You are responsible for checking.';
|
||
var CURRENCIES = { GBP: { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 }, EUR: { code: 'EUR', symbol: '€', minor: 'c', usd: 1.08 }, USD: { code: 'USD', symbol: '$', minor: 'c', usd: 1 } };
|
||
var EUROSTAT = 'Eurostat, second half of 2025';
|
||
var REGIONS = [
|
||
{ code: 'gb', name: 'United Kingdom', cur: 'GBP', price: 26.32, source: 'Ofgem cap, 1 October to 31 December 2026' },
|
||
{ code: 'ie', name: 'Ireland', cur: 'EUR', price: 40.42, source: EUROSTAT },
|
||
{ code: 'de', name: 'Germany', cur: 'EUR', price: 38.69, source: EUROSTAT },
|
||
{ code: 'be', name: 'Belgium', cur: 'EUR', price: 34.99, source: EUROSTAT },
|
||
{ code: 'eu', name: 'European Union, other', cur: 'EUR', price: 28.96, source: EUROSTAT + ', the EU average' },
|
||
{ code: 'hu', name: 'Hungary', cur: 'EUR', price: 10.82, source: EUROSTAT + ', in euro cents' },
|
||
{ code: 'mt', name: 'Malta', cur: 'EUR', price: 12.82, source: EUROSTAT },
|
||
{ code: 'bg', name: 'Bulgaria', cur: 'EUR', price: 13.55, source: EUROSTAT + ', in euro cents' },
|
||
{ code: 'us', name: 'United States', cur: 'USD', price: 17.7, source: 'EIA average, second half of 2025' },
|
||
{ code: 'kz', name: 'Kazakhstan', cur: 'USD', price: 4, source: 'about 4 c, a secondary source, approximate' },
|
||
{ code: 'py', name: 'Paraguay', cur: 'USD', price: 4.4, source: 'ANDE crypto tariff, 4.4 to 6 c; every crypto contract ends 31 December 2027' },
|
||
{ code: 'ir', name: 'Iran', cur: 'USD', price: 1, source: 'licensed mining tariff, about 1 c, a secondary source, approximate' },
|
||
{ code: 'et', name: 'Ethiopia', cur: 'USD', price: 2, source: '2 to 5.3 c, a secondary source' },
|
||
{ code: 'ng', name: 'Nigeria', cur: 'USD', price: 4.8, source: 'hosted, 4.8 c, a secondary source' },
|
||
{ code: 'ru-mow', name: 'Russia, Moscow region', cur: 'USD', price: 0, restricted: 'Mining is banned in the Moscow region from 15 August 2026 to 2032.' },
|
||
{ code: 'ru-irk', name: 'Russia, Irkutsk', cur: 'USD', price: 0, restricted: 'Irkutsk has a seasonal mining ban.' },
|
||
{ code: 'ru-bur', name: 'Russia, Buryatia', cur: 'USD', price: 0, restricted: 'Buryatia has a seasonal mining ban.' },
|
||
{ code: 'ru-zab', name: 'Russia, Zabaikalsky', cur: 'USD', price: 0, restricted: 'Zabaikalsky has a seasonal mining ban.' },
|
||
{ code: 'ru', name: 'Russia, other region', cur: 'USD', price: 0, restricted: 'Mining is banned in ten regions from 1 January 2025 to 15 March 2031, in the Moscow region from 15 August 2026, and seasonally in Irkutsk, Buryatia and Zabaikalsky. This app does not carry the list of ten.' },
|
||
{ code: 'cn', name: 'China', cur: 'USD', price: 0, restricted: 'Mining is illegal in China. The joint Notice of 6 February 2026 reaffirmed it.' },
|
||
{ code: 'other', name: 'Somewhere else', cur: 'USD', price: 0 }
|
||
];
|
||
var synthRegions = {};
|
||
function regionOf(code) {
|
||
for (var i = 0; i < REGIONS.length; i++) if (REGIONS[i].code === code) return REGIONS[i];
|
||
if (!/^[a-z]{2}$/.test(code || '')) return null;
|
||
if (synthRegions[code]) return synthRegions[code];
|
||
var cur = ISO_CURRENCY[code] || '', eu = EU_MEMBERS.indexOf(code) >= 0 && cur === 'EUR', avg = null;
|
||
for (var j = 0; j < REGIONS.length; j++) if (REGIONS[j].code === 'eu') avg = REGIONS[j];
|
||
return (synthRegions[code] = { code: code, name: regionName(code), cur: cur || 'USD', unknown: !cur, price: eu && avg ? avg.price : 0, source: eu && avg ? avg.source : '' });
|
||
}
|
||
function currencyOf(code) { var r = regionOf(code); return currencyInfo(r ? r.cur : 'GBP') || CURRENCIES.GBP; }
|
||
// the money symbol follows the region, unless an override is set (every money figure on every page)
|
||
function setRegion(code) { regionCode = code || ''; currency = (override && currencyInfo(override)) || currencyOf(regionCode); return currency; }
|
||
function setCurrency(code) { override = code && currencyInfo(code) ? String(code).toUpperCase() : ''; return setRegion(regionCode); }
|
||
function setLocale(l) { locale = l || 'en-GB'; return locale; }
|
||
function currencyNow() { return currency; }
|
||
|
||
// ---------- currency by locale (currency-21, the project lead, 7 October 2026: "£/day is for UK; we need other currencies") ----------
|
||
// No IP lookups: the OS locale's region (navigator.language in the app's web view) picks the region row, its currency
|
||
// and its default tariff at first run; a Settings override (settings.currency, in the engine's settings file) wins
|
||
// and survives restart; the user's own tariff is the number, so no exchange rate exists anywhere. The long tail of
|
||
// regions is synthesised from ISO_CURRENCY with Intl.DisplayNames for the name; an EU member without its own row
|
||
// takes the EU average (euro cents) when it uses the euro; a region with no currency row falls back to USD and asks.
|
||
var ISO_CURRENCY = { at: 'EUR', be: 'EUR', cy: 'EUR', de: 'EUR', ee: 'EUR', es: 'EUR', fi: 'EUR', fr: 'EUR', gr: 'EUR', hr: 'EUR', ie: 'EUR', it: 'EUR', lt: 'EUR', lu: 'EUR', lv: 'EUR', mt: 'EUR', nl: 'EUR', pt: 'EUR', si: 'EUR', sk: 'EUR',
|
||
gb: 'GBP', us: 'USD', ca: 'CAD', au: 'AUD', nz: 'NZD', ch: 'CHF', li: 'CHF', se: 'SEK', no: 'NOK', dk: 'DKK', is: 'ISK', pl: 'PLN', cz: 'CZK', hu: 'HUF', ro: 'RON', bg: 'BGN', rs: 'RSD', tr: 'TRY', ua: 'UAH', ge: 'GEL', kz: 'KZT', ru: 'RUB',
|
||
jp: 'JPY', kr: 'KRW', cn: 'CNY', hk: 'HKD', tw: 'TWD', sg: 'SGD', my: 'MYR', th: 'THB', vn: 'VND', ph: 'PHP', id: 'IDR', in: 'INR', pk: 'PKR', bd: 'BDT', ae: 'AED', sa: 'SAR', il: 'ILS', ir: 'IRR',
|
||
za: 'ZAR', ng: 'NGN', ke: 'KES', eg: 'EGP', ma: 'MAD', et: 'ETB', br: 'BRL', mx: 'MXN', ar: 'ARS', cl: 'CLP', co: 'COP', pe: 'PEN', py: 'PYG' };
|
||
var EU_MEMBERS = ['at', 'be', 'bg', 'hr', 'cy', 'cz', 'dk', 'ee', 'fi', 'fr', 'de', 'gr', 'hu', 'ie', 'it', 'lv', 'lt', 'lu', 'mt', 'nl', 'pl', 'pt', 'ro', 'sk', 'si', 'es', 'se'];
|
||
var CURRENCY_CODES = ['GBP', 'EUR', 'USD', 'CAD', 'AUD'];
|
||
(function () { var seen = {}; CURRENCY_CODES.forEach(function (c) { seen[c] = 1; }); var rest = []; for (var k in ISO_CURRENCY) if (!seen[ISO_CURRENCY[k]]) { seen[ISO_CURRENCY[k]] = 1; rest.push(ISO_CURRENCY[k]); } rest.sort(); CURRENCY_CODES = CURRENCY_CODES.concat(rest); })();
|
||
// the minor unit's word and short form, and the currency's decimal digits (0 for yen or won: the price is typed in the unit itself)
|
||
var MINOR = { GBP: ['pence', 'p'], EUR: ['euro cents', 'c'], USD: ['cents', 'c'], CAD: ['cents', 'c'], AUD: ['cents', 'c'], NZD: ['cents', 'c'], CHF: ['rappen', 'rp'], SEK: ['öre', 'öre'], NOK: ['øre', 'øre'], DKK: ['øre', 'øre'], PLN: ['grosze', 'gr'], CZK: ['haléře', 'h'], INR: ['paise', 'p'], BRL: ['centavos', 'c'], MXN: ['centavos', 'c'], ZAR: ['cents', 'c'], JPY: ['yen', '¥'], KRW: ['won', '₩'] };
|
||
function minorOf(cur) {
|
||
cur = String(cur || '').toUpperCase(); var digits = 2;
|
||
try { digits = new Intl.NumberFormat('en', { style: 'currency', currency: cur }).resolvedOptions().maximumFractionDigits; } catch (e) { }
|
||
var m = MINOR[cur] || (digits === 0 ? [cur.toLowerCase(), cur] : ['cents', 'c']);
|
||
return { word: m[0], abbr: m[1], digits: digits };
|
||
}
|
||
// the engine stores the price in hundredths of the unit; a zero-decimal currency is typed in the unit, so x100 on the way in
|
||
function priceFactor(cur) { return minorOf(cur).digits === 0 ? 100 : 1; }
|
||
function symbolOf(cur) { try { var parts = new Intl.NumberFormat('en', { style: 'currency', currency: cur, currencyDisplay: 'narrowSymbol' }).formatToParts(1); for (var i = 0; i < parts.length; i++) if (parts[i].type === 'currency') return parts[i].value; } catch (e) { } return cur; }
|
||
// a currency's row: the three the heat lane carried (with their approximate USD rates for the rent comparison), CAD and
|
||
// AUD (approximate rates, October 2026), the long tail without a rate (the rent line says so)
|
||
function currencyInfo(cur) {
|
||
cur = String(cur || '').toUpperCase(); if (CURRENCIES[cur]) return CURRENCIES[cur];
|
||
if (CURRENCY_CODES.indexOf(cur) < 0) return null;
|
||
return (CURRENCIES[cur] = { code: cur, symbol: symbolOf(cur), minor: minorOf(cur).abbr, usd: cur === 'CAD' ? 0.72 : cur === 'AUD' ? 0.65 : 0 });
|
||
}
|
||
function regionName(code) { try { var n = new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase()); if (n && n !== code.toUpperCase()) return n; } catch (e) { } return code.toUpperCase(); }
|
||
// the region of a BCP 47 tag: "de-DE" and "fr-CA" carry it, "de" alone is filled in by Intl.Locale; a language with no region ("eo") gives ''
|
||
function regionFromLocale(lang) {
|
||
lang = String(lang || '').trim(); if (!lang) return ''; var reg = '';
|
||
try { var l = new Intl.Locale(lang); reg = l.region || (l.maximize ? l.maximize().region : '') || ''; } catch (e) { var m = /^[A-Za-z]{2,3}[-_]([A-Za-z]{2})\b/.exec(lang); reg = m ? m[1] : ''; }
|
||
return /^[A-Za-z]{2}$/.test(reg) ? reg.toLowerCase() : '';
|
||
}
|
||
// what the money shows as: the override, else the chosen region, else the locale's region, else USD with the prompt
|
||
function currencyChoice(settings, lang) {
|
||
settings = settings || {}; var ov = String(settings.currency || '').toUpperCase(), region = settings.region || '', loc = regionFromLocale(lang);
|
||
if (ov && currencyInfo(ov)) return { code: ov, source: 'override', region: region || loc, prompt: false };
|
||
var r = region ? regionOf(region) : null;
|
||
if (r && !(r.unknown || r.code === 'other')) return { code: r.cur, source: 'region', region: region, prompt: false };
|
||
if (r) return { code: 'USD', source: 'fallback', region: region, prompt: true };
|
||
var lr = loc ? regionOf(loc) : null;
|
||
if (lr && !lr.unknown) return { code: lr.cur, source: 'locale', region: loc, prompt: false };
|
||
return { code: 'USD', source: 'fallback', region: loc, prompt: true };
|
||
}
|
||
function currencyAsk(ch) { return ch && ch.prompt ? 'This app could not tell your currency from this machine (' + (ch.region ? 'region ' + ch.region.toUpperCase() : 'no region in its locale') + '). Choose one and type what you pay per kWh. Figures show in US dollars until you do.' : ''; }
|
||
// the render path: set the module's currency and locale from the engine's settings and the web view's language
|
||
function applyMoney(settings, lang) { var ch = currencyChoice(settings, lang); override = ch.source === 'override' ? ch.code : ''; regionCode = ch.region || ''; currency = currencyInfo(ch.code) || CURRENCIES.USD; return ch; }
|
||
// the words for the price field: the minor unit of the currency in force (the override, else the region's)
|
||
function unitWords(code) { var c = (override && currencyInfo(override)) || currencyOf(code); return minorOf(c.code).word + ' per kWh'; }
|
||
// the restricted line for a region: the standing sentence plus the entry, or '' for a region with no entry
|
||
function restrictedLine(code) { var r = regionOf(code); return r && r.restricted ? RESTRICTED_SENTENCE + ' ' + r.restricted : ''; }
|
||
// the price note under the field: where the default came from, or that the miner types their own
|
||
function priceNote(code) {
|
||
var r = regionOf(code), c = currencyOf(code);
|
||
if (!r) return 'Choose a region to see a typical price. Every ' + c.symbol + ' figure uses what you type here. Nothing is fetched.';
|
||
return (r.price > 0 ? 'Typical: ' + r.price + ' ' + c.minor + ' per kWh (' + r.source + '). ' : 'No table price for this region. ') + 'Type what you pay, in ' + minorOf(((override && currencyInfo(override)) || c).code).word + ' per kWh. Nothing is fetched.';
|
||
}
|
||
// the measured rental rate of hash (docs/bench-log.md, "Rental cost of hash, 6 October 2026"); the test
|
||
// heat-region.test.mjs reads the bench log and fails when this number drifts from it
|
||
var RENT = { usd_per_mhs_hour: 0.0117, measured: '6 October 2026', source: 'RunPod community pods, 38 pods at list price, 1,748 MH/s for USD 20.44 an hour' };
|
||
// the miner's own cost of hash: watts per MH/s at the price, in the region's minor unit per MH/s-hour, against the rent.
|
||
// Live numbers while mining; the last tune's point otherwise; nothing without a draw reading or a price.
|
||
function rentLine(cards, price, code) {
|
||
var c = (override && currencyInfo(override)) || currencyOf(code), w = 0, h = 0, live = false;
|
||
(cards || []).filter(present).forEach(function (cd) {
|
||
if (cd.state === 'mining' && cd.power_w > 0 && cd.hash_now > 0) { w += cd.power_w; h += cd.hash_now; live = true; }
|
||
});
|
||
if (!(h > 0)) { w = 0; (cards || []).filter(present).forEach(function (cd) { if (cd.sweep_watts > 0 && cd.sweep_mhs > 0) { w += cd.sweep_watts; h += cd.sweep_mhs; } }); }
|
||
var out = { kind: 'none', text: '', sub: '', cost: 0, usd: 0, ratio: 0, live: live, rentUsd: RENT.usd_per_mhs_hour };
|
||
if (!(h > 0 && w > 0)) { out.text = 'Your cost of hash: no reading yet'; out.sub = 'needs a card mining with a power reading, or a finished tune. Rented hash costs USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + ').'; return out; }
|
||
if (!(price > 0)) { out.kind = 'price'; out.text = 'Your cost of hash: set a price'; out.sub = Math.round(w) + ' W for ' + (h >= 100 ? h.toFixed(0) : h.toFixed(1)) + ' MH/s is ' + (w / h).toFixed(2) + ' W per MH/s. Set your electricity price to compare it with rented hash at USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour.'; return out; }
|
||
var cost = w / h / 1000 * price; // minor units per MH/s-hour
|
||
if (!(c.usd > 0)) { out.kind = 'nocur'; out.cost = cost; out.text = 'Your cost of hash: ' + fmtMoney(cost / 100, c.code, locale) + ' per MH/s-hour'; out.sub = 'no USD rate is carried for ' + c.code + ', so no comparison with rented hash (USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour). Nothing is fetched.'; return out; }
|
||
var usd = cost / 100 * c.usd, ratio = RENT.usd_per_mhs_hour / usd;
|
||
out.kind = 'line'; out.cost = cost; out.usd = usd; out.ratio = ratio;
|
||
var costText = cost < 0.1 ? cost.toFixed(3) : cost.toFixed(2);
|
||
out.text = costText + ' ' + c.minor + ' per MH/s-hour';
|
||
var verdict = ratio >= 1.05 ? 'Yours is ' + (ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)) + 'x cheaper than rented hash.' : ratio <= 0.95 ? 'Rented hash undercuts your card by ' + ((1 / ratio) >= 10 ? Math.round(1 / ratio) : (1 / ratio).toFixed(1)) + 'x.' : 'Level with rented hash.';
|
||
out.verdict = verdict;
|
||
out.sub = 'your cost of hash at ' + price + ' ' + c.minor + '/kWh and ' + (w / h).toFixed(2) + ' W per MH/s' + (live ? '' : ' (the last tune)') + (c.code === 'USD' ? '' : ', about USD ' + usd.toFixed(4)) + '. Rented hash: USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + '). ' + verdict;
|
||
return out;
|
||
}
|
||
// heat mode on the Ember strip and the card rows: h = state.heat
|
||
function heatLine(h) {
|
||
if (!h || !h.on) return { text: '', tone: '' };
|
||
var room = h.room_source === 'typed' ? 'room ' + h.room_c.toFixed(1) + ' °C' : h.room_source === 'card' ? 'room about ' + Math.round(h.room_c) + ' °C' : 'no room reading yet';
|
||
var heat = h.heat_w > 0 ? Math.round(h.heat_w) + ' W of heat' : h.phase === 'resting' ? 'resting' + (h.full_w > 0 ? ', ' + Math.round(h.full_w) + ' W when heating' : '') : h.full_w > 0 ? Math.round(h.full_w) + ' W when heating' : 'no draw reading';
|
||
var duty = Math.round((h.duty_hour > 0 ? h.duty_hour : h.duty) * 100);
|
||
return { text: 'Heat mode: holding ' + h.set_c.toFixed(1) + ' °C · ' + room + ' · heating ' + duty + '% of the time · ' + heat, tone: h.phase === 'resting' ? 'rest' : h.phase === 'heating' ? 'heat' : '' };
|
||
}
|
||
// the short form on a card row's Ember line
|
||
function heatRowWords(h) { if (!h || !h.on) return ''; return 'heat ' + Math.round((h.duty_hour > 0 ? h.duty_hour : h.duty) * 100) + '% · ' + (h.phase === 'resting' ? 'resting' : h.heat_w > 0 ? Math.round(h.heat_w) + ' W of heat' : 'heating'); }
|
||
|
||
// ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei
|
||
// (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign
|
||
// (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ----
|
||
function ign(wei) { var n = Number(wei); return isNaN(n) ? null : n / 1e18; }
|
||
// the Earnings balance row: {text, sub, pounds} ; the UI shows "not read yet" until the first read
|
||
function balanceWords(a, now) {
|
||
a = a || {};
|
||
if (a.balance_wei === undefined) return { text: '', sub: '', pounds: null };
|
||
if (a.balance_wei === null || a.balance_wei === '') return { text: 'not read yet', sub: a.balance_note || 'the node answers the balance once it is synced', pounds: null };
|
||
var v = ign(a.balance_wei); if (v === null) return { text: 'not read yet', sub: a.balance_note || '', pounds: null };
|
||
var age = a.balance_age_s >= 0 ? ' · read ' + ago(a.balance_age_s) : '';
|
||
var price = a.price_gbp_per_ign;
|
||
return { text: (v >= 1000 ? withCommas(v) : v.toFixed(4)) + ' IGN', sub: 'in the wallet' + age + (a.balance_note ? ' · ' + a.balance_note : ''), pounds: price > 0 ? v * price : null };
|
||
}
|
||
// the fleet watts and £ a day: the engine's figures when it carries them, else the UI's own sums
|
||
function fleetWatts(m, cards) { return m && m.watts_total > 0 ? m.watts_total : wattsTotal(cards); }
|
||
function fleetPounds(m, cards, pence) { if (m && m.pounds_per_day > 0) return m.pounds_per_day; return poundsPerDay(fleetWatts(m, cards), pence); }
|
||
// is a lane key this machine's? The cards' ids are the first 8 hex of each vote key hash; a source may shorten the
|
||
// hash to another length (the 0.3.16 engine keeps 12), so either prefix of the other counts
|
||
function isMine(miner, cards) {
|
||
var m = String(miner || '').replace(/^0x/, '').toLowerCase(); if (!m) return false;
|
||
var head = m.split('..')[0]; // the 0.3.16 engine shortens a long hash to "head6..tail6"; the site keeps 8 hex
|
||
return (cards || []).some(function (c) { return (c.ids || []).some(function (id) {
|
||
id = String(id).replace(/^0x/, '').toLowerCase(); if (!id) return false;
|
||
var n = Math.min(head.length, id.length); return n >= 6 && head.slice(0, n) === id.slice(0, n);
|
||
}); });
|
||
}
|
||
// the chain scene's note from the reply's source
|
||
function liveNote(reply) {
|
||
if (!reply) return '';
|
||
if (reply.pending) return 'connecting';
|
||
var src = reply.source || ((reply.state || {}).source);
|
||
if (src === 'node') return 'From your node. Your blocks are ringed.';
|
||
if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.';
|
||
return '';
|
||
}
|
||
// finality (ember-tune 1357d280, folded in at the rebase onto a9eb58f1): f = state.finality. While the network's finality is paused
|
||
// (under two thirds of the weight signing) the node line's sub-text and the lock row say so, and no surface calls a
|
||
// lock "final" (main, Q83/Q84). {paused, age, note}
|
||
function finalityWords(f) {
|
||
f = f || {};
|
||
if (f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' };
|
||
if (f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' };
|
||
return { paused: false, age: 'n/a', note: f.message || 'Locks appear once the miner votes on checkpoints.' };
|
||
}
|
||
// whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | ""
|
||
function nodeSource(n) {
|
||
n = n || {};
|
||
if (n.source === 'app') return { text: 'this app’s own node', sub: n.mode_reason || n.port_note || '' };
|
||
if (n.source === 'external') return { text: 'another node on this machine', sub: n.rules_check === 'match' ? 'same network and rules, checked' : n.rules_check === 'unknown' ? 'its rules could not be checked (an older node)' : (n.port_note || '') };
|
||
if (n.source === 'none') return { text: 'no node', sub: n.port_note || n.mode_reason || 'the ports are taken by a node on other rules' };
|
||
return { text: '', sub: '' };
|
||
}
|
||
// ---- miner-ui-5 (7 October 2026): the miner's first month. The Poisson count-up before the first block, the block
|
||
// card, Earnings in IGN first with a typed price, the ladder rungs as chain facts, the first-hour timeline.
|
||
// Every number names its RPC field on hover: FIELDS holds the one sentence per number, the renderers put it in
|
||
// the title attribute. `chain` is GET api/ladder (src/chainfacts.rs): the node's getFinalityWeights table with
|
||
// this machine's keys ranked, the params, the locked index and the public /api/stats numbers. ----
|
||
var FIELDS = {
|
||
hashrate: '/api/stats hashrate: the node’s estimateNetworkHashesPerSecond over 1,000 blocks (H/s)',
|
||
reward: '/api/stats block_reward.miner_ign: the 80 producer points of E(daa) (spec 2.5) at the newest DAA score, after the launch ramp',
|
||
ramp: '/api/stats block_reward.ramp_factor: the launch ramp, 10% on day one to 100% at day 30',
|
||
blocks: 'getFinalityWeights keys[].blocks: blue blocks with your key in the weight window (params.weightWindow)',
|
||
voter: 'getFinalityWeights keys[].voter: blocks at or above params.dust',
|
||
dust: 'getFinalityWeights params.dust: the blocks a key needs in the window to vote',
|
||
window: 'getFinalityWeights params.weightWindow: the window in DAA steps (one a second)',
|
||
participation: 'getFinalityWeights keys[].participation: the share of the last params.presenceWindow checkpoints your key signed',
|
||
rank: 'getFinalityWeights keys[] sorted by blocks, your key’s place; "of" = keys_listed (the observer relays at most 64)',
|
||
voters: 'getFinalityWeights voters: keys at or above dust',
|
||
total: 'getFinalityWeights totalWeight: every key’s blocks in the window',
|
||
locked: 'getFinalityCheckpoints latestLockedIndex: the newest checkpoint locked by two thirds of all weight',
|
||
signed: 'this app’s miner log: VOTE index=N lines, read as locked once a LOCK checkpoint line reaches N; the per-key signer list is owed from the node (getFinalityCheckpoints checkpoints[].signers)',
|
||
shards: 'igneum_getProofRecords(block).paid[shard] with your payout address (src/prover.rs)',
|
||
found: 'this app: the miner’s ACCEPTED block lines, each matched to the node’s PoW accepted line by nonce (ladder.json)',
|
||
found_hour: 'this app: mining.found, the accepted blocks of the last hour',
|
||
days: 'this app: distinct UTC days with a block in the last 30 days (ladder.json); per-key days are owed from the node (getFinalityWeights keys[].daysMined)',
|
||
streak: 'this app: unbroken VOTE lines inside the presence window (ladder.json); the chain fact is keys[].participation',
|
||
watts: 'the cards’ power draw now (nvidia-smi, the AMD telemetry, or the engine’s mining.watts_total)',
|
||
share: 'your rate over /api/stats hashrate'
|
||
};
|
||
function fmtIgn(v) { if (v === null || v === undefined || isNaN(v)) return ''; var a = Math.abs(v); return (a >= 1000 ? withCommas(v) : a >= 100 ? v.toFixed(1) : a >= 1 ? v.toFixed(2) : v.toFixed(4)) + ' IGN'; }
|
||
function pct(v, digits) { if (!(v >= 0) || !isFinite(v)) return ''; var p = v * 100; return (p >= 10 ? p.toFixed(1) : p >= 1 ? p.toFixed(2) : p >= 0.01 ? p.toFixed(3) : p.toPrecision(2)) + '%'; }
|
||
// "16 minutes" / "1.6 hours" / "16 hours" / "6.8 days" for an expected gap
|
||
function gapWords(s) {
|
||
if (!(s > 0) || !isFinite(s)) return '';
|
||
if (s < 90) return Math.max(1, Math.round(s)) + ' seconds';
|
||
if (s < 5400) return Math.round(s / 60) + ' minutes';
|
||
if (s < 36 * 3600) { var h = s / 3600; return (h < 10 ? h.toFixed(1) : Math.round(h)) + ' hours'; }
|
||
var d = s / 86400; return (d < 10 ? d.toFixed(1) : Math.round(d)) + ' days';
|
||
}
|
||
// "41 days" / "3 h 12 min" / "48 min" for a streak or an age
|
||
function spanWords(s) { s = Math.max(0, Math.round(s || 0)); if (s < 60) return s + ' s'; if (s < 3600) return Math.floor(s / 60) + ' min'; if (s < 86400) return Math.floor(s / 3600) + ' h ' + Math.floor(s % 3600 / 60) + ' min'; var d = Math.floor(s / 86400); return d + ' day' + (d === 1 ? '' : 's'); }
|
||
// the chain's numbers the lines need, with nulls where the reply has none: c = GET api/ladder
|
||
function chainNums(c) {
|
||
var ok = !!(c && c.ok), n = (ok && c.network) || {}, p = (ok && c.params) || {};
|
||
return { ok: ok, hps: n.hashrate_hps > 0 ? n.hashrate_hps : null, minerIgn: n.miner_ign_per_block > 0 ? n.miner_ign_per_block : null, ramp: n.ramp_factor > 0 ? n.ramp_factor : null, bps: n.bps > 0 ? n.bps : 1, dust: p.dust > 0 ? p.dust : null, windowS: p.weight_window > 0 ? p.weight_window : null, presence: p.presence_window > 0 ? p.presence_window : null, interval: p.checkpoint_interval > 0 ? p.checkpoint_interval : 30, best: ok && c.best ? c.best : null, mine: (ok && c.mine) || [], listed: ok ? (c.keys_listed || 0) : 0, cap: ok ? (c.keys_cap || 0) : 0, voters: ok ? (c.voters || 0) : 0, locked: ok ? (c.latest_locked_index || 0) : 0, active: ok ? !!c.finality_active : false, source: ok ? c.source : '', stale: !ok || !!n.stale, error: !ok && c ? (c.error || '') : '' };
|
||
}
|
||
// the Poisson count-up: a card at `mhs` on a network at `hps` finds a block every hps / (mhs * 1e6 * bps) seconds on
|
||
// average; after `elapsed` seconds the chance of at least one is 1 - exp(-elapsed / gap). {gap_s, gapText, chance,
|
||
// chanceText, line, pool, share}
|
||
function poisson(mhs, hps, elapsed, bps, fleet) {
|
||
if (!(mhs > 0) || !(hps > 0)) return null;
|
||
bps = bps > 0 ? bps : 1;
|
||
var share = mhs * 1e6 / hps, gap = 1 / (share * bps), chance = 1 - Math.exp(-Math.max(0, elapsed || 0) / gap);
|
||
var who = fleet ? 'Your cards find' : 'A card like yours finds';
|
||
var line = who + ' a block about every ' + gapWords(gap) + ' on today’s network. Chance so far: ' + Math.round(chance * 100) + '%.';
|
||
var pool = gap > 8 * 3600 ? 'Above an 8-hour gap a pool pays by the share, every minute. The pool setting arrives with pool-0.' : '';
|
||
return { gap_s: gap, gapText: gapWords(gap), chance: chance, chanceText: Math.round(chance * 100) + '%', line: line, pool: pool, share: share };
|
||
}
|
||
// IGN a day for a rate on a network: 86,400 blocks a day at one a second, your share of them, the producer's points of each
|
||
function ignPerDay(mhs, hps, minerIgn, bps) { if (!(mhs > 0) || !(hps > 0) || !(minerIgn > 0)) return null; bps = bps > 0 ? bps : 1; return 86400 * bps * (mhs * 1e6 / hps) * minerIgn; }
|
||
// the window in the user's words: "30 days" on mainnet, "2 hours" on the devnet (params.weightWindow in seconds)
|
||
function windowWords(windowS) { if (!(windowS > 0)) return { n: 30, unit: 'days', text: '30 days' }; if (windowS >= 86400) { var d = Math.round(windowS / 86400); return { n: d, unit: 'days', text: d + ' day' + (d === 1 ? '' : 's') }; } var h = Math.max(1, Math.round(windowS / 3600)); return { n: h, unit: 'hours', text: h + ' hour' + (h === 1 ? '' : 's') }; }
|
||
// the days (or hours on the devnet) this key has mined inside the window: the ladder's day ring, or the hours
|
||
// spanned by its blocks when the window is under a day
|
||
function daysIn(ladder, windowS, now) {
|
||
var w = windowWords(windowS); ladder = ladder || {};
|
||
if (w.unit === 'days') return { have: Math.min(w.n, ladder.days_mined_30 || 0), of: w.n, unit: 'days' };
|
||
var hours = 0;
|
||
if (ladder.first_block_at > 0 && now) hours = Math.min(w.n, Math.floor((now - ladder.first_block_at) / 3600) + ((ladder.blocks_today || 0) > 0 || (ladder.blocks_30d || 0) > 0 ? 1 : 0));
|
||
return { have: Math.max(0, hours), of: w.n, unit: 'hours' };
|
||
}
|
||
// the ladder's rungs for a state: s = state, chain = api/ladder, now = unix s. Each rung: {id, name, state (done | now |
|
||
// next | off), line, sub, field, progress (-1 or 0..1)}. The gate (mission 2.6): a key walks every rung on screen.
|
||
function ladderRungs(s, chain, now) {
|
||
s = s || {}; var m = s.mining || {}, ld = s.ladder || {}, pv = s.proving || {}, c = chainNums(chain), rungs = [];
|
||
var best = c.best, inTable = !!best, blocks = inTable ? best.blocks : 0, found = m.accepted_total || 0;
|
||
// 0. first block (first-block-21: a machine whose first block came, on any run, never reads "waiting" again,
|
||
// whatever the lifetime count says after a settings reset)
|
||
var first = found > 0 || blocks > 0 || ld.first_block_at > 0 || !!ld.first_block_shown;
|
||
rungs.push({ id: 'first', name: 'First block', state: first ? 'done' : 'now', line: first ? 'First block' + (ld.first_block_at > 0 && now ? ' ' + ago(now - ld.first_block_at) : '') + (ld.first_block_card ? ', ' + cardName(s, ld.first_block_card) : '') + '.' : 'Waiting for your first block.', sub: inTable ? withCommas(blocks) + ' block' + (blocks === 1 ? '' : 's') + ' with your key in the window' : (found ? withCommas(found) + ' found by this machine' : ''), field: inTable ? FIELDS.blocks : FIELDS.found, progress: -1 });
|
||
// 1. the vote
|
||
var dust = c.dust, voter = inTable && best.voter;
|
||
rungs.push({ id: 'vote', name: 'Your key has a vote', state: voter ? 'done' : first ? 'now' : 'next', line: voter ? 'Your key has a vote.' : (dust ? withCommas(blocks) + ' of ' + withCommas(dust) + ' blocks to a vote.' : 'A vote needs the dust line’s blocks in the window.'), sub: voter ? withCommas(blocks) + ' blocks in the window, the line is ' + withCommas(dust || 0) : (inTable ? 'your key is in the table' : c.ok ? 'your key is not in the table yet' : (c.error || 'the chain facts are not read yet')), field: FIELDS.voter + ' · ' + FIELDS.dust, progress: voter ? 1 : dust ? Math.min(1, blocks / dust) : -1 });
|
||
// 2. the signature in a locked checkpoint
|
||
var signedIdx = ld.last_signed_locked || 0, part = inTable ? best.participation : 0;
|
||
rungs.push({ id: 'signed', name: 'Your signature is in a checkpoint', state: signedIdx > 0 ? 'done' : voter ? 'now' : 'next', line: signedIdx > 0 ? 'Your signature is in checkpoint ' + withCommas(signedIdx) + '.' : voter ? 'Signing: the next locked checkpoint carries your vote.' : 'Comes with the vote.', sub: signedIdx > 0 ? withCommas(ld.signed_locked_count || 0) + ' signed and locked' + (inTable ? ' · presence ' + Math.round(part * 100) + '%' : '') + (c.locked ? ' · latest lock ' + withCommas(c.locked) : '') : (c.locked ? 'latest lock ' + withCommas(c.locked) : ''), field: FIELDS.signed + ' · ' + FIELDS.participation + ' · ' + FIELDS.locked, progress: -1 });
|
||
// 3. the full window
|
||
var d = daysIn(ld, c.windowS, now), full = first && d.have >= d.of;
|
||
rungs.push({ id: 'window', name: 'Full window', state: full ? 'done' : first ? 'now' : 'next', line: full ? d.of + ' of ' + d.of + ' ' + d.unit + '. Full weight.' : d.have + ' of ' + d.of + ' ' + d.unit + '.', sub: 'weight is your blocks over the last ' + windowWords(c.windowS).text + '; a key that mined the whole window weighs what it mined', field: FIELDS.days + ' · ' + FIELDS.window, progress: d.of ? Math.min(1, d.have / d.of) : -1 });
|
||
// 4. rank by weight
|
||
var rankLine = inTable ? 'Rank ' + withCommas(best.rank) + ' of ' + withCommas(c.listed) + ' keys.' : c.ok ? (c.cap ? 'Below the top ' + c.cap + ' keys.' : 'Not in the table yet.') : 'Rank: not read yet.';
|
||
rungs.push({ id: 'rank', name: 'Rank by weight', state: inTable ? 'done' : first ? 'now' : 'next', line: rankLine, sub: c.ok ? withCommas(c.voters) + ' keys vote' + (c.cap && c.listed >= c.cap ? ' · the top ' + c.cap + ' are listed' : '') + ' · weight, never hashrate: a card that arrived today sits at the bottom' : '', field: FIELDS.rank + ' · ' + FIELDS.voters, progress: -1 });
|
||
// 5. the signing streak
|
||
var streak = ld.streak_s || 0;
|
||
rungs.push({ id: 'streak', name: 'Signing streak', state: streak > 0 ? 'done' : voter ? 'now' : 'next', line: streak > 0 ? 'Signing 8 of 80 points, ' + spanWords(streak) + ' unbroken.' : 'The 8 signing points start with the vote.', sub: streak > 0 ? 'miss two hours of signing and the next blocks pay 72 until you sign again' + (inTable ? ' · presence ' + Math.round(part * 100) + '%' : '') : '', field: FIELDS.streak + ' · ' + FIELDS.participation, progress: -1 });
|
||
// P. a paid shard
|
||
var sh = (ld.shards || [])[0], prove = !!(s.settings && s.settings.prove);
|
||
rungs.push({ id: 'shard', name: 'Your card proved a shard', state: sh ? 'done' : prove ? 'now' : 'off', line: sh ? 'Your card proved shard ' + sh.shard + ' of block ' + withCommas(sh.block) + '.' : prove ? 'Proving: the first paid shard lands here.' : 'Proving is off.', sub: sh ? fmtIgn(ign(sh.wei)) + ' · ' + withCommas(pv.paid || 0) + ' paid' : prove ? (pv.message || '') : 'The Prove page switches it on; 20 points of every block pay the provers', field: FIELDS.shards, progress: -1 });
|
||
return rungs;
|
||
}
|
||
function cardName(s, key) { var l = ((s.mining || {}).cards || []).filter(function (c) { return c.key === key; }); return l.length ? l[0].name : ''; }
|
||
// the rung to put on the Overview strip: the first that is "now", else the last done
|
||
function currentRung(rungs) { for (var i = 0; i < rungs.length; i++) if (rungs[i].state === 'now') return rungs[i]; for (var j = rungs.length - 1; j >= 0; j--) if (rungs[j].state === 'done') return rungs[j]; return rungs[0]; }
|
||
// the block card: m = state.ladder.milestone, s = state, chain, now. {kind, eyebrow, title, when, cardLine, ignLine,
|
||
// rankLine, hash, url, fields}
|
||
// the Poisson count-up card shows only while this machine waits for its first ever block (first-block-21): never
|
||
// once the ladder records one, whatever the lifetime count says after a settings reset
|
||
function firstWait(s) {
|
||
s = s || {}; var m = s.mining || {}, ld = s.ladder || {};
|
||
return m.state === 'mining' && !(m.accepted_total > 0) && !(ld.first_block_at > 0) && !ld.first_block_shown;
|
||
}
|
||
// a card is seen once (first-block-21, the project lead, 7 October 2026: "the 'you got your first block' situation only shows
|
||
// for the miner's first ever block"; main's reading: seen once). An engine of 0.3.21 or later owns the rule: it
|
||
// keeps a card no window has shown across restarts and updates (a block found overnight greets the miner at the
|
||
// first open) and drops a seen one at its next start (ladder.rs start_run, the page's POST api/card/seen), so
|
||
// whatever such an engine sends is shown. A 0.3.20 engine (its ladder has no first_block_shown field) re-sends the
|
||
// persisted card on every run: against it a milestone raised before this run began is refused here (a minute of
|
||
// slack; uptime_s stands in when the engine has no started_at).
|
||
function staleCard(m, s, now) {
|
||
if (!m || !(m.at > 0) || !s) return false;
|
||
if (s.ladder && s.ladder.first_block_shown !== undefined) return false;
|
||
var started = s.started_at > 0 ? s.started_at : (s.uptime_s >= 0 && now > 0 ? now - s.uptime_s : 0);
|
||
return started > 0 && m.at + 60 < started;
|
||
}
|
||
// the body the page sends to api/card/seen for a card it displays or the user dismisses (null for no card)
|
||
function cardSeenBody(bc) { return bc && bc.count > 0 ? { count: bc.count, at: bc.when || 0 } : null; }
|
||
function blockCard(m, s, chain, now) {
|
||
if (!m || !(m.count > 0)) return null;
|
||
if (staleCard(m, s, now)) return null;
|
||
var c = chainNums(chain), cards = (s.mining || {}).cards || [], cd = cards.filter(function (x) { return x.key === m.card; })[0] || {};
|
||
var name = m.card_name || cd.name || 'your card', mhs = cd.hash_avg > 0 ? cd.hash_avg : cd.hash_now || 0;
|
||
var eyebrow = m.kind === 'first' ? 'your first block' : m.kind === 'hundred' ? 'block 100 of this machine' : m.kind === 'thousand' ? 'block 1,000 of this machine' : m.kind === 'ten_thousand' ? 'block ' + withCommas(m.count) + ' of this machine' : 'first block on ' + name;
|
||
var title = m.daa > 0 ? 'Block ' + withCommas(m.daa) + ' is yours.' : 'A block is yours.';
|
||
var share = mhs > 0 && c.hps ? ' · ' + pct(mhs * 1e6 / c.hps) + ' of the network' : '';
|
||
var cardLine = name + (mhs > 0 ? ' · ' + (mhs >= 100 ? mhs.toFixed(0) : mhs.toFixed(1)) + ' MH/s' : '') + share;
|
||
var addr = (s.address || {}).display || '';
|
||
var ignLine = c.minerIgn ? fmtIgn(c.minerIgn) + (addr ? ' to ' + shortHex(addr, 6, 4) : '') + ' (72 producer points, 8 for signing' + (c.ramp && c.ramp < 1 ? ', ramp ' + Math.round(c.ramp * 100) + '%' : '') + ')' : (addr ? 'Paid to ' + shortHex(addr, 6, 4) : '');
|
||
var d = daysIn(s.ladder, c.windowS, now), rankLine = (c.best ? 'rank ' + withCommas(c.best.rank) + ' of ' + withCommas(c.listed) + ' keys' : 'rank: not in the table yet') + ' · ' + (d.unit === 'days' ? 'day ' : 'hour ') + Math.max(1, d.have) + ' of ' + d.of;
|
||
return { kind: m.kind, count: m.count, eyebrow: eyebrow, title: title, when: m.at, cardLine: cardLine, card: { name: name, vendor: cd.vendor, kind: cd.kind, code: cd.code }, ignLine: ignLine, rankLine: rankLine, hash: m.hash || '', hashShort: m.hash ? shortHex(m.hash, 8, 6) : 'hash: waiting for the node’s line', url: m.hash ? 'https://igneum.network/block/' + m.hash : '', key: 'card:' + m.count + ':' + Math.floor(m.at || 0), fields: { card: FIELDS.found, ign: FIELDS.reward, rank: FIELDS.rank + ' · ' + FIELDS.days } };
|
||
}
|
||
// Earnings (reinvent 3.4): IGN first, the window, a typed price, the share and rank, IGN per kWh. price = £ per IGN
|
||
// typed by the user (null = none), pence = electricity, now = unix s
|
||
function uptimeWords(sec) { sec = sec || 0; var h = Math.floor(sec / 3600), mi = Math.floor(sec % 3600 / 60); return h ? h + ' h ' + mi + ' min' : mi + ' min'; }
|
||
// the Earnings card (earnings-tidy, 7 October 2026): the day rate with its reason, the run line, then weight,
|
||
// electricity and lifetime, and the dev-fee sentence. IGN only: no typed price, no money but the electricity cell.
|
||
function earningsLines(s, chain, pence, now, minor) {
|
||
s = s || {}; var m = s.mining || {}, ld = s.ladder || {}, pv = s.proving || {}, c = chainNums(chain);
|
||
var hour = (m.found || []).length, mhs = m.hash_total || 0, rate = mhs >= 100 ? mhs.toFixed(0) : mhs.toFixed(1);
|
||
var measured = c.minerIgn ? hour * 24 * c.minerIgn : null, expected = ignPerDay(mhs, c.hps, c.minerIgn, c.bps);
|
||
var out = {};
|
||
if (measured !== null && hour > 0) out.day = { text: fmtIgn(measured) + ' a day', sub: 'at your last hour’s rate: ' + hour + ' block' + (hour === 1 ? '' : 's') + ' at ' + fmtIgn(c.minerIgn) + ' each' + (expected !== null ? ' · about ' + fmtIgn(expected) + ' expected at ' + rate + ' MH/s' : ''), field: FIELDS.found_hour + ' × 24 × ' + FIELDS.reward, value: measured };
|
||
else if (expected !== null) out.day = { text: 'about ' + fmtIgn(expected) + ' a day', sub: 'expected at ' + rate + ' MH/s, ' + pct(mhs * 1e6 / c.hps) + ' of today’s network · no block in the last hour yet', field: FIELDS.share + ' × 86,400 × ' + FIELDS.reward, value: expected };
|
||
else out.day = { text: mhs > 0 ? 'IGN a day: reading the network' : '0 IGN a day', sub: mhs > 0 ? (c.error || 'the network rate and the reward come from igneum.network/api/stats') : 'no card is mining', field: FIELDS.reward, value: null };
|
||
var dayIgn = out.day.value;
|
||
// this run: blocks, their IGN at today’s reward, the shards paid, the uptime
|
||
var run = m.accepted_session || 0, shards = pv.paid || 0, shardIgn = pv.paid_wei && Number(pv.paid_wei) ? Number(pv.paid_wei) / 1e18 : 0, runParts = [];
|
||
if (c.minerIgn) runParts.push(fmtIgn(run * c.minerIgn) + ' at today’s reward');
|
||
if (shards) runParts.push(shards + ' shard' + (shards === 1 ? '' : 's') + (shardIgn ? ', ' + fmtIgn(shardIgn) : ''));
|
||
runParts.push(uptimeWords(s.uptime_s));
|
||
out.run = { text: withCommas(run) + ' block' + (run === 1 ? '' : 's') + ' this run', sub: runParts.join(' · '), field: FIELDS.found + ' · ' + FIELDS.shards };
|
||
// weight: the rank in the table, the window, the days, the share
|
||
var wblocks = c.best ? c.best.blocks : (ld.blocks_30d || 0), ww = windowWords(c.windowS), d = daysIn(ld, c.windowS, now), share = mhs > 0 && c.hps ? pct(mhs * 1e6 / c.hps) : '';
|
||
var wtext = c.best ? 'rank ' + withCommas(c.best.rank) + ' of ' + withCommas(c.listed) + ' keys' : c.ok ? (c.cap ? 'below the top ' + c.cap + ' keys' : 'not in the weight table yet') : 'reading';
|
||
var wparts = [withCommas(wblocks) + ' block' + (wblocks === 1 ? '' : 's') + ' in ' + ww.text, d.have + ' of ' + d.of + ' ' + d.unit]; if (share) wparts.push(share + ' of the network');
|
||
out.weight = { text: wtext, sub: wparts.join(' · '), field: FIELDS.rank + ' · ' + FIELDS.days + ' · ' + FIELDS.share + (c.source === 'observer' ? ' · through igneum.network’s observer until this node answers igneum_getFinalityWeights' : '') };
|
||
// electricity: £ a day at the Settings price, or the draw; IGN per kWh beside it
|
||
var watts = fleetWatts(m, m.cards), kwhDay = watts > 0 ? watts * 24 / 1000 : 0, ppd = fleetPounds(m, m.cards, pence);
|
||
var perKwh = dayIgn !== null && kwhDay > 0 ? fmtIgn(dayIgn / kwhDay) + ' per kWh' : '';
|
||
out.cost = ppd === null ? { text: watts > 0 ? Math.round(watts) + ' W' : 'no draw', sub: watts > 0 ? 'set a price in Settings' + (perKwh ? ' · ' + perKwh : '') : 'no card reports its draw', field: FIELDS.watts }
|
||
: { text: money(ppd) + ' a day', sub: 'at ' + (pence / priceFactor(currency.code)) + (minor || minorOf(currency.code).abbr) + '/kWh for ' + Math.round(watts) + ' W' + (perKwh ? ' · ' + perKwh : ''), field: FIELDS.watts + ' × 24 × the price in Settings' };
|
||
var life = m.accepted_total || 0;
|
||
out.lifetime = { text: withCommas(life) + ' block' + (life === 1 ? '' : 's'), sub: c.minerIgn ? fmtIgn(life * c.minerIgn) + ' at today’s reward' : '', field: FIELDS.found };
|
||
out.devFee = function (on, feeTotal) { return on ? 'Dev fee: 1 block in 100 pays the people who make this app' + (feeTotal ? ' (' + withCommas(feeTotal) + ' block' + (feeTotal === 1 ? '' : 's') + ' so far)' : '') + '.' : 'Dev fee off. Every block pays your address.'; };
|
||
return out;
|
||
}
|
||
// the per-card IGN a day cell on the Cards page
|
||
function cardIgnDay(cd, chain) { var c = chainNums(chain); var v = ignPerDay(cd.hash_now, c.hps, c.minerIgn, c.bps); return v === null ? '' : fmtIgn(v).replace(' IGN', ''); }
|
||
// the first hour as this machine lived it: steps with unix times (0 = not yet), from the settings' install stamp,
|
||
// the ladder's marks and the engine's events
|
||
function timeline(s, now) {
|
||
s = s || {}; var ld = s.ladder || {}, st = s.settings || {}, n = s.node || {}, m = s.mining || {};
|
||
var installed = s.installed_at > 0 ? s.installed_at : 0, started = s.uptime_s >= 0 && now ? now - s.uptime_s : 0;
|
||
var synced = ld.first_synced_at > 0 ? ld.first_synced_at : (n.synced && started ? started : 0);
|
||
var mining = ld.first_mining_at > 0 ? ld.first_mining_at : (m.state === 'mining' && started ? started : 0);
|
||
var steps = [
|
||
{ id: 'install', name: 'Installed', at: installed, note: installed ? '' : 'not recorded on this install' },
|
||
{ id: 'started', name: 'App started', at: started, note: '' },
|
||
{ id: 'synced', name: 'Node synced', at: synced, note: synced ? (ld.first_synced_at > 0 ? '' : 'the moment is not recorded; synced now') : 'syncing' },
|
||
{ id: 'mining', name: 'Card mining', at: mining, note: mining ? (ld.first_mining_at > 0 ? '' : 'the moment is not recorded; mining now') : 'waiting for the node' },
|
||
{ id: 'block', name: 'First block', at: ld.first_block_at || 0, note: ld.first_block_at ? '' : 'waiting' },
|
||
{ id: 'payout', name: 'First payout', at: ld.first_block_at || 0, note: ld.first_block_at ? 'the coinbase of the first block, yours when the block is blue' : 'the first block pays, seconds later' }
|
||
];
|
||
var base = installed || started;
|
||
steps.forEach(function (x) { x.rel = x.at > 0 && base > 0 && x.at >= base ? '+' + spanWords(x.at - base) : ''; });
|
||
return { base: base, steps: steps, field: 'settings.installed_at, uptime_s, ladder.first_synced_at, ladder.first_mining_at, ladder.first_block_at' };
|
||
}
|
||
function profileWords(on) { return on ? 'Your address page on igneum.network is public: your key ids, blocks in the window, weight rank, signing presence and card model are shown. The site reads it from the chain and from this switch.' : 'Off: igneum.network/address keeps your page unlisted (noindex). Nothing about this machine is published.'; }
|
||
// the confirmations' words (in place, never a dialog)
|
||
// ui-ota (src/uiota.rs): Settings > Interface. u = state.ui. {line, eyebrow, help}
|
||
function interfaceWords(u) {
|
||
u = u || {};
|
||
var ver = u.active_version || u.embedded_version || '';
|
||
if (!ver) return { line: '', eyebrow: '', help: '' };
|
||
var ota = u.source === 'ota';
|
||
var when = ota && u.installed_at > 0 ? dateWords(u.installed_at) : '';
|
||
var line = 'Interface ' + ver + (ota ? ', over the air' + (when ? ', ' + when : '') : ', built in');
|
||
var help = '';
|
||
if (ota && !u.confirmed) help = 'Waiting for this window to confirm the new interface.';
|
||
else if (u.error) help = 'The last interface over the air was refused: ' + u.error + ' The built-in one serves.';
|
||
else if (u.builtin && u.published_version && u.published_version !== u.embedded_version) help = 'Interface ' + u.published_version + ' is published over the air; switch this off to take it.';
|
||
else if (u.busy) help = 'Interface ' + u.published_version + ' is downloading.';
|
||
else if (u.note) help = cap(u.note) + '.';
|
||
else if (ota) help = 'Built in: ' + (u.embedded_version || '') + '.';
|
||
return { line: line, eyebrow: ota ? 'over the air' : 'built in', help: help };
|
||
}
|
||
function dateWords(unix) { var d = new Date(unix * 1000); return d.getDate() + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' + d.getFullYear(); }
|
||
// the page reloads itself when the served interface changed under it and nobody is mid-task:
|
||
// loaded = the version this page came up with, u = state.ui, ctx = {typing, sheetOpen, phase}
|
||
function shouldReload(loaded, u, ctx) {
|
||
if (!loaded || !u || !u.active_version || u.active_version === loaded) return false;
|
||
ctx = ctx || {};
|
||
if (ctx.typing || ctx.sheetOpen) return false;
|
||
if (ctx.phase && ctx.phase !== 'dashboard') return false;
|
||
return true;
|
||
}
|
||
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
|
||
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
|
||
return { NETWORKS: NETWORKS, networkWords: networkWords, networkAsk: networkAsk, networkIdOf: networkIdOf, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
|
||
})();
|
||
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
|
||
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
|
||
("Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" plus the point and when), measure only (Apple, NVIDIA without Power
|
||
control: the measured line and why nothing is set), stopped (the reason), or not run yet. The Settings-page block
|
||
of miner-ui-2; miner-ui-3 reads View.tuneWords for the row and keeps this for the details and ember-tune's test. */
|
||
var TuneLine = (function () {
|
||
'use strict';
|
||
function point(cd) {
|
||
var pct = cd.sweep_pct || cd.power_pct || 0;
|
||
if (cd.tune_clock_mhz > 0) return cd.tune_clock_mhz + ' MHz at ' + pct + '%';
|
||
return pct ? pct + '%, clock unlocked' : '';
|
||
}
|
||
// the model: {kind: running|tuned|measured|stopped|idle|off, text, note}
|
||
function model(cd, now) {
|
||
cd = cd || {};
|
||
if (cd.vendor === 'intel') return { kind: 'off', text: 'tuning: measure only on Intel Arc once a power reading exists', note: 'no cap through OpenCL; IGCL next' };
|
||
if (cd.vendor !== 'nvidia' && cd.vendor !== 'amd' && cd.vendor !== 'apple') return { kind: 'off', text: cd.sweep_note || 'tuning: no power or clock control for this card' };
|
||
if (cd.sweep_state === 'running') return { kind: 'running', text: cd.sweep_note || 'tuning: running' };
|
||
var when = cd.sweep_at && now ? ', ' + rel(now - cd.sweep_at) + ' ago' : '';
|
||
if (cd.tune_line) {
|
||
if (cd.tune_source === 'baseline' || !cd.tune_control) return { kind: 'measured', text: cd.tune_line + ' (measured as it runs' + when + ')', note: cd.tune_control ? '' : (cd.sweep_note || '') };
|
||
var src = cd.tune_source === 'confirm' ? 'from the fleet prior, confirmed' : 'full tune';
|
||
return { kind: 'tuned', text: cd.tune_line, note: point(cd) + ', ' + src + when + (cd.pinned ? '; your setting stays pinned' : '') + (cd.sweep_note && cd.sweep_note.indexOf('stopped') === 0 ? '; ' + cd.sweep_note : '') };
|
||
}
|
||
if (cd.sweep_note && cd.sweep_note.indexOf('tuning stopped') === 0) return { kind: 'stopped', text: cd.sweep_note };
|
||
return { kind: 'idle', text: cd.sweep_note || 'tuning: not run yet (starts after 120 s of steady mining)' };
|
||
}
|
||
function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; }
|
||
// Ember 2: £ a day for a draw at a price in pence per kWh (the same formula as View.poundsPerDay and ember::pounds_per_day)
|
||
function pounds(watts, pence) { return View.money(View.poundsPerDay(watts, pence)); }
|
||
return { model: model, point: point, rel: rel, pounds: pounds };
|
||
})();
|
||
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; }
|
||
|
||
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', page = 'mine', forced = null, cardsSeen = false, keyShownThisRun = false;
|
||
var params = new URLSearchParams(location.search);
|
||
// the web view's language is the OS locale; ?lang=de-DE overrides it for screenshots and tests
|
||
var lang = params.get('lang') || navigator.language || 'en-GB'; View.setLocale(lang);
|
||
if (params.get('screen')) forced = params.get('screen');
|
||
if (params.get('page')) page = params.get('page'); // ?page=overview|cards|earnings|prove|settings (screenshots)
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||
var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord, shownCards = View.shownCards, cardTitle = View.cardTitle, money = View.money;
|
||
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'); }
|
||
function setText(id, text) { var el = $(id); if (el && el.textContent !== text) el.textContent = text; }
|
||
function setHidden(id, hidden) { var el = $(id); if (el && el.hidden !== !!hidden) el.hidden = !!hidden; }
|
||
function store(key, val) { try { if (val === null) localStorage.removeItem(key); else localStorage.setItem(key, String(val)); } catch (e) { } }
|
||
function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
|
||
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;
|
||
var t = $(b.getAttribute('data-copy')).textContent;
|
||
if (!t || /not (set|read|printed)/.test(t)) { toast('Nothing to copy yet'); return; }
|
||
copyText(t);
|
||
});
|
||
// the electricity price: the engine's setting (ember-tune), else what this page stored; pence per kWh
|
||
function priceNow() { var p = state && state.settings && state.settings.power_price_pence; if (p > 0) return p; var s = parseFloat(stored('igneum.price') || ''); return s > 0 ? s : 0; }
|
||
function goalNow() { var g = state && state.settings && state.settings.tune_goal; return View.GOALS[g] ? g : (View.GOALS[stored('igneum.goal') || ''] ? stored('igneum.goal') : 'balanced'); }
|
||
|
||
// ---------- appearance: system, light or dark (the page's own storage) ----------
|
||
function applyTheme(t) {
|
||
t = t === 'light' || t === 'dark' ? t : 'system';
|
||
if (t === 'system') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t);
|
||
document.querySelectorAll('#theme-seg .seg-b').forEach(function (b) { var on = b.dataset.theme === t; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); });
|
||
store('igneum.theme', t === 'system' ? null : t);
|
||
}
|
||
applyTheme(params.get('theme') || stored('igneum.theme') || 'system'); // ?theme=light|dark for screenshots
|
||
$('theme-seg').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) applyTheme(b.dataset.theme); });
|
||
|
||
// ---------- screens (setup) and pages (the dashboard) ----------
|
||
function show(p) {
|
||
phase = p;
|
||
document.body.setAttribute('data-phase', p);
|
||
var dash = p === 'dashboard';
|
||
document.body.classList.toggle('has-rail', dash);
|
||
$('rail').hidden = !dash;
|
||
$('top-brand').hidden = dash;
|
||
$('page-title').hidden = !dash;
|
||
if (!dash && drawerOpen()) setDrawer(false);
|
||
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
|
||
// the region step; ?region=<code> preselects one for screenshots
|
||
if (p === 'network' && state) renderNetworkStep(state);
|
||
if (p === 'region') { fillRegions($('region-select')); fillCurrencies($('region-currency'), regionNow() || View.regionFromLocale(lang)); var rc = params.get('region') || regionNow() || View.regionFromLocale(lang); $('region-select').value = rc; $('region-currency').value = currencyNowCode(); var ask0 = View.currencyAsk(View.currencyChoice({ region: rc, currency: currencyNowCode() }, lang)); setText('region-currency-ask', ask0); setHidden('region-currency-ask', !ask0); regionWords(rc, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), !(priceNow() > 0)); if (priceNow() > 0) $('region-price').value = String(priceNow()); }
|
||
if (dash) { showPage(page); mountDag(); }
|
||
api('api/phase', { phase: p });
|
||
}
|
||
function showPage(id) {
|
||
var meta = View.page(id);
|
||
page = meta.id;
|
||
document.body.setAttribute('data-page', page);
|
||
document.querySelectorAll('.page').forEach(function (el) { el.hidden = el.dataset.page !== page; });
|
||
document.querySelectorAll('#rail-nav .nav').forEach(function (b) {
|
||
var on = b.dataset.page === page;
|
||
b.classList.toggle('on', on);
|
||
if (on) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current');
|
||
});
|
||
setText('page-title-text', meta.title); setText('page-sub', meta.sub);
|
||
$('main').scrollTop = 0;
|
||
if (state) { renderPage(state); if (page === 'overview') size(); }
|
||
if (typeof pollChain === 'function') pollChain(true);
|
||
}
|
||
$('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); });
|
||
// the rail answers the arrow keys and Home/End, so the sections are reachable without a mouse
|
||
$('rail-nav').addEventListener('keydown', function (e) {
|
||
var items = Array.prototype.slice.call(document.querySelectorAll('#rail-nav .nav')), i = items.indexOf(document.activeElement);
|
||
if (i < 0) return;
|
||
var j = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : -1;
|
||
if (j < 0 || j >= items.length) return;
|
||
e.preventDefault(); items[j].focus(); showPage(items[j].dataset.page);
|
||
});
|
||
$('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('region'); }); else show('region');
|
||
});
|
||
// ---------- the region and price step (Ember Heat): the list, a typed price never fetched, the restricted line ----------
|
||
// the currency picker: "Follow the region" first, then the five, then the long tail, each with its English name
|
||
function fillCurrencies(sel, regionCodeNow) {
|
||
var follow = View.currencyOf(regionCodeNow || '').code, names = null; try { names = new Intl.DisplayNames(['en'], { type: 'currency' }); } catch (e) { }
|
||
var want = 'follow:' + follow; if (sel.dataset.filled === want) return; sel.dataset.filled = want;
|
||
var v = sel.value;
|
||
sel.innerHTML = '<option value="">Follow the region (' + esc(follow) + ')</option>' + View.CURRENCY_CODES.map(function (c) { var n = names ? names.of(c) : c; return '<option value="' + c + '">' + esc(c + (n && n !== c ? ' · ' + n : '')) + '</option>'; }).join('');
|
||
sel.value = v;
|
||
}
|
||
function currencyNowCode() { return (state && state.settings && state.settings.currency) || stored('igneum.currency') || ''; }
|
||
// the region list: the table, plus the locale's region when it is not in the table (synthesised from ISO_CURRENCY)
|
||
function fillRegions(sel) {
|
||
var extra = View.regionFromLocale(lang), rows = View.REGIONS.slice();
|
||
if (extra && !View.REGIONS.some(function (r) { return r.code === extra; })) rows.splice(1, 0, View.regionOf(extra));
|
||
if (sel.dataset.filled === String(rows.length)) return; sel.dataset.filled = String(rows.length);
|
||
sel.innerHTML = '<option value="">Choose a region</option>' + rows.map(function (r) { return '<option value="' + esc(r.code) + '">' + esc(r.name) + '</option>'; }).join('');
|
||
}
|
||
// the price field, unit, note and restricted line follow the chosen region; a typed price is kept unless `prefill`
|
||
function regionWords(code, priceEl, unitEl, noteEl, restrictedEl, prefill) {
|
||
var r = View.regionOf(code);
|
||
if (prefill && priceEl) priceEl.value = r && r.price > 0 ? String(r.price) : '';
|
||
if (priceEl) priceEl.placeholder = r && r.price > 0 ? String(r.price) : '';
|
||
if (unitEl) setText(unitEl.id, View.unitWords(code));
|
||
if (noteEl) setText(noteEl.id, View.priceNote(code));
|
||
var line = View.restrictedLine(code);
|
||
if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); }
|
||
}
|
||
function regionNow() { return (state && state.settings && state.settings.region) || stored('igneum.region') || ''; }
|
||
// ---------- the network step (0.3.23): the cards on the first-run step and in Settings ----------
|
||
var netPick = '';
|
||
function renderNetCards(el, w, pick) {
|
||
el.innerHTML = w.cards.map(function (c) { var on = (pick || w.selected) === c.id; return '<button type="button" class="net-card" role="radio" data-net="' + esc(c.id) + '" aria-checked="' + (on ? 'true' : 'false') + '"' + (c.open ? '' : ' disabled') + '>' + (c.current ? '<span class="nc-cur">running now</span>' : '') + '<span class="nc-name">' + esc(c.label) + '</span><span class="nc-line">' + esc(c.line) + '</span><span class="nc-sub">' + esc(c.sub) + '</span></button>'; }).join('');
|
||
}
|
||
function renderNetworkStep(s) {
|
||
var w = View.networkWords(s); renderNetCards($('network-cards'), w, netPick);
|
||
var note = w.error || (w.cards[1].open ? '' : 'igneum-testnet-1 opens when the network does; Devnet 3 is the chain to mine today.');
|
||
setText('network-note', note); setHidden('network-note', !note);
|
||
}
|
||
$('network-cards').addEventListener('click', function (e) { var b = e.target.closest('.net-card'); if (!b || b.disabled) return; netPick = b.getAttribute('data-net'); if (state) renderNetworkStep(state); });
|
||
$('btn-network-back').addEventListener('click', function () { show('region'); });
|
||
$('btn-network-next').addEventListener('click', function () { var w = View.networkWords(state || {}), pick = netPick || w.selected; api('api/network', { network: pick, confirm: true }).then(function () { show('address'); }).catch(function () { show('address'); }); });
|
||
function renderNetworkCard(s) {
|
||
var w = View.networkWords(s); renderNetCards($('s-network-cards'), w, '');
|
||
setText('s-network-line', w.line); setText('s-network-eyebrow', w.pending ? 'next start' : 'running');
|
||
setText('s-network-note', w.error); setHidden('s-network-note', !w.error);
|
||
}
|
||
$('s-network-cards').addEventListener('click', function (e) { var b = e.target.closest('.net-card'); if (!b || b.disabled || !state) return; var want = b.getAttribute('data-net'), w = View.networkWords(state); if (want === w.selected) return; $('ask-network-text').textContent = View.networkAsk(want, state.network_name || 'igneum-devnet-3'); $('ask-network').dataset.want = want; $('ask-network').hidden = false; });
|
||
$('ask-network-no').addEventListener('click', function () { $('ask-network').hidden = true; });
|
||
$('ask-network-yes').addEventListener('click', function () { var want = $('ask-network').dataset.want; $('ask-network').hidden = true; api('api/network', { network: want, confirm: true }).then(function (r) { if (r.ok) toast('Network saved: it takes effect at the next start'); }); });
|
||
function saveRegion(code, price, cur) {
|
||
store('igneum.region', code || null); store('igneum.price', price > 0 ? price : null); if (cur !== undefined) store('igneum.currency', cur || null);
|
||
var body = { region: code, price_pence: price }; if (cur !== undefined) body.currency = cur || '';
|
||
return api('api/region', body).catch(function () { return api('api/tune/goal', { price_pence: price }); });
|
||
}
|
||
// the override: picked, the price field's unit follows at once; "" follows the region again
|
||
function saveCurrency(sel, priceEl, unitEl, noteEl, regionSel) {
|
||
var cur = sel.value; View.setCurrency(cur); store('igneum.currency', cur || null);
|
||
api('api/region', { currency: cur }).catch(function () { });
|
||
regionWords(regionSel.value, null, unitEl, noteEl, null, false);
|
||
if (state) renderPage(state);
|
||
}
|
||
$('region-select').addEventListener('change', function () { regionWords(this.value, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), true); });
|
||
$('region-currency').addEventListener('change', function () { saveCurrency(this, $('region-price'), $('region-unit'), $('region-note'), $('region-select')); var a = View.currencyAsk(View.currencyChoice({ region: $('region-select').value, currency: this.value }, lang)); setText('region-currency-ask', a); setHidden('region-currency-ask', !a); });
|
||
$('btn-region-back').addEventListener('click', function () { show('cards'); });
|
||
$('btn-region-skip').addEventListener('click', function () { show('network'); });
|
||
$('btn-region-next').addEventListener('click', function () {
|
||
var code = $('region-select').value, p = parseFloat($('region-price').value) * View.priceFactor(View.currencyNow().code);
|
||
if (!(p >= 0)) p = 0;
|
||
saveRegion(code, p, $('region-currency').value).then(function () { show('network'); }).catch(function () { show('network'); });
|
||
});
|
||
$('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); });
|
||
$('btn-address-back').addEventListener('click', function () { show('network'); });
|
||
|
||
// 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(); setText('btn-start', el.id === 'opt-paste' ? 'Start mining' : 'Continue'); }
|
||
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 app 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'); });
|
||
});
|
||
|
||
// ---------- disclosures: one chevron per details block ----------
|
||
function setOpen(btn, det, open) { btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.classList.toggle('open', open); det.hidden = !open; }
|
||
function disclosure(btnId, detId, onOpen) {
|
||
$(btnId).addEventListener('click', function () { var open = $(detId).hidden; setOpen($(btnId), $(detId), open); if (open && onOpen) onOpen(); });
|
||
}
|
||
disclosure('node-toggle', 'node-details');
|
||
disclosure('pv-toggle', 'pv-details');
|
||
disclosure('addr-toggle', 'addr-details', function () { $('s-address-input').focus(); });
|
||
disclosure('key-toggle', 'key-details');
|
||
$('s-jobs-history-btn').addEventListener('click', function () { setOpen(this, $('s-jobs-history'), $('s-jobs-history').hidden); $('s-jobs-key').hidden = $('s-jobs-history').hidden; });
|
||
|
||
// ---------- Mine: the big button, the rows, the tune controls ----------
|
||
$('btn-toggle').addEventListener('click', function () {
|
||
if (!state) return;
|
||
var t = View.toggle(state.mining, state.node, state);
|
||
if (!t.act) return;
|
||
api('api/' + t.act, {}).then(function () { toast(t.act === 'pause' ? 'Mining stops. The node keeps running' : 'Mining starts'); });
|
||
});
|
||
// a row's switch, slider or stepper sends the whole list to the engine (pending[key] holds a flipped switch until
|
||
// the engine agrees, so the row does not flicker back)
|
||
var pending = {}, cardsT = null, openCards = {};
|
||
function sendCards(msg) {
|
||
clearTimeout(cardsT);
|
||
cardsT = setTimeout(function () {
|
||
var choices = readCardRows($('d-cards'));
|
||
if (choices.length) api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(msg || 'Applied. Only that card restarts'); else toast(r.error || 'could not change'); });
|
||
}, 400);
|
||
}
|
||
$('d-cards').addEventListener('change', function (e) {
|
||
if (!state) return;
|
||
if (e.target.matches('.gpu-line > .gl-main .switch input')) {
|
||
var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked;
|
||
pending[key] = { on: on, at: Date.now() };
|
||
var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; });
|
||
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Starting that card' : 'That card is off'); else { delete pending[key]; toast(r.error || 'could not change'); } });
|
||
} else if (e.target.matches('.ids input')) sendCards('Set. The card restarts');
|
||
});
|
||
$('d-cards').addEventListener('input', function (e) {
|
||
if (e.target.matches('input[type=range]')) {
|
||
var row = e.target.closest('.gpu-line'), cd = cardOf(row.dataset.key), 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' : '');
|
||
sendCards('Power cap set. It stays until you choose Default again');
|
||
}
|
||
});
|
||
['cards-list', 's-cards'].forEach(function (id) {
|
||
var el = $(id); if (!el) return;
|
||
el.addEventListener('click', function (e) {
|
||
var b = e.target.closest('button'); if (!b) return;
|
||
if (b.dataset.driverInstall !== undefined) api('api/drivers/install', { vendor: b.dataset.driverInstall }).then(function (r) { toast(r && r.ok === false ? (r.error || 'could not start the driver install') : 'Driver download started. Windows asks for permission once before the install.'); });
|
||
else if (b.dataset.driverRestart !== undefined) { if (window.confirm('Restart Windows now to finish the driver install?')) api('api/drivers/restart', {}).then(function () { toast('Restarting in 20 seconds'); }); }
|
||
});
|
||
});
|
||
$('d-cards').addEventListener('click', function (e) {
|
||
var b = e.target.closest('button'); if (!b) return;
|
||
var row = b.closest('.gpu-line'), key = row ? row.dataset.key : '';
|
||
if (b.dataset.open !== undefined) { openCards[key] = !openCards[key]; applyOpen(row); }
|
||
else if (b.dataset.tune !== undefined) api('api/sweep/start', { key: key }).then(function (r) { toast(r && r.ok === false ? (r.error || 'could not start the tune') : 'Tune queued for ' + (cardOf(key) || {}).name); });
|
||
else if (b.dataset.stop !== undefined) api('api/sweep/stop', {}).then(function () { toast('Tune stopped. The card is back where it was'); });
|
||
else if (b.dataset.d) { var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; inp.value = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); sendCards('Identities set; that card’s worker restarts'); }
|
||
else if (b.dataset.pin !== undefined) api('api/sweep/pin', { key: key, pinned: b.dataset.pin === '1' }).then(function () { toast(b.dataset.pin === '1' ? 'Cap pinned' : 'Default: Ember chooses the cap again'); });
|
||
else if (b.dataset.powerRetry !== undefined) api('api/power/apply', {}).then(function () { toast('Allow the administrator prompt to set the cap'); });
|
||
else if (b.dataset.driverInstall !== undefined) api('api/drivers/install', { vendor: b.dataset.driverInstall }).then(function (r) { toast(r && r.ok === false ? (r.error || 'could not start the driver install') : 'Driver download started. Windows asks for permission once before the install; the workers on this vendor\u2019s cards stop for it and come back after.'); });
|
||
else if (b.dataset.driverRestart !== undefined) { if (window.confirm('Restart Windows now to finish the driver install? Mining stops until the app is back.')) api('api/drivers/restart', {}).then(function () { toast('Restarting in 20 seconds'); }); }
|
||
else if (b.dataset.price !== undefined) { showPage('settings'); $('s-price').focus(); }
|
||
else if (b.closest('[data-card-goal]') && b.dataset.goal !== undefined) {
|
||
api('api/tune/goal', { key: key, goal: b.dataset.goal || '' }).then(function (r) { if (r && r.ok) toast(b.dataset.goal ? 'Goal for this card: ' + View.GOALS[b.dataset.goal] : 'Default: this card follows the goal set for all cards'); else toast('A goal for one card needs the next Ember Tune update'); }).catch(function () { toast('A per-card goal needs the Ember Tune update'); });
|
||
}
|
||
});
|
||
function applyOpen(row) { var open = !!openCards[row.dataset.key], det = row.querySelector('.gl-details'), chev = row.querySelector('[data-open]'); if (det) det.hidden = !open; if (chev) { chev.setAttribute('aria-expanded', open ? 'true' : 'false'); chev.classList.toggle('open', open); } row.classList.toggle('open', open); }
|
||
function cardOf(key) { return state ? state.mining.cards.filter(function (c) { return c.key === key; })[0] : null; }
|
||
$('btn-tune-all').addEventListener('click', function () {
|
||
if (!state) return;
|
||
var list = shownCards(state.mining.cards).filter(function (c) { return View.tunable(c) && c.enabled; });
|
||
if (!list.length) { toast('No card to tune'); return; }
|
||
var n = 0;
|
||
list.reduce(function (p, c) { return p.then(function () { return api('api/sweep/start', { key: c.key }).then(function (r) { if (!r || r.ok !== false) n++; }); }); }, Promise.resolve()).then(function () { toast(n === 1 ? 'Tune queued for 1 card' : 'Tune queued for ' + n + ' cards, one after another'); });
|
||
});
|
||
// the goal (ember-tune: POST api/tune/goal); a build without the route says so and keeps the old value
|
||
function setGoal(goal) {
|
||
api('api/tune/goal', { goal: goal }).then(function (r) { if (r && r.ok) { store('igneum.goal', goal); toast('Goal: ' + View.GOALS[goal]); } else toast('The goal needs the next Ember Tune update'); if (state) renderTuneCard(state); }).catch(function () { toast('The goal needs the Ember Tune update'); });
|
||
}
|
||
['goal-seg', 'goal-seg-2'].forEach(function (id) { $(id).addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) setGoal(b.dataset.goal); }); });
|
||
function powerControl(on) { 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'); }); }
|
||
$('m-power-control').addEventListener('change', function () { powerControl(this.checked); });
|
||
$('s-power-control').addEventListener('change', function () { powerControl(this.checked); });
|
||
$('m-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
|
||
$('btn-open-log').addEventListener('click', function () { setDrawer(true); });
|
||
|
||
// ---------- Earnings ----------
|
||
$('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied. Mining restarts' : 'Applied'); }); });
|
||
$('t-money-cell').addEventListener('click', function () { if ($('t-money').classList.contains('ask-price')) { showPage('settings'); $('s-price').focus(); } });
|
||
$('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; }
|
||
$('s-address-err').hidden = true;
|
||
$('ask-address-text').textContent = View.addressAsk(a); $('ask-address').hidden = false;
|
||
});
|
||
$('ask-address-no').addEventListener('click', function () { $('ask-address').hidden = true; });
|
||
$('ask-address-yes').addEventListener('click', function () {
|
||
var a = $('s-address-input').value.trim();
|
||
api('api/settings', { address: a }).then(function (r) { $('ask-address').hidden = true; if (r.ok) { $('s-address-input').value = ''; setOpen($('addr-toggle'), $('addr-details'), false); 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; $('ask-key').hidden = true; } else toast(r.error || 'no key'); });
|
||
});
|
||
function hideKey() { $('s-key-box').hidden = true; $('s-key').textContent = ''; $('ask-key').hidden = false; }
|
||
$('s-hide').addEventListener('click', hideKey);
|
||
$('ask-key-no').addEventListener('click', function () { setOpen($('key-toggle'), $('key-details'), false); });
|
||
$('r-wallet').addEventListener('click', function () { api('api/open', { url: 'https://igneum.network/wallet' }); });
|
||
|
||
// ---------- Prove ----------
|
||
$('s-prove').addEventListener('change', function () { var on = this.checked; api('api/prove', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Proving on. The first shard arrives within a minute' : 'Proving off'); else { toast(r.error || 'could not change'); $('s-prove').checked = !on; } }); });
|
||
$('s-prove-instead').addEventListener('change', function () { var on = this.checked; api('api/prove/instead', { on: on }).then(function (r) { if (r.ok === false) { $('s-prove-instead').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')); }); });
|
||
|
||
// ---------- Settings ----------
|
||
$('s-update').addEventListener('click', function () { api('api/update/check', {}); setText('s-update-note', 'Checking.'); });
|
||
$('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-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); setText('s-jobs-note', 'Checking.'); });
|
||
$('s-name-save').addEventListener('click', function () { api('api/settings', { display_name: $('s-name').value }).then(function (r) { if (r.ok) toast('Renamed'); }); });
|
||
$('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-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-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); });
|
||
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on. Once after install, then every 7 days' : 'Ember Tune off'); }); });
|
||
$('s-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (!r || !r.ok) { toast('Needs the Ember Tune update'); $('s-climb').checked = !on; } }); });
|
||
function savePrice() {
|
||
var typed = parseFloat($('s-price').value), code = $('s-region').value, c = View.currencyNow(), f = View.priceFactor(c.code), p = typed * f;
|
||
if (!(p >= 0)) { toast('Type a price in ' + View.minorOf(c.code).word + ' per kWh, for example ' + (View.regionOf(code) && View.regionOf(code).price || 26.32)); return; }
|
||
saveRegion(code, p, $('s-currency').value).then(function () { toast(p > 0 ? 'Price saved: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); });
|
||
}
|
||
$('s-price-save').addEventListener('click', savePrice);
|
||
$('s-price').addEventListener('keydown', function (e) { if (e.key === 'Enter') savePrice(); });
|
||
$('s-region').addEventListener('change', function () { regionWords(this.value, $('s-price'), $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), true); });
|
||
$('s-currency').addEventListener('change', function () { saveCurrency(this, $('s-price'), $('s-price-unit'), $('s-price-note'), $('s-region')); });
|
||
// ---------- Heat mode (Ember Heat) ----------
|
||
$('s-heat').addEventListener('change', function () { var on = this.checked; api('api/heat', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Heat mode on. The cards heat for a share of every 10 minutes' : 'Heat mode off'); else { toast(r.error || 'could not change'); $('s-heat').checked = !on; } }); });
|
||
function saveHeat() {
|
||
var set = parseFloat($('s-heat-set').value), body = { schedule: $('s-heat-schedule').value.trim(), tz_min: -new Date().getTimezoneOffset() };
|
||
if ($('s-heat-set').value !== '') { if (!(set >= 5 && set <= 30)) { toast('The set point is 5 to 30 degrees'); return; } body.set_c = set; }
|
||
api('api/heat', body).then(function (r) { toast(r.ok ? 'Heat settings saved' : (r.error || 'could not save')); if (state) renderPage(state); });
|
||
}
|
||
$('s-heat-save').addEventListener('click', saveHeat);
|
||
$('s-heat-schedule').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveHeat(); });
|
||
$('s-heat-set').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveHeat(); });
|
||
function saveRoom() {
|
||
var c = parseFloat($('s-heat-room').value);
|
||
if (!(c >= -20 && c <= 50)) { toast('A room reading is -20 to 50 degrees'); return; }
|
||
api('api/heat', { room_c: c }).then(function (r) { toast(r.ok ? 'Room reading used: ' + c + ' °C for the next two hours' : (r.error || 'could not save')); $('s-heat-room').value = ''; if (state) renderPage(state); });
|
||
}
|
||
$('s-heat-room-save').addEventListener('click', saveRoom);
|
||
$('s-heat-room').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveRoom(); });
|
||
$('s-trust').addEventListener('change', function () { var on = this.checked; $('s-trust').checked = !on; $('ask-trust-text').textContent = View.trustAsk(on); $('ask-trust').hidden = false; $('ask-trust').dataset.on = on ? '1' : ''; });
|
||
$('ask-trust-no').addEventListener('click', function () { $('ask-trust').hidden = true; });
|
||
$('ask-trust-yes').addEventListener('click', function () {
|
||
var on = $('ask-trust').dataset.on === '1'; $('ask-trust').hidden = true;
|
||
api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) { $('s-trust').checked = on; toast(on ? 'Trust mode on (devnet only). The node restarts' : 'Trust mode off. The node restarts'); } else toast(r.error || 'could not change'); });
|
||
});
|
||
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
|
||
$('s-log-open').addEventListener('click', function () { setDrawer(true); });
|
||
// the log export: the last lines the engine holds (the ring keeps 3,000; one call returns up to 2,000), to the clipboard
|
||
$('s-log-copy').addEventListener('click', function () {
|
||
api('api/log?after=0&limit=2000').then(function (r) {
|
||
var lines = (r.lines || []).map(function (l) { return clock(l.t) + ' ' + l.src.padEnd(6) + ' ' + l.text; });
|
||
if (!lines.length) { toast('Nothing logged yet'); return; }
|
||
copyText('Igneum Miner ' + (state ? state.version : '') + ' log, ' + lines.length + ' lines\n' + lines.join('\n'), 'Copied ' + lines.length + ' lines');
|
||
}).catch(function () { toast('The engine did not answer'); });
|
||
});
|
||
|
||
// ---------- rail: logs and quit (the quit question is a strip, never a dialog) ----------
|
||
$('btn-logs').addEventListener('click', function () { setDrawer(!document.body.classList.contains('drawer-open')); });
|
||
$('log-close').addEventListener('click', function () { setDrawer(false); });
|
||
$('btn-quit').addEventListener('click', function () { $('ask-quit').hidden = false; $('ask-quit-yes').focus(); });
|
||
$('ask-quit-no').addEventListener('click', function () { $('ask-quit').hidden = true; });
|
||
$('ask-quit-yes').addEventListener('click', function () { $('ask-quit').hidden = true; 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.classList.toggle('logs', 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 = 12, PAD = 10, RIGHT_PAD = 26;
|
||
function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||
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, line = css('--line') || '#2A2A30', ash = css('--ash') || '#9A9A9E', molten = css('--molten') || '#FFB35C';
|
||
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 ? molten : line; gx.lineWidth = 1; gx.setLineDash(m === 0 ? [] : [3, 6]);
|
||
gx.beginPath(); gx.moveTo(px, 10); gx.lineTo(px, H - 18); gx.stroke(); gx.setLineDash([]);
|
||
if (px > 30 && (m === 0 || m % 2 === 0)) { gx.fillStyle = ash; gx.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); }
|
||
}
|
||
var rail = (H - 12) * 0.5;
|
||
gx.strokeStyle = line; 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 - 12) * 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) * 15;
|
||
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, 3); 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, 4); 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 = css('--ash') || '#9A9A9E'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center';
|
||
var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'Mining. A block this machine finds appears here' : state.mining.state === 'paused' ? 'Paused. No blocks while mining is stopped' : state.node.synced ? 'waiting for the card' : 'waiting for the node to sync';
|
||
x.fillText(msg, W / 2, rail - 10);
|
||
}
|
||
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' && page === 'overview' && !$('dag').hidden) { 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; if (typeof liveTimer !== 'undefined') { clearTimeout(liveTimer); liveTimer = null; } }
|
||
else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } if (typeof pollLive === 'function') pollLive(); }
|
||
});
|
||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { size(); });
|
||
requestDraw();
|
||
|
||
// ---------- the chain scene (EMBER 02: live-dag.js and proof-core.js, byte-equal copies of scene/ written by tools/scene/sync.mjs) ----------
|
||
// Same renderer, same tokens, same feed and the same mount options as the site's /live (window 60, fps 60, poll false; the
|
||
// page asks for 300 s so the viewer can pan the full range). The phone rule keys on the viewport inside the renderer; this
|
||
// machine's vote keys are the `mine` overlay (own blocks glow, the lane reads YOUR KEY), never a rewritten feed (scene/README.md).
|
||
// One owner of the feed: this page reads the engine's api/live every 2 s while the Overview shows and pushes the reply
|
||
// into the compact scene on the card and, once opened, the full scene with the block inspector (poll: false on both).
|
||
// "mine" is this machine's vote key ids (the 8 hex the feed names as b.miner), never the payout address. The fallback
|
||
// strip (this machine's own blocks) draws when the feed fails three times in a row.
|
||
var dag = null, dagFull = null, proof = null, dagMode = '', liveTimer = null, liveBusy = false, lastLive = null, liveFails = 0, fullOpen = false;
|
||
function mineFn(b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); }
|
||
function dagFallback(on, why) {
|
||
var st = on ? 'failed' : (lastLive ? 'live' : 'connecting');
|
||
if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? (why || 'The chain feed is not answering. This is the last 10 minutes of your own blocks.') : (lastLive ? View.liveNote(lastLive) : '')); }
|
||
$('dag-live').hidden = on; $('dag-tip').hidden = true; $('dag-pause').hidden = on; $('dag-inspect').hidden = on; $('dag').hidden = !on;
|
||
if (on) { size(); requestDraw(); }
|
||
}
|
||
function mountDag() {
|
||
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
|
||
// key-22: the key renders from the scene's one legend export (Your blocks first for this machine's view); the source line stays after it
|
||
if (IgneumDag.renderLegend) document.querySelectorAll('[data-legend]').forEach(function (el) { IgneumDag.renderLegend(el, { mine: el.getAttribute('data-legend') === 'mine' }); });
|
||
try {
|
||
dag = IgneumDag.mount($('dag-live'), { compact: true, window: 60, fps: 60, poll: false, mine: mineFn, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'),
|
||
onSelect: function (b, why) { if (b && why !== 'update') openFull(b.hash); },
|
||
onState: function (st, why) { if (dagMode === 'failed') return; if (st === 'live' || st === 'stale') { setText('dag-state', st === 'live' ? 'live' : 'behind'); setText('dag-note', st === 'stale' ? (why || '') : (lastLive ? View.liveNote(lastLive) : '')); } } });
|
||
pollLive();
|
||
} catch (e) { dag = null; dagFallback(true, 'The chain view could not start.'); }
|
||
}
|
||
function pollLive() {
|
||
clearTimeout(liveTimer); liveTimer = null;
|
||
if (phase !== 'dashboard' || hiddenNow() || !dag) return;
|
||
if (!liveBusy) {
|
||
liveBusy = true;
|
||
fetch('api/live?window=300', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
|
||
liveBusy = false;
|
||
if (!d || d.ok === false) { liveFails++; if (liveFails >= 3) dagFallback(true, d && d.error ? 'The chain feed says: ' + d.error + '. This is the last 10 minutes of your own blocks.' : ''); return; }
|
||
liveFails = 0; lastLive = d;
|
||
if (dagMode === 'failed') dagFallback(false);
|
||
if (dag) dag.push(d); if (dagFull) dagFull.push(d);
|
||
if (dagMode !== 'failed') setText('dag-note', View.liveNote(d));
|
||
}).catch(function () { liveBusy = false; liveFails++; if (liveFails >= 3) dagFallback(true); });
|
||
}
|
||
liveTimer = setTimeout(pollLive, 2000);
|
||
}
|
||
function openFull(hash) {
|
||
if (!fullOpen) { fullOpen = true; $('chain-full').hidden = false; $('dag-inspect').setAttribute('aria-expanded', 'true'); setText('dag-inspect', 'Close'); mountFull(); }
|
||
if (hash && dagFull) dagFull.select(hash, 'card');
|
||
}
|
||
function closeFull() { fullOpen = false; $('chain-full').hidden = true; $('dag-inspect').setAttribute('aria-expanded', 'false'); setText('dag-inspect', 'Inspect'); if (dagFull) { dagFull.destroy(); dagFull = null; } if (proof) { proof.destroy(); proof = null; } uninspect(); }
|
||
function mountFull() {
|
||
if (dagFull || typeof IgneumDag === 'undefined') return;
|
||
if (typeof IgneumProof !== 'undefined' && !proof) proof = IgneumProof.mount($('proof'));
|
||
dagFull = IgneumDag.mount($('dag-full'), { poll: false, window: 60, fps: 60, mine: mineFn, tooltip: $('dag-tip-full'), chip: $('dag-chip-full'), pauseButton: $('dag-pause-full'),
|
||
onWindow: function (w) { setText('z-pct', w + ' s'); },
|
||
onSelect: function (b) { if (b) inspect(b); else uninspect(); },
|
||
onPause: function (p) { $('dag-follow').classList.toggle('on', !p); if (proof) proof.setPaused(p || dagFull.getState().state !== 'live'); },
|
||
onFollow: function (f) { $('dag-follow').classList.toggle('on', f); },
|
||
onState: function (st) { if (proof && dagFull) proof.setPaused(st !== 'live' || dagFull.getStats().paused); } });
|
||
if (lastLive) dagFull.push(lastLive);
|
||
}
|
||
$('dag-inspect').addEventListener('click', function () { if (fullOpen) closeFull(); else openFull(); });
|
||
$('dag-filter').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (!b) return; document.querySelectorAll('#dag-filter .seg-b').forEach(function (x) { var on = x === b; x.classList.toggle('on', on); x.setAttribute('aria-checked', on ? 'true' : 'false'); }); if (dagFull) dagFull.setFilter(b.dataset.filter); });
|
||
$('dag-follow').addEventListener('click', function () { if (dagFull) { dagFull.setPaused(false); dagFull.follow(); } });
|
||
$('z-in').addEventListener('click', function () { if (dagFull) dagFull.setWindow(dagFull.getWindow() - 15); });
|
||
$('z-out').addEventListener('click', function () { if (dagFull) dagFull.setWindow(dagFull.getWindow() + 15); });
|
||
$('insp-close').addEventListener('click', function () { if (dagFull) dagFull.select(null, 'clear'); uninspect(); });
|
||
// the inspector: the site's /live words, from the module's selected record (kept fresh through onSelect(b, 'update'))
|
||
function fmtN(v) { return v === null || v === undefined ? 'pending' : withCommas(v); }
|
||
function hmsUtc(ms) { var d = new Date(ms); return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0'); }
|
||
function inspect(b) {
|
||
$('insp-close').hidden = false;
|
||
var mineB = mineFn(b);
|
||
setText('i-kind', mineB ? 'your block' : b.locked ? 'checkpoint' : b.chain ? 'chain block' : b.color === 'red' ? 'excluded' : 'side block');
|
||
setText('i-title', b.locked ? 'Checkpoint locked' : b.proven ? 'Proof complete' : (b.shards || []).some(function (x) { return x.state === 'proving'; }) ? 'Proof in progress' : 'Block observed');
|
||
setText('i-hash', b.hash + (b.number ? ' · #' + fmtN(b.number) : ''));
|
||
var sh = b.shards || [], done = sh.filter(function (x) { return x.state === 'verified' || x.state === 'paid'; }).length;
|
||
var sw = typeof IgneumDag !== 'undefined' && IgneumDag.shardWords ? IgneumDag.shardWords(b, Date.now()) : { summary: sh.length ? '' : 'no shard plan', items: sh.map(function (x) { return { word: x.state }; }) };
|
||
setText('i-shard-count', sh.length ? done + ' / ' + sh.length + ' complete' : sw.summary);
|
||
$('i-track').innerHTML = sh.map(function (x) { return '<i class="' + esc(x.state) + '"></i>'; }).join('');
|
||
$('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return '<li class="' + esc(x.state) + '"><span>Shard ' + String(i + 1).padStart(2, '0') + '</span><span>' + esc((sw.items[i] ? sw.items[i].word : x.state).replace(/^./, function (c) { return c.toUpperCase(); })) + '</span></li>'; }).join('') : '<li class="muted">' + esc(sw.summary.replace(/^./, function (c) { return c.toUpperCase(); })) + '.</li>';
|
||
setText('i-incl', (b.color === 'blue' ? 'Included' : b.color === 'red' ? 'Excluded' : 'Pending') + (b.chain ? ' / selected chain' : ''));
|
||
setText('i-miner', (b.miner === 'you' ? 'you' : b.miner) + (mineB && b.miner !== 'you' ? ' (you)' : ''));
|
||
setText('i-blue', fmtN(b.blue_score)); setText('i-daa', fmtN(b.daa));
|
||
setText('i-parents', (b.parents || []).length + ' observed' + ((b.parents || []).length ? ': ' + b.parents.map(function (h) { return View.shortHex(h, 8, 6); }).join(', ') : ''));
|
||
setText('i-time', b.ts ? hmsUtc(b.ts) + ' UTC' : 'not set');
|
||
var fn = lastLive && lastLive.finality, cp = null; if (fn && fn.checkpoints) fn.checkpoints.forEach(function (c) { if (b.locked && c.hash === b.hash) cp = c; });
|
||
setText('i-cp', cp ? 'this block, #' + fmtN(cp.index) + (cp.fraction_total ? ', ' + Math.round(cp.fraction_total * 100) + '% of weight' : '') : 'not a checkpoint');
|
||
setText('i-lock', b.final ? 'Final (reported)' : 'Not marked final');
|
||
if (proof) proof.setBlock(b);
|
||
}
|
||
function uninspect() {
|
||
$('insp-close').hidden = true; setText('i-kind', 'select a block'); setText('i-title', 'Select a block'); setText('i-hash', 'Click the graph.'); setText('i-shard-count', 'not set'); $('i-track').innerHTML = ''; $('i-shards').innerHTML = '<li class="muted">No block selected.</li>';
|
||
['i-incl', 'i-miner', 'i-blue', 'i-daa', 'i-parents', 'i-time', 'i-cp', 'i-lock'].forEach(function (id) { setText(id, 'not set'); });
|
||
if (proof) proof.setBlock(null);
|
||
}
|
||
// ---------- miner-ui-5: the miner's first month ----------
|
||
// the chain facts (GET api/ladder, src/chainfacts.rs) every 15 s while the dashboard shows; `chain` feeds the
|
||
// Poisson line, the block card, Earnings and the ladder; every number names its field on hover (View.FIELDS)
|
||
var chain = null, chainAt = 0, chainBusy = false, miningSince = 0, cardDismissed = stored('igneum.card.dismissed') || '', cardSaving = false, chainSig = '';
|
||
function pollChain(force) {
|
||
if (phase !== 'dashboard' || hiddenNow() || chainBusy) return;
|
||
if (!force && Date.now() - chainAt < 15000) return;
|
||
chainBusy = true;
|
||
api('api/ladder').then(function (r) { chain = r; chainAt = Date.now(); chainBusy = false; var sig = JSON.stringify(r); if (sig !== chainSig) { chainSig = sig; if (state) renderPage(state); } }).catch(function () { chainBusy = false; chainAt = Date.now(); });
|
||
}
|
||
$('ls-more').addEventListener('click', function () { showPage('earnings'); });
|
||
disclosure('tl-toggle', 'tl-details');
|
||
$('s-profile').addEventListener('change', function () { var on = this.checked; api('api/settings', { profile_public: on }).then(function (r) { if (r.ok) toast(on ? 'Your page is public from the next site read' : 'Your page is unlisted again'); else { toast(r.error || 'could not change'); $('s-profile').checked = !on; } }).catch(function () { toast('This engine has no profile switch yet'); $('s-profile').checked = !on; }); });
|
||
// the block card's buttons
|
||
// first-block-21: the card is spent once a window has shown it. The first render in a visible window reports it to
|
||
// the engine (api/card/seen: the lifetime flag, the drop at the engine's next start); the dismiss reports it too; and
|
||
// the window hiding to the tray or menu bar with the card on screen counts as the dismiss, so a second open in the
|
||
// same run shows nothing either.
|
||
var cardSeenKey = '';
|
||
function reportCardSeen(bc) { if (!bc || cardSeenKey === bc.key) return; cardSeenKey = bc.key; var b = View.cardSeenBody(bc); if (b) api('api/card/seen', b).catch(function () { }); }
|
||
function dismissCard() { cardDismissed = $('block-card').dataset.key || ''; store('igneum.card.dismissed', cardDismissed); $('block-card').hidden = true; }
|
||
$('bc-close').addEventListener('click', function () { if (state && state.ladder) reportCardSeen(View.blockCard(state.ladder.milestone, state, chain, state.now)); dismissCard(); });
|
||
document.addEventListener('visibilitychange', function () { if (document.hidden && !$('block-card').hidden && $('block-card').dataset.key) dismissCard(); });
|
||
$('bc-open').addEventListener('click', function () { var u = $('block-card').dataset.url; if (u) api('api/open', { url: u }); else toast('The hash is not known yet'); });
|
||
$('bc-copy').addEventListener('click', function () { var u = $('block-card').dataset.url; if (u) copyText(u, 'Link copied'); else toast('The hash is not known yet'); });
|
||
$('bc-save').addEventListener('click', function () {
|
||
if (!state || cardSaving) return;
|
||
var m = View.blockCard(state.ladder.milestone, state, chain, state.now); if (!m) return;
|
||
cardSaving = true; setText('bc-saved', 'drawing');
|
||
drawCard($('bc-canvas'), m, state).then(function (png) {
|
||
return api('api/card', { name: 'block-' + (m.count || 0), png: png }).then(function (r) { cardSaving = false; if (r.ok) { setText('bc-saved', 'saved: ' + r.path); toast('Card saved. The folder is open'); } else { setText('bc-saved', ''); toast(r.error || 'could not save'); } });
|
||
}).catch(function (e) { cardSaving = false; setText('bc-saved', ''); toast('Could not draw the card: ' + (e && e.message || e)); });
|
||
});
|
||
// the PNG, 1200x630, drawn from the card's own words with the local fonts: no network call. The miner's object,
|
||
// never the project's number: the card, the rate, the IGN, the rank and the day, the hash and the link.
|
||
function drawCard(cv, m, s) {
|
||
var loads = ['900 72px Unbounded', '700 28px Unbounded', '500 30px "IBM Plex Sans"', '400 22px "IBM Plex Mono"', '500 18px "IBM Plex Mono"'].map(function (f) { return document.fonts && document.fonts.load ? document.fonts.load(f).catch(function () { }) : Promise.resolve(); });
|
||
return Promise.all(loads).then(function () {
|
||
var W = 1200, H = 630, x = cv.getContext('2d');
|
||
cv.width = W; cv.height = H;
|
||
x.fillStyle = '#0C0C0E'; x.fillRect(0, 0, W, H);
|
||
var g = x.createRadialGradient(W - 140, H + 60, 20, W - 140, H + 60, 620); g.addColorStop(0, 'rgba(255,179,92,.22)'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.fillRect(0, 0, W, H);
|
||
x.fillStyle = '#F2541B'; x.fillRect(0, 0, 14, H);
|
||
// the mark (ui/mark.svg's two polygons) at 64 px
|
||
var pts = [[50, 4], [74, 34], [67, 58], [80, 54], [61, 96], [39, 96], [20, 54], [33, 58], [26, 34]], inner = [[50, 42], [59, 58], [50, 82], [41, 58]], ox = 72, oy = 56, k = 0.64;
|
||
x.fillStyle = '#F2541B'; x.beginPath(); pts.forEach(function (p, i) { x[i ? 'lineTo' : 'moveTo'](ox + p[0] * k, oy + p[1] * k); }); x.closePath(); x.fill();
|
||
x.fillStyle = '#0C0C0E'; x.beginPath(); inner.forEach(function (p, i) { x[i ? 'lineTo' : 'moveTo'](ox + p[0] * k, oy + p[1] * k); }); x.closePath(); x.fill();
|
||
x.fillStyle = '#F4F1EC'; x.font = '900 28px Unbounded'; x.textBaseline = 'alphabetic'; x.fillText('IGNEUM', 152, 100);
|
||
x.fillStyle = '#9A9A9E'; x.font = '500 18px "IBM Plex Mono"'; x.textAlign = 'right'; x.fillText(whenWords(m.when) + ' · Igneum Miner ' + (s.version || ''), W - 56, 100); x.textAlign = 'left';
|
||
x.fillStyle = '#F2541B'; x.font = '500 20px "IBM Plex Mono"'; x.fillText(m.eyebrow.toUpperCase().split('').join(' '), 72, 196);
|
||
x.fillStyle = '#F4F1EC'; x.font = '900 72px Unbounded';
|
||
var t = m.title.replace(/\.$/, ''), parts = t.split(' is yours');
|
||
if (x.measureText(t).width <= W - 144) x.fillText(t, 72, 286);
|
||
else { x.fillText(parts[0], 72, 266); x.fillStyle = '#FFB35C'; x.fillText('is yours', 72, 346); }
|
||
var y = parts.length > 1 && x.measureText(t).width > W - 144 ? 420 : 370;
|
||
x.fillStyle = '#C9C7C2'; x.font = '500 30px "IBM Plex Sans"'; x.fillText(m.cardLine.split(' · ').slice(0, 2).join(' · '), 72, y);
|
||
x.fillStyle = '#FFB35C'; x.fillText(m.ignLine, 72, y + 50);
|
||
x.fillStyle = '#9A9A9E'; x.font = '500 26px "IBM Plex Sans"'; x.fillText(m.rankLine, 72, y + 98);
|
||
x.fillStyle = '#9A9A9E'; x.font = '400 22px "IBM Plex Mono"';
|
||
var ids = ((s.mining || {}).cards || []).filter(function (c) { return c.key === (s.ladder.milestone || {}).card; }).map(function (c) { return (c.ids || [])[0]; }).filter(Boolean);
|
||
x.fillText((ids.length ? 'Miner key ' + ids[0] + ' · ' : '') + 'hash ' + (m.hash ? m.hashShort : 'pending'), 72, 560);
|
||
x.fillStyle = '#F4F1EC'; x.font = '400 20px "IBM Plex Mono"'; x.fillText(m.url ? m.url.replace(/^https:\/\//, '') : 'igneum.network/explorer', 72, 598);
|
||
return cv.toDataURL('image/png');
|
||
});
|
||
}
|
||
function whenWords(t) { if (!(t > 0)) return ''; var d = new Date(t * 1000); return d.getDate() + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' + d.getFullYear() + ' ' + clock(t); }
|
||
var rungsSig = '', stripSig = '';
|
||
function rungHtml(r) {
|
||
return '<li class="rung ' + esc(r.state) + '" title="' + esc(r.field) + '"><span class="rg-dot" aria-hidden="true"></span><span class="rg-body"><span class="rg-name">' + esc(r.name) + '</span><span class="rg-line">' + esc(r.line) + '</span>' + (r.sub ? '<span class="rg-sub">' + esc(r.sub) + '</span>' : '') + (r.progress >= 0 && r.state !== 'done' ? '<i class="bar"><b style="width:' + Math.round(r.progress * 100) + '%"></b></i>' : '') + '</span></li>';
|
||
}
|
||
// the Overview's first-month surfaces: the count-up or the block card, then the ladder strip
|
||
function renderFirstMonth(s) {
|
||
var m = s.mining, now = s.now, ld = s.ladder || {};
|
||
if (m.state === 'mining' && m.hash_total > 0) { if (!miningSince) miningSince = now; } else miningSince = 0;
|
||
var c = View.chainNums(chain), p = View.poisson(m.hash_total, c.hps, miningSince ? now - miningSince : 0, c.bps, (m.cards || []).filter(function (x) { return x.state === 'mining'; }).length > 1);
|
||
var waiting = View.firstWait(s);
|
||
setHidden('first-wait', !waiting);
|
||
if (waiting) {
|
||
if (p) { setText('first-wait-line', p.line); setText('first-wait-sub', p.pool || 'The chance counts up from the first job. The line gives way to the block card.'); setText('first-wait-pct', p.chanceText); $('first-wait-arc').style.strokeDashoffset = (97.4 * (1 - p.chance)).toFixed(1); }
|
||
else { setText('first-wait-line', 'Waiting for your first block.'); setText('first-wait-sub', m.hash_total > 0 ? (c.error || 'The expected gap needs the network rate from igneum.network/api/stats.') : 'The cards are starting.'); setText('first-wait-pct', ''); $('first-wait-arc').style.strokeDashoffset = '97.4'; }
|
||
$('first-wait').title = View.FIELDS.share + ' · ' + View.FIELDS.hashrate + ' · gap = 1 / (share × blocks per second); chance = 1 − e^(−elapsed / gap)';
|
||
}
|
||
var bc = View.blockCard(ld.milestone, s, chain, now), card = $('block-card');
|
||
var show = !!bc && bc.key !== cardDismissed;
|
||
setHidden('block-card', !show);
|
||
if (show) {
|
||
if (!hiddenNow()) reportCardSeen(bc);
|
||
card.dataset.key = bc.key; card.dataset.url = bc.url;
|
||
setText('bc-eyebrow', bc.eyebrow); setText('bc-when', bc.when > 0 ? clock(bc.when) : ''); setText('bc-title', bc.title);
|
||
$('bc-card').innerHTML = View.markHtml(bc.card, 'mini') + '<span>' + esc(bc.cardLine) + '</span>'; $('bc-card').title = bc.fields.card;
|
||
setText('bc-ign', bc.ignLine); $('bc-ign').title = bc.fields.ign;
|
||
setText('bc-rank', bc.rankLine); $('bc-rank').title = bc.fields.rank;
|
||
setText('bc-hash', 'hash ' + bc.hashShort); $('bc-hash').title = bc.hash;
|
||
$('bc-open').disabled = !bc.url; $('bc-copy').disabled = !bc.url;
|
||
}
|
||
var rungs = View.ladderRungs(s, chain, now), cur = View.currentRung(rungs), hasCards = (m.cards || []).length > 0;
|
||
setHidden('ladder-strip', !hasCards);
|
||
var sig = JSON.stringify([rungs.map(function (r) { return r.state; }), cur.id, cur.line, cur.sub]);
|
||
if (sig !== stripSig) {
|
||
stripSig = sig;
|
||
$('ls-rungs').innerHTML = rungs.map(function (r) { return '<span class="ls-rung ' + esc(r.state) + (r.id === cur.id ? ' cur' : '') + '" role="listitem" title="' + esc(r.line + ' — ' + r.field).replace(/—/, '·') + '"><i></i><span>' + esc(r.name) + '</span></span>'; }).join('');
|
||
setText('ls-line', cur.line); setText('ls-sub', cur.sub); $('ls-line').title = cur.field;
|
||
}
|
||
}
|
||
function renderLadderCard(s) {
|
||
var rungs = View.ladderRungs(s, chain, s.now), c = View.chainNums(chain);
|
||
setText('ladder-source', c.source === 'node' ? 'from your node' : c.source === 'observer' ? 'through the observer' : (c.error ? 'not read' : 'reading'));
|
||
$('ladder-source').title = c.source === 'observer' ? 'getFinalityWeights relayed by igneum.network/api/live until this node answers igneum_getFinalityWeights; keys cut to 64' : c.source === 'node' ? 'igneum_getFinalityWeights on this machine’s node' : (c.error || '');
|
||
var sig = JSON.stringify(rungs);
|
||
if (sig !== rungsSig) { rungsSig = sig; $('rungs').innerHTML = rungs.map(rungHtml).join(''); }
|
||
var tl = View.timeline(s, s.now), done = tl.steps.filter(function (x) { return x.at > 0; }).length;
|
||
setText('tl-sum', done + ' of ' + tl.steps.length + (tl.steps[4].rel ? ' · first block ' + tl.steps[4].rel : ''));
|
||
$('tl-steps').innerHTML = tl.steps.map(function (x) { return '<li class="' + (x.at > 0 ? 'done' : '') + '"><span class="tl-name">' + esc(x.name) + '</span><span class="tl-at mono">' + esc(x.at > 0 ? (x.rel || clock(x.at)) : '') + '</span>' + (x.note ? '<span class="tl-note dim">' + esc(x.note) + '</span>' : '') + '</li>'; }).join('');
|
||
$('tl-steps').title = tl.field;
|
||
setText('tl-note', 'Times from install (' + (tl.base > 0 ? whenWords(tl.base) : 'not recorded') + '). The site’s /miner page shows the same steps measured on fresh installs.');
|
||
}
|
||
// ---------- render ----------
|
||
// the vendor mark and the name block come from View.rowHead (one mark per row, never two)
|
||
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' : ''); }
|
||
// driver-check: the first-run row's driver line (the same words as the dashboard strip, the same buttons)
|
||
function setupDriverHtml(cd) {
|
||
var dw = View.driverWords(cd, state || {});
|
||
if (dw.kind === 'none' || dw.kind === 'fine') return '';
|
||
return '<div class="driver ' + dw.kind + '">' + (dw.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round((dw.progress || 0) * 100) + '%"></b></i>' : '') + '<span class="t">' + esc(dw.text) + '</span>' +
|
||
(dw.button === 'install' ? ' <button class="btn tiny primary" data-driver-install="' + esc(cd.driver_offer.vendor) + '">Install</button>' : dw.button === 'restart' ? ' <button class="btn tiny primary" data-driver-restart="1">Restart now</button>' : '') +
|
||
(dw.sub ? '<div class="n">' + esc(dw.sub) + '</div>' : '') + '</div>';
|
||
}
|
||
// ---------- the first-run GPU rows: one per card, with a switch ----------
|
||
function renderCardRows(container, cards) {
|
||
container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); });
|
||
shownCards(cards).forEach(function (cd) {
|
||
var d = document.createElement('div');
|
||
var removed = cd.removed_at > 0, unusable = !!cd.problem;
|
||
d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : '');
|
||
d.dataset.key = cd.key;
|
||
var meta = (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>' : '');
|
||
d.innerHTML = View.markHtml(cd) +
|
||
'<div class="info">' + View.nameHtml(cd, unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + '<div class="meta">' + meta + '</div>' +
|
||
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + setupDriverHtml(cd) + '</div>' +
|
||
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(cd.name) + '"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
|
||
container.appendChild(d);
|
||
});
|
||
}
|
||
// what a container's rows say now, for api/cards: a row without a stepper or a slider keeps the engine's value
|
||
function readCardRows(container) {
|
||
var out = [];
|
||
container.querySelectorAll('.gpu-row, .gpu-line').forEach(function (r) {
|
||
if (r.classList.contains('removed') || r.classList.contains('unusable')) return;
|
||
var cd = cardOf(r.dataset.key) || {};
|
||
var idsInput = r.querySelector('.ids input'), n = idsInput ? parseInt(idsInput.value, 10) : (cd.identities || 1);
|
||
if (!(n >= 1)) n = 1; if (n > 64) n = 64;
|
||
var sw = r.querySelector('.gl-main .switch input, :scope > .switch input'), pr = r.querySelector('input[type=range]');
|
||
var item = { key: r.dataset.key, enabled: sw ? sw.checked : !!cd.enabled, identities: n };
|
||
if (pr) item.power_pct = parseInt(pr.value, 10); else if (cd.vendor === 'nvidia' && cd.power_default_w > 0) item.power_pct = cd.power_pct || 80;
|
||
out.push(item);
|
||
});
|
||
return out;
|
||
}
|
||
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); }
|
||
});
|
||
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 = shownCards(s.mining.cards);
|
||
var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|');
|
||
if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); }
|
||
if (!cards.length) {
|
||
$('cards-sub').textContent = 'No graphics card this app can use 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;
|
||
}
|
||
$('cards-sub').textContent = cards.length === 1 ? 'One card found. It mines when it is on.' : cards.length + ' cards found. Switch on the ones you want to mine with.';
|
||
$('cards-note').hidden = !s.detect_message; $('cards-note').textContent = s.detect_message || '';
|
||
$('cards-help').hidden = !cards.some(function (c) { return c.kind === 'integrated'; });
|
||
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 = 'Continue';
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
// ---------- Mine ----------
|
||
var mineRowsSig = '', lastEventsKey = '';
|
||
function numCell(cls, v, unit, k, tone, title) { return '<div class="num ' + cls + '"' + (title ? ' title="' + esc(title) + '"' : '') + '><span class="v' + (tone ? ' ' + tone : '') + '">' + esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') + '</span>' + (k ? '<span class="k">' + esc(k) + '</span>' : '') + '</div>'; }
|
||
function gpuLineHtml(cd, s, r) {
|
||
var cells = View.cells(cd, priceNow()), tw = View.tuneWords(cd, s, s.now), word = View.rowWord(r, s.mining);
|
||
var nums = '';
|
||
if (r.hash) nums += numCell('hash', r.hash, 'MH/s', '', '');
|
||
var ignd = r.hash ? View.cardIgnDay(cd, chain) : '';
|
||
if (ignd) nums += numCell('ign', ignd, 'IGN', 'a day', '', View.FIELDS.share + ' \u00d7 86,400 \u00d7 ' + View.FIELDS.reward);
|
||
if (cells.power) nums += numCell('power', cells.power.replace(' W', ''), 'W', cells.eff, '');
|
||
if (cells.money) nums += numCell('money', cells.money, '', 'a day', '');
|
||
else if (cells.needsPrice) nums += '<div class="num money"><button class="btn tiny ghost" data-price="1">Set a price</button><span class="k">£ a day</span></div>';
|
||
if (r.temp) nums += numCell('temp', r.temp.replace(' °C', ''), '°C', 'GPU', r.tempTone);
|
||
var em = View.ember(cd, s, s.now);
|
||
if (em.kind === 'tuned' && em.before > 0 && em.after > 0 && cells.power) nums = nums.replace(numCell('power', cells.power.replace(' W', ''), 'W', cells.eff, ''), numCell('power', Math.round(em.before) + ' → ' + cells.power.replace(' W', ''), 'W', cells.eff, ''));
|
||
if (em.points.length > 1 && cells.power) nums = nums.replace('<span class="k">' + esc(cells.eff) + '</span>', '<span class="k spark">' + esc(cells.eff) + sparkHtml(em.points) + '</span>');
|
||
var tuneBtn = tw.button === 'stop' ? '<button class="btn small" data-stop="1">Stop</button>' : tw.button === 'tune' ? '<button class="btn small ghost" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune') + '</button>' : '';
|
||
var sw = '<label class="switch" title="' + (r.on ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(r.name) + '"' + (r.on ? ' checked' : '') + (r.canToggle ? '' : ' disabled') + '><span class="track"></span></label>';
|
||
var chev = '<button class="chev-btn" data-open="1" aria-expanded="false" aria-label="Details for ' + esc(r.name) + '"><span class="chev"></span></button>';
|
||
var subs = [];
|
||
if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>');
|
||
if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</div>');
|
||
var dw = View.driverWords(cd, s);
|
||
var drv = dw.kind === 'none' || dw.kind === 'fine' ? '' : '<div class="gl-driver ' + dw.kind + '">' + (dw.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round((dw.progress || 0) * 100) + '%"></b></i>' : '') + '<span class="t">' + esc(dw.text) + '</span>' +
|
||
(dw.button === 'install' ? '<button class="btn small primary" data-driver-install="' + esc(cd.driver_offer.vendor) + '">Install</button>' : dw.button === 'restart' ? '<button class="btn small primary" data-driver-restart="1">Restart now</button>' : '') +
|
||
(dw.sub ? '<span class="n dim">' + esc(dw.sub) + '</span>' : '') + (dw.dry ? '<span class="n dim">dry run: the installer is not run</span>' : '') + '</div>';
|
||
var tune = em.kind === 'none' ? '' : '<div class="gl-tune ' + em.kind + '">' + flameHtml(em.fill, em.kind) + '<span class="t">' + esc(em.label) + '</span>' + (em.sub ? '<span class="n dim">' + esc(em.sub) + '</span>' : '') + (em.savingText ? '<span class="n save">' + esc(em.savingText) + '</span>' : '') + (em.heat ? '<span class="n heat">' + esc(em.heat) + '</span>' : '') + (em.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round(em.fill * 100) + '%"></b></i>' : '') + '</div>';
|
||
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' +
|
||
'<div class="gl-main">' + View.markHtml(cd) +
|
||
'<div class="who">' + View.nameHtml(cd) +
|
||
'<span class="st ' + r.tone + '"><i></i>' + esc(word) + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' + subs.join('') + '</div>' +
|
||
'<div class="nums">' + nums + '</div>' +
|
||
'<div class="acts">' + tuneBtn + sw + chev + '</div></div>' + drv + tune +
|
||
'<div class="gl-details" hidden>' + detailsHtml(cd, s) + '</div></div>';
|
||
}
|
||
// the flame mark (the brand mark's two polygons) filled to a fraction: outline when not tuned, filling while tuning, solid when tuned
|
||
var flameN = 0;
|
||
function flameHtml(fill, kind) {
|
||
var id = 'fl' + (++flameN), h = Math.round(100 * (1 - Math.max(0, Math.min(1, fill))));
|
||
return '<svg class="flame ' + kind + '" viewBox="0 0 100 100" width="16" height="16" aria-hidden="true"><defs><clipPath id="' + id + '"><rect x="0" y="' + h + '" width="100" height="' + (100 - h) + '"/></clipPath></defs>' +
|
||
'<path class="fo" d="M50 4 L78 50 L50 96 L22 50 Z M50 34 L62 50 L50 66 L38 50 Z" fill-rule="evenodd"/>' +
|
||
'<path class="ff" clip-path="url(#' + id + ')" d="M50 4 L78 50 L50 96 L22 50 Z M50 34 L62 50 L50 66 L38 50 Z" fill-rule="evenodd"/></svg>';
|
||
}
|
||
// a tiny sparkline of the tune's ladder: MH/W per step, the chosen step as a dot
|
||
function sparkHtml(points) {
|
||
var effs = points.map(function (p) { return p.eff; }), lo = Math.min.apply(null, effs), hi = Math.max.apply(null, effs), n = points.length, W = 44, H = 12;
|
||
var pts = points.map(function (p, i) { var x = n > 1 ? i / (n - 1) * (W - 2) + 1 : W / 2, y = hi > lo ? H - 1 - (p.eff - lo) / (hi - lo) * (H - 2) : H / 2; return { x: x, y: y, c: p.chosen, m: p.marked }; });
|
||
var d = pts.map(function (p, i) { return (i ? 'L' : 'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1); }).join(' ');
|
||
var dot = pts.filter(function (p) { return p.c; })[0];
|
||
return '<svg class="spark" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '" aria-hidden="true"><path d="' + d + '"/>' + (dot ? '<circle cx="' + dot.x.toFixed(1) + '" cy="' + dot.y.toFixed(1) + '" r="2"/>' : '') + '</svg>';
|
||
}
|
||
// the tune's curve behind the chevron: W across, MH/s up, every step a dot, the chosen one ringed, a marked one hollow
|
||
function curveHtml(points) {
|
||
if (points.length < 2) return '';
|
||
var ws = points.map(function (p) { return p.w; }), ms = points.map(function (p) { return p.mhs; });
|
||
var w0 = Math.min.apply(null, ws), w1 = Math.max.apply(null, ws), m0 = Math.min.apply(null, ms), m1 = Math.max.apply(null, ms), W = 320, H = 110, L = 34, B = 18;
|
||
var X = function (w) { return L + (w1 > w0 ? (w - w0) / (w1 - w0) : 0.5) * (W - L - 8); }, Y = function (m) { return 6 + (m1 > m0 ? 1 - (m - m0) / (m1 - m0) : 0.5) * (H - B - 6); };
|
||
var sorted = points.slice().sort(function (a, b) { return a.w - b.w; });
|
||
var path = sorted.map(function (p, i) { return (i ? 'L' : 'M') + X(p.w).toFixed(1) + ' ' + Y(p.mhs).toFixed(1); }).join(' ');
|
||
var dots = sorted.map(function (p) { return '<circle class="' + (p.chosen ? 'chosen' : p.marked ? 'marked' : '') + '" cx="' + X(p.w).toFixed(1) + '" cy="' + Y(p.mhs).toFixed(1) + '" r="' + (p.chosen ? 5 : 3) + '"/>'; }).join('');
|
||
return '<div class="curve-wrap"><svg class="curve-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="The tune\'s curve: watts across, hash rate up"><path class="ln" d="' + path + '"/>' + dots +
|
||
'<text x="' + L + '" y="' + (H - 4) + '">' + Math.round(w0) + ' W</text><text x="' + (W - 8) + '" y="' + (H - 4) + '" text-anchor="end">' + Math.round(w1) + ' W</text>' +
|
||
'<text x="2" y="' + (H - B - 2) + '">' + m0.toFixed(0) + '</text><text x="2" y="12">' + m1.toFixed(0) + '</text></svg><span class="note">Hash rate against power for every step of the last tune. The ringed point is the one Ember kept. A hollow point was rejected: a fault, a hot card or a slowed memory clock.</span></div>';
|
||
}
|
||
// the card's details (one chevron): the cap slider, identities, pin, the telemetry, the facts, the last tune's table
|
||
function detailsHtml(cd, s) {
|
||
var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, h = '';
|
||
if (cd.removed_at > 0) return '<p class="help">The card was unplugged. Its row goes in five minutes.</p>';
|
||
if (cd.problem) return '<p class="help">' + esc(cd.reason || 'This app cannot use this card.') + ' Reboot with the card attached; if it persists, reinstall the driver with the card attached.</p>';
|
||
if (nv) {
|
||
var want = Math.round(cd.power_default_w * pct / 100);
|
||
h += '<div class="ctl"><span class="k">Power cap</span><input type="range" min="50" max="100" step="5" value="' + pct + '" aria-label="Power cap for ' + esc(cd.name) + '"><span class="pv mono">' + pct + '% · ' + want + ' W</span>' +
|
||
'<p class="help">Lower is cooler and quieter. Most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so Ember leaves it. <button class="btn tiny ghost" data-pin="0">Back to Default</button>' : 'Default: Ember may move it.') + '</p></div>';
|
||
h += cd.power_applied ? '<div class="line ok">Cap in force: <b>' + want + ' W</b>' + (cd.sweep_pct === pct && cd.sweep_pct ? ' <span class="pin">Ember’s choice</span>' : '') + '</div>' : '<div class="line hot">Cap not set yet. Windows needs the administrator prompt. <button class="btn tiny" data-power-retry="1">Retry</button></div>';
|
||
} else if (cd.vendor === 'apple') h += '<p class="help">Apple silicon manages its own power. There is no cap to set.</p>';
|
||
else if (cd.vendor === 'intel') h += '<p class="help">Intel Arc exposes no power cap through OpenCL; the card runs at the driver’s default limit (Intel Graphics Software can set one). No watts reading yet, so the MH/W cell stays empty until the IGCL reader lands.</p>';
|
||
var tl = [];
|
||
if (cd.temp_mem > 0) tl.push('memory <b>' + Math.round(cd.temp_mem) + ' °C</b>');
|
||
if (cd.gclk_mhz > 0) tl.push('core clock <b>' + Math.round(cd.gclk_mhz) + ' MHz</b>' + (cd.clock_cap_mhz > 0 ? ' (cap ' + cd.clock_cap_mhz + ')' : ''));
|
||
if (cd.mclk_mhz > 0) tl.push('memory clock <b>' + Math.round(cd.mclk_mhz) + ' MHz</b>' + (cd.mem_cap_mhz > 0 ? ' (set ' + cd.mem_cap_mhz + ')' : ''));
|
||
if (cd.fan_pct > 0) tl.push('fan <b>' + Math.round(cd.fan_pct) + '%</b>');
|
||
if (cd.util_pct > 0) tl.push('busy <b>' + Math.round(cd.util_pct) + '%</b>');
|
||
if (tl.length) h += '<div class="line">' + tl.join('<span class="sep"></span>') + '</div>';
|
||
var tm = TuneLine.model(cd, s.now), em = View.ember(cd, s, s.now);
|
||
if (View.tunable(cd)) {
|
||
var g = cd.tune_goal || '', goalSeg = '<div class="seg small" data-card-goal="1" role="radiogroup" aria-label="Goal for ' + esc(cd.name) + '">' + [['', 'Default', 'the goal set for all cards'], ['efficiency', 'Efficiency', 'most hashes per watt, a little rate traded'], ['balanced', 'Balanced', 'a little rate for most of the saving'], ['rate', 'Maximum', 'most hashes, watts second']].map(function (o) { return '<button class="seg-b' + (g === o[0] ? ' on' : '') + '" data-goal="' + o[0] + '" role="radio" aria-checked="' + (g === o[0] ? 'true' : 'false') + '" title="' + o[2] + '">' + o[1] + (o[0] === '' ? ' <span class="dim">· ' + esc(View.GOAL_NAMES[goalNow()]) + '</span>' : '') + '</button>'; }).join('') + '</div>';
|
||
h += '<div class="ctl"><span class="k">Goal</span>' + goalSeg + (em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '<p class="help">' + (cd.tune_goal === undefined ? 'A goal for one card needs the next Ember Tune update. Until then every card follows the goal above.' : 'This card’s own goal. Default follows the goal set for all cards. A new goal applies from the next tune.') + '</p></div>';
|
||
}
|
||
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + (em.savingText ? ' <span class="save">' + esc(em.savingText) + '</span>' : '') + '</div>';
|
||
h += curveHtml(em.points);
|
||
h += '<div class="ctl"><span class="k">Miners on this card</span><p class="help">How many miners this card runs as. Each one votes and is paid on its own. 8 suits a big card, 2 a small one, 1 a built-in GPU. Changing it restarts the card.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">−</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>';
|
||
var facts = [cd.worker ? 'runs on ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver_os ? 'driver ' + cd.driver_os : cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean);
|
||
if (cd.driver_offer && cd.driver_offer.status === 'fine') h += '<p class="help">' + esc(cd.driver_offer.text) + (cd.driver_offer.wanted && cd.driver_offer.wanted !== cd.driver_os ? ' The table offers ' + esc(cd.driver_offer.wanted) + '; the one installed is enough.' : '') + '</p>';
|
||
if (facts.length) h += '<p class="note mono small">' + esc(facts.join(' · ')) + '</p>';
|
||
return h;
|
||
}
|
||
function renderMine(s) {
|
||
var m = s.mining, n = s.node, p = s.program;
|
||
var t = View.toggle(m, n, s), tb = $('btn-toggle');
|
||
tb.className = 'toggle-big' + (t.cls ? ' ' + t.cls : ''); tb.disabled = t.disabled;
|
||
setText('btn-toggle-text', t.label); setText('btn-toggle-sub', t.sub);
|
||
var shown = shownCards(m.cards), presentCards = shown.filter(View.present);
|
||
var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length;
|
||
// the live total: rate, watts, £ a day, blocks
|
||
setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
|
||
setText('t-hash-sub', m.state === 'paused' ? 'paused' : (s.heat && s.heat.on && s.heat.phase === 'resting' && !mining) ? 'resting · heat mode' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state);
|
||
var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
|
||
setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now');
|
||
var ppd = View.fleetPounds(m, m.cards, price);
|
||
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, per kWh' : 'electricity at ' + (price / View.priceFactor(View.currencyNow().code)) + View.minorOf(View.currencyNow().code).abbr + '/kWh');
|
||
setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
|
||
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
|
||
var rows = shown.map(View.cardRow), now = Date.now();
|
||
rows.forEach(function (r) { var pd = pending[r.key]; if (pd) { if (pd.on === r.on || now - pd.at > 8000) delete pending[r.key]; else { r.on = pd.on; r.word = pd.on ? 'starting' : 'stopping'; r.tone = ''; } } });
|
||
var on = rows.filter(function (r) { return r.on; }).length;
|
||
setText('d-cards-eyebrow', s.detecting ? 'detecting' : !shown.length ? 'none found' : on + ' of ' + presentCards.length + ' on');
|
||
var tunables = presentCards.filter(function (c) { return View.tunable(c) && c.enabled; });
|
||
setHidden('btn-tune-all', !(tunables.length > 0) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
|
||
var box = $('d-cards'), busy = box.querySelector('input[type=range]:active') || (box.contains(document.activeElement) && document.activeElement.tagName === 'INPUT');
|
||
var sig = JSON.stringify([rows, s.drivers, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.driver_offer, c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs, View.cardIgnDay(c, chain)]; }), goalNow()]);
|
||
if (sig !== mineRowsSig && !busy) {
|
||
mineRowsSig = sig;
|
||
box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '</div>';
|
||
box.querySelectorAll('.gpu-line').forEach(applyOpen);
|
||
}
|
||
var note = !shown.length && s.detect_message ? s.detect_message : '';
|
||
$('d-cards-note').hidden = !note; setText('d-cards-note', note);
|
||
renderTuneCard(s);
|
||
renderNode(s);
|
||
if (page === 'overview') renderFirstMonth(s);
|
||
// activity: the counts, the strip, the newest 8 events in a user's words
|
||
var nowU = s.now, f10 = m.found.filter(function (x) { return nowU - x <= 600; }).length;
|
||
setText('d-stats', f10 + ' block' + (f10 === 1 ? '' : 's') + ' in 10 min · ' + m.found.length + ' in the last hour' + (m.fee_total ? ' · ' + withCommas(m.fee_total) + ' to the dev fee' : ''));
|
||
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, 8).map(function (e) { var pe = View.plainEvent(e.text); return '<div' + (pe.tech ? ' title="' + esc(pe.tech) + '"' : '') + '><span><i class="k ' + esc(e.kind) + '"></i>' + esc(pe.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>';
|
||
}
|
||
}
|
||
// the Tuning card on Mine and the Tuning rows on Settings share the goal, the schedule and the Power control switch
|
||
function renderTuneCard(s) {
|
||
var st = s.settings || {}, goal = goalNow(), cards = shownCards(s.mining.cards), gw = View.goalWords(goal, cards, priceNow());
|
||
['goal-seg', 'goal-seg-2'].forEach(function (id) { document.querySelectorAll('#' + id + ' .seg-b').forEach(function (b) { var on = b.dataset.goal === goal; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); });
|
||
setText('goal-line', gw.consequence); setText('goal-line-2', gw.consequence);
|
||
var sched = View.schedule(s.mining.cards, s, s.now);
|
||
setText('tune-schedule', sched); setText('s-tune-schedule', sched);
|
||
setText('fleet-saving', View.fleetSaving(s.mining.cards, s, s.now, priceNow()));
|
||
var hl = View.heatLine(s.heat);
|
||
setHidden('heat-line', !hl.text); setText('heat-line', hl.text); $('heat-line').className = 'line-text heat-line' + (hl.tone ? ' ' + hl.tone : '');
|
||
var nvOff = cards.some(function (c) { return c.vendor === 'nvidia' && View.present(c); }) && !st.power_control;
|
||
setHidden('m-power-row', !nvOff);
|
||
if (document.activeElement !== $('m-power-control')) $('m-power-control').checked = !!st.power_control;
|
||
}
|
||
|
||
// ---------- the node line and its details ----------
|
||
var switchesSig = '';
|
||
function renderNode(s) {
|
||
var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now);
|
||
var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f);
|
||
setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub);
|
||
$('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : '');
|
||
$('node-card').classList.toggle('bad', nl.tone === 'bad');
|
||
setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : ''));
|
||
var ns = View.nodeSource(n); setText('n-source', ns.text); setText('n-source-m', ns.sub); setHidden('n-source', !ns.text);
|
||
setText('n-blocks', withCommas(n.blocks)); setText('n-blocks-sub', View.heightLine(n));
|
||
setText('n-peers', String(n.peers)); setText('n-peers-sub', View.peersLine(n));
|
||
setText('n-version', n.version ? n.version.replace(/^igneumd\s*/, '') : '--');
|
||
setText('d-node-net', s.chain || s.network || '');
|
||
setText('n-headers', withCommas(n.headers)); setText('n-daa', withCommas(n.daa)); setText('n-diff', compact(n.difficulty)); setText('n-tips', String(n.tips)); setText('n-blue', withCommas(n.blue));
|
||
if (n.daa > 0 && p && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', 'until the next hourly program' + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '') + (p.message ? ' · ' + p.message : '')); }
|
||
else { setText('d-eta', '--:--'); setText('d-eta-sub', n.state === 'syncing' ? 'known after the node is synced' : 'known once the node is running'); }
|
||
setText('n-digest', n.consensus_digest || (n.state === 'stopped' || n.state === 'failed' ? 'the node is not running' : 'not read yet'));
|
||
var sw = View.nextSwitch(n.consensus_switches, n.daa);
|
||
setText('n-switch', sw ? sw.name + ' at ' + withCommas(sw.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'DAA ' + withCommas(n.consensus_switch_daa) : (n.consensus_switches && n.consensus_switches.length ? 'none ahead' : 'none planned')));
|
||
setText('n-switch-help', sw ? View.switchLine(sw, n.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'A rule change is announced at block ' + withCommas(n.consensus_switch_daa) + ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : View.switchLine(null, n.daa)));
|
||
var sig = JSON.stringify([n.consensus_switches, sw && sw.key]);
|
||
if (sig !== switchesSig) {
|
||
switchesSig = sig;
|
||
$('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '<span class="' + (sw && x.key === sw.key ? 'next' : x.daa <= n.daa ? 'past' : '') + '"><span>' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '</span><span>' + withCommas(x.daa) + '</span></span>'; }).join('');
|
||
}
|
||
setText('f-lock', (f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet') + ' · ' + fw.age);
|
||
setText('f-note', fw.paused ? fw.note : f.last_lock > 0 ? 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' : (f.message || 'checkpoints lock once this machine and the others vote'));
|
||
$('f-lock').classList.toggle('warn', !!fw.paused);
|
||
setText('f-votes', withCommas(f.votes));
|
||
}
|
||
|
||
// ---------- Earnings ----------
|
||
function renderEarnings(s) {
|
||
var a = s.address || {}, m = s.mining;
|
||
var el = View.earningsLines(s, chain, priceNow(), s.now);
|
||
setText('e-day', el.day.text); setText('e-day-sub', el.day.sub); $('e-day').title = el.day.field;
|
||
setText('e-run', el.run.text); setText('e-run-sub', el.run.sub); $('e-run').title = el.run.field;
|
||
setText('e-rung', el.weight.text); setText('e-rung-sub', el.weight.sub); $('e-rung').title = el.weight.field;
|
||
setText('e-cost', el.cost.text); setText('e-cost-sub', el.cost.sub); $('e-cost').title = el.cost.field;
|
||
setText('e-blocks', el.lifetime.text); setText('e-blocks-sub', el.lifetime.sub); $('e-blocks').title = el.lifetime.field;
|
||
renderLadderCard(s);
|
||
if (document.activeElement !== $('s-devfee')) $('s-devfee').checked = !!(s.settings && s.settings.dev_fee);
|
||
setText('s-devfee-text', el.devFee(!!(s.settings && s.settings.dev_fee), m.fee_total));
|
||
setText('r-address', a.display || 'not set');
|
||
setText('r-source', a.source === 'generated' ? 'made on this machine' : a.source === 'pasted' ? 'your own address' : '');
|
||
setText('r-address-help', a.display ? 'Every block this machine finds pays this address.' : 'No address yet. Finish the setup to choose one.');
|
||
var gen = a.source === 'generated';
|
||
setHidden('key-toggle', !gen); if (!gen) { $('key-details').hidden = true; hideKey(); }
|
||
setText('r-key-help', 'The key for this address was made on this machine and is stored in the app folder. Only your user can read it. Keep a copy somewhere safe.');
|
||
setText('r-key-file', gen && a.wallet_file ? a.wallet_file : '');
|
||
}
|
||
|
||
// ---------- Prove ----------
|
||
var tiersSig = '';
|
||
function renderProve(s) {
|
||
var pv = s.proving || {}, enabled = !!(s.settings && s.settings.prove);
|
||
// miner-ui-5: "your card proved shard N of block M" from the newest paid record (ladder.shards)
|
||
var sh = ((s.ladder || {}).shards || [])[0];
|
||
setHidden('shard-card', !sh);
|
||
if (sh) { setText('sc-when', clock(sh.at)); setText('sc-title', 'Your card proved shard ' + sh.shard + ' of block ' + withCommas(sh.block) + '.'); setText('sc-line', View.fmtIgn(View.ign(sh.wei)) + ' paid for it \u00b7 ' + withCommas(pv.paid || 0) + ' shard' + (pv.paid === 1 ? '' : 's') + ' paid on this machine'); setText('sc-sub', '20 points of every block pay the provers, split by shard. A paid record is a chain fact: anyone can read it.'); $('sc-title').title = View.FIELDS.shards; $('sc-line').title = 'proving.paid_wei, proving.paid'; }
|
||
if (document.activeElement !== $('s-prove')) $('s-prove').checked = enabled;
|
||
var pi = proveInsteadWords(pv, s.settings);
|
||
$('pv-instead-row').hidden = !pi.show;
|
||
if (pi.show) { $('pv-instead-help').textContent = pi.help; if (document.activeElement !== $('s-prove-instead')) $('s-prove-instead').checked = pi.on; }
|
||
var w = View.proveWords(pv, enabled, s.node.synced);
|
||
var cards = shownCards(s.mining.cards).filter(View.present), tiers = cards.map(View.proveTier);
|
||
var sig = tiers.join('|');
|
||
if (sig !== tiersSig) { tiersSig = sig; $('pv-tiers').innerHTML = tiers.map(function (t) { return '<li>' + esc(t) + '</li>'; }).join(''); }
|
||
setText('pv-line', View.proveLine(pv, enabled, s.node.synced));
|
||
$('pv-line').className = 'line-text ' + (w.tone === 'on' || w.tone === 'ok' ? 'ok' : w.tone === 'bad' ? 'bad' : '');
|
||
setText('pv-note', (!enabled && pv.default_note) ? pv.default_note + '.' : w.note);
|
||
var v = View.verifierWords(pv);
|
||
setText('pv-verifier', v.word); $('pv-verifier').className = 'v ' + v.tone;
|
||
setText('pv-verifier-help', pv.verifier_note || 'the verifier: the node checks a proof before it counts');
|
||
$('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || v.needsSetup);
|
||
var seg = pv.segments_submitted ? pv.segments_submitted + ' proven whole, ' + (pv.segments_paid || 0) + ' paid' + (pv.segment_paid_wei && Number(pv.segment_paid_wei) ? ' (' + (Number(pv.segment_paid_wei) / 1e18).toFixed(4) + ' IGN)' : '') + (pv.segment_last_s ? ', the last took ' + Math.round(pv.segment_last_s) + ' s' : '') + (pv.segments_held ? ', ' + pv.segments_held + ' waiting for the chain' : '') : 'none yet';
|
||
setText('pv-seg', seg); setText('pv-seg-note', pv.segment_note && enabled ? pv.segment_note : 'whole segments (runs of 8 blocks) this machine proved');
|
||
setText('pv-program', pv.program_id || 'not read yet');
|
||
setText('pv-aggregator', pv.aggregator_id || 'not read yet');
|
||
}
|
||
|
||
// ---------- Settings ----------
|
||
var jobsSig = '';
|
||
function renderSettings(s) {
|
||
var st = s.settings || {}, sw = function (id, on) { if (document.activeElement !== $(id)) $(id).checked = !!on; };
|
||
sw('s-vote', st.vote); sw('s-login', st.start_at_login); sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', st.sweep); sw('s-trust', st.proof_verify_trust); sw('s-power-control', st.power_control); sw('s-climb', st.tune_climb); sw('s-profile', st.profile_public);
|
||
setText('s-profile-text', View.profileWords(!!st.profile_public));
|
||
setHidden('s-climb-row', st.tune_climb === undefined);
|
||
setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : ''));
|
||
renderNetworkCard(s);
|
||
fillRegions($('s-region'));
|
||
var rc = regionNow();
|
||
if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; }
|
||
fillCurrencies($('s-currency'), rc); if (document.activeElement !== $('s-currency')) $('s-currency').value = currencyNowCode();
|
||
var askS = View.currencyAsk(View.currencyChoice({ region: rc, currency: currencyNowCode() }, lang)); setText('s-currency-ask', askS); setHidden('s-currency-ask', !askS);
|
||
if (document.activeElement !== $('s-price')) { var p = priceNow() / View.priceFactor(View.currencyNow().code); $('s-price').value = p > 0 ? String(p) : ''; }
|
||
if (document.activeElement !== $('s-region')) regionWords($('s-region').value, null, $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), false);
|
||
var h = s.heat || {};
|
||
sw('s-heat', st.heat_on);
|
||
setText('s-heat-line', h.note || '');
|
||
$('s-heat-line').classList.toggle('ok', h.phase === 'heating'); $('s-heat-line').classList.toggle('rest', h.phase === 'resting');
|
||
if (document.activeElement !== $('s-heat-set')) $('s-heat-set').value = st.heat_set_c > 0 ? String(st.heat_set_c) : '';
|
||
if (document.activeElement !== $('s-heat-schedule')) $('s-heat-schedule').value = st.heat_schedule || '';
|
||
setText('s-heat-room-note', (st.heat_room_at > 0 ? 'Your reading ' + st.heat_room_c + ' °C, ' + View.ago(s.now - st.heat_room_at) + (s.now - st.heat_room_at > 7200 ? ' (older than two hours, so the card reads the room now)' : '') + '. ' : '') + 'A reading you type is the room for two hours. Without one the card\'s own sensor reads the room after 3 minutes of rest, within about 3 degrees' + (h.offset_learned ? ' (idle offset learned: ' + h.offset_c.toFixed(1) + ' degrees)' : '') + '. Typing a reading while the cards rest teaches the app the card\'s idle offset.');
|
||
renderTuneCard(s);
|
||
if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || '';
|
||
setText('s-mid', 'machine id ' + (s.machine_id || '').slice(0, 8));
|
||
setText('s-node-dir', 'chain data: ' + s.node_dir);
|
||
setText('s-log-dir', 'log files: ' + s.log_dir);
|
||
$('s-live').hidden = !s.live_page;
|
||
var iw = View.interfaceWords(s.ui);
|
||
setText('s-ui-line', iw.line || 'Interface'); setText('s-ui-eyebrow', iw.eyebrow || 'built in'); setText('s-ui-help', iw.help || 'Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.');
|
||
sw('s-ui-builtin', st.ui_builtin);
|
||
var u = s.update || {};
|
||
setText('s-version', 'Igneum Miner ' + s.version);
|
||
setText('s-update-note', settingsUpdateNote(u));
|
||
sw('s-auto-update', st.auto_update);
|
||
$('s-install').hidden = !(u.version && (u.ready || u.downloaded || u.status === 'error') && !/no update manifest configured/.test(u.error || '') && !u.applying);
|
||
$('nav-updates-dot').hidden = !(u.available && !u.applying);
|
||
fillJobs(s.jobs || {}, s.now);
|
||
}
|
||
function fillJobs(j, now) {
|
||
setText('s-jobs-key', j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : '');
|
||
setText('s-jobs-note', View.jobsNote(j, now, jobTitle));
|
||
var h = j.history || [], sig = JSON.stringify(h);
|
||
if (sig === jobsSig) return;
|
||
jobsSig = sig;
|
||
$('s-jobs-history-btn').hidden = !h.length;
|
||
$('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="empty">No job has run on this machine yet.</p>';
|
||
}
|
||
|
||
function renderPage(s) {
|
||
if (page === 'overview' || page === 'cards') renderMine(s);
|
||
else if (page === 'earnings') renderEarnings(s);
|
||
else if (page === 'prove') renderProve(s);
|
||
else if (page === 'settings') renderSettings(s);
|
||
}
|
||
function renderPill(s) {
|
||
var pill = $('pill'), pw = View.pill(s);
|
||
pill.classList.remove('on', 'warn');
|
||
if (pw.tone === 'on') pill.classList.add('on'); else if (pw.tone === 'bad') pill.classList.add('warn');
|
||
$('pill-text').textContent = pw.text;
|
||
var foot = (s.display_name ? '<b title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</b><br>' : '') + (s.address.display ? 'pays ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet') + '<br>up ' + uptime(s.uptime_s);
|
||
if (foot !== lastFoot) { lastFoot = foot; $('rail-status').innerHTML = foot; }
|
||
var fv = 'v' + esc(s.version) + '<span class="chain"> · ' + esc(s.chain || s.network || '') + '</span>';
|
||
if (fv !== lastVersion) { lastVersion = fv; $('foot-version').innerHTML = fv; }
|
||
}
|
||
var lastFoot = '', lastVersion = '';
|
||
// the clock card on Mine (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 = $('m-clock');
|
||
card.hidden = !(bad || warn);
|
||
card.classList.toggle('warn', warn);
|
||
setText('m-clock-msg', c.message || '');
|
||
setText('m-clock-hint', c.hint ? 'By hand: ' + c.hint : '');
|
||
setText('m-clock-result', c.sync_result || '');
|
||
$('m-clock-sync').disabled = !!c.syncing; setText('m-clock-sync', c.syncing ? 'Syncing' : 'Sync clock');
|
||
$('btn-start').disabled = bad;
|
||
$('start-blocked').hidden = !bad;
|
||
setText('start-blocked', bad ? c.message + ' Use "Sync clock" above.' : '');
|
||
$('btn-key-done').disabled = bad || !$('key-ack').checked;
|
||
}
|
||
|
||
// ---------- the status 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 = true; $('notice-detail').title = n.detail;
|
||
$('notice-more').hidden = !n.detail; $('notice-more').setAttribute('aria-expanded', 'false'); $('notice-more').classList.remove('open');
|
||
$('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 === 'more') { var open = $('notice-detail').hidden; $('notice-detail').hidden = !open; b.setAttribute('aria-expanded', open ? 'true' : 'false'); b.classList.toggle('open', open); layoutStrip(); }
|
||
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') { if (!$('upd').hidden) { e.preventDefault(); cardLater(); } else if (!$('ask-quit').hidden) $('ask-quit').hidden = true; } });
|
||
|
||
// ---------- over-the-air updates (src/ota.rs): the Settings card's line; 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('Up to date' + (u.checked_at ? '. Checked ' + rel(state.now - u.checked_at) : '') + '.');
|
||
else if (/no update manifest configured/.test(u.error || '')) parts.push('This build has no update address; updates come as a new download.');
|
||
else if (u.error) parts.push(u.error);
|
||
else if (!u.status || u.status === 'unknown') parts.push('Not checked yet. The app checks by itself every hour.');
|
||
if (u.activation_height && !u.urgent && state && state.node && u.activation_height > state.node.daa) parts.push('A rule change comes at block ' + 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.14', notes: 'The miner has four sections: Mine, Earnings, Prove, Settings. Every card is a row with its rate, watts, £ a day and a Tune button. The node is one line. Updates are one card.', 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.14 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.13'; u.version = '0.3.14'; }
|
||
return u;
|
||
}
|
||
// ---------- remote jobs (src/jobrun.rs): the Settings row; 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']; j.history = [{ id: j.id, kind: j.kind, title: j.title, status: 'running', started_at: j.started_at, finished_at: 0, exit: 0, run_id: 'r2', uploaded: false, summary: '' }]; }
|
||
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' }; j.history = [j.last]; }
|
||
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)']; j.history = [j.last]; }
|
||
return j;
|
||
}
|
||
// ?tune=running|tuned|measured : a sample tune state on the first card (screenshots, no engine)
|
||
function sampleTune(s, kind) {
|
||
var c = s.mining.cards[0]; if (!c) return;
|
||
if (kind === 'running') { c.state = 'tuning'; c.sweep_state = 'running'; c.tune_step = 7; c.tune_steps = 9; c.tune_eta_s = 230; c.tune_plan = 'full'; c.sweep_note = 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)'; if (!(c.power_w > 0)) c.power_w = 219.6; if (!(c.hash_now > 0)) c.hash_now = 123.4; }
|
||
var curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 70, watts: 264, mhs: 118.1, eff: 0.447 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2780, power_pct: 80, watts: 298, mhs: 122.4, eff: 0.411 }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 2160, power_pct: 80, watts: 281, mhs: 117.0, eff: 0.416 }, { clock_mhz: 1850, power_pct: 80, watts: 270, mhs: 109.2, eff: 0.404 }];
|
||
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; }
|
||
if (kind === 'mix' && s.mining.cards[1]) { var d = s.mining.cards[1]; d.state = 'tuning'; d.sweep_state = 'running'; d.tune_step = 4; d.tune_steps = 9; d.tune_eta_s = 410; d.tune_plan = 'full'; d.tune_control = true; d.hash_now = d.hash_now || 98.1; }
|
||
if (kind === 'measured') { c.tune_line = 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)'; c.tune_source = 'baseline'; c.tune_control = false; c.sweep_at = s.now - 600; c.sweep_note = 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed'; }
|
||
}
|
||
function render(s) {
|
||
state = s; stateAt = performance.now();
|
||
View.applyMoney({ region: (s.settings && s.settings.region) || stored('igneum.region') || '', currency: (s.settings && s.settings.currency) || stored('igneum.currency') || '' }, lang);
|
||
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.14'; s.uptime_s = 20; } }
|
||
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
|
||
if (params.get('tune')) sampleTune(s, params.get('tune'));
|
||
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') renderPage(s);
|
||
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }
|
||
}
|
||
// ---------- ui-ota: the health ping after the first paint, the first-paint error, the gentle reload ----------
|
||
var uiLoaded = '', uiPinged = false, uiFirstError = '';
|
||
window.addEventListener('error', function (e) { if (!uiPinged && !uiFirstError) { uiFirstError = String(e && e.message || 'script error'); api('api/ui/health', { error: uiFirstError }).catch(function () { }); } });
|
||
function uiHealth(s) {
|
||
if (uiPinged) return;
|
||
uiPinged = true;
|
||
uiLoaded = (s.ui && s.ui.active_version) || '';
|
||
if (!uiFirstError) api('api/ui/health', {}).catch(function () { });
|
||
}
|
||
function uiReloadIfChanged(s) {
|
||
var typing = !!(document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA' || document.activeElement.tagName === 'SELECT'));
|
||
if (View.shouldReload(uiLoaded, s.ui, { typing: typing, sheetOpen: !$('sheet-key').hidden || !$('upd').hidden, phase: phase })) { uiLoaded = ''; location.reload(); }
|
||
}
|
||
$('s-ui-builtin').addEventListener('change', function () { var on = this.checked; api('api/ui/builtin', { on: on }).then(function (r) { if (r.ok) toast(on ? 'The built-in interface serves from the next load' : 'The interface over the air serves again'); else { toast(r.error || 'could not change'); $('s-ui-builtin').checked = !on; } }); });
|
||
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') { page = 'settings'; show('dashboard'); }
|
||
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);
|
||
if (params.get('logs') === '1' && start === 'dashboard') setDrawer(true); // screenshots: the log drawer open
|
||
}
|
||
render(s);
|
||
pollChain(false);
|
||
uiHealth(s);
|
||
uiReloadIfChanged(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' : 'App not answering';
|
||
if (phase === 'dashboard') {
|
||
setText('node-line-text', 'Node: no answer'); setText('node-sub', 'the app is not answering. The window reconnects by itself'); $('node-card').classList.add('bad');
|
||
setText('btn-toggle-sub', 'the app is not answering'); $('btn-toggle').disabled = true;
|
||
setText('t-hash-sub', 'no answer from the app');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
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;
|
||
};
|
||
})();
|