miner-ui-4: pinned to the 0.3.16 engine fields (ember-tune 7003126): watts_total, pounds_per_day, balance_wei with its age and note, price_gbp_per_ign, tune_floor, the chain scene's source word

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 19:51:40 +00:00
parent 6084a7ea64
commit d0c98db2fa
4 changed files with 74 additions and 9 deletions

View file

@ -16,8 +16,8 @@ static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(2);
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
/// where the data came from. Anything else passes through untouched, so the module's contract holds.
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` is "site"
/// (the 0.3.16 engine's live.rs answers "node" when igneumd carries igneum_getRecentBlocks). Anything else passes through untouched, so the module's contract holds.
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mut yours = 0u64;
if let Some(blocks) = reply.get_mut("blocks").and_then(|b| b.as_array_mut()) {
@ -31,7 +31,7 @@ pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
}
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("you_blocks".into(), json!(yours));
st.insert("source".into(), json!("observer"));
st.insert("source".into(), json!("site"));
}
reply
}
@ -109,7 +109,7 @@ mod tests {
assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2);
assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked");
assert_eq!(out["state"]["you_blocks"], 2);
assert_eq!(out["state"]["source"], "observer");
assert_eq!(out["state"]["source"], "site");
assert_eq!(out["state"]["height"], 198490);
assert_eq!(out["ok"], true);
}

View file

@ -705,10 +705,36 @@ var View = (function () {
var ppd = poundsPerDay(w, pence);
return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's');
}
// ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei
// (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign
// (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ----
function ign(wei) { var n = Number(wei); return isNaN(n) ? null : n / 1e18; }
// the Earnings balance row: {text, sub, pounds} ; the UI shows "not read yet" until the first read
function balanceWords(a, now) {
a = a || {};
if (a.balance_wei === undefined) return { text: '', sub: '', pounds: null };
if (a.balance_wei === null || a.balance_wei === '') return { text: 'not read yet', sub: a.balance_note || 'the node answers the balance once it is synced', pounds: null };
var v = ign(a.balance_wei); if (v === null) return { text: 'not read yet', sub: a.balance_note || '', pounds: null };
var age = a.balance_age_s >= 0 ? ' · read ' + ago(a.balance_age_s) : '';
var price = a.price_gbp_per_ign;
return { text: (v >= 1000 ? withCommas(v) : v.toFixed(4)) + ' IGN', sub: 'in the wallet' + age + (a.balance_note ? ' · ' + a.balance_note : ''), pounds: price > 0 ? v * price : null };
}
// the fleet watts and £ a day: the engine's figures when it carries them, else the UI's own sums
function fleetWatts(m, cards) { return m && m.watts_total > 0 ? m.watts_total : wattsTotal(cards); }
function fleetPounds(m, cards, pence) { if (m && m.pounds_per_day > 0) return m.pounds_per_day; return poundsPerDay(fleetWatts(m, cards), pence); }
// the chain scene's note from the reply's source
function liveNote(reply) {
if (!reply) return '';
if (reply.pending) return 'connecting';
var src = reply.source || ((reply.state || {}).source);
if (src === 'node') return 'From your node. Your blocks are ringed.';
if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.';
return '';
}
// the confirmations' words (in place, never a dialog)
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, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, 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 };
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, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, 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 };
})();
/* ---------- 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
@ -1318,7 +1344,8 @@ if (typeof document !== 'undefined') (function () {
function mountDag() {
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
try {
dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: 'you', tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onState: function (st, why) {
var mine = function (b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return state.mining.cards.some(function (c) { return (c.ids || []).indexOf(b.miner) >= 0; }); };
dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: mine, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onData: function (d) { var n = View.liveNote(d); if (n && dagMode !== 'failed') setText('dag-note', n); }, onState: function (st, why) {
var live = st === 'live' || st === 'stale';
if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); }
$('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live;
@ -1499,9 +1526,9 @@ if (typeof document !== 'undefined') (function () {
// the live total: rate, watts, £ a day, blocks
setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state);
var watts = View.wattsTotal(m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now');
var ppd = View.poundsPerDay(watts, price);
var ppd = View.fleetPounds(m, m.cards, price);
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh');
setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
@ -1580,7 +1607,9 @@ if (typeof document !== 'undefined') (function () {
var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000';
setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : ''));
setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s));
var watts = View.wattsTotal(m.cards), ppd = View.poundsPerDay(watts, price);
var watts = View.fleetWatts(m, m.cards), ppd = View.fleetPounds(m, m.cards, price);
var bw = View.balanceWords(a, s.now);
if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); }
setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day');
setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W');
setHidden('e-price-btn', !(watts > 0 && ppd === null));

View file

@ -375,3 +375,27 @@ test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
});
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
const cards = [card(), card({ key: 'b', power_w: 100 })];
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
assert.equal(V.fleetPounds({}, cards, 0), null);
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
assert.equal(V.liveNote(null), '');
});

View file

@ -66,3 +66,15 @@ Tests: `node --test app/igneum-app/ui/*.test.mjs`: 37 pass.
The Rust tests did not run on this Mac (its cargo predates the lock file's version 4); the box runs them at the
merge, as the rule says. UI tests: 37 pass. Screenshots `u01`, `u03`, `u04`, `u06` reshot after the sweep.
## 7. Pinned to the 0.3.16 engine (ember-tune fd03f35), 20:5x UK
| Field | Where the UI reads it |
|---|---|
| `mining.watts_total`, `mining.pounds_per_day` | the Overview hero and Earnings use the engine's figures when present (`View.fleetWatts`, `View.fleetPounds`), else the UI's own sums as before |
| `address.balance_wei` (string or null), `address.balance_age_s` (-1 until read), `address.balance_note`, `address.price_gbp_per_ign` (null until a signed manifest carries a price) | Earnings' first row: "12.3456 IGN · in the wallet · read 20 s ago" (`View.balanceWords`); a priced balance shows £ with "at the signed price"; an older engine without the field keeps the row as it was |
| `card.tune_floor` | the Ember line's "(the lowest clock Ember will set)" |
| `/api/live` reply `source` ("node" when igneumd answers `igneum_getRecentBlocks`, "site" until then; `pending: true` before the first body) | the chain scene's note (`View.liveNote`): "From your node. Your blocks are ringed." or "From Igneum’s observer. Your blocks are ringed."; "you" is matched in the UI from every card's vote-key ids whatever the source names the lane |
At the 0.3.16 merge `src/live.rs` is ember-tune's (the fuller one, node first, site second); this branch's 0.3.15
file is superseded and its `source` word is "site" too, so nothing in the UI changes between the two.