diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2f1535f4..5b37b644e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,5 @@ jobs: run: bash tools/ci/copied-sources-check.sh - name: relay unit tests (parsers, secret compare, the wake endpoint) run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs + - name: miner app notice strip (ordering, keys, wording, timers) + run: node --test app/igneum-app/ui/notices.test.mjs diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 51a606de7..73ed4fd19 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -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} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 6fd6f2f12..cd372c3b5 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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= 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 ''; }).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; } diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index da8c1d022..8c166611c 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -23,22 +23,17 @@ - - -