ui-ota (0.3.20): Settings > Interface, the health ping, the first-paint error and the gentle reload

"Interface <version>, over the air, <date>" or "built in", with the help line for pending, refused, held back and
skipped; the "Use the built-in interface" switch (POST /api/ui/builtin). The page posts /api/ui/health once after its
first paint, or the first script error it catches before that; when the served interface changes under it, the page
reloads itself only when idle (no input focused, no sheet or update card open, on the dashboard). ui-ota.test.mjs
covers the words, the reload rule, the ping and that the page loads no remote script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 10:28:03 +00:00
parent 3417f1e1fa
commit a9ca84c90f
4 changed files with 106 additions and 1 deletions

View file

@ -941,9 +941,36 @@ var View = (function () {
}
function profileWords(on) { return on ? 'Your address page on igneum.network is public: your key ids, blocks in the window, weight rank, signing presence and card model are shown. The site reads it from the chain and from this switch.' : 'Off: igneum.network/address keeps your page unlisted (noindex). Nothing about this machine is published.'; }
// the confirmations' words (in place, never a dialog)
// ui-ota (src/uiota.rs): Settings > Interface. u = state.ui. {line, eyebrow, help}
function interfaceWords(u) {
u = u || {};
var ver = u.active_version || u.embedded_version || '';
if (!ver) return { line: '', eyebrow: '', help: '' };
var ota = u.source === 'ota';
var when = ota && u.installed_at > 0 ? dateWords(u.installed_at) : '';
var line = 'Interface ' + ver + (ota ? ', over the air' + (when ? ', ' + when : '') : ', built in');
var help = '';
if (ota && !u.confirmed) help = 'Waiting for this window to confirm the new interface.';
else if (u.error) help = 'The last interface over the air was refused: ' + u.error + ' The built-in one serves.';
else if (u.builtin && u.published_version && u.published_version !== u.embedded_version) help = 'Interface ' + u.published_version + ' is published over the air; switch this off to take it.';
else if (u.busy) help = 'Interface ' + u.published_version + ' is downloading.';
else if (u.note) help = cap(u.note) + '.';
else if (ota) help = 'Built in: ' + (u.embedded_version || '') + '.';
return { line: line, eyebrow: ota ? 'over the air' : 'built in', help: help };
}
function dateWords(unix) { var d = new Date(unix * 1000); return d.getDate() + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' + d.getFullYear(); }
// the page reloads itself when the served interface changed under it and nobody is mid-task:
// loaded = the version this page came up with, u = state.ui, ctx = {typing, sheetOpen, phase}
function shouldReload(loaded, u, ctx) {
if (!loaded || !u || !u.active_version || u.active_version === loaded) return false;
ctx = ctx || {};
if (ctx.typing || ctx.sheetOpen) return false;
if (ctx.phase && ctx.phase !== 'dashboard') return false;
return true;
}
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords };
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -2081,6 +2108,9 @@ if (typeof document !== 'undefined') (function () {
setText('s-node-dir', 'chain data: ' + s.node_dir);
setText('s-log-dir', 'log files: ' + s.log_dir);
$('s-live').hidden = !s.live_page;
var iw = View.interfaceWords(s.ui);
setText('s-ui-line', iw.line || 'Interface'); setText('s-ui-eyebrow', iw.eyebrow || 'built in'); setText('s-ui-help', iw.help || 'Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.');
sw('s-ui-builtin', st.ui_builtin);
var u = s.update || {};
setText('s-version', 'Igneum Miner ' + s.version);
setText('s-update-note', settingsUpdateNote(u));
@ -2299,6 +2329,20 @@ if (typeof document !== 'undefined') (function () {
if (phase === 'dashboard') renderPage(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }
}
// ---------- ui-ota: the health ping after the first paint, the first-paint error, the gentle reload ----------
var uiLoaded = '', uiPinged = false, uiFirstError = '';
window.addEventListener('error', function (e) { if (!uiPinged && !uiFirstError) { uiFirstError = String(e && e.message || 'script error'); api('api/ui/health', { error: uiFirstError }).catch(function () { }); } });
function uiHealth(s) {
if (uiPinged) return;
uiPinged = true;
uiLoaded = (s.ui && s.ui.active_version) || '';
if (!uiFirstError) api('api/ui/health', {}).catch(function () { });
}
function uiReloadIfChanged(s) {
var typing = !!(document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA' || document.activeElement.tagName === 'SELECT'));
if (View.shouldReload(uiLoaded, s.ui, { typing: typing, sheetOpen: !$('sheet-key').hidden || !$('upd').hidden, phase: phase })) { uiLoaded = ''; location.reload(); }
}
$('s-ui-builtin').addEventListener('change', function () { var on = this.checked; api('api/ui/builtin', { on: on }).then(function (r) { if (r.ok) toast(on ? 'The built-in interface serves from the next load' : 'The interface over the air serves again'); else { toast(r.error || 'could not change'); $('s-ui-builtin').checked = !on; } }); });
var failures = 0, first = true, pollTick = 0;
function poll() {
// hidden windows poll every 5 s (the pill and the tray text still need a recent state), visible ones every second
@ -2317,6 +2361,8 @@ if (typeof document !== 'undefined') (function () {
}
render(s);
pollChain(false);
uiHealth(s);
uiReloadIfChanged(s);
perf.render += performance.now() - a; perf.polls++;
if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); }
}).catch(function () {

View file

@ -477,6 +477,13 @@
</div>
</div>
<div class="card" id="s-interface-card">
<div class="card-head"><h3>Interface</h3><span class="eyebrow" id="s-ui-eyebrow">built in</span></div>
<p class="line-text" id="s-ui-line">Interface 1.0.0, built in</p>
<p class="help" id="s-ui-help">Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.</p>
<label class="switch"><input type="checkbox" id="s-ui-builtin"><span class="track"></span><span class="sw-text"><b>Use the built-in interface</b> <span class="dim">Serves the interface this version of the app was built with, even when a newer one has arrived over the air.</span></span></label>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">

View file

@ -0,0 +1,49 @@
// node --test app/igneum-app/ui/ui-ota.test.mjs (no dependencies; the pre-push gate runs it)
// The interface over the air (docs/plans/ui-ota.md): Settings > Interface's words and the gentle reload rule.
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 here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
test('the embedded interface says built in; an over-the-air one says so with its date', () => {
assert.deepEqual(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true }), { line: 'Interface 1.0.0, built in', eyebrow: 'built in', help: '' });
const w = V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', installed_at: Date.UTC(2026, 9, 7, 12) / 1000, confirmed: true });
assert.equal(w.line, 'Interface 1.0.1, over the air, 7 Oct 2026');
assert.equal(w.eyebrow, 'over the air');
assert.equal(w.help, 'Built in: 1.0.0.');
assert.equal(V.interfaceWords({}).line, '');
assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.0', 'the embedded interface version is three-part and in ui/VERSION');
});
test('the help line says what is pending, refused, held back by the switch, or skipped', () => {
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', confirmed: false }).help, 'Waiting for this window to confirm the new interface.');
assert.ok(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, error: 'the page did not answer within 10 s of loading it.' }).help.startsWith('The last interface over the air was refused:'));
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, builtin: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is published over the air; switch this off to take it.');
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, busy: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is downloading.');
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, note: 'interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19)' }).help, 'Interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19).');
});
test('the page reloads only when the served interface changed and nobody is mid-task', () => {
const u = { active_version: '1.0.1' };
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard' }), true);
assert.equal(V.shouldReload('1.0.1', u, { phase: 'dashboard' }), false, 'same version: nothing');
assert.equal(V.shouldReload('', u, { phase: 'dashboard' }), false, 'a page that never pinged does not reload');
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', typing: true }), false);
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', sheetOpen: true }), false);
assert.equal(V.shouldReload('1.0.0', u, { phase: 'address' }), false, 'never mid-setup');
assert.equal(V.shouldReload('1.0.0', null, { phase: 'dashboard' }), false);
});
test('the page pings health once after its first paint and reports a first-paint error (the DOM block carries both)', () => {
assert.ok(src.includes("api('api/ui/health', {})"), 'the health ping');
assert.ok(src.includes("api('api/ui/health', { error: uiFirstError })"), 'the first-paint error');
assert.ok(src.indexOf("window.addEventListener('error'") < src.indexOf('function poll()'), 'the error listener is installed before the first poll');
assert.ok(!/<script[^>]+src=["']https?:/.test(readFileSync(join(here, 'index.html'), 'utf8')), 'no remote script in the page');
});

View file

@ -161,6 +161,9 @@ function stateNow() {
s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b);
s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length;
if (extra.length) s.events = [{ kind: 'block', t: extra[extra.length - 1], text: 'block accepted by the node (Apple M5 Max)' }].concat(s.events).slice(0, 40);
// ui-ota: the interface state (Settings > Interface); ?ui=ota shows an over-the-air bundle active
s.ui = { embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', installed_at: 0, confirmed: true, published_version: '', busy: false, error: '', bad: [], builtin: false, note: '' };
s.settings = { ...s.settings, ui_builtin: false };
s.display_name = 'studio-mac-pro-with-a-long-name';
s.host = s.display_name;