Merge app-ui (the notice strip) into update-popup

This commit is contained in:
igneum-josh 2026-10-05 10:15:50 +01:00
commit 4ef1a78341
6 changed files with 349 additions and 113 deletions

View file

@ -65,3 +65,5 @@ jobs:
run: bash tools/ci/copied-sources-check.sh
- name: relay unit tests (parsers, secret compare, the wake endpoint)
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
- name: miner app notice strip (ordering, keys, wording, timers)
run: node --test app/igneum-app/ui/notices.test.mjs

View file

@ -78,18 +78,23 @@ body.mac .top{padding-left:92px}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}}
@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}}
/* banners under the top bar: clock, update, job */
.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:var(--ember-12);border-bottom:1px solid var(--ember-40);font-size:var(--t-md);line-height:1.4}
.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap}
.banner.clock.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
.banner .hint{font-size:var(--t-xs);color:var(--ash);flex-basis:100%;text-align:center}
.banner.update{flex-wrap:wrap;row-gap:var(--s-2)}
.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px}
.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
.banner.job{flex-wrap:wrap;row-gap:var(--s-2);background:var(--molten-10);border-bottom-color:var(--molten-40)}
.banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)}
.banner.job .job-results{flex-basis:100%;margin:0;font-size:var(--t-xs);line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto}
/* the notice strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's
top moves once per change with a 150 ms transition (layoutStrip), never per poll. Tones: default molten (running,
available, done), bad ember (failed, clock block, urgent). Nothing here is newer than 2022 CSS (WebView2 host). */
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-md);line-height:1.4;color:var(--bone)}
.notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
.notice-text{flex:1 1 320px;min-width:0}
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
.notice-actions:empty{display:none}
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
.notice.update-urgent .notice-close{color:#fff}
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
.notice .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
.job-history table{margin-top:var(--s-2)}
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0}
.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top}
@ -101,7 +106,8 @@ body.mac .top{padding-left:92px}
.clock-card .note{margin-top:0}
/* screens */
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain}
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
@media (prefers-reduced-motion:reduce){main{transition:none}}
body.has-bottom main{bottom:var(--bottom)}
body.drawer-open main{bottom:calc(var(--bottom) + var(--drawer-h))}
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}

View file

