Merge app-ui (ce31cbb) into release-0.3.6: one notice strip under the header; CI runs both the wake and the notice tests
This commit is contained in:
commit
356b3e60d0
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, 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
|
||||
|
|
|
|||
|
|
@ -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