Merge miner-ui-4 feb7f13 for 0.3.15: Overview (hero, chain scene, node line, activity), Cards with Ember woven in, Earnings, Prove, Settings
Every file under app/igneum-app/ui/ is that branch's side, per the merge rule; outside ui/: the GET /live-dag.js route in server.rs, the ui-mock's synthetic /api/live, the plan and its screenshots. .gitignore: the shipper's local test target dir and the build box's artefact dir. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5
.gitignore
vendored
|
|
@ -31,3 +31,8 @@ vendor/igneum-node-ship/
|
|||
# Trademark instruction packs name the director and the applicant company; never in the repository
|
||||
brand/trademark/pbip-pack/
|
||||
brand/trademark/*.zip
|
||||
|
||||
# cargo target dirs of the shipper's local test runs and the build box's fetched artefacts (6 October 2026: a stray add -A staged 450 of them)
|
||||
app/igneum-app/target-tests/
|
||||
proving/igneum-prove/target-remote/
|
||||
**/target-remote/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const INDEX: &str = include_str!("../ui/index.html");
|
|||
const CSS: &str = include_str!("../ui/app.css");
|
||||
const JS: &str = include_str!("../ui/app.js");
|
||||
const MARK: &str = include_str!("../ui/mark.svg");
|
||||
const DAG_JS: &str = include_str!("../ui/live-dag.js");
|
||||
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
|
||||
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
|
||||
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
|
||||
|
|
@ -165,6 +166,7 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
||||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
||||
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
|
||||
("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false),
|
||||
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
|
||||
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
|
||||
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
|
||||
|
|
|
|||
|
|
@ -614,3 +614,80 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
.drawer-head{padding-bottom:6px}
|
||||
.toggle-big{min-height:96px}
|
||||
}
|
||||
|
||||
/* miner-ui-4 (6 October 2026): Overview and Cards. The Overview hero (the project lead's pick, C): the fleet rate at 84 px on a
|
||||
graphite-to-obsidian fade with W, £ a day and blocks beside it, Start/Stop as the full-width bar under it. */
|
||||
.hero-row{grid-template-columns:1fr}
|
||||
.totals{order:1;grid-template-columns:2fr 1fr 1fr 1fr;align-items:end;padding:28px 28px 24px;border-color:transparent;background:linear-gradient(180deg,var(--graphite),var(--obsidian))}
|
||||
.tot{border-left:0;padding-left:0}
|
||||
.tot:first-child .v{font-size:84px;line-height:.95;letter-spacing:-.03em}
|
||||
.tot:first-child .k{font-size:var(--t-sm);margin-top:10px}
|
||||
.tot:first-child .s{font-size:var(--t-md)}
|
||||
.tot .v{font-size:30px}
|
||||
.tot .v.ask-price{font-size:var(--t-md);padding:9px 0 8px}
|
||||
.toggle-big{order:2;min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 20px}
|
||||
.toggle-big .ring{margin:0}
|
||||
.toggle-big .ts{margin-left:auto}
|
||||
/* the chain scene: the site's live-dag.js in a 220 px box, the legend words under it, the blocks strip as the fallback */
|
||||
.dag-wrap{position:relative}
|
||||
#dag-live{display:block;width:100%;height:220px;border-radius:10px;background:var(--obsidian);border:1px solid var(--line)}
|
||||
#dag{height:120px;margin-bottom:0}
|
||||
.dag-tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;font-size:var(--t-xs);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 8px 24px var(--shadow);z-index:3}
|
||||
.dag-tip b{color:var(--bone);font-weight:500;word-break:break-all}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
|
||||
.legend span{display:inline-flex;align-items:center;gap:7px}
|
||||
.lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
|
||||
.lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
|
||||
.lg.pending{border-color:var(--ash)}
|
||||
.lg.included{border-color:var(--ember)}
|
||||
.lg.excluded{border-color:var(--ash);opacity:.45}
|
||||
.lg.proven{background:var(--ember);border-color:var(--ember)}
|
||||
.lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
|
||||
/* Cards: the Tuning strip above the list */
|
||||
.tune-strip{padding:var(--s-4) var(--card-pad)}
|
||||
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
|
||||
.ts-row .goal-line{flex:0 1 auto}
|
||||
.ts-saving{font-size:var(--t-base);color:var(--molten);font-weight:500;margin-left:auto}
|
||||
.ts-saving:empty{display:none}
|
||||
.tune-strip .line-text{margin-top:var(--s-2);font-size:var(--t-base);color:var(--ash)}
|
||||
.tune-strip .switch{padding-bottom:0}
|
||||
/* the Ember layer on a row: the flame mark, the saving, the sparkline, the curve */
|
||||
.gl-tune{align-items:center}
|
||||
.flame{flex:0 0 16px;display:inline-block}
|
||||
.flame .fo{fill:none;stroke:var(--ash);stroke-width:7}
|
||||
.flame .ff{fill:var(--ember)}
|
||||
.flame.running .ff{fill:var(--molten)}
|
||||
.flame.measured .ff{fill:var(--ash)}
|
||||
.flame.idle .ff,.flame.paused .ff,.flame.stopped .ff{fill:none}
|
||||
.gl-tune.tuned .t{color:var(--ink-2)}
|
||||
.gl-tune .save,.gl-details .save{color:var(--molten);white-space:nowrap}
|
||||
.num .k.spark{display:inline-flex;align-items:center;gap:6px}
|
||||
.spark{display:inline-block;vertical-align:middle}
|
||||
.spark path{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.spark circle{fill:var(--ember)}
|
||||
.curve-wrap{display:flex;flex-direction:column;gap:6px;max-width:420px}
|
||||
.curve-svg{width:100%;height:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px}
|
||||
.curve-svg .ln{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.curve-svg circle{fill:var(--ember)}
|
||||
.curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2}
|
||||
.curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
|
||||
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
|
||||
.gl-details .ctl .seg{justify-self:start}
|
||||
@media (max-width:1180px){
|
||||
.totals{grid-template-columns:1fr 1fr 1fr 1fr}
|
||||
.tot:first-child .v{font-size:64px}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.totals{grid-template-columns:1fr 1fr;row-gap:var(--s-4)}
|
||||
.tot:first-child{grid-column:1 / -1}
|
||||
.tot:nth-child(3){padding-left:0}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.tot:first-child .v{font-size:56px}
|
||||
.toggle-big .ts{display:none}
|
||||
#dag-live{height:170px}
|
||||
.ts-saving{margin-left:0}
|
||||
.legend{gap:6px 12px}
|
||||
.rail-foot .nav.small{padding:6px 4px}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,7 +266,8 @@ var UpdateCard = (function () {
|
|||
var View = (function () {
|
||||
'use strict';
|
||||
var PAGES = [
|
||||
{ id: 'mine', title: 'Mine', sub: 'your cards, the rate, what it costs' },
|
||||
{ id: 'overview', title: 'Overview', sub: 'the rate, what it costs, the chain as it grows' },
|
||||
{ id: 'cards', title: 'Cards', sub: 'every card, tuned by Ember' },
|
||||
{ id: 'earnings', title: 'Earnings', sub: 'what it made, where it goes' },
|
||||
{ id: 'prove', title: 'Prove', sub: 'turn blocks into proofs, paid in IGN' },
|
||||
{ id: 'settings', title: 'Settings', sub: 'tuning, this machine, updates, logs' }
|
||||
|
|
@ -396,7 +397,7 @@ var View = (function () {
|
|||
var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
|
||||
var nc = nextCheck(cd.sweep_at, st.tune_period_s, now);
|
||||
if (cd.tune_line) {
|
||||
var nums = String(cd.tune_line).replace(/^Tuned:\s*/, '');
|
||||
var nums = String(cd.tune_line).replace(/^(Tuned|Measured):\s*/, '');
|
||||
if (cd.tune_source === 'baseline' || !cd.tune_control) return { kind: 'measured', text: 'Measured ' + nums + ' as it runs' + (when ? ', ' + when : ''), note: reasonOf(cd.sweep_note), button: 'tune', progress: -1 };
|
||||
var src = cd.tune_source === 'confirm' ? ' from the fleet prior, confirmed' : '';
|
||||
var pin = cd.pinned ? 'Your cap stays pinned at ' + (cd.power_pct || 0) + '% · ' : '';
|
||||
|
|
@ -657,10 +658,56 @@ var View = (function () {
|
|||
if (m.state === 'idle') return { text: 'Not mining', tone: '' };
|
||||
return { text: cap(m.state || 'starting'), tone: '' };
|
||||
}
|
||||
// ---- Ember on the card row (miner-ui-4, 6 October 2026): the flame mark's fill, the before -> after watts, the saving,
|
||||
// the sparkline points and the plain label. cd = the card, s = state, now = unix s ----
|
||||
// the "before" point (Ember, 6 October 2026): card.tune_before_watts / tune_before_mhs, step 0 of the last FULL plan,
|
||||
// kept across confirm plans; until a build carries them, the full plan's step 0 in tune_curve (unlocked, 100%); a
|
||||
// confirm or baseline plan without the fields has no before, so no saving is claimed
|
||||
function beforeOf(cd) {
|
||||
if (cd.tune_before_watts > 0) return { watts: cd.tune_before_watts, mhs: cd.tune_before_mhs || 0 };
|
||||
if (cd.tune_before_watts !== undefined) return null;
|
||||
var r = (cd.tune_curve || [])[0];
|
||||
if (cd.tune_source === 'full' && r && r.watts > 0 && (r.power_pct || 0) >= 100 && !(r.clock_mhz > 0)) return { watts: r.watts, mhs: r.mhs || 0 };
|
||||
return null;
|
||||
}
|
||||
// {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points}
|
||||
function ember(cd, s, now) {
|
||||
var tw = tuneWords(cd, s, now), out = { kind: tw.kind, fill: 0, label: '', sub: '', before: 0, after: 0, savingW: 0, savingPct: 0, savingText: '', next: '', points: [] };
|
||||
if (tw.kind === 'none') return out;
|
||||
var st = (s && s.settings) || {};
|
||||
out.next = nextCheck(cd.sweep_at, st.tune_period_s, now).replace(/^next check /, '');
|
||||
out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05 }; });
|
||||
if (cd.tune_floor) out.floor = true;
|
||||
if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''; return out; }
|
||||
if (tw.kind === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; }
|
||||
if (tw.kind === 'stopped') { out.label = tw.text; return out; }
|
||||
if (tw.kind === 'measured') { out.fill = 0.5; out.label = 'Measured, not tuned'; out.sub = /Power control/i.test(cd.sweep_note || '') ? 'Power control is off' : cd.vendor === 'apple' ? 'Apple silicon sets its own clocks' : (tw.note || '').replace(/^Measured only /, ''); return out; }
|
||||
if (tw.kind === 'tuned') {
|
||||
out.fill = 1; var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : '';
|
||||
out.label = 'Tuned' + (when ? ' ' + when : '') + (cd.tune_source === 'confirm' ? ' from the fleet prior' : '');
|
||||
out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the ladder’s floor)' : '') + (out.next ? ' · next check ' + out.next : '');
|
||||
var b = beforeOf(cd); out.after = cd.sweep_watts > 0 ? cd.sweep_watts : 0; out.before = b ? b.watts : 0;
|
||||
if (out.before > 0 && out.after > 0 && out.before > out.after) {
|
||||
out.savingW = Math.round(out.before - out.after);
|
||||
out.savingPct = b.mhs > 0 && cd.sweep_mhs > 0 ? Math.max(0, (b.mhs - cd.sweep_mhs) / b.mhs * 100) : 0;
|
||||
out.savingText = 'saves ' + out.savingW + ' W' + (b.mhs > 0 ? ', ' + (out.savingPct < 0.05 ? 'no' : out.savingPct.toFixed(2) + '% of') + ' rate' : '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = 'starts after 2 min of steady mining'; return out;
|
||||
}
|
||||
// the Tuning strip's fleet saving: watts and £ a day summed over the tuned cards
|
||||
function fleetSaving(cards, s, now, pence) {
|
||||
var w = 0, n = 0;
|
||||
(cards || []).forEach(function (c) { if (!present(c)) return; var e = ember(c, s, now); if (e.kind === 'tuned' && e.savingW > 0) { w += e.savingW; n++; } });
|
||||
if (!n) return '';
|
||||
var ppd = poundsPerDay(w, pence);
|
||||
return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' over ' + n + ' tuned card' + (n === 1 ? '' : 's');
|
||||
}
|
||||
// the confirmations' words (in place, never a dialog)
|
||||
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? The miner restarts.'; }
|
||||
function trustAsk(on) { return on ? 'Include proof records the node never checked? Devnet only; the node restarts.' : 'Verify proof records 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, 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, 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, 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 };
|
||||
})();
|
||||
/* ---------- 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
|
||||
|
|
@ -703,7 +750,7 @@ if (typeof document !== 'undefined') (function () {
|
|||
var state = null, phase = 'welcome', page = 'mine', forced = null, cardsSeen = false, keyShownThisRun = false;
|
||||
var params = new URLSearchParams(location.search);
|
||||
if (params.get('screen')) forced = params.get('screen');
|
||||
if (params.get('page')) page = params.get('page'); // ?page=mine|earnings|prove|settings (screenshots)
|
||||
if (params.get('page')) page = params.get('page'); // ?page=overview|cards|earnings|prove|settings (screenshots)
|
||||
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');
|
||||
|
|
@ -777,7 +824,7 @@ if (typeof document !== 'undefined') (function () {
|
|||
$('page-title').hidden = !dash;
|
||||
if (!dash && drawerOpen()) setDrawer(false);
|
||||
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
|
||||
if (dash) showPage(page);
|
||||
if (dash) { showPage(page); mountDag(); }
|
||||
api('api/phase', { phase: p });
|
||||
}
|
||||
function showPage(id) {
|
||||
|
|
@ -792,7 +839,7 @@ if (typeof document !== 'undefined') (function () {
|
|||
});
|
||||
setText('page-title-text', meta.title); setText('page-sub', meta.sub);
|
||||
$('main').scrollTop = 0;
|
||||
if (state) { renderPage(state); if (page === 'mine') size(); }
|
||||
if (state) { renderPage(state); if (page === 'overview') size(); }
|
||||
}
|
||||
$('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); });
|
||||
// the rail answers the arrow keys and Home/End, so the sections are reachable without a mouse
|
||||
|
|
@ -899,6 +946,9 @@ if (typeof document !== 'undefined') (function () {
|
|||
else if (b.dataset.pin !== undefined) api('api/sweep/pin', { key: key, pinned: b.dataset.pin === '1' }).then(function () { toast(b.dataset.pin === '1' ? 'Cap pinned' : 'The tune chooses the cap again'); });
|
||||
else if (b.dataset.powerRetry !== undefined) api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); });
|
||||
else if (b.dataset.price !== undefined) { showPage('settings'); $('s-price').focus(); }
|
||||
else if (b.closest('[data-card-goal]') && b.dataset.goal !== undefined) {
|
||||
api('api/tune/goal', { key: key, goal: b.dataset.goal || '' }).then(function (r) { if (r && r.ok) toast(b.dataset.goal ? 'Goal for this card: ' + View.GOALS[b.dataset.goal] : 'This card follows the fleet goal'); else toast('A per-card goal needs the Ember Tune update'); }).catch(function () { toast('A per-card goal needs the Ember Tune update'); });
|
||||
}
|
||||
});
|
||||
function applyOpen(row) { var open = !!openCards[row.dataset.key], det = row.querySelector('.gl-details'), chev = row.querySelector('[data-open]'); if (det) det.hidden = !open; if (chev) { chev.setAttribute('aria-expanded', open ? 'true' : 'false'); chev.classList.toggle('open', open); } row.classList.toggle('open', open); }
|
||||
function cardOf(key) { return state ? state.mining.cards.filter(function (c) { return c.key === key; })[0] : null; }
|
||||
|
|
@ -1249,7 +1299,7 @@ if (typeof document !== 'undefined') (function () {
|
|||
drawRaf = 0; clearTimeout(drawTimer); drawTimer = null;
|
||||
if (hiddenNow()) return;
|
||||
var fast = false;
|
||||
if (phase === 'dashboard' && page === 'mine') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
|
||||
if (phase === 'dashboard' && page === 'overview' && !$('dag').hidden) { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
|
||||
if (fast) drawRaf = requestAnimationFrame(frame);
|
||||
else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500);
|
||||
}
|
||||
|
|
@ -1261,6 +1311,20 @@ if (typeof document !== 'undefined') (function () {
|
|||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { size(); });
|
||||
requestDraw();
|
||||
|
||||
// ---------- the chain scene (live-dag.js, the site's module, copied in): the engine's own api/live, this machine's
|
||||
// lane as "you"; until the engine serves it (owed) the module reports failed and the blocks strip draws instead ----------
|
||||
var dag = null, dagMode = '';
|
||||
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'), pauseButton: $('dag-pause'), 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' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next engine 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;
|
||||
if (!live) { size(); requestDraw(); }
|
||||
} });
|
||||
} catch (e) { dag = null; $('dag-live').hidden = true; $('dag').hidden = false; size(); }
|
||||
}
|
||||
// ---------- render ----------
|
||||
function badgeHtml(cd) {
|
||||
if (cd.vendor === 'apple') return '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
|
||||
|
|
@ -1347,13 +1411,16 @@ if (typeof document !== 'undefined') (function () {
|
|||
if (cells.money) nums += numCell('money', cells.money, '', 'a day', '');
|
||||
else if (cells.needsPrice) nums += '<div class="num money"><button class="btn tiny ghost" data-price="1">Set a price</button><span class="k">£ a day</span></div>';
|
||||
if (r.temp) nums += numCell('temp', r.temp.replace(' °C', ''), '°C', 'GPU', r.tempTone);
|
||||
var tuneBtn = tw.button === 'stop' ? '<button class="btn small" data-stop="1">Stop</button>' : tw.button === 'tune' ? '<button class="btn small ghost" data-tune="1">Tune</button>' : '';
|
||||
var em = View.ember(cd, s, s.now);
|
||||
if (em.kind === 'tuned' && em.before > 0 && em.after > 0 && cells.power) nums = nums.replace(numCell('power', cells.power.replace(' W', ''), 'W', cells.eff, ''), numCell('power', Math.round(em.before) + ' → ' + cells.power.replace(' W', ''), 'W', cells.eff, ''));
|
||||
if (em.points.length > 1 && cells.power) nums = nums.replace('<span class="k">' + esc(cells.eff) + '</span>', '<span class="k spark">' + esc(cells.eff) + sparkHtml(em.points) + '</span>');
|
||||
var tuneBtn = tw.button === 'stop' ? '<button class="btn small" data-stop="1">Stop</button>' : tw.button === 'tune' ? '<button class="btn small ghost" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune') + '</button>' : '';
|
||||
var sw = '<label class="switch" title="' + (r.on ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(r.name) + '"' + (r.on ? ' checked' : '') + (r.canToggle ? '' : ' disabled') + '><span class="track"></span></label>';
|
||||
var chev = '<button class="chev-btn" data-open="1" aria-expanded="false" aria-label="Details for ' + esc(r.name) + '"><span class="chev"></span></button>';
|
||||
var subs = [];
|
||||
if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>');
|
||||
if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</div>');
|
||||
var tune = tw.kind === 'none' ? '' : '<div class="gl-tune ' + tw.kind + '"><span class="t">' + esc(tw.text) + '</span>' + (tw.note ? '<span class="n dim">' + esc(tw.note) + '</span>' : '') + (tw.progress >= 0 ? '<i class="bar"><b style="width:' + Math.round(tw.progress * 100) + '%"></b></i>' : '') + '</div>';
|
||||
var tune = em.kind === 'none' ? '' : '<div class="gl-tune ' + em.kind + '">' + flameHtml(em.fill, em.kind) + '<span class="t">' + esc(em.label) + '</span>' + (em.sub ? '<span class="n dim">' + esc(em.sub) + '</span>' : '') + (em.savingText ? '<span class="n save">' + esc(em.savingText) + '</span>' : '') + (em.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round(em.fill * 100) + '%"></b></i>' : '') + '</div>';
|
||||
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' +
|
||||
'<div class="gl-main"><div class="badge ' + esc(r.vendor) + '">' + badgeHtml({ vendor: r.vendor }) + '</div>' +
|
||||
'<div class="who"><div class="name" title="' + esc(r.title) + '">' + esc(r.name) + ' <span class="kind ' + esc(r.kind) + '">' + esc(r.kindWord) + '</span></div>' +
|
||||
|
|
@ -1362,6 +1429,35 @@ if (typeof document !== 'undefined') (function () {
|
|||
'<div class="acts">' + tuneBtn + sw + chev + '</div></div>' + tune +
|
||||
'<div class="gl-details" hidden>' + detailsHtml(cd, s) + '</div></div>';
|
||||
}
|
||||
// the flame mark (the brand mark's two polygons) filled to a fraction: outline when not tuned, filling while tuning, solid when tuned
|
||||
var flameN = 0;
|
||||
function flameHtml(fill, kind) {
|
||||
var id = 'fl' + (++flameN), h = Math.round(100 * (1 - Math.max(0, Math.min(1, fill))));
|
||||
return '<svg class="flame ' + kind + '" viewBox="0 0 100 100" width="16" height="16" aria-hidden="true"><defs><clipPath id="' + id + '"><rect x="0" y="' + h + '" width="100" height="' + (100 - h) + '"/></clipPath></defs>' +
|
||||
'<path class="fo" d="M50 4 L78 50 L50 96 L22 50 Z M50 34 L62 50 L50 66 L38 50 Z" fill-rule="evenodd"/>' +
|
||||
'<path class="ff" clip-path="url(#' + id + ')" d="M50 4 L78 50 L50 96 L22 50 Z M50 34 L62 50 L50 66 L38 50 Z" fill-rule="evenodd"/></svg>';
|
||||
}
|
||||
// a tiny sparkline of the tune's ladder: MH/W per step, the chosen step as a dot
|
||||
function sparkHtml(points) {
|
||||
var effs = points.map(function (p) { return p.eff; }), lo = Math.min.apply(null, effs), hi = Math.max.apply(null, effs), n = points.length, W = 44, H = 12;
|
||||
var pts = points.map(function (p, i) { var x = n > 1 ? i / (n - 1) * (W - 2) + 1 : W / 2, y = hi > lo ? H - 1 - (p.eff - lo) / (hi - lo) * (H - 2) : H / 2; return { x: x, y: y, c: p.chosen, m: p.marked }; });
|
||||
var d = pts.map(function (p, i) { return (i ? 'L' : 'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1); }).join(' ');
|
||||
var dot = pts.filter(function (p) { return p.c; })[0];
|
||||
return '<svg class="spark" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '" aria-hidden="true"><path d="' + d + '"/>' + (dot ? '<circle cx="' + dot.x.toFixed(1) + '" cy="' + dot.y.toFixed(1) + '" r="2"/>' : '') + '</svg>';
|
||||
}
|
||||
// the tune's curve behind the chevron: W across, MH/s up, every step a dot, the chosen one ringed, a marked one hollow
|
||||
function curveHtml(points) {
|
||||
if (points.length < 2) return '';
|
||||
var ws = points.map(function (p) { return p.w; }), ms = points.map(function (p) { return p.mhs; });
|
||||
var w0 = Math.min.apply(null, ws), w1 = Math.max.apply(null, ws), m0 = Math.min.apply(null, ms), m1 = Math.max.apply(null, ms), W = 320, H = 110, L = 34, B = 18;
|
||||
var X = function (w) { return L + (w1 > w0 ? (w - w0) / (w1 - w0) : 0.5) * (W - L - 8); }, Y = function (m) { return 6 + (m1 > m0 ? 1 - (m - m0) / (m1 - m0) : 0.5) * (H - B - 6); };
|
||||
var sorted = points.slice().sort(function (a, b) { return a.w - b.w; });
|
||||
var path = sorted.map(function (p, i) { return (i ? 'L' : 'M') + X(p.w).toFixed(1) + ' ' + Y(p.mhs).toFixed(1); }).join(' ');
|
||||
var dots = sorted.map(function (p) { return '<circle class="' + (p.chosen ? 'chosen' : p.marked ? 'marked' : '') + '" cx="' + X(p.w).toFixed(1) + '" cy="' + Y(p.mhs).toFixed(1) + '" r="' + (p.chosen ? 5 : 3) + '"/>'; }).join('');
|
||||
return '<div class="curve-wrap"><svg class="curve-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="The tune\'s curve: watts across, hash rate up"><path class="ln" d="' + path + '"/>' + dots +
|
||||
'<text x="' + L + '" y="' + (H - 4) + '">' + Math.round(w0) + ' W</text><text x="' + (W - 8) + '" y="' + (H - 4) + '" text-anchor="end">' + Math.round(w1) + ' W</text>' +
|
||||
'<text x="2" y="' + (H - B - 2) + '">' + m0.toFixed(0) + '</text><text x="2" y="12">' + m1.toFixed(0) + '</text></svg><span class="note">Hash rate against draw for every step of the last tune. The ringed point is the one Ember kept; a hollow point was rejected (a fault, a hot card or a dragged memory clock).</span></div>';
|
||||
}
|
||||
// the card's details (one chevron): the cap slider, identities, pin, the telemetry, the facts, the last tune's table
|
||||
function detailsHtml(cd, s) {
|
||||
var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, h = '';
|
||||
|
|
@ -1380,11 +1476,13 @@ if (typeof document !== 'undefined') (function () {
|
|||
if (cd.fan_pct > 0) tl.push('fan <b>' + Math.round(cd.fan_pct) + '%</b>');
|
||||
if (cd.util_pct > 0) tl.push('busy <b>' + Math.round(cd.util_pct) + '%</b>');
|
||||
if (tl.length) h += '<div class="line">' + tl.join('<span class="sep"></span>') + '</div>';
|
||||
var tm = TuneLine.model(cd, s.now);
|
||||
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + '</div>';
|
||||
if (cd.tune_curve && cd.tune_curve.length) {
|
||||
h += '<table class="curve"><thead><tr><th>step</th><th>clock</th><th>power</th><th>MH/s</th><th>W</th><th>MH/W</th><th>note</th></tr></thead><tbody>' + cd.tune_curve.map(function (r, i) { return '<tr' + (r.mark ? ' class="marked"' : '') + '><td>' + (i + 1) + '</td><td>' + (r.clock_mhz ? r.clock_mhz + ' MHz' : 'unlocked') + '</td><td>' + (r.power_pct || 0) + '%</td><td>' + (r.mhs || 0).toFixed(1) + '</td><td>' + Math.round(r.watts || 0) + '</td><td>' + (r.eff || 0).toFixed(3) + '</td><td>' + esc(r.mark || '') + '</td></tr>'; }).join('') + '</tbody></table>';
|
||||
var tm = TuneLine.model(cd, s.now), em = View.ember(cd, s, s.now);
|
||||
if (View.tunable(cd)) {
|
||||
var g = cd.tune_goal || '', goalSeg = '<div class="seg small" data-card-goal="1" role="radiogroup" aria-label="Goal for ' + esc(cd.name) + '">' + [['', 'As the fleet'], ['efficiency', 'Efficiency'], ['balanced', 'Balanced'], ['rate', 'Maximum']].map(function (o) { return '<button class="seg-b' + (g === o[0] ? ' on' : '') + '" data-goal="' + o[0] + '" role="radio" aria-checked="' + (g === o[0] ? 'true' : 'false') + '">' + o[1] + '</button>'; }).join('') + '</div>';
|
||||
h += '<div class="ctl"><span class="k">Goal</span>' + goalSeg + (em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '<p class="help">This card’s own goal. "As the fleet" follows the Tuning strip above' + (cd.tune_goal === undefined ? ' (a per-card goal needs the Ember Tune update; the fleet goal applies until then)' : '; a new goal applies from the next tune') + '.</p></div>';
|
||||
}
|
||||
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + (em.savingText ? ' <span class="save">' + esc(em.savingText) + '</span>' : '') + '</div>';
|
||||
h += curveHtml(em.points);
|
||||
h += '<div class="ctl"><span class="k">Identities</span><p class="help">Each identity votes and is paid on its own. 8 suits a big card, 2 a small one, 1 an integrated GPU. Changing it restarts that card’s worker.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">−</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>';
|
||||
var facts = [cd.worker ? 'worker ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean);
|
||||
if (facts.length) h += '<p class="note mono small">' + esc(facts.join(' · ')) + '</p>';
|
||||
|
|
@ -1411,9 +1509,9 @@ if (typeof document !== 'undefined') (function () {
|
|||
var on = rows.filter(function (r) { return r.on; }).length;
|
||||
setText('d-cards-eyebrow', s.detecting ? 'detecting' : !shown.length ? 'none found' : on + ' of ' + presentCards.length + ' on');
|
||||
var tunables = presentCards.filter(function (c) { return View.tunable(c) && c.enabled; });
|
||||
setHidden('btn-tune-all', !(tunables.length > 1) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
|
||||
setHidden('btn-tune-all', !(tunables.length > 0) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
|
||||
var box = $('d-cards'), busy = box.querySelector('input[type=range]:active') || (box.contains(document.activeElement) && document.activeElement.tagName === 'INPUT');
|
||||
var sig = JSON.stringify([rows, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length]; })]);
|
||||
var sig = JSON.stringify([rows, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs]; })]);
|
||||
if (sig !== mineRowsSig && !busy) {
|
||||
mineRowsSig = sig;
|
||||
box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can drive was found. The node runs on its own.') + '</div>';
|
||||
|
|
@ -1439,7 +1537,7 @@ if (typeof document !== 'undefined') (function () {
|
|||
setText('goal-line', gw.consequence); setText('goal-line-2', gw.consequence);
|
||||
var sched = View.schedule(s.mining.cards, s, s.now);
|
||||
setText('tune-schedule', sched); setText('s-tune-schedule', sched);
|
||||
setText('tune-eyebrow', st.tuning_off ? 'paused by Igneum' : st.sweep === false ? 'Ember Tune off' : 'Ember Tune');
|
||||
setText('fleet-saving', View.fleetSaving(s.mining.cards, s, s.now, priceNow()));
|
||||
var nvOff = cards.some(function (c) { return c.vendor === 'nvidia' && View.present(c); }) && !st.power_control;
|
||||
setHidden('m-power-row', !nvOff);
|
||||
if (document.activeElement !== $('m-power-control')) $('m-power-control').checked = !!st.power_control;
|
||||
|
|
@ -1554,7 +1652,7 @@ if (typeof document !== 'undefined') (function () {
|
|||
}
|
||||
|
||||
function renderPage(s) {
|
||||
if (page === 'mine') renderMine(s);
|
||||
if (page === 'overview' || page === 'cards') renderMine(s);
|
||||
else if (page === 'earnings') renderEarnings(s);
|
||||
else if (page === 'prove') renderProve(s);
|
||||
else if (page === 'settings') renderSettings(s);
|
||||
|
|
@ -1732,7 +1830,9 @@ if (typeof document !== 'undefined') (function () {
|
|||
function sampleTune(s, kind) {
|
||||
var c = s.mining.cards[0]; if (!c) return;
|
||||
if (kind === 'running') { c.state = 'tuning'; c.sweep_state = 'running'; c.tune_step = 7; c.tune_steps = 9; c.tune_eta_s = 230; c.tune_plan = 'full'; c.sweep_note = 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)'; if (!(c.power_w > 0)) c.power_w = 219.6; if (!(c.hash_now > 0)) c.hash_now = 123.4; }
|
||||
if (kind === 'tuned') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 100; c.sweep_at = s.now - 7200; c.sweep_watts = 290; }
|
||||
var curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 70, watts: 264, mhs: 118.1, eff: 0.447 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2780, power_pct: 80, watts: 298, mhs: 122.4, eff: 0.411 }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 2160, power_pct: 80, watts: 281, mhs: 117.0, eff: 0.416 }, { clock_mhz: 1850, power_pct: 80, watts: 270, mhs: 109.2, eff: 0.404 }];
|
||||
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; }
|
||||
if (kind === 'mix' && s.mining.cards[1]) { var d = s.mining.cards[1]; d.state = 'tuning'; d.sweep_state = 'running'; d.tune_step = 4; d.tune_steps = 9; d.tune_eta_s = 410; d.tune_plan = 'full'; d.tune_control = true; d.hash_now = d.hash_now || 98.1; }
|
||||
if (kind === 'measured') { c.tune_line = 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)'; c.tune_source = 'baseline'; c.tune_control = false; c.sweep_at = s.now - 600; c.sweep_note = 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed'; }
|
||||
}
|
||||
function render(s) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="mine">
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="overview">
|
||||
|
||||
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
|
||||
is a bottom tab bar. The setup screens have no rail. -->
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
<span class="word">IGNEUM</span>
|
||||
</div>
|
||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
|
||||
<button class="nav on" data-page="overview" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Overview</span></button>
|
||||
<button class="nav" data-page="cards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="11" rx="2"/><path d="M7 20h10M9 16v4M15 16v4M7 10h3M14 10h3"/></svg><span>Cards</span></button>
|
||||
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
|
||||
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
|
||||
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
||||
|
|
@ -37,7 +38,7 @@
|
|||
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
||||
</div>
|
||||
<div class="page-title" id="page-title" hidden>
|
||||
<h1 id="page-title-text">Mine</h1>
|
||||
<h1 id="page-title-text">Overview</h1>
|
||||
<span class="page-sub" id="page-sub"></span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
|
|
@ -162,8 +163,8 @@
|
|||
<!-- 4. the dashboard: four pages behind the rail -->
|
||||
<section class="screen" id="screen-dashboard">
|
||||
|
||||
<!-- Mine -->
|
||||
<section class="page" id="page-mine" data-page="mine">
|
||||
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
|
||||
<section class="page" id="page-overview" data-page="overview">
|
||||
<div class="clock-card" id="m-clock" hidden>
|
||||
<p class="clock-msg" id="m-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
|
||||
|
|
@ -184,24 +185,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tune-card">
|
||||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></div>
|
||||
<div class="goal-row">
|
||||
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line"></span>
|
||||
<div class="card dag-card" id="dag-card">
|
||||
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
|
||||
<div class="dag-wrap">
|
||||
<canvas id="dag-live" aria-label="The last two minutes of blocks: this machine's lane and the others, the selected chain as a lit path, checkpoints as bands"></canvas>
|
||||
<div class="dag-tip mono" id="dag-tip" hidden></div>
|
||||
<canvas id="dag" aria-hidden="true" hidden></canvas>
|
||||
</div>
|
||||
<p class="line-text" id="tune-schedule"></p>
|
||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||
<div class="legend"><span><i class="lg you"></i>yours</span><span><i class="lg pending"></i>pending</span><span><i class="lg included"></i>included</span><span><i class="lg excluded"></i>excluded</span><span><i class="lg proven"></i>proven</span><span><i class="lg locked"></i>locked</span><span class="dim" id="dag-note"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="card node-card" id="node-card">
|
||||
|
|
@ -240,11 +231,34 @@
|
|||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards: every card with Ember as its second layer -->
|
||||
<section class="page" id="page-cards" data-page="cards" hidden>
|
||||
<div class="card tune-strip" id="tune-card">
|
||||
<div class="ts-row">
|
||||
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line"></span>
|
||||
<span class="ts-saving" id="fleet-saving"></span>
|
||||
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
|
||||
</div>
|
||||
<p class="line-text" id="tune-schedule"></p>
|
||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span></div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Earnings -->
|
||||
<section class="page" id="page-earnings" data-page="earnings" hidden>
|
||||
<div class="card">
|
||||
|
|
@ -496,6 +510,7 @@
|
|||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="live-dag.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
221
app/igneum-app/ui/live-dag.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/* Igneum live DAG view (docs/plans/site-ui-3.md section 6, 6 October 2026).
|
||||
One module for /live and the home page. Reads /api/live only; every pixel is a block, edge or checkpoint the observer
|
||||
stored. Blocks are placed by header time on x (the axis is labelled in DAA score) and by miner lane on y. Parent edges
|
||||
are drawn; the edge between two selected-chain blocks is heavier and the chain glows as one path. Colour words are the
|
||||
ledger's: pending, included (blue), excluded (red), proven (every shard verified or paid), locked (a checkpoint block).
|
||||
Checkpoints are vertical bands at their blue score with the lock weight; a locked band is solid, a pending one dashed.
|
||||
The view scrolls at chain speed, new blocks arrive from the right with a brief glow; wheel and pinch zoom the window
|
||||
(30 to 300 s, the API's ?window=); a pause button; hover and tap show a block's details.
|
||||
|
||||
Usage: var dag = IgneumDag.mount(canvas, { compact, window, url, poll, mine, onState, onData, onWindow, pauseButton, tooltip })
|
||||
- url: the /api/live endpoint (default '/api/live'); poll: false stops the module's own 2 s fetch, the host then calls
|
||||
dag.push(reply) with a reply of the same shape ({ok, state:{stale, age_s, height, ...}, blocks:[...], finality:{checkpoints:[...]}, proving})
|
||||
whenever it has new data (every poll or every new block, either is fine; blocks already on screen are updated in place).
|
||||
- block record (the /api/live shape): hash (string, 16 hex), ts (ms, header time), blue_score, daa (numbers), parents ([hash]),
|
||||
chain (bool, selected chain), color ('blue' | 'red' | 'pending'), miner (string lane key, 8 hex of the vote key hash),
|
||||
locked (bool, a locked checkpoint block), final (bool, at or before the newest lock), shards ([{state: 'planned' | 'proving' |
|
||||
'verified' | 'paid'}]), proven (bool, every shard verified or paid). Checkpoints: {index, blue_score, state: 'locked' | 'pending', fraction_total}.
|
||||
- mine: a miner lane key (string) or function(block) -> bool; the user's own blocks take a molten ring and the lane is labelled "you".
|
||||
- The legend is the host's; the module draws no HTML. Colours come from the CSS custom properties on :root (--ember, --ember-hi,
|
||||
--molten, --bone, --ash, --line, --line-2, --row or --obsidian), re-read on data-theme and prefers-color-scheme changes.
|
||||
- Sizing: the canvas fills its CSS box (width and height from the stylesheet); 280 px wide works, 600 or more shows lane labels well.
|
||||
onState(state, reason): 'connecting' | 'live' | 'stale' | 'failed'; onData(d) with every fresh reply. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var COL = {
|
||||
ember: '#F2541B', emberHi: '#FF6A2B', molten: '#FFB35C', bone: '#F4F1EC', ash: '#9A9A9E', line: '#2A2A30', line2: '#3A3A42', obsidian: '#0C0C0E',
|
||||
red: '#B0362B'
|
||||
};
|
||||
function cssVar(name, fallback) { try { var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } }
|
||||
function readTheme() {
|
||||
COL.ember = cssVar('--ember', COL.ember); COL.emberHi = cssVar('--ember-hi', COL.emberHi); COL.molten = cssVar('--molten', COL.molten);
|
||||
COL.bone = cssVar('--bone', COL.bone); COL.ash = cssVar('--ash', COL.ash); COL.line = cssVar('--line', COL.line); COL.line2 = cssVar('--line-2', COL.line2);
|
||||
COL.obsidian = cssVar('--row', cssVar('--obsidian', COL.obsidian));
|
||||
}
|
||||
function rgba(hex, a) { var h = hex.replace('#', ''); if (h.length !== 6) return hex; return 'rgba(' + parseInt(h.slice(0, 2), 16) + ',' + parseInt(h.slice(2, 4), 16) + ',' + parseInt(h.slice(4, 6), 16) + ',' + a + ')'; }
|
||||
function fmt(n) { return n === null || n === undefined ? 'pending' : Number(n).toLocaleString('en-GB'); }
|
||||
function fmtAge(sec) { sec = Math.round(sec); return sec < 90 ? sec + ' s' : sec < 5400 ? Math.round(sec / 60) + ' min' : Math.round(sec / 3600) + ' h'; }
|
||||
|
||||
function mount(canvas, opts) {
|
||||
opts = opts || {};
|
||||
var compact = !!opts.compact, windowS = Math.max(30, Math.min(300, opts.window || (compact ? 90 : 120)));
|
||||
var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var isMine = typeof opts.mine === 'function' ? opts.mine : opts.mine ? function (b) { return b.miner === opts.mine; } : null;
|
||||
var ctx = canvas.getContext('2d'), dpr = Math.min(root.devicePixelRatio || 1, 2), W = 0, H = 0;
|
||||
var blocks = [], byHash = {}, lanes = [], laneOf = {}, cps = [], data = null, state = 'connecting', reason = 'connecting';
|
||||
var paused = false, offsetMs = 0, hover = null, pinned = null, lastPoll = 0, failures = 0, running = false, inView = true, lastT = 0;
|
||||
var vnow = function () { return Date.now() - 2500 - offsetMs; }; // the right edge sits 2.5 s behind the clock so a block has arrived before it is drawn
|
||||
var padL = compact ? 10 : 56, padR = 14, padT = compact ? 12 : 22, padB = compact ? 10 : 26;
|
||||
|
||||
function size() { W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); layout(); }
|
||||
root.addEventListener('resize', size);
|
||||
|
||||
// ---- data ----
|
||||
function apply(d) {
|
||||
data = d;
|
||||
var seen = {}, now = performance.now();
|
||||
d.blocks.forEach(function (b) {
|
||||
seen[b.hash] = 1;
|
||||
var e = byHash[b.hash];
|
||||
if (e) { e.chain = !!b.chain; e.color = b.color || 'pending'; e.locked = !!b.locked; e.final = !!b.final; e.shards = b.shards || []; e.proven = !!b.proven; e.blue = b.blue_score; e.daa = b.daa; return; }
|
||||
e = { hash: b.hash, ts: b.ts, blue: b.blue_score, daa: b.daa, parents: b.parents || [], chain: !!b.chain, color: b.color || 'pending', miner: b.miner || '?', locked: !!b.locked, final: !!b.final, shards: b.shards || [], proven: !!b.proven, born: now, x: 0, y: 0 };
|
||||
byHash[b.hash] = e; blocks.push(e);
|
||||
});
|
||||
var cut = vnow() - windowS * 1000 - 20000;
|
||||
blocks = blocks.filter(function (b) { if (b.ts < cut && !seen[b.hash]) { delete byHash[b.hash]; return false; } return true; });
|
||||
blocks.sort(function (a, b) { return a.ts - b.ts; });
|
||||
cps = (d.finality && d.finality.checkpoints) || [];
|
||||
layout();
|
||||
}
|
||||
function layout() {
|
||||
// lanes: miners of the visible window ordered by blocks, as many as fit at 22 px, the rest in one "others" lane
|
||||
var vis = vnow() - windowS * 1000, count = {};
|
||||
blocks.forEach(function (b) { if (b.ts >= vis) count[b.miner] = (count[b.miner] || 0) + 1; });
|
||||
var ids = Object.keys(count).sort(function (a, b) { return count[b] - count[a] || (a < b ? -1 : 1); });
|
||||
var usable = H - padT - padB, laneH = compact ? 26 : 22, maxLanes = Math.max(2, Math.floor(usable / laneH));
|
||||
var shown = ids.length > maxLanes ? ids.slice(0, maxLanes - 1) : ids;
|
||||
lanes = shown.slice(); if (ids.length > shown.length) lanes.push('others');
|
||||
laneOf = {}; shown.forEach(function (id, i) { laneOf[id] = i; });
|
||||
}
|
||||
function laneY(b) { var i = b.miner in laneOf ? laneOf[b.miner] : lanes.length - 1; var n = Math.max(1, lanes.length); return padT + (i + 0.5) * ((H - padT - padB) / n); }
|
||||
function xOf(ts) { return padL + (W - padL - padR) * (1 - (vnow() - ts) / (windowS * 1000)); }
|
||||
|
||||
// the host can feed replies itself (poll: false); the same path the fetch takes
|
||||
function push(d) {
|
||||
if (!d || !d.ok || !d.state || !d.blocks) { setState('failed', 'live feed unavailable'); return; }
|
||||
failures = 0;
|
||||
if (d.state.stale) setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown'));
|
||||
else setState('live', null);
|
||||
apply(d); if (opts.onData) opts.onData(d); kick();
|
||||
}
|
||||
function poll() {
|
||||
if (opts.poll === false || document.hidden) return;
|
||||
var u = (opts.url || '/api/live') + '?window=' + windowS;
|
||||
fetch(u, { cache: 'no-store' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (d) {
|
||||
if (!d || !d.ok || !d.state || !d.blocks) throw new Error('bad reply');
|
||||
failures = 0;
|
||||
if (d.state.stale) { setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown')); }
|
||||
else setState('live', null);
|
||||
apply(d); if (opts.onData) opts.onData(d);
|
||||
kick();
|
||||
}).catch(function () { failures++; if (failures >= 2) setState('failed', 'live feed unavailable'); });
|
||||
}
|
||||
function setState(s, r) { if (s === state && r === reason) return; state = s; reason = r; if (opts.onState) opts.onState(s, r); }
|
||||
|
||||
// ---- drawing ----
|
||||
function draw(t) {
|
||||
if (!W || !H) return;
|
||||
var dt = lastT ? Math.min(50, t - lastT) : 16; lastT = t;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
var vis = vnow() - windowS * 1000, n = Math.max(1, lanes.length), lh = (H - padT - padB) / n;
|
||||
// lane rails and labels
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var y = padT + (i + 0.5) * lh;
|
||||
ctx.strokeStyle = rgba(COL.line, 0.9); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(W - padR, y); ctx.stroke();
|
||||
if (!compact) { var mineLane = isMine && typeof opts.mine === 'string' && lanes[i] === opts.mine; ctx.fillStyle = mineLane ? COL.molten : COL.ash; ctx.font = (mineLane ? '500' : '400') + ' 10px IBM Plex Mono, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(mineLane ? 'you' : lanes[i] === 'others' ? 'others' : String(lanes[i]).slice(0, 8), 6, y); }
|
||||
}
|
||||
// checkpoint bands at their blue score (x from the chain block carrying that score, else the nearest)
|
||||
cps.forEach(function (c) {
|
||||
var ref = null, best = Infinity;
|
||||
for (var k = blocks.length - 1; k >= 0; k--) { var b = blocks[k]; if (b.blue === null || b.blue === undefined) continue; var dd = Math.abs(b.blue - c.blue_score); if (dd < best) { best = dd; ref = b; } }
|
||||
if (!ref || best > 3 || ref.ts < vis) return;
|
||||
var x = xOf(ref.ts), locked = c.state === 'locked';
|
||||
ctx.strokeStyle = rgba(COL.ember, locked ? 0.55 : 0.3); ctx.lineWidth = locked ? 1.5 : 1; ctx.setLineDash(locked ? [] : [4, 6]);
|
||||
ctx.beginPath(); ctx.moveTo(x, padT - 6); ctx.lineTo(x, H - padB + 6); ctx.stroke(); ctx.setLineDash([]);
|
||||
if (!compact) { ctx.fillStyle = rgba(COL.ember, 0.9); ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText((locked ? 'locked ' : 'pending ') + (c.fraction_total !== null && c.fraction_total !== undefined ? Math.round(c.fraction_total * 100) + '%' : ''), x, H - 8); }
|
||||
});
|
||||
// positions
|
||||
blocks.forEach(function (b) { b.x = xOf(b.ts); b.y = laneY(b); b.on = b.ts >= vis && b.x >= padL - 10; });
|
||||
// edges: merge edges thin, chain-to-chain heavier; the chain as one glowing path
|
||||
blocks.forEach(function (b) {
|
||||
if (!b.on) return;
|
||||
b.parents.forEach(function (h) {
|
||||
var p = byHash[h]; if (!p || !p.on) return;
|
||||
var chainEdge = b.chain && p.chain;
|
||||
ctx.strokeStyle = chainEdge ? rgba(COL.ember, 0.75) : rgba(COL.ash, b.color === 'red' ? 0.12 : 0.22);
|
||||
ctx.lineWidth = chainEdge ? 2 : 1;
|
||||
ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + p.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke();
|
||||
if (chainEdge) { ctx.strokeStyle = rgba(COL.ember, 0.18); ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke(); }
|
||||
});
|
||||
});
|
||||
// blocks
|
||||
var S = compact ? 9 : 8, now = performance.now();
|
||||
blocks.forEach(function (b) {
|
||||
if (!b.on) return;
|
||||
var age = now - b.born, glow = age < 1600 ? 1 - age / 1600 : 0, ex = b.color === 'red';
|
||||
var r = b.chain ? S + 1 : S;
|
||||
if (glow > 0) { var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, r * 3); g.addColorStop(0, rgba(COL.ember, 0.4 * glow)); g.addColorStop(1, rgba(COL.ember, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r * 3, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.globalAlpha = ex ? 0.45 : 1;
|
||||
// fill: proven = ember, otherwise the row surface; outline: included (blue or chain) = ember, pending = grey, excluded = dim
|
||||
var proving = !b.proven && b.shards.some(function (s) { return s.state === 'proving' || s.state === 'verified' || s.state === 'paid'; });
|
||||
ctx.beginPath(); rr(b.x - r, b.y - r, 2 * r, 2 * r, 3);
|
||||
ctx.fillStyle = b.proven ? COL.ember : COL.obsidian; ctx.fill();
|
||||
ctx.lineWidth = b.chain ? 2 : 1.5;
|
||||
ctx.strokeStyle = b.proven ? COL.ember : proving ? COL.molten : (b.color === 'blue' || b.chain) ? rgba(COL.ember, 0.85) : ex ? rgba(COL.ash, 0.6) : rgba(COL.ash, 0.8);
|
||||
ctx.stroke();
|
||||
if (b.locked) { ctx.strokeStyle = rgba(COL.ember, 0.8 + 0.2 * Math.sin(t / 350)); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.9, 0, Math.PI * 2); ctx.stroke(); }
|
||||
if (isMine && isMine(b)) { ctx.strokeStyle = rgba(COL.molten, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.5, 0, Math.PI * 2); ctx.stroke(); }
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
// hover or pinned ring
|
||||
var f = pinned || hover; if (f && f.on) { ctx.strokeStyle = COL.bone; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.arc(f.x, f.y, S * 2.4, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); }
|
||||
// axis in DAA at the left and right edges, and the lock line label
|
||||
if (!compact) {
|
||||
ctx.fillStyle = COL.ash; ctx.font = '400 10px IBM Plex Mono, monospace'; ctx.textBaseline = 'alphabetic';
|
||||
var first = null, last = null; blocks.forEach(function (b) { if (!b.on) return; if (!first || b.ts < first.ts) first = b; if (!last || b.ts > last.ts) last = b; });
|
||||
if (first && last) { ctx.textAlign = 'left'; ctx.fillText('daa ' + fmt(first.daa), padL, padT - 8); ctx.textAlign = 'right'; ctx.fillText('daa ' + fmt(last.daa) + (paused ? ' paused' : ''), W - padR, padT - 8); }
|
||||
}
|
||||
if (paused) { ctx.fillStyle = rgba(COL.obsidian, 0.0); }
|
||||
}
|
||||
function rr(x, y, w, h, r) { ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
|
||||
|
||||
function frame(t) { if (!running) return; if (!paused) draw(t); if (reduce || document.hidden || !inView) { running = false; return; } requestAnimationFrame(frame); }
|
||||
function start() { if (running) return; running = true; requestAnimationFrame(frame); }
|
||||
function kick() { if (!running) requestAnimationFrame(function (t) { draw(t); if (!reduce && !document.hidden && inView) start(); }); }
|
||||
|
||||
// ---- interaction ----
|
||||
function hit(ev) {
|
||||
var rect = canvas.getBoundingClientRect(), px = ev.clientX - rect.left, py = ev.clientY - rect.top, best = null, bd = 14 * 14;
|
||||
blocks.forEach(function (b) { if (!b.on) return; var dx = b.x - px, dy = b.y - py, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = b; } });
|
||||
return best;
|
||||
}
|
||||
function words(b) {
|
||||
var state = b.locked ? 'locked checkpoint' : b.proven ? 'proven' : b.chain ? 'included, selected chain' : b.color === 'blue' ? 'included' : b.color === 'red' ? 'excluded' : 'pending';
|
||||
var sh = b.shards.length ? b.shards.map(function (s) { return s.state; }).join(', ') : 'no shard plan yet';
|
||||
return { title: b.hash + ' ' + state, lines: [(isMine && isMine(b) ? 'your block, miner ' : 'miner ') + b.miner + (b.miner in laneOf ? '' : ' (others lane)'), 'blue score ' + fmt(b.blue) + ' daa ' + fmt(b.daa), b.parents.length + (b.parents.length === 1 ? ' parent' : ' parents'), 'proof: ' + sh + (b.final ? ' final' : '')] };
|
||||
}
|
||||
var tip = opts.tooltip || null;
|
||||
function showTip(b, ev) {
|
||||
if (!tip) return;
|
||||
if (!b) { tip.hidden = true; return; }
|
||||
var w = words(b); tip.hidden = false; tip.innerHTML = '<b>' + w.title + '</b>' + w.lines.map(function (l) { return '<span>' + l + '</span>'; }).join('');
|
||||
var rect = canvas.getBoundingClientRect(); var x = Math.min(rect.width - tip.offsetWidth - 8, Math.max(8, b.x + 12)), y = Math.max(8, b.y - tip.offsetHeight - 10); if (y < 8) y = b.y + 14;
|
||||
tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';
|
||||
}
|
||||
canvas.addEventListener('mousemove', function (ev) { hover = hit(ev); canvas.style.cursor = hover ? 'pointer' : 'default'; if (!pinned) showTip(hover, ev); kick(); });
|
||||
canvas.addEventListener('mouseleave', function () { hover = null; if (!pinned) showTip(null); kick(); });
|
||||
canvas.addEventListener('click', function (ev) { var b = hit(ev); pinned = (pinned && b && pinned.hash === b.hash) ? null : b; showTip(pinned || hover); kick(); });
|
||||
canvas.addEventListener('wheel', function (ev) { if (!ev.ctrlKey && Math.abs(ev.deltaY) < 1) return; ev.preventDefault(); zoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15); }, { passive: false });
|
||||
var pinch = null;
|
||||
canvas.addEventListener('touchstart', function (ev) { if (ev.touches.length === 2) pinch = dist(ev.touches); }, { passive: true });
|
||||
canvas.addEventListener('touchmove', function (ev) { if (ev.touches.length === 2 && pinch) { var d = dist(ev.touches); if (Math.abs(d - pinch) > 12) { zoom(d > pinch ? 1 / 1.1 : 1.1); pinch = d; } } }, { passive: true });
|
||||
canvas.addEventListener('touchend', function () { pinch = null; }, { passive: true });
|
||||
function dist(t) { var dx = t[0].clientX - t[1].clientX, dy = t[0].clientY - t[1].clientY; return Math.sqrt(dx * dx + dy * dy); }
|
||||
function zoom(f) { var w = Math.round(Math.max(30, Math.min(300, windowS * f))); if (w === windowS) return; windowS = w; layout(); if (opts.onWindow) opts.onWindow(w); poll(); kick(); }
|
||||
function setPaused(p) { paused = !!p; if (paused) { offsetMs = 0; pausedAt = Date.now(); } else { offsetMs = 0; } if (opts.pauseButton) { opts.pauseButton.setAttribute('aria-pressed', paused ? 'true' : 'false'); opts.pauseButton.textContent = paused ? 'Resume' : 'Pause'; } kick(); }
|
||||
var pausedAt = 0;
|
||||
if (opts.pauseButton) opts.pauseButton.addEventListener('click', function () { setPaused(!paused); });
|
||||
// while paused the clock stops: offsetMs grows so the scene holds still but keeps its blocks
|
||||
setInterval(function () { if (paused) offsetMs = Date.now() - pausedAt; }, 250);
|
||||
|
||||
if ('IntersectionObserver' in root) { new IntersectionObserver(function (es) { es.forEach(function (e) { inView = e.isIntersecting; if (inView) kick(); }); }, { threshold: 0.05 }).observe(canvas); }
|
||||
document.addEventListener('visibilitychange', function () { if (!document.hidden) { poll(); kick(); } });
|
||||
if (root.matchMedia) { try { root.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { readTheme(); kick(); }); } catch (e) { } }
|
||||
new MutationObserver(function () { readTheme(); kick(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
readTheme(); size(); if (opts.poll !== false) { poll(); setInterval(poll, 2000); } kick();
|
||||
return { push: push, setPaused: setPaused, zoom: zoom, getWindow: function () { return windowS; }, getState: function () { return { state: state, reason: reason }; }, redraw: kick, words: words };
|
||||
}
|
||||
root.IgneumDag = { mount: mount };
|
||||
})(window);
|
||||
|
|
@ -14,10 +14,11 @@ const V = mod.exports.View;
|
|||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the four sections and their order (miner-ui-3)', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
|
||||
test('the five sections and their order (miner-ui-4)', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['overview', 'cards', 'earnings', 'prove', 'settings']);
|
||||
assert.equal(V.page('cards').title, 'Cards');
|
||||
assert.equal(V.page('earnings').title, 'Earnings');
|
||||
assert.equal(V.page('nonsense').id, 'mine');
|
||||
assert.equal(V.page('nonsense').id, 'overview');
|
||||
});
|
||||
|
||||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||||
|
|
@ -346,3 +347,31 @@ test('the activity feed and the pill speak in the user\'s voice; the engine line
|
|||
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
|
||||
});
|
||||
|
||||
test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving, the sparkline points, the plain label', () => {
|
||||
const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }];
|
||||
const tuned = card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 290, sweep_mhs: 122.3, tune_curve: curve, sweep_state: 'idle', sweep_note: '' });
|
||||
const e = V.ember(tuned, settings(), NOW);
|
||||
assert.equal(e.kind, 'tuned'); assert.equal(e.fill, 1);
|
||||
assert.equal(e.label, 'Tuned 2 h ago');
|
||||
assert.match(e.sub, /^2470 MHz at 80% · next check /);
|
||||
assert.equal(e.before, 374); assert.equal(e.after, 290); assert.equal(e.savingW, 84);
|
||||
assert.equal(e.savingText, 'saves 84 W, 0.16% of rate');
|
||||
assert.equal(e.points.length, 4); assert.equal(e.points.filter((p) => p.chosen).length, 1); assert.equal(e.points.filter((p) => p.marked).length, 1);
|
||||
// Ember's own fields win over the curve (tune_before_watts / tune_before_mhs, step 0 of the last full plan)
|
||||
const withFields = V.ember(card({ ...tuned, tune_before_watts: 380, tune_before_mhs: 122.4, tune_source: 'confirm' }), settings(), NOW);
|
||||
assert.equal(withFields.before, 380); assert.equal(withFields.savingW, 90); assert.match(withFields.label, /from the fleet prior$/);
|
||||
// a confirm plan without the fields claims no saving (its curve's first row is the prior, not an untuned point)
|
||||
assert.equal(V.ember(card({ ...tuned, tune_source: 'confirm' }), settings(), NOW).savingText, '');
|
||||
assert.equal(V.ember(card({ ...tuned, tune_before_watts: 0 }), settings(), NOW).savingText, '', 'zero means never measured');
|
||||
const running = V.ember(card({ state: 'tuning', tune_step: 4, tune_steps: 9, tune_eta_s: 410, sweep_state: 'running' }), settings(), NOW);
|
||||
assert.equal(running.kind, 'running'); assert.equal(running.fill.toFixed(2), '0.33'); assert.equal(running.label, 'Tuning: step 4 of 9'); assert.equal(running.sub, 'about 7 min left');
|
||||
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
|
||||
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
|
||||
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
|
||||
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet');
|
||||
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
|
||||
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day over 1 tuned card');
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
57
docs/plans/miner-ui-4.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Igneum Miner UI 4: Overview, Cards, Ember on the row
|
||||
|
||||
6 October 2026, evening. the project lead: "add Cards under its own tab, and have Overview and the cool mining demo on the main
|
||||
page with the revamped C option", then "incorporate Ember into the Cards tab beautifully", then "I would like the new
|
||||
miner UI to go into .15". Branch `miner-ui-4` from the 0.3.15 tip `6ea9497` (untouched), worktree
|
||||
`../igneum-wt-miner-ui-4`. Rust: one route (`GET /live-dag.js`, `server.rs`), nothing else.
|
||||
|
||||
## 1. The rail
|
||||
|
||||
Overview, Cards, Earnings, Prove, Settings; Logs and Quit in the foot; a bottom tab bar under 720 px.
|
||||
|
||||
## 2. Overview
|
||||
|
||||
| Block | What |
|
||||
|---|---|
|
||||
| The hero (the project lead's pick, C) | the fleet rate at 84 px on a graphite-to-obsidian fade, W with MH/W, £ a day at the user's price, blocks (this run, lifetime) beside it; Start/Stop as the full-width bar under it with its state sentence at the right |
|
||||
| The chain, live | the site's `live-dag.js` (a byte copy at `app/igneum-app/ui/live-dag.js`, served by the engine at `/live-dag.js`), mounted on the engine's own `api/live` with `mine: 'you'` so this machine's lane reads "you" and its blocks take a molten ring; the legend words in HTML under the canvas: yours, pending, included, excluded, proven, locked; Pause; a tooltip per block. The engine's `/api/live` is OWED (it maps the local node's RPC into the site's reply shape: `{ok, state, blocks[{hash, ts, blue_score, daa, parents, chain, color, miner, locked, final, shards, proven}], finality.checkpoints}`); until it exists the module reports `failed` and the app's own 10-minute blocks strip draws in its place with the note "The full chain view needs the next engine update". |
|
||||
| The node line | as 0.3.15, Details under it |
|
||||
| Activity | the event feed in the app's voice (the strip canvas moved into the chain card as the fallback) |
|
||||
|
||||
## 3. Cards
|
||||
|
||||
| Block | What |
|
||||
|---|---|
|
||||
| The Tuning strip | the goal control (Efficiency, Balanced, Maximum) with "about £X a day at Y W"; the fleet saving ("Ember saves 84 W, £0.56 a day over 1 tuned card", `View.fleetSaving`); Tune all; the schedule line; Power control only when an NVIDIA card has it off |
|
||||
| The rows | as 0.3.15 (state word with its reason, MH/s, W with MH/W, £ a day, °C, the switch, the chevron) with Ember as the second layer |
|
||||
| The flame mark | the brand mark's two polygons, 16 px, outline when not tuned, filling bottom-up with step k of n while tuning (molten), solid ember when tuned, half grey when measured only |
|
||||
| The Ember line | "Tuned 2 h ago · 2470 MHz at 80% · next check Sunday · saves 84 W, 0.16% of rate"; "Tuning: step 4 of 9 · about 7 min left" with the bar; "Measured, not tuned · Power control is off"; "Not tuned yet · starts after 2 min of steady mining"; the fleet pause and a stop in the same place |
|
||||
| The W cell | before → after when tuned (`tune_before_watts` → the live draw) |
|
||||
| The MH/W cell | a 44 px sparkline of the ladder's MH/W per step, the chosen step as a dot |
|
||||
| Tune / Retune / Stop | the row's button; Retune once tuned |
|
||||
| The chevron | the cap slider, the applied or Retry line, memory and clocks, the card's own goal (As the fleet, Efficiency, Balanced, Maximum: `POST api/tune/goal {key, goal}`, "" clears; a build without it gets a toast), Retune, the Tuned line, the curve (W across, MH/s up, every step a dot, the chosen ringed, a rejected one hollow), identities, the facts |
|
||||
|
||||
Fields read (Ember Tune 5429e82, on the 0.3.16 lane): `tune_before_watts`, `tune_before_mhs` (step 0 of the last full
|
||||
plan; 0 = never; the UI falls back to `tune_curve[0]` only for a full plan on an older build and claims no saving for a
|
||||
confirm or baseline plan), `sweep_watts`, `sweep_mhs`, `sweep_eff`, `tune_curve`, `tune_goal`, `tune_step`,
|
||||
`tune_steps`, `tune_eta_s`, `sweep_state`, `sweep_note`, `tune_floor` (asked for). `View.ember` and `View.beforeOf`
|
||||
carry the rules; `view.test.mjs` covers every state.
|
||||
|
||||
## 4. Screenshots (the mock on port 4318 with the rig state, the 5090 tuned, the 4070 at step 4 of 9)
|
||||
|
||||
`docs/plans/miner-ui-4/u01-overview-dark-1440.png`, `u02-overview-light-1440.png`, `u03-cards-dark-1440.png` (the
|
||||
5090's details open: cap, goal, Retune, the curve), `u04-cards-light-1440.png`, `u05-overview-dark-390.png`,
|
||||
`u06-cards-dark-390.png`, `u07-overview-light-390.png`, `u08-cards-light-390.png`, `u09-overview-mac-dark.png`.
|
||||
The chain scene in the shots is the mock's `/api/live` (four lanes, one block a second, "you" ringed); on a real
|
||||
0.3.15 engine the fallback strip shows until `/api/live` lands.
|
||||
|
||||
## 5. Owed
|
||||
|
||||
| What | Who |
|
||||
|---|---|
|
||||
| `GET /api/live` on the engine: the local node's blocks over the window, mapped into the site's shape, this machine's vote keys as the lane "you" | engine |
|
||||
| `card.tune_floor` | Ember (said yes) |
|
||||
| `watts_total`, `pounds_per_day` for the tray tip; `address.balance_wei`; `price_gbp_per_ign` | engine (from miner-ui-3) |
|
||||
| Re-copy `site/live-dag.js` over `app/igneum-app/ui/live-dag.js` when the site agent's audit commit lands, so the two stay identical | the shipper at merge |
|
||||
|
||||
Tests: `node --test app/igneum-app/ui/*.test.mjs`: 37 pass.
|
||||
BIN
docs/plans/miner-ui-4/u01-overview-dark-1440.png
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
docs/plans/miner-ui-4/u02-overview-light-1440.png
Normal file
|
After Width: | Height: | Size: 561 KiB |
BIN
docs/plans/miner-ui-4/u03-cards-dark-1440.png
Normal file
|
After Width: | Height: | Size: 476 KiB |
BIN
docs/plans/miner-ui-4/u04-cards-light-1440.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
docs/plans/miner-ui-4/u05-overview-dark-390.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/plans/miner-ui-4/u06-cards-dark-390.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/plans/miner-ui-4/u07-overview-light-390.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
docs/plans/miner-ui-4/u08-cards-light-390.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/plans/miner-ui-4/u09-overview-mac-dark.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
|
|
@ -80,6 +80,30 @@ function growLog() {
|
|||
}
|
||||
setInterval(growLog, 1000);
|
||||
|
||||
// /api/live for the embedded DAG scene (site/live-dag.js): a synthetic chain at one block a second, four miner lanes
|
||||
// ("you" is this machine), parents into the last two seconds, the selected chain through "you" and "miner-2", blue
|
||||
// and red colouring, proofs filling two blocks in three after 20 s, a checkpoint band every 30 s locked after 10 s.
|
||||
const LIVE_T0 = Math.floor(Date.now() / 1000) - 400;
|
||||
function liveNow(windowS) {
|
||||
const t = Math.floor(now()), from = t - Math.min(300, Math.max(30, windowS)) - 25, blocks = [];
|
||||
const lanes = ['you', 'miner-2', 'miner-3', 'miner-4'];
|
||||
let prev = [];
|
||||
for (let s = from; s <= t - 2; s++) {
|
||||
const seed = ((s % 100000) * 7919 + ((s >> 3) % 97) * 131) % 1000;
|
||||
const miner = lanes[seed % 4], hash = 'b' + s.toString(16).padStart(10, '0');
|
||||
const chain = miner === 'you' || miner === 'miner-2' ? (seed % 7 !== 3) : (seed % 5 === 0);
|
||||
const color = seed % 11 === 0 ? 'red' : (s < t - 4 ? 'blue' : 'pending');
|
||||
const proven = color === 'blue' && s < t - 20 && seed % 3 !== 0;
|
||||
const shards = proven ? [{ state: 'paid' }] : color === 'blue' && s < t - 8 ? [{ state: 'proving' }] : [];
|
||||
const parents = prev.slice(-2).map(b => b.hash);
|
||||
const locked = (s - LIVE_T0) % 30 === 0 && s < t - 10;
|
||||
const b = { hash, ts: s * 1000, blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), parents, chain, color, miner, locked, final: s < t - 40, shards, proven };
|
||||
blocks.push(b); prev.push(b); if (prev.length > 3) prev.shift();
|
||||
}
|
||||
const checkpoints = [];
|
||||
for (let s = from; s <= t - 2; s++) if ((s - LIVE_T0) % 30 === 0) checkpoints.push({ index: Math.floor((s - LIVE_T0) / 30), hash: 'c' + s.toString(16), blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), state: s < t - 10 ? 'locked' : 'pending', fraction_total: s < t - 10 ? 0.71 : 0.44 });
|
||||
return { ok: true, now: t, state: { stale: false, age_s: 1, height: 198000 + (t - LIVE_T0) }, blocks, finality: { checkpoints } };
|
||||
}
|
||||
function stateNow() {
|
||||
const s = clone(recState);
|
||||
const t = now();
|
||||
|
|
@ -156,6 +180,7 @@ http.createServer((req, res) => {
|
|||
return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html')));
|
||||
}
|
||||
if (rest === '/api/state' && req.method === 'GET') return json(res, stateNow());
|
||||
if (rest === '/api/live' && req.method === 'GET') return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
|
||||
if (rest === '/api/log' && req.method === 'GET') {
|
||||
const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10));
|
||||
let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit);
|
||||
|
|
|
|||