@ -1,7 +1,120 @@
/* Igneum Miner dashboard. Talks to the engine on the same origin: api/state every second, api/log while the
drawer is open. Screens: welcome, cards, address (plus the one-time key sheet), dashboard. ?screen=<name> forces
a screen (screenshots). Everything is relative to the token path the page was opened on. */
(function () {
/* ---------- notices: what the one strip under the header says (pure; notices.test.mjs loads this block) ----------
The state (src/state.rs) becomes at most one notice at a time, the most important first. Each notice has a key;
closing it hides that key until the state moves on (a new status, version or job). Levels, lowest number first:
0 update installing, urgent or failed 1 job failed 2 clock 3 job running
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new
version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job
notice is for the dashboard. */
var Notices = (function () {
'use strict';
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; }
function notice(kind, key, text, extra) {
var n = { kind: kind, level: LEVEL[kind], key: key, text: text, tone: '', detail: '', actions: [], progress: -1, title: '' };
if (extra) for (var k in extra) n[k] = extra[k];
return n;
}
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'close', label: 'Later' };
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
// u = state.update; s = { version, uptime_s, quitting }
function updateNotice(u, s) {
if (!u) return null;
s = s || {};
var ver = u.version || '', v = 'Igneum Miner ' + ver, pct = u.progress > 0 ? Math.round(u.progress * 100) : 0;
if (u.status === 'error') {
if (/no update manifest configured/.test(u.error || '')) return null; // a build without a manifest: nothing to try, Settings says so
var text = u.rolled_back ? (v + ' did not stay up and was rolled back.') : ('The update' + (ver ? ' to ' + ver : '') + ' failed.');
return notice('update-failed', 'update:error:' + ver, text, { tone: 'bad', detail: firstLine(u.error), actions: [RETRY], title: u.error || '' });
}
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
var t = s.quitting ? 'Installing ' + v + '. The app restarts itself in a moment.' : 'Installing ' + v + '. The app restarts itself. Mining continues until then.';
return notice('update-installing', 'update:applying:' + ver, t, { title: u.wait || '' });
}
if (u.urgent && u.urgent_text) {
var dl = u.status === 'downloading';
return notice('update-urgent', 'update:urgent:' + ver, u.urgent_text + (dl ? ' Downloading' + (pct ? ': ' + pct + '%' : '') + '.' : ''), { tone: 'bad', progress: dl ? (u.progress || 0) : -1 });
}
switch (u.status) {
case 'available': return notice('update-available', 'update:' + ver + ':pending', v + ' is available.', { actions: [INSTALL, LATER] });
case 'downloading': return notice('update-available', 'update:' + ver + ':pending', 'Downloading ' + v + (pct ? ': ' + pct + '%' : '') + '.', { progress: u.progress || 0, actions: [INSTALL, LATER], title: u.size ? Math.round(u.size / 1e6) + ' MB' : '' });
case 'staging': return notice('update-available', 'update:' + ver + ':pending', 'Checking ' + v + '.', { actions: [LATER] });
case 'ready':
var why = /failed to install before/.test(u.wait || '') ? ' It failed to install before.' : u.auto ? ' It installs by itself at a quiet moment.' : '';
return notice('update-available', 'update:' + ver + ':ready', v + ' is ready.' + why, { actions: [INSTALL, LATER], title: u.wait || '' });
case 'deferred': return notice('update-available', 'update:' + ver + ':deferred', v + ' is waiting for permission. It installs the next time someone is at this PC. Mining continues.', { actions: [INSTALL, LATER] });
case 'manual': return notice('update-available', 'update:' + ver + ':manual', v + ' is downloaded. Open it and drag the app over the old one.', { actions: [OPEN, LATER], title: u.wait || '' });
}
if (u.updated_from && s.uptime_s >= 0 && s.uptime_s < INSTALLED_S) {
var now = s.version || ver;
return notice('update-installed', 'update:installed:' + now, 'Updated to Igneum Miner ' + now + ' from ' + u.updated_from + '.');
}
return null;
}
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (mining continues)' : j.kind); }
function firstErrorLine(results, summary) {
for (var i = 0; i < results.length; i++) if (/BUILD FAILED|error|failed|panic/i.test(results[i])) return firstLine(results[i]);
return firstLine(summary);
}
// j = state.jobs; now = state.now (unix seconds)
function jobNotice(j, now) {
if (!j) return null;
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
var results = (j.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; });
return notice('job-running', 'job:run:' + j.id, 'Job: ' + jobTitle(j) + ' running, ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: results.length ? results[results.length - 1] : '', title: j.message || '' });
}
var l = j.last;
if (!l || !l.id) return null;
var mins = Math.max(0, Math.round((l.finished_at - l.started_at) / 60)), t = l.title || l.kind;
var report = l.uploaded ? 'Report uploaded.' : 'Report not uploaded.';
if (l.status === 'done') {
if (l.finished_at && now - l.finished_at > JOB_DONE_S) return null;
return notice('job-done', 'job:done:' + l.id, 'Job: ' + t + ' done after ' + mins + ' min. ' + report, { title: l.summary || '' });
}
var word = l.status === 'timeout' ? 'hit its time cap' : l.status === 'aborted' ? 'was stopped' : 'failed';
return notice('job-failed', 'job:failed:' + l.id, 'Job: ' + t + ' ' + word + ' after ' + mins + ' min, exit ' + l.exit + '. ' + report, { tone: 'bad', detail: firstErrorLine(j.last_results || [], l.summary), title: l.summary || '' });
}
// c = state.clock: the engine's words, Sync clock, the manual hint underneath
function clockNotice(c) {
if (!c || !(c.severity === 'block' || c.severity === 'warn')) return null;
return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] });
}
// every notice the state carries for this screen; pick() chooses the one to show
function gather(s, where) {
where = where || {};
var out = [], u = updateNotice(s.update, s);
if (u) out.push(u);
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
return out;
}
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
function pick(list, dismissed) {
var best = null;
for (var i = 0; i < list.length; i++) {
var n = list[i];
if (!n || (dismissed && dismissed[n.key])) continue;
if (!best || n.level < best.level) best = n;
}
return best;
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
})();
if (typeof module === 'object' && module && module.exports) module.exports = Notices;
if (typeof document !== 'undefined') (function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@ -10,6 +123,7 @@
var params = new URLSearchParams(location.search);
if (params.get('screen')) forced = params.get('screen');
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
var forcedJob = params.get('job'); // a sample remote job for screenshots: running|done|failed
if (params.get('host') === 'mac') document.body.classList.add('mac');
// ?debug=1 prints a budget line every 5 s (ms of JS per second in the canvas, the state render and the log) and
// exposes the counters as window.__igneumPerf
@ -171,10 +285,6 @@
$('s-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); });
$('s-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); });
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('update-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); });
$('update-open').addEventListener('click', function () { api('api/update/open', {}); });
$('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = (state && state.update) ? (state.update.status + ':' + state.update.version) : '1'; layoutBanners(); });
$('job-hide').addEventListener('click', function () { $('job-banner').hidden = true; $('job-banner').dataset.dismissed = jobKey(state && state.jobs); layoutBanners(); });
$('s-prove').addEventListener('change', function () { api('api/prove', { on: this.checked }).then(function (r) { if (r.ok) toast(r.ok && $('s-prove').checked ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); setTimeout(fillSettings, 800); });
@ -672,14 +782,9 @@
else text = s.mining.state;
$('pill-text').textContent = text;
}
// the clock on the dashboard: the node card (the strip carries it on the setup screens, see Notices)
function renderClock(s) {
var c = s.clock || { severity: 'none' }, bad = c.severity === 'block', warn = c.severity === 'warn';
var banner = $('clock-banner');
banner.hidden = !(bad || warn) || phase === 'dashboard';
banner.classList.toggle('warn', warn);
$('clock-banner-text').textContent = c.message || '';
$('clock-banner-hint').textContent = c.hint || '';
$('clock-sync').disabled = !!c.syncing; $('clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock';
var card = $('n-clock');
card.hidden = !(bad || warn);
card.classList.toggle('warn', warn);
@ -691,60 +796,65 @@
$('start-blocked').hidden = !bad;
$('start-blocked').textContent = bad ? c.message + ' Use "Sync clock" above.' : '';
$('btn-key-done').disabled = bad || !$('key-ack').checked;
layoutBanners();
}
// fixed banners push the content down by their height
function layoutBanners() {
var h = 0;
['clock-banner', 'update-banner', 'job-banner'].forEach(function (id) { var b = $(id); if (!b.hidden) { b.style.top = (60 + h) + 'px'; h += b.offsetHeight; } });
$('main').style.top = (60 + h) + 'px';
}
window.addEventListener('resize', layoutBanners);
$('clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
$('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
// ---------- over-the-air updates (src/ota.rs): one banner, the settings note ----------
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function updateLine(u) {
var v = 'Igneum Miner ' + u.version;
if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' };
switch (u.status) {
case 'available': return { text: v + ' is available. Downloading it.' };
case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true };
case 'staging': return { text: v + ' downloaded and verified. Preparing it.' };
case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs at the next safe moment.'), install: true };
case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the miners stop, then the node, then the app opens again.') };
case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC. Mining continues.', install: true };
case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true };
case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) };
default: return null;
// ---------- the notice strip (Notices, top of this file): one notice, its actions, the close control ----------
// dismissed: the keys closed this session; a notice with a closed key stays hidden until its key changes
var dismissed = {}, noticeSig = '', stripH = 0;
function renderNotices(s) {
var n = Notices.pick(Notices.gather(s, { dashboard: phase === 'dashboard' }), dismissed);
var strip = $('notices'), el = $('notice');
if (!n) {
if (noticeSig) { noticeSig = ''; strip.hidden = true; layoutStrip(); }
return;
}
var sig = [n.key, n.kind, n.tone, n.text, n.detail, n.progress >= 0 ? 'p' : '', JSON.stringify(n.actions)].join('|');
if (sig !== noticeSig) {
noticeSig = sig;
el.className = 'notice ' + n.kind + (n.tone ? ' ' + n.tone : '');
el.dataset.key = n.key;
$('notice-text').textContent = n.text; $('notice-text').title = n.title || '';
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = !n.detail; $('notice-detail').title = n.detail;
$('notice-actions').innerHTML = n.actions.map(function (a) { return '<button class="btn small ' + (a.primary ? 'primary' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('notice-prog').hidden = !(n.progress >= 0);
strip.hidden = false;
}
if (n.progress >= 0) $('notice-prog').firstElementChild.style.width = Math.round(n.progress * 100) + '%';
layoutStrip();
}
// main sits under the strip: its top is set once per height change and moves with the 150 ms transition in the
// CSS; an empty strip reserves nothing
function layoutStrip() {
var strip = $('notices'), h = strip.hidden ? 0 : strip.offsetHeight;
if (h === stripH) return;
stripH = h;
var top = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--top'), 10) || 60;
$('main').style.top = h ? (top + h) + 'px' : '';
}
window.addEventListener('resize', layoutStrip);
$('notice').addEventListener('click', function (e) {
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, key = $('notice').dataset.key;
if (act === 'close') { dismissed[key] = true; renderNotices(state || {}); }
else if (act === 'install') { api('api/update/install', {}); toast('Installing at once'); }
else if (act === 'retry') { api('api/update/install', {}); toast('Trying the update again'); }
else if (act === 'open') api('api/update/open', {});
else if (act === 'sync') api('api/clock/sync', {});
});
// ---------- over-the-air updates (src/ota.rs): the settings note; the strip is Notices.updateNotice ----------
function settingsUpdateNote(u) {
var l = updateLine(u), parts = [];
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 (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text);
if (n && n.kind !== 'update-installed' && !(u.rolled_back && u.status === 'error')) parts.push(n.text + (n.detail ? ' ' + Notices.endDot(n.detail) : ''));
else if (u.status === 'checking') parts.push('Checking.');
else if (u.status === 'current') parts.push('This is the latest version' + (u.checked_at ? ' (checked ' + rel(state.now - u.checked_at) + ')' : '') + '.');
else if (u.error) parts.push(u.error);
if (u.activation_height && !u.urgent) parts.push('Consensus upgrade at height ' + withCommas(u.activation_height) + '.');
return parts.join(' ');
}
function renderUpdate(s) {
var u = s.update, b = $('update-banner'), l = updateLine(u);
var key = u.status + ':' + u.version;
var show = !!l && (u.urgent || u.applying || b.dataset.dismissed !== key);
if (show) {
$('update-text').textContent = l.text;
b.classList.toggle('urgent', !!l.urgent);
$('update-install').hidden = !l.install || u.applying;
$('update-open').hidden = !l.open;
$('update-later').hidden = !!l.urgent || !!u.applying;
$('update-prog').hidden = !l.prog;
$('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%';
}
if (b.hidden === show) { b.hidden = !show; layoutBanners(); }
}
function sampleUpdate(kind) {
var u = { available: true, version: '0.3.1', notes: 'difficulty v2, OTA updates', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' };
if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; }
@ -758,34 +868,14 @@
if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.0'; u.version = '0.3.1'; }
return u;
}
// ---------- remote jobs (src/jobrun.rs): one strip while a job runs and after it, the settings block ----------
function jobKey(j) { if (!j) return ''; return j.active ? ('run:' + j.id) : (j.last && j.last.id ? ('done:' + j.last.id + ':' + j.last.status) : ''); }
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (node and app, mining continues)' : j.kind); }
function jobLine(j, now) {
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
return { text: 'Job: ' + jobTitle(j) + ' running, ' + m + ' min' + (j.stage ? '. ' + cap(j.stage) + '.' : '.') + (j.message ? ' ' + j.message : '') + (j.account ? ' ' + j.account : ''), results: j.results || [] };
}
var l = j.last;
if (!l || !l.id) return null;
// the last outcome stays up for 5 minutes when it succeeded, 30 minutes when it failed (hide dismisses it at once)
if (l.finished_at && now - l.finished_at > (l.status === 'done' ? 300 : 1800)) return null;
var d = Math.max(0, Math.round((l.finished_at - l.started_at) / 60));
return { text: 'Job: ' + (l.title || l.kind) + ' ' + l.status + ' after ' + d + ' min, exit ' + l.exit + (l.uploaded ? ', report uploaded' : ', report not uploaded') + (l.summary ? '. ' + l.summary : ''), results: j.last_results || [], failed: l.status !== 'done' };
}
function renderJobs(s) {
var j = s.jobs || {}, b = $('job-banner'), l = jobLine(j, s.now || 0);
var key = jobKey(j);
var show = !!l && b.dataset.dismissed !== key && phase === 'dashboard';
if (show) {
$('job-text').textContent = l.text;
b.classList.toggle('failed', !!l.failed);
var r = (l.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; }).slice(-6);
$('job-results').textContent = r.join('\n');
$('job-results').hidden = !r.length;
}
if (b.hidden === show) { b.hidden = !show; layoutBanners(); }
else if (show) layoutBanners();
// ---------- remote jobs (src/jobrun.rs): the settings block; the strip is Notices.jobNotice ----------
var jobTitle = Notices.jobTitle;
function sampleJob(kind, now) {
var j = { allowed: true, key_fingerprint: '8f186e37', url_set: true, checked_at: now - 30, error: '', queued: 0, account: '', active: false, id: '', kind: '', title: '', stage: '', message: '', started_at: 0, run_id: '', results: [], last: {}, last_results: [], history: [] };
if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; }
else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; }
else if (kind === 'failed') { j.last = { id: 'job-2026-10-05-01', kind: 'build', title: 'build', status: 'failed', started_at: now - 2000, finished_at: now - 900, exit: 1, run_id: 'r0', uploaded: false, summary: 'build exited with code 1' }; j.last_results = ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo` in this scope (src/engine.rs:2082)']; }
return j;
}
function fillJobsSettings(j) {
$('s-jobs-allow').checked = !!j.allowed;
@ -807,11 +897,11 @@
}
function render(s) {
state = s; stateAt = performance.now();
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') s.version = '0.3.1'; }
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.1'; s.uptime_s = 20; } }
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
renderPill(s);
renderClock(s);
renderUpdate(s);
renderJobs(s);
renderNotices(s);
if (phase === 'cards') renderCards(s);
if (phase === 'dashboard') renderDashboard(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }

View file

@ -23,22 +23,17 @@
</div>
</header>
<div class="banner clock" id="clock-banner" hidden>
<span id="clock-banner-text"></span>
<button class="btn small primary" id="clock-sync">Sync clock</button>
<span class="hint mono" id="clock-banner-hint"></span>
</div>
<div class="banner update" id="update-banner" hidden>
<span id="update-text">A new version of Igneum Miner is ready.</span>
<button class="btn small primary" id="update-install" hidden>Install now</button>
<button class="btn small primary" id="update-open" hidden>Open the download</button>
<button class="btn small ghost" id="update-later">Later</button>
<span class="prog" id="update-prog" hidden><i></i></span>
</div>
<div class="banner job" id="job-banner" hidden>
<span id="job-text"></span>
<button class="btn small ghost" id="job-hide">Hide</button>
<pre class="job-results mono" id="job-results" hidden></pre>
<!-- the notice strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
it from the state and the content below moves once when it appears or goes. The close control hides a notice
until its state moves on. -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
<span class="notice-actions" id="notice-actions"></span>
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">&times;</button>
<span class="notice-detail mono" id="notice-detail" hidden></span>
<span class="prog" id="notice-prog" hidden><i></i></span>
</div>
</div>
<main id="main">

View file

@ -0,0 +1,141 @@
// node --test app/igneum-app/ui/notices.test.mjs (no dependencies; CI runs it in the site job)
// Loads the Notices block at the top of app.js (plain browser JS: the file is run with `module` defined and no
// `document`, so only the pure block executes) and checks the ordering, the keys, the wording and the timers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const N = mod.exports;
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
const NOW = 1_800_000_000;
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
const run = (over) => ({ allowed: true, active: true, id: 'job-7', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', message: 'some output line', started_at: NOW - 400, results: ['STAGE prove', 'RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], last: {}, last_results: [], history: [], ...over });
const fin = (status, over) => ({ allowed: true, active: false, id: '', last: { id: 'job-6', kind: 'build', title: 'build', status, started_at: NOW - 2000, finished_at: NOW - 60, exit: status === 'done' ? 0 : 1, run_id: 'r', uploaded: status === 'done', summary: status === 'done' ? 'build finished, exit 0' : 'build exited with code 1' }, last_results: status === 'done' ? ['RESULT ok'] : ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo`'], history: [], ...over });
const s = (over) => ({ version: '0.3.5', uptime_s: 5000, now: NOW, quitting: false, update: upd({ status: 'current', available: false, ready: false, downloaded: false }), jobs: { active: false, last: {} }, clock: { severity: 'none' }, ...over });
test('ordering: installing > job failed > clock > job running > update available > job done > updated', () => {
const all = [
updateNotice(upd({ status: 'applying', applying: true }), s()),
jobNotice(fin('failed'), NOW),
clockNotice({ severity: 'warn', message: 'Clock 40 s ahead.', hint: 'Settings' }),
jobNotice(run(), NOW),
updateNotice(upd(), s()),
jobNotice(fin('done'), NOW),
updateNotice(upd({ status: 'current', updated_from: '0.3.4' }), s({ uptime_s: 10 })),
];
const kinds = ['update-installing', 'job-failed', 'clock', 'job-running', 'update-available', 'job-done', 'update-installed'];
assert.deepEqual(all.map((n) => n.kind), kinds);
// the levels climb, and shuffling the list changes nothing: the lowest level wins
for (let i = 1; i < all.length; i++) assert.ok(all[i].level > all[i - 1].level, `${all[i].kind} after ${all[i - 1].kind}`);
const shuffled = [all[5], all[2], all[6], all[0], all[3], all[1], all[4]];
for (let i = 0; i < kinds.length; i++) {
const rest = shuffled.filter((n) => kinds.indexOf(n.kind) >= i);
assert.equal(pick(rest, {}).kind, kinds[i]);
}
// urgent and failed updates sit with installing at the top
assert.equal(updateNotice(upd({ urgent: true, urgent_text: 'Consensus upgrade at height 120000.' }), s()).level, 0);
assert.equal(updateNotice(upd({ status: 'error', error: 'sha256 mismatch' }), s()).level, 0);
});
test('a closed key hides that notice and the next one waits its turn', () => {
const list = [jobNotice(fin('failed'), NOW), jobNotice(run({ id: 'job-8' }), NOW)];
assert.equal(pick(list, {}).kind, 'job-failed');
const dismissed = { 'job:failed:job-6': true };
assert.equal(pick(list, dismissed).kind, 'job-running');
assert.equal(pick([list[0]], dismissed), null);
// a new job id is a new key, so it shows again
const again = jobNotice(fin('failed', { last: { ...fin('failed').last, id: 'job-9' } }), NOW);
assert.equal(pick([again], dismissed).kind, 'job-failed');
});
test('update keys: Later on the download hides it until it is ready; Later on ready hides that version', () => {
const avail = updateNotice(upd({ status: 'available', ready: false, downloaded: false, progress: 0 }), s());
const down = updateNotice(upd({ status: 'downloading', ready: false, downloaded: false, progress: 0.43 }), s());
const stage = updateNotice(upd({ status: 'staging', ready: false }), s());
const ready = updateNotice(upd(), s());
assert.equal(avail.key, down.key); assert.equal(down.key, stage.key); assert.notEqual(stage.key, ready.key);
const dismissed = { [avail.key]: true };
assert.equal(pick([down], dismissed), null);
assert.equal(pick([ready], dismissed).kind, 'update-available');
// a different version is a different key
assert.notEqual(updateNotice(upd({ version: '0.3.7' }), s()).key, ready.key);
// percent changes do not change the key
assert.equal(updateNotice(upd({ status: 'downloading', progress: 0.9 }), s()).key, down.key);
});
test('update wording: available, downloading with percent, installing, failed with one line of cause, updated', () => {
const avail = updateNotice(upd({ status: 'available' }), s());
assert.equal(avail.text, 'Igneum Miner 0.3.6 is available.');
assert.deepEqual(avail.actions.map((a) => a.label), ['Install now', 'Later']);
const down = updateNotice(upd({ status: 'downloading', progress: 0.43 }), s());
assert.equal(down.text, 'Downloading Igneum Miner 0.3.6: 43%.');
assert.equal(down.progress, 0.43);
assert.equal(updateNotice(upd({ status: 'downloading', progress: 0 }), s()).text, 'Downloading Igneum Miner 0.3.6.');
const inst = updateNotice(upd({ status: 'applying', applying: true }), s());
assert.equal(inst.text, 'Installing Igneum Miner 0.3.6. The app restarts itself. Mining continues until then.');
assert.equal(updateNotice(upd({ status: 'applying', applying: true }), s({ quitting: true })).text, 'Installing Igneum Miner 0.3.6. The app restarts itself in a moment.');
// Install now on a ready update: the engine says "installing now" for up to 3 s before it flips to applying
assert.equal(updateNotice(upd({ wait: 'installing now' }), s()).kind, 'update-installing');
const err = updateNotice(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line' }), s());
assert.equal(err.text, 'The update to 0.3.6 failed.');
assert.equal(err.detail, 'sha256 mismatch: the file is not what the manifest signed');
assert.deepEqual(err.actions.map((a) => a.label), ['Try again']);
assert.equal(err.tone, 'bad');
const rb = updateNotice(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.3.6: did not stay up' }), s());
assert.equal(rb.text, 'Igneum Miner 0.3.6 did not stay up and was rolled back.');
const ready = updateNotice(upd(), s());
assert.equal(ready.text, 'Igneum Miner 0.3.6 is ready. It installs by itself at a quiet moment.');
assert.equal(updateNotice(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), s()).text, 'Igneum Miner 0.3.6 is ready.');
assert.equal(updateNotice(upd({ status: 'manual', wait: '/Applications is not writable; open the downloaded disk image and drag the app over the old one' }), s()).actions[0].label, 'Open the download');
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
// updated: gone within 60 s of the new version starting
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
assert.equal(updateNotice(upd({ status: 'error', error: 'no update manifest configured in this build' }), s()), null);
assert.equal(updateNotice(upd({ status: 'checking', available: false }), s()), null);
});
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
const r = jobNotice(run(), NOW);
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
const d = jobNotice(fin('done'), NOW);
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
assert.equal(d.key, 'job:done:job-6');
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
const f = jobNotice(fin('failed'), NOW);
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
assert.equal(f.tone, 'bad');
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
// no error line among the results: the summary is the cause
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
assert.equal(jobNotice(null, NOW), null);
});
test('clock: the engine words, Sync clock, the hint; gather() keeps it off the dashboard and the job off the setup screens', () => {
const c = clockNotice({ severity: 'block', message: 'This clock is 7 min behind.', hint: 'Date & Time', syncing: false });
assert.equal(c.text, 'This clock is 7 min behind.'); assert.equal(c.detail, 'Date & Time'); assert.equal(c.tone, 'bad');
assert.equal(c.actions[0].label, 'Sync clock'); assert.equal(c.actions[0].disabled, false);
assert.equal(clockNotice({ severity: 'warn', message: 'x', syncing: true }).actions[0].label, 'Syncing');
assert.equal(clockNotice({ severity: 'none' }), null);
const st = s({ clock: { severity: 'warn', message: 'Clock 40 s ahead.' }, jobs: run(), update: upd({ status: 'available' }) });
assert.deepEqual(gather(st, { dashboard: true }).map((n) => n.kind), ['update-available', 'job-running']);
assert.deepEqual(gather(st, { dashboard: false }).map((n) => n.kind), ['update-available', 'clock']);
assert.equal(pick(gather(st, { dashboard: true }), {}).kind, 'job-running');
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
assert.deepEqual(gather({}, { dashboard: true }), []);
});

View file

@ -114,7 +114,9 @@ Windows: the previous version's installer kept in `updates/`, so the FIRST updat
on Windows, said so in the state) and shows "rolled back" in Settings.
Settings: `auto_update` (default on). Off: downloads still happen, the banner waits for Install now. The forced
screenshots: `?update=available|downloading|ready|waiting|applying|urgent|manual|error|updated` on the dashboard URL.
screenshots: `?update=available|downloading|ready|waiting|applying|urgent|manual|error|updated` on the dashboard URL
(and `?job=running|done|failed` for the remote-job notice). The dashboard shows one notice at a time in the strip under
the header, the most important first (app/igneum-app/ui/app.js, Notices).
## Testing