Miner app UI: one notice strip under the header replaces the three stacked banners (updates, jobs, clock)
One notice at a time, the most important first (app/igneum-app/ui/app.js, Notices; pure, unit tested):
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
Lower notices wait their turn. Every notice has a close control; closing hides that notice's key until the
state moves on (a new status, version or job id).
States and their rules:
update available "Igneum Miner X is available." Install now, Later. Key update:X:pending.
update downloading "Downloading Igneum Miner X: 43%." (no percent when unknown), progress bar; same key as
available and checking, so Later hides the whole download until it is ready.
update checking "Checking Igneum Miner X." (the engine's staging step).
update ready "Igneum Miner X is ready. It installs by itself at a quiet moment." (auto on) or just
"... is ready." Install now, Later. Key update:X:ready.
update waiting Windows, nobody answered the administrator prompt: "... is waiting for permission. It
installs the next time someone is at this PC. Mining continues."
update manual "... is downloaded. Open it and drag the app over the old one." Open the download.
update installing "Installing Igneum Miner X. The app restarts itself. Mining continues until then."
(on a Mac, where the engine quits at once: "The app restarts itself in a moment.")
Also while the engine says "installing now" after Install now.
update urgent the engine's consensus-deadline text, ember, downloading percent when it downloads.
update failed "The update to X failed." plus one line of cause and Try again; rolled back:
"Igneum Miner X did not stay up and was rolled back." A dev build with no manifest
configured shows nothing (Settings still says it).
updated "Updated to Igneum Miner X from Y." Gone 60 s after the new version started.
job running "Job: <title> running, N min. <Stage>." with the last RESULT line underneath.
job done "Job: <title> done after N min. Report uploaded." Gone after 5 minutes.
job failed "Job: <title> failed after N min, exit C. Report not uploaded." plus the first error
line (BUILD FAILED / error / failed / panic among the result lines, else the summary).
Stays until closed. Timeout and aborted are "hit its time cap" and "was stopped".
clock as before: the engine's words, Sync clock, the manual hint; on the setup screens only
(the node card carries it on the dashboard). Jobs show on the dashboard only.
Layout: the strip reserves no height while empty; when a notice appears or goes, main's top moves once with a
150 ms transition (none under prefers-reduced-motion). Existing tokens only, nothing newer than 2022 CSS.
Screenshots: ?update=<state> as before, ?job=running|done|failed added (packaging/ota/README.md).
Test: node --test app/igneum-app/ui/notices.test.mjs (ordering, dismissed keys, wording, the 5-minute and
60-second timers); added to the CI site job. Built once with cargo (include_str) and checked against the
ui-mock scenarios and a scratch engine instance (IGNEUM_APP_DATA in a temp dir, fake worker).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
eadb6c9352
commit
7388a20f95
6 changed files with 349 additions and 113 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -63,3 +63,5 @@ jobs:
|
|||
run: bash tools/ci/identity-check.sh
|
||||
- name: relay unit tests (parsers, secret compare)
|
||||
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs
|
||||
- name: miner app notice strip (ordering, keys, wording, timers)
|
||||
run: node --test app/igneum-app/ui/notices.test.mjs
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">×</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">
|
||||
|
|
|
|||
141
app/igneum-app/ui/notices.test.mjs
Normal file
141
app/igneum-app/ui/notices.test.mjs
Normal 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 }), []);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue