From fc94277775dd0806d9ac1bdcceb8a7db83a576e4 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:48:26 +0000 Subject: [PATCH] miner-ui-4: Overview (the C hero, the chain live, the node line) and Cards (every row with Ember as its second layer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rail: Overview, Cards, Earnings, Prove, Settings. Overview: the fleet rate at 84 px on the fade with W, £ a day and blocks, Start/Stop as the bar under it, the site's live-dag.js on the engine's api/live with this machine's lane as 'you' (the app's blocks strip as the fallback until the engine serves api/live), the node line, the activity feed. Cards: the Tuning strip (goal with its £ a day, the fleet saving in W and £, Tune all, the schedule, Power control when off), the rows with a flame mark that fills with the tune's progress, before -> after watts, a sparkline of the ladder, 'Tuned 2 h ago · 2470 MHz at 80% · next check Sunday · saves 84 W, 0.16% of rate', Retune, and under the chevron the cap, the card's own goal, the curve with the chosen point ringed. Reads Ember's tune_before_watts, tune_before_mhs, tune_goal, sweep_watts, sweep_mhs, tune_curve. Rust: one route, GET /live-dag.js. The ui-mock gains /api/live. 37 UI tests pass. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/server.rs | 2 + app/igneum-app/ui/app.css | 77 ++++++ app/igneum-app/ui/app.js | 136 +++++++++-- app/igneum-app/ui/index.html | 61 +++-- app/igneum-app/ui/live-dag.js | 221 ++++++++++++++++++ app/igneum-app/ui/view.test.mjs | 35 ++- docs/plans/miner-ui-4.md | 57 +++++ .../miner-ui-4/u01-overview-dark-1440.png | Bin 0 -> 554135 bytes .../miner-ui-4/u02-overview-light-1440.png | Bin 0 -> 574394 bytes docs/plans/miner-ui-4/u03-cards-dark-1440.png | Bin 0 -> 486983 bytes .../plans/miner-ui-4/u04-cards-light-1440.png | Bin 0 -> 300938 bytes .../miner-ui-4/u05-overview-dark-390.png | Bin 0 -> 205829 bytes docs/plans/miner-ui-4/u06-cards-dark-390.png | Bin 0 -> 163944 bytes .../miner-ui-4/u07-overview-light-390.png | Bin 0 -> 213321 bytes docs/plans/miner-ui-4/u08-cards-light-390.png | Bin 0 -> 163849 bytes .../miner-ui-4/u09-overview-mac-dark.png | Bin 0 -> 470919 bytes tools/ui-mock/server.mjs | 25 ++ 17 files changed, 570 insertions(+), 44 deletions(-) create mode 100644 app/igneum-app/ui/live-dag.js create mode 100644 docs/plans/miner-ui-4.md create mode 100644 docs/plans/miner-ui-4/u01-overview-dark-1440.png create mode 100644 docs/plans/miner-ui-4/u02-overview-light-1440.png create mode 100644 docs/plans/miner-ui-4/u03-cards-dark-1440.png create mode 100644 docs/plans/miner-ui-4/u04-cards-light-1440.png create mode 100644 docs/plans/miner-ui-4/u05-overview-dark-390.png create mode 100644 docs/plans/miner-ui-4/u06-cards-dark-390.png create mode 100644 docs/plans/miner-ui-4/u07-overview-light-390.png create mode 100644 docs/plans/miner-ui-4/u08-cards-light-390.png create mode 100644 docs/plans/miner-ui-4/u09-overview-mac-dark.png diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index b2ddb9f3..bc888ee1 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -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) { ("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), diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 623e74fa..8aa99654 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -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} +} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 92f93165..c22242ec 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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 ''; @@ -1347,13 +1411,16 @@ if (typeof document !== 'undefined') (function () { if (cells.money) nums += numCell('money', cells.money, '', 'a day', ''); else if (cells.needsPrice) nums += '
£ a day
'; if (r.temp) nums += numCell('temp', r.temp.replace(' °C', ''), '°C', 'GPU', r.tempTone); - var tuneBtn = tw.button === 'stop' ? '' : tw.button === 'tune' ? '' : ''; + 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('' + esc(cells.eff) + '', '' + esc(cells.eff) + sparkHtml(em.points) + ''); + var tuneBtn = tw.button === 'stop' ? '' : tw.button === 'tune' ? '' : ''; var sw = ''; var chev = ''; var subs = []; if (r.sub) subs.push('
' + esc(r.sub) + '
'); if (cells.noReading && r.on) subs.push('
' + esc(cells.noReading) + '
'); - var tune = tw.kind === 'none' ? '' : '
' + esc(tw.text) + '' + (tw.note ? '' + esc(tw.note) + '' : '') + (tw.progress >= 0 ? '' : '') + '
'; + var tune = em.kind === 'none' ? '' : '
' + flameHtml(em.fill, em.kind) + '' + esc(em.label) + '' + (em.sub ? '' + esc(em.sub) + '' : '') + (em.savingText ? '' + esc(em.savingText) + '' : '') + (em.kind === 'running' ? '' : '') + '
'; return '
' + '
' + badgeHtml({ vendor: r.vendor }) + '
' + '
' + esc(r.name) + ' ' + esc(r.kindWord) + '
' + @@ -1362,6 +1429,35 @@ if (typeof document !== 'undefined') (function () { '
' + tuneBtn + sw + chev + '
' + tune + '
'; } + // 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 ''; + } + // 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 ''; + } + // 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 ''; }).join(''); + return '
' + dots + + '' + Math.round(w0) + ' W' + Math.round(w1) + ' W' + + '' + m0.toFixed(0) + '' + m1.toFixed(0) + '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).
'; + } // 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 ' + Math.round(cd.fan_pct) + '%'); if (cd.util_pct > 0) tl.push('busy ' + Math.round(cd.util_pct) + '%'); if (tl.length) h += '
' + tl.join('') + '
'; - var tm = TuneLine.model(cd, s.now); - if (tm.kind === 'tuned' || tm.kind === 'measured') h += '
' + esc(tm.text) + '' + (tm.note ? ' ' + esc(tm.note) + '' : '') + '
'; - if (cd.tune_curve && cd.tune_curve.length) { - h += '' + cd.tune_curve.map(function (r, i) { return ''; }).join('') + '
stepclockpowerMH/sWMH/Wnote
' + (i + 1) + '' + (r.clock_mhz ? r.clock_mhz + ' MHz' : 'unlocked') + '' + (r.power_pct || 0) + '%' + (r.mhs || 0).toFixed(1) + '' + Math.round(r.watts || 0) + '' + (r.eff || 0).toFixed(3) + '' + esc(r.mark || '') + '
'; + var tm = TuneLine.model(cd, s.now), em = View.ember(cd, s, s.now); + if (View.tunable(cd)) { + var g = cd.tune_goal || '', goalSeg = '
' + [['', 'As the fleet'], ['efficiency', 'Efficiency'], ['balanced', 'Balanced'], ['rate', 'Maximum']].map(function (o) { return ''; }).join('') + '
'; + h += '
Goal' + goalSeg + (em.kind === 'running' ? '' : '') + '

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') + '.

'; } + if (tm.kind === 'tuned' || tm.kind === 'measured') h += '
' + esc(tm.text) + '' + (tm.note ? ' ' + esc(tm.note) + '' : '') + (em.savingText ? ' ' + esc(em.savingText) + '' : '') + '
'; + h += curveHtml(em.points); h += '
Identities

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.

'; var facts = [cd.worker ? 'worker ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean); if (facts.length) h += '

' + esc(facts.join(' · ')) + '

'; @@ -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('') : '
' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can drive was found. The node runs on its own.') + '
'; @@ -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) { diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 46655052..5848fee3 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -8,7 +8,7 @@ - + @@ -18,7 +18,8 @@ IGNEUM