From db97665a5e73c984fb2505de4c2dd8faecdb2667 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:05:37 +0100 Subject: [PATCH 01/45] playbook-quit-check: the ruling of 6 October 2026 15:30 UTC (a job script never pauses or resumes the installed app's miners, not even as a fallback; --stop-miners is the way): the two agg-cost allow entries dropped, so the gate names tools/proving-v1/pc2-agg-cost.ps1 and pc2-agg-cost-restore.ps1 until they move to --stop-miners Co-Authored-By: Claude Fable 5.1 --- tools/ci/playbook-quit-check.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/ci/playbook-quit-check.sh b/tools/ci/playbook-quit-check.sh index 20c7713d5..b0c178f85 100755 --- a/tools/ci/playbook-quit-check.sh +++ b/tools/ci/playbook-quit-check.sh @@ -25,11 +25,11 @@ if [ "${1:-}" = "--self-test" ]; then if ! check_file "$t/good.ps1"; then echo "self-test FAILED: the good playbook failed"; exit 1; fi rm -rf "$t"; echo "self-test passed: the installed app's URL file with a quit fails, a scratch URL file passes"; exit 0 fi -# allowed senders: the installer's own stop step (the update-now path the rule names), and, pending the rule owner's -# word (6 October 2026, 15:10 UTC): tools/proving-v1/pc2-agg-cost.ps1 and its restore step pc2-agg-cost-restore.ps1, which switch the 5090 off through /api/cards -# and falls back to /api/pause with /api/resume in its finally block (the aggregation-cost agent's measurement; the -# rule's letter forbids pause and resume of the installed app, its owner decides whether a card switch's fallback is one) -ALLOW='^(packaging/windows/stop-igneum\.ps1|tools/proving-v1/pc2-agg-cost\.ps1|tools/proving-v1/pc2-agg-cost-restore\.ps1)$' +# the one allowed sender is the installer's own stop step (the update-now path the rule names). Ruled 6 October 2026, +# 15:30 UTC: a job script never pauses or resumes the installed app's miners through /api/pause or /api/resume, not even +# as a fallback with a finally block (a script that dies before its finally leaves the box paused unattended); the +# job runner's --stop-miners is the sanctioned way (it stops them around the job and restarts them on any exit). +ALLOW='^packaging/windows/stop-igneum\.ps1$' fail=0 while IFS= read -r f; do [[ "$f" =~ $ALLOW ]] && continue; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/windows/**' 'tools/proving-v1/**' 'packaging/**' | grep -E '\.(ps1|sh)$') [ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses or resumes the installed app" From d8e03cd98bd1132ef01d76e88eebc655209cd504 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:10:53 +0100 Subject: [PATCH 02/45] ember-tune-pc1.ps1: budget 55 min (three cards: 5090 9 steps, 4070 10 steps, 9070 XT 4 steps, 75 s each) Co-Authored-By: Claude Fable 5.1 --- relay/playbooks/ember-tune-pc1.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 1a272e34f..84fef3659 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -15,7 +15,7 @@ # therefore measure only tonight unless the engine finds itself elevated. $ErrorActionPreference = 'Continue' $resultTag = 'TUNE' -$budgetMinutes = 45 +$budgetMinutes = 55 if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35) $started = Get-Date $deadline = $started.AddMinutes($budgetMinutes) From e4b9a52aed4b768badd66cb99c93e22b02ddb0df Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:11:37 +0100 Subject: [PATCH 03/45] ember: a registered Igneum Power Helper task counts as permission for the tune (an unelevated measurement engine, Power control forced off, still sets NVIDIA limits through the task with no prompt) Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index bd690bdfa..d3bf7a823 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2295,7 +2295,9 @@ impl Engine { }; // NVIDIA control: this process is elevated (direct), or Power control is on so the one-prompt helper may run. // The --sweep job alone never counts: it must not raise a prompt on a PC with nobody there (5 October 2026). - let power_control = probe.direct || self.shared.settings.lock().unwrap().power_control; + // a registered Igneum Power Helper task (src/powertask.rs) is permission too: it sets limits with no prompt, + // so an unelevated measurement engine (Power control forced off) still controls NVIDIA through it + let power_control = probe.direct || self.shared.settings.lock().unwrap().power_control || (cfg!(windows) && self.power_task_registered()); // AMD's power limit is a percent offset from the default (ADLX): the plan's watts scale becomes a percent // scale (default 100, floor 100 + plimit_min, ceiling 100 + plimit_max), tune_apply sends pct - 100 let limits = if c.vendor == "amd" && probe.direct && probe.plimit_max >= probe.plimit_min && probe.plimit_min > -100.0 { @@ -2388,7 +2390,8 @@ impl Engine { /// The elevated helper (src/sweep.rs helper_script_*): one administrator prompt; it polls /sweep/cmd.txt. fn sweep_helper_start(&mut self, c: &CardState) -> Result<(), String> { - if !self.shared.settings.lock().unwrap().power_control { + let task = cfg!(windows) && self.power_task_registered(); + if !task && !self.shared.settings.lock().unwrap().power_control { return Err("Power control is off in Settings".into()); } let dir = self.sweep_dir(); From ccad9a13d90b68ba6bea6d3f7ac75eb1f67cb938 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:30:16 +0100 Subject: [PATCH 04/45] powertask: a kit run from a jobs folder registers the INSTALLED exe as the task's action (the 15:28Z run on PC 1 registered jobs\ember-kit-5\igneum-app-ember.exe); the playbook uses a kit only on an app that predates Ember Tune Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 3 ++- app/igneum-app/src/powertask.rs | 28 ++++++++++++++++++++++++++++ relay/playbooks/ember-tune-pc1.ps1 | 9 ++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index d3bf7a823..146a62c81 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1818,7 +1818,8 @@ impl Engine { let dir = self.sweep_dir(); let _ = std::fs::create_dir_all(&dir); let script = dir.join("register-power-task.ps1"); - match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&exe).as_bytes()].concat())) { + let installed: Vec = [std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe"))].into_iter().flatten().collect(); + match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)).as_bytes()].concat())) { Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()), _ => cmds.join(" & "), } diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index dbdf81830..afff52b8c 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -80,6 +80,20 @@ pub fn smi_args(dev: &str, c: &HelperCmd) -> Option> { } } +/// The executable the task should point at: the running one, unless it runs out of a jobs folder (a measurement +/// kit a job fetched: `\app\jobs\\igneum-app-ember.exe`), in which case the installed app's own +/// exe beside it is the durable target (6 October 2026: the first registration on PC 1 came from such a kit). +pub fn task_exe(running: &Path, install_candidates: &[PathBuf]) -> PathBuf { + // either separator: the path comes from Windows, the tests run on the Mac too + let under_jobs = running.to_string_lossy().split(['\\', '/']).any(|c| c.eq_ignore_ascii_case("jobs")); + if under_jobs { + if let Some(p) = install_candidates.iter().find(|p| p.is_file()) { + return p.clone(); + } + } + running.to_path_buf() +} + /// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this /// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no /// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs. @@ -253,6 +267,20 @@ mod tests { assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'")); } + #[test] + fn a_kit_run_from_a_jobs_folder_registers_the_installed_exe() { + let kit = Path::new(r"C:\Users\Admin\AppData\Local\igneum\app\jobs\ember-kit-5\igneum-app-ember.exe"); + let own = Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"); + // no installed candidate exists on this machine: the running exe stands + assert_eq!(task_exe(kit, &[PathBuf::from(r"C:\nonexistent\igneum-app.exe")]), kit.to_path_buf()); + assert_eq!(task_exe(own, &[]), own.to_path_buf()); + // an installed candidate that exists (the test binary stands in for it) wins for a kit, not for the installed exe + let here = std::env::current_exe().unwrap(); + assert!(here.is_file()); + assert_eq!(task_exe(kit, &[here.clone()]), here); + assert_eq!(task_exe(own, &[here]), own.to_path_buf()); + } + #[test] fn a_stale_command_file_does_not_run_at_start() { // the helper's start reads the highest sequence already in the file and runs nothing below or at it diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 84fef3659..183099ab0 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -47,11 +47,10 @@ $installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out $installedHasEmber = $false if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) } $ember = $null -# ember-kit-3 (the engine with Settings::for_measurement) is preferred when present, whatever the installed version; -# older kits only when the installed app predates Ember Tune -$k3 = Join-Path $appDir 'jobs\ember-kit-5\igneum-app-ember.exe' -if (Test-Path $k3) { $ember = $k3 } -elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } +# a kit is used only when the installed app predates Ember Tune (0.3.13); from 0.3.13 on the installed engine is the +# tune engine, so the Power Helper task it registers points at the install folder (the 15:28Z run on PC 1 registered +# the kit's path because kit-5 was still preferred) +if (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } if ($ember) { Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force Say ("engine: the Ember build from " + $ember) From 6f46cd40dc1c784a6a9e447e574b02e08c4ce0c5 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:36:07 +0100 Subject: [PATCH 05/45] =?UTF-8?q?A=20card=20never=20shows=200=20MH/s=20wit?= =?UTF-8?q?hout=20a=20reason=20word=20(Josh,=206=20October=202026,=20watch?= =?UTF-8?q?ing=20PC=201=20during=20the=20table=20run):=20the=20Mine=20row?= =?UTF-8?q?=20reads=20'tuning:=20step=20k=20of=20n=20=C2=B7=20measuring=20?= =?UTF-8?q?W=20W'=20with=20the=20live=20rate,=20'held=20for=20a=20remote?= =?UTF-8?q?=20job:=20'=20while=20a=20job=20holds=20the=20miners,=20?= =?UTF-8?q?and=20the=20Tuned=20line=20at=20the=20end;=20the=20big=20button?= =?UTF-8?q?=20reads=20'Tuning,=20mining=20again=20in=20about=20N=20min';?= =?UTF-8?q?=20the=20strip=20carries=20one=20tune=20line;=20a=20measurement?= =?UTF-8?q?=20engine=20prints=20a=20TUNE=20progress=20line=20every=2010=20?= =?UTF-8?q?s=20and=20the=20playbook=20forwards=20it=20(and=20TUNE=20chosen?= =?UTF-8?q?=20as=20done)=20to=20the=20installed=20app's=20POST=20/api/tune?= =?UTF-8?q?-progress,=20a=20post=20of=20state,=20never=20quit,=20pause=20o?= =?UTF-8?q?r=20resume;=20the=20rule=20in=20ember-tune.md=206a;=20UI=20test?= =?UTF-8?q?s=20for=20the=20three=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 13 +++++ app/igneum-app/src/engine.rs | 86 ++++++++++++++++++++++++++-- app/igneum-app/src/server.rs | 5 ++ app/igneum-app/src/state.rs | 6 ++ app/igneum-app/ui/app.js | 43 +++++++++++--- app/igneum-app/ui/tune-line.test.mjs | 33 +++++++++++ docs/plans/ember-tune.md | 15 +++++ relay/playbooks/ember-tune-pc1.ps1 | 21 ++++++- tools/ci/second-engine-check.sh | 2 +- 9 files changed, 211 insertions(+), 13 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index d839135e5..dcd96dafd 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -591,6 +591,19 @@ impl Run { self.rows.first().map(|r| r.mclk).unwrap_or(0.0) } + /// Seconds left in the plan from here: the rest of this step plus (settle + hold + a 10 s apply) per step to come. + pub fn eta_s(&self, now: Instant) -> i64 { + let per = (self.timing.settle + self.timing.hold).as_secs() as i64 + 10; + let this = match &self.phase { + Phase::Applying { .. } => per, + Phase::Settling { since } => per - 10 - now.duration_since(*since).as_secs().min(per as u64) as i64, + Phase::Holding { since } => (self.timing.hold.as_secs() as i64 - now.duration_since(*since).as_secs() as i64).max(0), + Phase::Finishing { .. } | Phase::Done => 0, + }; + let left = self.plan.len().saturating_sub(self.rows.len() + 1) as i64; + this.max(0) + left * per + } + /// The phase in words for the card row. pub fn words(&self, now: Instant) -> String { let what = |p: &Point| if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) }; diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 146a62c81..7ed7050a8 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -73,6 +73,8 @@ pub enum Cmd { SweepHelperDone(Result<(), String>), /// a tune request (by number) was carried out: what the vendor tool printed, or why it refused TuneSet(u64, Result<String, String>), + /// POST /api/tune-progress from a measurement engine beside this app: the card's live tune state + TuneProgress(Value), /// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the /// prover found a host that was not there when the node started RestartNode(String), @@ -1142,6 +1144,7 @@ impl Engine { self.shared.log("sweep helper: exited"); } } + Cmd::TuneProgress(v) => self.tune_progress(&v), Cmd::TuneSet(seq, r) => match r { Ok(text) => { self.tune_acked = Some(seq); @@ -2527,6 +2530,49 @@ impl Engine { self.shared.log(&format!("tune: {} MHz, {}% ({w} W) requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct)); } + /// A measurement engine's progress for one card (POST /api/tune-progress, forwarded by the job playbook from the + /// engine's `TUNE progress` lines): the installed app shows the step, the live rate and draw and the time left + /// instead of a bare "off" while a job holds its miners. `done` ends it: the row reads the tuned line. + fn tune_progress(&mut self, v: &Value) { + let key = v.get("key").and_then(|k| k.as_str()).unwrap_or(""); + let mut st = self.st(); + let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == key || (!key.is_empty() && c.name.replace(' ', "_") == key)) else { return }; + let n = |k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0); + if v.get("done").and_then(|d| d.as_bool()).unwrap_or(false) { + c.sweep_state = "idle".into(); + c.state = if self.job_hold { "held".into() } else { c.state.clone() }; + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; + if n("mhs") > 0.0 && n("watts") > 0.0 { + c.sweep_mhs = n("mhs"); + c.sweep_watts = n("watts"); + c.sweep_eff = n("mhs") / n("watts"); + c.sweep_pct = n("power_pct") as u32; + c.tune_clock_mhz = n("clock_mhz") as u32; + c.tune_source = v.get("plan").and_then(|p| p.as_str()).unwrap_or("full").into(); + c.tune_line = crate::ember::tuned_line(n("mhs"), n("watts"), n("mhs") / n("watts")); + c.sweep_at = crate::platform::unix_now_f(); + } + c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("").to_string(); + return; + } + c.state = "tuning".into(); + c.sweep_state = "running".into(); + c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("tuning").to_string(); + c.tune_step = n("step") as u32; + c.tune_steps = n("of") as u32; + c.tune_eta_s = n("eta_s") as i64; + c.tune_plan = v.get("plan").and_then(|p| p.as_str()).unwrap_or("").into(); + if n("mhs") > 0.0 { + c.hash_now = n("mhs"); + } + if n("watts") > 0.0 { + c.power_w = n("watts"); + } + c.message = String::new(); + } + /// Faults and conditions first, then the state machine. fn sweep_drive(&mut self, now: Instant) { let Some((idx, device, started, seq)) = self.sweep.as_ref().map(|r| (r.card, r.device.clone(), r.started, r.seq)) else { return }; @@ -2542,7 +2588,7 @@ impl Engine { Some("a remote job took the GPU".into()) } else if self.st().mining.paused { Some("mining paused".into()) - } else if c.state != "mining" { + } else if c.state != "mining" && c.state != "tuning" { Some(format!("the card left mining ({})", if c.state == "starting" || c.state == "restarting" { "worker restart, likely the hour boundary" } else { &c.state })) } else if slot_error { Some("the worker reported an error".into()) @@ -2591,9 +2637,20 @@ impl Engine { } } } - if let Some(words) = self.sweep.as_ref().map(|r| r.words(now)) { + if let Some((words, step, of, eta, plan)) = self.sweep.as_ref().map(|r| (r.words(now), r.rows.len() as u32 + 1, r.plan.len() as u32, r.eta_s(now), r.plan.kind.name())) { + let (key, mhs, watts) = self.st().mining.cards.get(idx).map(|c| (c.key.clone(), c.hash_now, c.power_w)).unwrap_or_default(); if let Some(cc) = self.st().mining.cards.get_mut(idx) { - cc.sweep_note = words; + cc.sweep_note = words.clone(); + cc.state = if cc.state == "mining" { "tuning".into() } else { cc.state.clone() }; + cc.tune_step = step; + cc.tune_steps = of; + cc.tune_eta_s = eta; + cc.tune_plan = plan.into(); + } + if self.shared.runtime.sweep_only { + // the job playbook forwards this to the installed app's /api/tune-progress + println!("TUNE progress key={} step={step} of={of} eta_s={eta} mhs={mhs:.2} watts={watts:.1} plan={plan} note={}", key.replace(' ', "_"), words.replace(' ', "_")); + let _ = std::io::stdout().flush(); } } } @@ -2616,6 +2673,12 @@ impl Engine { c.sweep_mhs = row.mhs; c.sweep_at = unix as f64; c.sweep_state = "idle".into(); + if c.state == "tuning" { + c.state = "mining".into(); + } + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; c.tune_clock_mhz = row.point.clock_mhz; c.tune_source = kind.name().into(); c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff); @@ -2701,6 +2764,12 @@ impl Engine { match st.mining.cards.get_mut(idx) { Some(c) => { c.sweep_state = "idle".into(); + if c.state == "tuning" { + c.state = "mining".into(); + } + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; c.sweep_note = format!("tuning stopped: {why}"); if touched { c.power_pct = before.power_pct; @@ -2874,6 +2943,13 @@ impl Engine { } if self.job_hold && !self.jobs.holds_miners() { self.job_hold = false; + for c in self.st().mining.cards.iter_mut().filter(|c| c.state == "held" || c.state == "tuning") { + c.state = "off".into(); + c.sweep_state = if c.sweep_state == "running" { "idle".into() } else { c.sweep_state.clone() }; + c.tune_step = 0; + c.tune_steps = 0; + c.tune_eta_s = 0; + } if self.running { self.shared.event("info", "job finished; the miners restart"); for m in self.miners.iter_mut() { @@ -2890,7 +2966,9 @@ impl Engine { self.job_hold = true; self.stop_miners(&why); for c in self.st().mining.cards.iter_mut().filter(|c| c.enabled) { - c.message = "stopped for a remote job".into(); + // never a bare "off" at 0 MH/s: the row says why (a job holds the card) + c.state = "held".into(); + c.message = format!("held for a remote job: {}", short(&why, 80)); } self.jobs.miners_stopped(&self.shared); } diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index b2ddb9f31..e85d949b9 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -325,6 +325,11 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri shared.send(Cmd::PowerControl(on)); Ok(json!({ "ok": true })) } + // a measurement engine's live tune state for one card (forwarded by its job playbook): the row shows the step + "/api/tune-progress" => { + shared.send(Cmd::TuneProgress(body)); + Ok(json!({ "ok": true })) + } "/api/sweep/enable" => { let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?; shared.send(Cmd::SweepEnable(on)); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 7ec9a944c..4292b4de9 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -125,6 +125,12 @@ pub struct CardState { pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked) pub tune_source: String, // full | confirm | baseline pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned + // a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress + // (Josh, 6 October 2026: "don't we need to show in the app that tuning is in progress?") + pub tune_step: u32, + pub tune_steps: u32, + pub tune_eta_s: i64, + pub tune_plan: String, // the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose pub variant: String, pub race_mhs: f64, diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index f0c0d6c3e..168fa680d 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -13,7 +13,7 @@ 5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */ var Notices = (function () { 'use strict'; - var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; + var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, tuning: 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300; function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; } @@ -120,8 +120,19 @@ var Notices = (function () { if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); } else { var c = clockNotice(s.clock); if (c) out.push(c); } if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0)); + var tn = tuneNotice(s.mining && s.mining.cards); + if (tn) out.push(tn); return out; } + // the one line while a card is being tuned: which card, which plan, the step, when mining is back + function tuneNotice(cards) { + var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); + if (!l.length) return null; + var c = l[0], plan = c.tune_plan === 'confirm' ? 'confirming the fleet prior' : c.tune_plan === 'baseline' ? 'measuring as it runs' : 'the full tune'; + var step = c.tune_steps > 0 ? ', step ' + (c.tune_step || 1) + ' of ' + c.tune_steps : ''; + var eta = c.tune_eta_s > 0 ? ' Mining again in ' + (c.tune_eta_s < 60 ? 'under a minute' : 'about ' + Math.round(c.tune_eta_s / 60) + ' min') + '.' : ''; + return notice('tuning', 'tuning:' + c.key, 'Tuning ' + c.name + ': ' + plan + step + '.' + eta, { tone: 'info', title: 'Ember Tune' }); + } // the lowest level wins; a dismissed key is skipped, so the next one shows in its place function pick(list, dismissed) { var best = null; @@ -132,7 +143,7 @@ var Notices = (function () { } return best; } - return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; + return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; })(); /* ---------- the update card (pure; update-card.test.mjs loads this block) ---------- @@ -282,9 +293,11 @@ var View = (function () { function cardRow(cd) { var removed = cd.removed_at > 0, unusable = !!cd.problem; var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off'); - var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; - var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; - var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; + // the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word + var tuning = st === 'tuning', held = st === 'held'; + var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'held for a remote job') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; + var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; + var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : ''; var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : ''; var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : ''; @@ -297,12 +310,28 @@ var View = (function () { return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) }; } + // "tuning: step 7 of 9 · measuring 220 W" (the live draw; the plan's words when there is no draw yet) + function tuneWord(cd) { + var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : ''; + var w = cd.power_w > 0 ? 'measuring ' + Math.round(cd.power_w) + ' W' : ''; + var rest = [step, w].filter(Boolean).join(' · '); + return rest ? 'tuning: ' + rest : (cd.sweep_note || 'tuning'); + } + // "about 12 min" / "under a minute" from a tune's seconds left + function tuneEta(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? 'under a minute' : 'about ' + Math.round(s / 60) + ' min'; } + // the card a tune is running on, or null + function tuningCard(cards) { var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); return l.length ? l[0] : null; } + // the big button on Mine: m = state.mining, n = state.node, s = { quitting } function toggle(m, n, s) { var cards = shownCards(m.cards).filter(present); var on = cards.filter(function (c) { return c.enabled; }).length; var mining = cards.filter(function (c) { return c.state === 'mining'; }).length; if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' }; + var tc = tuningCard(cards); + if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' }; + var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length; + if (heldCards && !mining) return { label: 'Held', sub: 'a remote job holds the cards; mining restarts when it ends', cls: 'stop', disabled: true, act: '' }; if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' }; if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }; if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' }; @@ -384,7 +413,7 @@ var View = (function () { else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.'); return parts.join(' '); } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; })(); /* ---------- 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 @@ -414,7 +443,7 @@ var TuneLine = (function () { function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; } return { model: model, point: point, rel: rel }; })(); -if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; } +if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; } if (typeof document !== 'undefined') (function () { 'use strict'; diff --git a/app/igneum-app/ui/tune-line.test.mjs b/app/igneum-app/ui/tune-line.test.mjs index 17b6daed0..18fafce33 100644 --- a/app/igneum-app/ui/tune-line.test.mjs +++ b/app/igneum-app/ui/tune-line.test.mjs @@ -10,6 +10,8 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js') const mod = { exports: {} }; new Function('module', src)(mod); const { model, point } = mod.exports.TuneLine; +const View = mod.exports.View; +const tuneNotice = mod.exports.tuneNotice; const NOW = 1_800_000_000; const card = over => ({ vendor: 'nvidia', sweep_state: 'idle', sweep_note: '', sweep_pct: 0, power_pct: 80, sweep_at: 0, tune_line: '', tune_source: '', tune_clock_mhz: 0, tune_control: true, pinned: false, ...over }); @@ -42,3 +44,34 @@ test('running, stopped, idle and off', () => { assert.equal(model(card({ vendor: 'other' }), NOW).kind, 'off'); assert.equal(point({ tune_clock_mhz: 0, sweep_pct: 0, power_pct: 0 }), ''); }); + +test('a card never shows 0 MH/s without a reason word: tuning, held and tuned rows, the button, the strip', () => { + const base = { key: 'nvidia:0:x', name: 'RTX 5090', vendor: 'nvidia', kind: 'discrete', enabled: true, state: 'tuning', hash_now: 123.4, power_w: 219.6, tune_step: 7, tune_steps: 9, tune_eta_s: 230, tune_plan: 'full', sweep_note: 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)', vram_mb: 32768 }; + const r = View.cardRow(base); + assert.equal(r.word, 'tuning: step 7 of 9 · measuring 220 W'); + assert.equal(r.hash, '123', 'the live rate stays in the rate column'); + assert.equal(r.tone, 'on'); + const h = View.cardRow({ ...base, state: 'held', hash_now: 0, power_w: 0, message: 'held for a remote job: Ember Tune on PC 1', tune_steps: 0 }); + assert.equal(h.word, 'held for a remote job: Ember Tune on PC 1'); + assert.equal(h.hash, '', 'blank, never 0'); + const off = View.cardRow({ ...base, state: 'off', enabled: false, hash_now: 0 }); + assert.equal(off.word, 'off'); assert.equal(off.hash, ''); + // the big button during a tune + const t = View.toggle({ cards: [base], state: 'mining', paused: false }, { synced: true }, {}); + assert.equal(t.label, 'Tuning'); assert.equal(t.disabled, true); + assert.equal(t.sub, 'mining again in about 4 min · RTX 5090'); + const held = View.toggle({ cards: [{ ...base, state: 'held' }], state: 'waiting', paused: false }, { synced: true }, {}); + assert.equal(held.label, 'Held'); assert.equal(held.disabled, true); + // the strip + const n = tuneNotice([base]); + assert.equal(n.kind, 'tuning'); + assert.equal(n.text, 'Tuning RTX 5090: the full tune, step 7 of 9. Mining again in about 4 min.'); + assert.equal(tuneNotice([{ ...base, state: 'mining' }]), null); + // the finished row: TuneLine's tuned model (the table's "Tuned: ..." line with the point) + const done = model({ ...base, state: 'mining', sweep_state: 'idle', tune_line: 'Tuned: 123.4 MH/s at 220 W (0.561 MH/W)', tune_source: 'full', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 60, tune_control: true }, NOW); + assert.equal(done.kind, 'tuned'); + assert.equal(done.text, 'Tuned: 123.4 MH/s at 220 W (0.561 MH/W)'); + assert.match(done.note, /^2470 MHz at 100%, full tune, 1 min ago/); + assert.equal(View.tuneEta(30), 'under a minute'); + assert.equal(View.tuneEta(700), 'about 12 min'); +}); diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 4f990112f..352698906 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -161,6 +161,21 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r 10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout. +## 6a. A card never shows 0 MH/s without a reason word (Josh, 6 October 2026, watching PC 1 during run 5) + +Rule: the Mine page's rate column is blank, never "0", when a card is not mining, and the row's word says why: +`mining`, `tuning: step k of n · measuring W W` (the live rate stays in the rate column), `held for a remote job: +<title>`, `paused`, `waiting for the node`, `restart in N s`, `starting`, `ready`, `stopped after repeated faults`, +`failed`, `off`, `removed`, `not usable (<problem>)`. Checked against `View.cardRow`: every state the engine can set +has a word; `held` and `tuning` were the two that read "off" before (a job's `--stop-miners` left `state = off`). +During a tune the big button reads "Tuning, mining again in about N min" (disabled) and the strip carries one line +("Tuning <card>: the full tune, step k of n. Mining again in about N min."). A tune run by a job beside the installed +app reaches the installed app's rows through `POST /api/tune-progress` (the playbook forwards the measurement +engine's `TUNE progress` lines every 10 s and its `TUNE chosen` line as `done`): the posted state is the step, the +live MH/s and W, the seconds left and the plan; a POST of state, never a quit, pause or resume. At the end the row +reads `Tuned: <MH/s> at <W> W (<MH/W>)` with the point and the plan. Tests: `ui/tune-line.test.mjs` (tuning, held, +tuned rows; the button; the strip line). + ## 7a. One administrator approval, ever (0.3.13; Josh, 6 October 2026, 11:50 UTC) What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 183099ab0..da492d63c 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -126,6 +126,24 @@ function EndTree([int] $procId, [string] $why) { } $seen = 0 $rows = 0 +# the installed app shows the tune instead of a bare "off" (Josh, 6 October 2026): every `TUNE progress` line and the +# `TUNE chosen` line go to its /api/tune-progress (a POST of state, never quit, pause or resume: the 5 October rule) +$installedUrl = '' +try { $installedUrl = (Get-Content -LiteralPath (Join-Path $appDir 'app.url') -Raw -ErrorAction Stop).Trim() } catch { } +function Forward([string] $line) { + if (-not $installedUrl) { return } + $kv = @{} + foreach ($tok in ($line -split ' ')) { if ($tok -match '^([a-z_]+)=(.*)$') { $kv[$Matches[1]] = $Matches[2] } } + $body = @{ key = ($kv['card'] -replace '_', ' '); done = ($line -match '^TUNE chosen ') } + if ($kv['key']) { $body.key = ($kv['key'] -replace '_', ' ') } + foreach ($n in @('step', 'of', 'eta_s', 'mhs', 'watts', 'clock', 'cap')) { if ($kv[$n] -ne $null) { $body[$n] = [double]$kv[$n] } } + if ($kv['cap'] -ne $null) { $body['power_pct'] = [double]$kv['cap'] } + if ($kv['clock'] -ne $null) { $body['clock_mhz'] = [double]$kv['clock'] } + if ($kv['plan']) { $body.plan = $kv['plan'] } + if ($kv['note']) { $body.note = ($kv['note'] -replace '_', ' ') } + if ($body.done) { $body.note = ('tuned: ' + $kv['mhs'] + ' MH/s at ' + $kv['watts'] + ' W') } + try { Invoke-WebRequest -Uri ($installedUrl + 'api/tune-progress') -Method POST -Body ($body | ConvertTo-Json -Compress) -ContentType 'application/json' -UseBasicParsing -TimeoutSec 3 | Out-Null } catch { } +} # the watchdog (coordinator, 6 October 2026): a tune engine that mines nothing for 120 s after its first status line # (every card "waiting" or 0.00 MH/s: no payout address, no worker, no node) fails the job at once with the engine's # last log line in the RESULT, its tree ended, mining restored by the job runner; a job that cannot mine never burns @@ -165,7 +183,8 @@ while (-not $p.HasExited) { $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++ - if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } } + if ($l -match '^TUNE progress ') { Forward $l } + elseif ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ }; if ($l -match '^TUNE chosen ') { Forward $l } } elseif ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l) } elseif ($l -match '^(URL|STATE) ') { } else { Say $l } diff --git a/tools/ci/second-engine-check.sh b/tools/ci/second-engine-check.sh index 8f378b082..ec8e8a647 100755 --- a/tools/ci/second-engine-check.sh +++ b/tools/ci/second-engine-check.sh @@ -23,7 +23,7 @@ while IFS= read -r f; do if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1 fi - if grep -vE '^\s*#' "$f" | grep -qE 'ConvertTo-Json' && grep -qE 'settings\.json' "$f"; then + if grep -vE '^\s*#' "$f" | grep -E 'ConvertTo-Json' | grep -qE 'settings\.json|Set-Content|WriteAllText|Out-File'; then echo "second-engine: $f rewrites settings.json through ConvertTo-Json (a lossy round trip: big integers become doubles and the engine reads the whole file as defaults; run 4, 6 October 2026); copy the file verbatim, the engine applies Settings::for_measurement under --sweep"; fail=1 fi if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then From 5e4ef432362bcb7ca476cf54d539c6b4381ceffa Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:43:53 +0100 Subject: [PATCH 06/45] Run 5 (15:28 to 15:39Z): the 5090's five power rows in the bench log (the cap does not bind: 0.41 MH/W flat at 310 W); the playbook's watchdog killed the live three-card tune at the first clock step, so: idle = three consecutive status lines reading 0.00 MH/s and 300 s, never a missing match; the after snapshot and the engine-log dump on every exit; an elevated tune engine registers the Igneum Power Helper itself before its first step (the --sweep engine skipped the cap path where the registration lived); Ember 2 groundwork: the memory-clock knob in Point and Limits, the goal, the hill-climb plan (not yet wired to the engine) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 223 ++++++++++++++++++++++++----- app/igneum-app/src/engine.rs | 43 ++++-- app/igneum-app/src/state.rs | 1 + docs/bench-log.md | 12 ++ relay/playbooks/ember-tune-pc1.ps1 | 39 ++--- 5 files changed, 258 insertions(+), 60 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index dcd96dafd..e5a4584b1 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -49,6 +49,10 @@ pub struct Limits { pub clock_max_mhz: u32, /// the lowest cap the vendor allows (the ADLX gmax_range floor); 0 = CLOCK_FLOOR_PCT of the maximum pub clock_min_mhz: u32, + /// Ember 2: the memory clock the card runs at by default and the vendor's maximum (nvidia-smi clocks.mem and + /// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none) + pub mem_default_mhz: u32, + pub mem_max_mhz: u32, } impl Limits { @@ -66,6 +70,13 @@ impl Limits { } mhz.clamp(self.clock_floor(), self.clock_max_mhz) } + /// A memory clock inside the vendor's range; 0 stays 0 (the default). + pub fn clamp_mem(&self, mhz: u32) -> u32 { + if mhz == 0 || self.mem_max_mhz == 0 || self.mem_default_mhz == 0 { + return 0; + } + mhz.clamp(self.mem_default_mhz, self.mem_max_mhz) + } /// The watts a power percent asks for, inside the card's min and max, rounded to a watt. pub fn watts_for(&self, pct: u32) -> f64 { let mut w = self.power_default_w * pct as f64 / 100.0; @@ -79,13 +90,15 @@ impl Limits { } } -/// One setting of the two knobs. +/// One setting of the knobs (Ember 2 adds the memory clock: 0 = the driver's default). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Point { /// the core clock cap in MHz; 0 = unlocked pub clock_mhz: u32, /// the power limit, percent of the default pub power_pct: u32, + /// the memory clock in MHz (NVIDIA `-lmc`, locked to one value); 0 = the driver's default + pub mem_mhz: u32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -96,6 +109,8 @@ pub enum Kind { Clock, /// the fleet prior and one neighbour Confirm, + /// Ember 2: a hill-climb probe + Climb, } #[derive(Clone, Debug, PartialEq)] @@ -106,11 +121,53 @@ pub struct Step { pub kind: Kind, } +/// What the tune is for (Settings > Ember Tune > goal). The rate floor is the share of the best rate seen a point +/// must keep to win on MH per watt: efficiency keeps 90%, balanced 99% (the 1% rule of lever 3), maximum rate +/// takes the fastest point and uses MH per watt only to break ties. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Goal { + Efficiency, + Balanced, + MaxRate, +} + +impl Goal { + pub fn parse(s: &str) -> Goal { + match s { + "efficiency" | "eff" => Goal::Efficiency, + "rate" | "max_rate" | "maximum" => Goal::MaxRate, + _ => Goal::Balanced, + } + } + pub fn name(&self) -> &'static str { + match self { + Goal::Efficiency => "efficiency", + Goal::Balanced => "balanced", + Goal::MaxRate => "rate", + } + } + /// The rate tolerance the choice rule uses, percent under the best rate. + pub fn tolerance_pct(&self, manifest_default: f64) -> f64 { + match self { + Goal::Efficiency => 10.0, + Goal::Balanced => manifest_default, + Goal::MaxRate => 0.0, + } + } +} + +/// Pounds a day for a draw at a price in pence per kWh: watts × 24 h / 1000 × price / 100. +pub fn pounds_per_day(watts: f64, pence_per_kwh: f64) -> f64 { + watts * 24.0 / 1000.0 * pence_per_kwh / 100.0 +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PlanKind { Full, Confirm, Baseline, + /// Ember 2: the hill-climb over memory up and core down from the start point + Climb, } impl PlanKind { @@ -119,6 +176,7 @@ impl PlanKind { PlanKind::Full => "full", PlanKind::Confirm => "confirm", PlanKind::Baseline => "baseline", + PlanKind::Climb => "climb", } } } @@ -134,9 +192,82 @@ pub struct Plan { power: Vec<Step>, clock_pcts: Vec<u32>, fixed: Vec<Step>, + /// Ember 2 (Climb): the start point, the step sizes and the step budget + climb: Option<Climb>, +} + +/// The hill-climb's shape: from `start`, each probe moves the memory clock up by `mem_step` or the core clock down +/// by `core_step` (or both), keeps the move when the goal's score improves, else turns to the other knob; a +/// refused step (a fault on it) backs that knob off for good. At most `budget` steps including the start. +#[derive(Clone, Debug, PartialEq)] +pub struct Climb { + pub start: Point, + pub mem_step: u32, + pub core_step: u32, + pub budget: usize, + pub goal: Goal, } impl Plan { + /// Ember 2: the hill-climb. The start is the fleet prior (or the card's current point); a probe step is 5% of + /// the memory range above the default (0 when the card has no memory knob) and 5% of the maximum core clock; + /// five steps of 60 s converge in under 10 minutes. + pub fn climb(limits: &Limits, start: Point, goal: Goal, tolerance_pct: f64) -> Plan { + let mem_step = if limits.mem_max_mhz > limits.mem_default_mhz { ((limits.mem_max_mhz - limits.mem_default_mhz) / 20).max(25) } else { 0 }; + let core_step = if limits.clock_max_mhz > 0 { (limits.clock_max_mhz / 20).max(25) } else { 0 }; + let start = Point { clock_mhz: limits.clamp_clock(start.clock_mhz), power_pct: start.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(start.mem_mhz) }; + Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), fixed: Vec::new(), climb: Some(Climb { start, mem_step, core_step, budget: 5, goal }) } + } + + /// The goal's score of a row: MH per watt for efficiency and balanced, the rate for maximum rate. + pub fn score(&self, r: &Row) -> f64 { + match self.climb.as_ref().map(|c| c.goal) { + Some(Goal::MaxRate) => r.mhs, + _ => r.eff, + } + } + + /// The climb's next probe from the rows so far: None when the budget is spent or no move is left. + fn climb_next(&self, rows: &[Row]) -> Option<Step> { + let c = self.climb.as_ref()?; + let step = |p: Point| Step { point: p, watts: self.limits.watts_for(p.power_pct), kind: Kind::Climb }; + if rows.is_empty() { + return Some(step(c.start)); + } + if rows.len() >= c.budget { + return None; + } + // the best usable row so far is the hill's top; a knob that produced a marked (refused) row is backed off + let best = rows.iter().filter(|r| r.usable()).max_by(|a, b| self.score(a).partial_cmp(&self.score(b)).unwrap_or(std::cmp::Ordering::Equal))?; + let refused_mem = rows.iter().any(|r| !r.usable() && r.point.mem_mhz > best.point.mem_mhz); + let refused_core = rows.iter().any(|r| !r.usable() && r.point.clock_mhz != 0 && (best.point.clock_mhz == 0 || r.point.clock_mhz < best.point.clock_mhz)); + let last = rows.last()?; + let mem_up = |p: Point| -> Option<Point> { + if c.mem_step == 0 || refused_mem { return None; } + let base = if p.mem_mhz == 0 { self.limits.mem_default_mhz } else { p.mem_mhz }; + let m = self.limits.clamp_mem(base + c.mem_step); + (m > 0 && m != p.mem_mhz && m != base).then_some(Point { mem_mhz: m, ..p }) + }; + let core_down = |p: Point| -> Option<Point> { + if c.core_step == 0 || refused_core { return None; } + let base = if p.clock_mhz == 0 { self.limits.clock_max_mhz } else { p.clock_mhz }; + let k = self.limits.clamp_clock(base.saturating_sub(c.core_step)); + (k > 0 && k != p.clock_mhz).then_some(Point { clock_mhz: k, ..p }) + }; + let tried = |p: Point| rows.iter().any(|r| r.point == p); + // the last move improved: keep going the same way from the top; else turn: memory first, then core, then both + let last_improved = last.usable() && last.point == best.point && rows.len() > 1; + let last_was_mem = rows.len() > 1 && last.point.mem_mhz != rows[rows.len() - 2].point.mem_mhz; + let candidates: Vec<Option<Point>> = if last_improved && last_was_mem { + vec![mem_up(best.point), core_down(best.point)] + } else if last_improved { + vec![core_down(best.point), mem_up(best.point)] + } else { + vec![mem_up(best.point), core_down(best.point), mem_up(best.point).and_then(core_down)] + }; + candidates.into_iter().flatten().find(|p| !tried(*p)).map(step) + } + /// Power 100% to 50% at the unlocked clock (duplicate watts dropped, as the card's floor clamps them), then the /// clock ladder 90% to the floor of the maximum core clock at the chosen power. A card without a readable /// maximum clock gets the power ladder only; a card without a default limit gets the clock ladder only. @@ -148,41 +279,44 @@ impl Plan { if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) { continue; } - power.push(Step { point: Point { clock_mhz: 0, power_pct: pct }, watts: w, kind: Kind::Power }); + power.push(Step { point: Point { clock_mhz: 0, power_pct: pct, mem_mhz: 0 }, watts: w, kind: Kind::Power }); } } let clock_pcts = if limits.clock_max_mhz > 0 { CLOCK_STEPS_PCT[1..].to_vec() } else { Vec::new() }; - Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new() } + Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new(), climb: None } } /// The prior's point, then one neighbour: the next clock step up when the prior caps the clock (is the cap /// costing rate?), else one power step down (is there efficiency left?). pub fn confirm(limits: &Limits, prior: Point, before: Point, tolerance_pct: f64) -> Plan { - let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100) }; + let p = Point { clock_mhz: limits.clamp_clock(prior.clock_mhz), power_pct: prior.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(prior.mem_mhz) }; let first = Step { point: p, watts: limits.watts_for(p.power_pct), kind: Kind::Confirm }; let neighbour = if p.clock_mhz > 0 && limits.clock_max_mhz > 0 { let up = p.clock_mhz + limits.clock_max_mhz / 10; let clock = if up >= limits.clock_max_mhz { 0 } else { limits.clamp_clock(up) }; - Point { clock_mhz: clock, power_pct: p.power_pct } + Point { clock_mhz: clock, power_pct: p.power_pct, mem_mhz: p.mem_mhz } } else { - Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50) } + Point { clock_mhz: p.clock_mhz, power_pct: (p.power_pct.saturating_sub(10)).max(50), mem_mhz: p.mem_mhz } }; let mut fixed = vec![first]; if neighbour != p { fixed.push(Step { point: neighbour, watts: limits.watts_for(neighbour.power_pct), kind: Kind::Confirm }); } - Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed } + Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None } } /// One step at the card's current point: the before number, and all a measure-only card (Apple, or NVIDIA /// with Power control off) reports. pub fn baseline(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan { let fixed = vec![Step { point: before, watts: limits.watts_for(before.power_pct), kind: Kind::Baseline }]; - Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed } + Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None } } /// How many steps the plan has at most (the clock ladder counts whether or not it runs). pub fn len(&self) -> usize { + if let Some(c) = &self.climb { + return c.budget; + } self.fixed.len() + self.power.len() + self.clock_pcts.len() } pub fn is_empty(&self) -> bool { @@ -191,6 +325,9 @@ impl Plan { /// The next step after `rows` (one row per step done so far), or None when the plan is complete. pub fn next(&self, rows: &[Row]) -> Option<Step> { + if self.climb.is_some() { + return self.climb_next(rows); + } let i = rows.len(); if !self.fixed.is_empty() { return self.fixed.get(i).cloned(); @@ -207,7 +344,7 @@ impl Plan { if rows.last().map(|r| r.point.clock_mhz == clock).unwrap_or(false) { return None; } - Some(Step { point: Point { clock_mhz: clock, power_pct }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock }) + Some(Step { point: Point { clock_mhz: clock, power_pct, mem_mhz: 0 }, watts: self.limits.watts_for(power_pct), kind: Kind::Clock }) } } @@ -305,9 +442,10 @@ impl Row { /// `TUNE card=<label> step=<i> clock=<MHz> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH/W> gclk=<MHz> mclk=<MHz> tmax=<C> mark=<m>` pub fn line(&self, card: &str, i: usize) -> String { format!( - "TUNE card={card} step={i} clock={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} gclk={:.0} mclk={:.0} tmax={:.0} mark={}{}", + "TUNE card={card} step={i} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} gclk={:.0} mclk={:.0} tmax={:.0} mark={}{}", self.point.clock_mhz, self.point.power_pct, + self.point.mem_mhz, self.limit, self.watts, self.mhs, @@ -320,7 +458,7 @@ impl Row { ) } pub fn json(&self) -> serde_json::Value { - serde_json::json!({ "clock_mhz": self.point.clock_mhz, "power_pct": self.point.power_pct, "limit_w": self.limit, "watts": r1(self.watts), "mhs": r2(self.mhs), "eff": r4(self.eff), "gclk": self.gclk.round(), "mclk": self.mclk.round(), "tmax": self.tmax.round(), "faults": self.faults, "mark": self.mark.unwrap_or(Mark::NoReadings).name() }) + serde_json::json!({ "clock_mhz": self.point.clock_mhz, "power_pct": self.point.power_pct, "mem_mhz": self.point.mem_mhz, "limit_w": self.limit, "watts": r1(self.watts), "mhs": r2(self.mhs), "eff": r4(self.eff), "gclk": self.gclk.round(), "mclk": self.mclk.round(), "tmax": self.tmax.round(), "faults": self.faults, "mark": self.mark.unwrap_or(Mark::NoReadings).name() }) } } @@ -454,7 +592,7 @@ pub fn prior_of(tuning: Option<&serde_json::Value>, key: &str, min_samples: u32) if samples < min_samples.max(1) { return None; } - let point = Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100) }; + let point = Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100), mem_mhz: n("mem_mhz") as u32 }; if point.power_pct == 0 && point.clock_mhz == 0 { return None; } @@ -606,7 +744,13 @@ impl Run { /// The phase in words for the card row. pub fn words(&self, now: Instant) -> String { - let what = |p: &Point| if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) }; + let what = |p: &Point| { + let mut w = if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) }; + if p.mem_mhz > 0 { + w.push_str(&format!(" · mem {}", p.mem_mhz)); + } + w + }; let cur = self.current.as_ref().map(|s| what(&s.point)).unwrap_or_default(); let n = self.rows.len() + 1; let of = self.plan.len(); @@ -624,7 +768,7 @@ impl Run { fn applied(step: &Step, rb: Readback, before_w: f64) -> bool { let power_ok = rb.limit_w <= 0.0 || (rb.limit_w - step.watts).abs() < 1.5; let power_changed = (step.watts - before_w).abs() >= 1.5; - if step.point.clock_mhz > 0 || !power_changed { + if step.point.clock_mhz > 0 || step.point.mem_mhz > 0 || !power_changed { rb.acked && power_ok } else { rb.limit_w > 0.0 && power_ok @@ -732,6 +876,13 @@ impl Run { Verdict::NoReadings => None, }, PlanKind::Full => choose(&self.rows, self.plan.tolerance_pct), + PlanKind::Climb => { + if self.plan.climb.as_ref().map(|c| c.goal) == Some(Goal::MaxRate) { + self.rows.iter().filter(|r| r.usable()).max_by(|a, b| a.mhs.partial_cmp(&b.mhs).unwrap_or(std::cmp::Ordering::Equal)).cloned() + } else { + choose(&self.rows, self.plan.tolerance_pct) + } + } } } @@ -767,7 +918,7 @@ mod tests { /// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is /// read at the first tune (3,090 MHz is the shape used here, not a measurement). fn l5090() -> Limits { - Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0 } + Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001 } } fn row_at(p: Point, watts: f64, mhs: f64) -> Row { @@ -776,10 +927,10 @@ mod tests { #[test] fn the_full_plan_is_the_power_ladder_then_the_clock_ladder_at_the_chosen_power() { - let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80 }, 1.0); + let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); assert_eq!(plan.len(), 5 + 4, "five power steps (60% and 50% clamp to 400 W; one kept) and four clock steps"); let first = plan.next(&[]).unwrap(); - assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100 }, 575.0, Kind::Power)); + assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 575.0, Kind::Power)); // the power ladder: 575, 518, 460, 403, 400 let mut rows = Vec::new(); let mut watts_seen = Vec::new(); @@ -794,7 +945,7 @@ mod tests { // the clock ladder rides the chosen power point: a flat ladder ties on efficiency and rate, the lowest draw wins (100%) let s = plan.next(&rows).unwrap(); assert_eq!(s.kind, Kind::Clock); - assert_eq!(s.point, Point { clock_mhz: 2781, power_pct: 100 }); + assert_eq!(s.point, Point { clock_mhz: 2781, power_pct: 100, mem_mhz: 0 }); rows.push(row_at(s.point, 250.0, 123.8)); let s = plan.next(&rows).unwrap(); assert_eq!(s.point.clock_mhz, 2472); @@ -806,7 +957,7 @@ mod tests { assert_eq!(plan.next(&rows), None); // the choice: 2,472 MHz keeps 99.6% of the top rate at 220 W = 0.561 MH/W; 2,163 MHz (118 MH/s) is outside the 1% tolerance let best = choose(&rows, 1.0).unwrap(); - assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100 }); + assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }); // a wider tolerance lets the 2,163 MHz step (0.590 MH/W, 4.8% slower) win assert_eq!(choose(&rows, 5.0).unwrap().point.clock_mhz, 2163); // no power limits, clocks only; no clocks, power only; nothing, empty @@ -830,7 +981,7 @@ mod tests { #[test] fn the_choice_keeps_the_best_mh_per_watt_within_the_rate_tolerance() { - let p = |c: u32, pct: u32| Point { clock_mhz: c, power_pct: pct }; + let p = |c: u32, pct: u32| Point { clock_mhz: c, power_pct: pct, mem_mhz: 0 }; // a card that gets more efficient as the cap drops until it collapses: 70% wins (0.280 MH/W), 60% is 35% slower let rows = vec![row_at(p(0, 100), 560.0, 124.0), row_at(p(0, 90), 510.0, 123.5), row_at(p(0, 80), 455.0, 123.2), row_at(p(0, 70), 400.0, 122.9), row_at(p(0, 60), 345.0, 80.0)]; assert_eq!(choose(&rows, 1.0).unwrap().point, p(0, 70)); @@ -857,7 +1008,7 @@ mod tests { #[test] fn the_guards_mark_a_step_so_it_cannot_win() { - let step = Step { point: Point { clock_mhz: 2000, power_pct: 100 }, watts: 575.0, kind: Kind::Clock }; + let step = Step { point: Point { clock_mhz: 2000, power_pct: 100, mem_mhz: 0 }, watts: 575.0, kind: Kind::Clock }; let good = Samples { draws: vec![250.0, 251.0, 249.0], rates: vec![123.0], gclks: vec![1998.0], mclks: vec![2505.0], tmax: 70.0, faults: 0, unapplied: false }; assert_eq!(Row::from_samples(&step, &good, 2505.0).mark, Some(Mark::Ok)); // one rejected or mismatched hash: faulted @@ -875,15 +1026,15 @@ mod tests { assert!(!r.usable()); assert!(r.line("c", 3).contains("mark=no_readings"), "{}", r.line("c", 3)); let r = Row::from_samples(&step, &good, 0.0); - assert_eq!(r.line("nvidia-ae432dc7-1", 7), "TUNE card=nvidia-ae432dc7-1 step=7 clock=2000 cap=100 limit=575 watts=250.0 mhs=123.00 eff=0.4920 gclk=1998 mclk=2505 tmax=70 mark=ok"); + assert_eq!(r.line("nvidia-ae432dc7-1", 7), "TUNE card=nvidia-ae432dc7-1 step=7 clock=2000 cap=100 mem=0 limit=575 watts=250.0 mhs=123.00 eff=0.4920 gclk=1998 mclk=2505 tmax=70 mark=ok"); } #[test] fn a_fault_during_a_step_reverts_it_and_the_run_goes_on() { let t0 = Instant::now(); let timing = Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(4), apply: Duration::from_secs(30) }; - let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0 }; - let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80 }, 1.0); + let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0 }; + let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); let mut run = Run::new(0, "0", "c", plan, 240.0, false, timing, t0); let mut t = t0; let mut limit = 240.0; @@ -932,13 +1083,13 @@ mod tests { #[test] fn the_confirm_plan_checks_the_prior_and_its_neighbour() { let l = l5090(); - let prior = Point { clock_mhz: 2472, power_pct: 100 }; - let plan = Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 80 }, 1.0); + let prior = Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }; + let plan = Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0); assert_eq!(plan.len(), 2); let a = plan.next(&[]).unwrap(); assert_eq!((a.point, a.kind), (prior, Kind::Confirm)); let b = plan.next(&[row_at(prior, 220.0, 123.5)]).unwrap(); - assert_eq!(b.point, Point { clock_mhz: 2781, power_pct: 100 }, "one clock step up"); + assert_eq!(b.point, Point { clock_mhz: 2781, power_pct: 100, mem_mhz: 0 }, "one clock step up"); // the neighbour within 1%: the prior stands let rows = vec![row_at(prior, 220.0, 123.5), row_at(b.point, 250.0, 123.8)]; assert!(matches!(confirm_verdict(&rows, 1.0), Verdict::Keep(r) if r.point == prior)); @@ -947,20 +1098,20 @@ mod tests { assert!(matches!(confirm_verdict(&rows, 1.0), Verdict::FullDue { .. })); assert_eq!(confirm_verdict(&[], 1.0), Verdict::NoReadings); // an unlocked prior at 100%: the neighbour is one power step down; at the top clock the neighbour is unlocked - let plan = Plan::confirm(&l, Point { clock_mhz: 0, power_pct: 100 }, Point::default(), 1.0); - assert_eq!(plan.next(&[row_at(Point { clock_mhz: 0, power_pct: 100 }, 1.0, 1.0)]).unwrap().point, Point { clock_mhz: 0, power_pct: 90 }); - let plan = Plan::confirm(&l, Point { clock_mhz: 2900, power_pct: 100 }, Point::default(), 1.0); - assert_eq!(plan.next(&[row_at(Point { clock_mhz: 2900, power_pct: 100 }, 1.0, 1.0)]).unwrap().point.clock_mhz, 0); + let plan = Plan::confirm(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Point::default(), 1.0); + assert_eq!(plan.next(&[row_at(Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0, 1.0)]).unwrap().point, Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }); + let plan = Plan::confirm(&l, Point { clock_mhz: 2900, power_pct: 100, mem_mhz: 0 }, Point::default(), 1.0); + assert_eq!(plan.next(&[row_at(Point { clock_mhz: 2900, power_pct: 100, mem_mhz: 0 }, 1.0, 1.0)]).unwrap().point.clock_mhz, 0); // a prior outside the vendor's range is clamped, never applied as is - let plan = Plan::confirm(&l, Point { clock_mhz: 9000, power_pct: 30 }, Point::default(), 1.0); - assert_eq!(plan.next(&[]).unwrap().point, Point { clock_mhz: 3090, power_pct: 50 }); + let plan = Plan::confirm(&l, Point { clock_mhz: 9000, power_pct: 30, mem_mhz: 0 }, Point::default(), 1.0); + assert_eq!(plan.next(&[]).unwrap().point, Point { clock_mhz: 3090, power_pct: 50, mem_mhz: 0 }); } #[test] fn a_baseline_plan_measures_the_card_as_it_runs() { let t0 = Instant::now(); let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(2), apply: Duration::from_secs(3) }; - let before = Point { clock_mhz: 0, power_pct: 80 }; + let before = Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }; let mut run = Run::new(0, "0", "c", Plan::baseline(&l5090(), before, 1.0), 460.0, false, timing, t0); // nothing acknowledges the request (Power control is off): the apply window passes and the hold starts anyway let mut t = t0; @@ -992,7 +1143,7 @@ mod tests { #[test] fn the_record_and_the_prior_round_trip_through_the_manifest_shape() { - let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100 }, 220.0, 123.5); + let chosen = row_at(Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }, 220.0, 123.5); let rec = record_json(1_791_230_000.0, "8f3a2c1d", "0.3.10", "windows", "NVIDIA GeForce RTX 5090", "nvidia", "581.57", "l128w16", PlanKind::Full, &[chosen.clone()], Some(&chosen), None); assert_eq!(rec["card"], "NVIDIA_GeForce_RTX_5090"); assert_eq!(rec["key"], "NVIDIA_GeForce_RTX_5090|581|l128w16"); @@ -1012,7 +1163,7 @@ mod tests { let s = settings_of(Some(&tuning)); assert_eq!(s, Settings { enabled: true, min_samples: 5, tolerance_pct: 1.0, period_s: PERIOD_S }); let p = prior_of(Some(&tuning), "NVIDIA_GeForce_RTX_5090|581|l128w16", s.min_samples).unwrap(); - assert_eq!(p.point, Point { clock_mhz: 2472, power_pct: 100 }); + assert_eq!(p.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 }); assert_eq!(p.samples, 7); assert!(prior_of(Some(&tuning), "AMD_Radeon_RX_9070_XT|32|l128w16", 5).is_none(), "3 samples are under the floor"); assert!(prior_of(Some(&tuning), "AMD_Radeon_RX_9070_XT|32|l128w16", 3).is_some()); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 7ed7050a8..077b32101 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -2239,16 +2239,19 @@ impl Engine { let current = if c.power_limit_w > 0.0 { c.power_limit_w } else { c.power_default_w }.round() as u64; let known_direct = self.sweep_direct; std::thread::spawn(move || { - let q = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "--query-gpu=clocks.max.gr,driver_version", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)).unwrap_or_default(); + let q = crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "--query-gpu=clocks.max.gr,driver_version,clocks.mem,clocks.max.mem", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10)).unwrap_or_default(); let p: Vec<&str> = q.trim().split(',').map(|s| s.trim()).collect(); let clock_max = p.first().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32; let driver = p.get(1).map(|s| s.to_string()).unwrap_or_default(); + // Ember 2: the memory clock now (the default under load) and the vendor's maximum + let mem_default = p.get(2).and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32; + let mem_max = p.get(3).and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) as u32; let direct = match known_direct { Some(d) => Some(d), None if allowed || current > 0 => crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-pl", ¤t.to_string()]), None, Duration::from_secs(20)).map(|out| out.contains("All done")), None => Some(false), }; - shared.send(Cmd::TuneProbe(idx, Ok(TuneProbe { clock_max_mhz: clock_max, clock_min_mhz: 0, driver, direct: direct.unwrap_or(false), amd_ordinal: -1, ..Default::default() }))); + shared.send(Cmd::TuneProbe(idx, Ok(TuneProbe { clock_max_mhz: clock_max, clock_min_mhz: 0, driver, direct: direct.unwrap_or(false), amd_ordinal: -1, mem_default_mhz: mem_default, mem_max_mhz: mem_max, ..Default::default() }))); }); } "amd" => { @@ -2267,7 +2270,7 @@ impl Engine { // the stock clock, not MHz; a range with a negative floor is an offset range and the clock // knob stays closed until the stock clock is known (the power limit is the AMD lever), and // `plimit_range -30 10` bounds the power ladder (the percent scale rides power_* below) - Some(t) if t.ok => TuneProbe { clock_max_mhz: if t.gmax_min >= 0.0 && t.gmax_max > 0.0 { t.gmax_max as u32 } else { 0 }, clock_min_mhz: if t.gmax_min > 0.0 { t.gmax_min as u32 } else { 0 }, driver, direct: true, amd_ordinal: t.ordinal as i64, plimit_min: t.plimit_min, plimit_max: t.plimit_max }, + Some(t) if t.ok => TuneProbe { clock_max_mhz: if t.gmax_min >= 0.0 && t.gmax_max > 0.0 { t.gmax_max as u32 } else { 0 }, clock_min_mhz: if t.gmax_min > 0.0 { t.gmax_min as u32 } else { 0 }, driver, direct: true, amd_ordinal: t.ordinal as i64, plimit_min: t.plimit_min, plimit_max: t.plimit_max, mem_default_mhz: 0, mem_max_mhz: 0 }, Some(t) => TuneProbe { clock_max_mhz: 0, clock_min_mhz: 0, driver, direct: false, amd_ordinal: t.ordinal as i64, ..Default::default() }, None => TuneProbe { clock_max_mhz: 0, clock_min_mhz: 0, driver, direct: false, amd_ordinal: -1, ..Default::default() }, }))); @@ -2305,9 +2308,9 @@ impl Engine { // AMD's power limit is a percent offset from the default (ADLX): the plan's watts scale becomes a percent // scale (default 100, floor 100 + plimit_min, ceiling 100 + plimit_max), tune_apply sends pct - 100 let limits = if c.vendor == "amd" && probe.direct && probe.plimit_max >= probe.plimit_min && probe.plimit_min > -100.0 { - crate::ember::Limits { power_default_w: 100.0, power_min_w: 100.0 + probe.plimit_min, power_max_w: 100.0 + probe.plimit_max, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz } + crate::ember::Limits { power_default_w: 100.0, power_min_w: 100.0 + probe.plimit_min, power_max_w: 100.0 + probe.plimit_max, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz, mem_default_mhz: 0, mem_max_mhz: 0 } } else { - crate::ember::Limits { power_default_w: c.power_default_w, power_min_w: c.power_min_w, power_max_w: c.power_max_w, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz } + crate::ember::Limits { power_default_w: c.power_default_w, power_min_w: c.power_min_w, power_max_w: c.power_max_w, clock_max_mhz: probe.clock_max_mhz, clock_min_mhz: probe.clock_min_mhz, mem_default_mhz: probe.mem_default_mhz, mem_max_mhz: probe.mem_max_mhz } }; let control = match c.vendor.as_str() { "nvidia" => crate::ember::control_reason("nvidia", &limits, &c.device, power_control, false), @@ -2316,6 +2319,26 @@ impl Engine { }; if c.vendor == "nvidia" { self.sweep_direct = Some(probe.direct); + // the approved step that made this engine elevated registers the Igneum Power Helper here too (run 5, + // 6 October 2026: a --sweep engine skipped the cap path where the registration lived, so the one + // approved click registered nothing); no prompt: this process already holds the rights + if probe.direct && cfg!(windows) && !self.power_task_registered() { + let dir = self.sweep_dir(); + let _ = std::fs::create_dir_all(&dir); + let script = dir.join("register-power-task.ps1"); + let installed: Vec<PathBuf> = [std::env::var_os("LOCALAPPDATA").map(|l| PathBuf::from(l).join("Programs").join("Igneum Miner").join("igneum-app.exe")), std::env::var_os("ProgramFiles").map(|p| PathBuf::from(p).join("Igneum Miner").join("igneum-app.exe"))].into_iter().flatten().collect(); + if let Ok(exe) = std::env::current_exe() { + let target = crate::powertask::task_exe(&exe, &installed); + if std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&target).as_bytes()].concat()).is_ok() { + let mut p = std::process::Command::new(crate::platform::tool("powershell")); + p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]); + crate::platform::quiet(&mut p); + let ok = p.status().map(|s| s.success()).unwrap_or(false); + self.power_task = None; + self.sweep_say(&format!("TUNE helper registered={} action={}", ok, target.display())); + } + } + } } { let mut st = self.st(); @@ -2333,7 +2356,7 @@ impl Engine { } let tuning = self.tuning_object(); let ember = crate::ember::settings_of(tuning.as_ref()); - let before = crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: if c.power_pct == 0 { 80 } else { c.power_pct } }; + let before = crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: if c.power_pct == 0 { 80 } else { c.power_pct }, mem_mhz: c.mem_cap_mhz }; let before_w = if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }; let key = crate::ember::prior_key(&c.name, &probe.driver, &c.program_class); let prior = crate::ember::prior_of(tuning.as_ref(), &key, ember.min_samples); @@ -2386,6 +2409,7 @@ impl Engine { crate::ember::PlanKind::Full => format!("{steps} steps over the power limit and the core clock, {} s each on the live program", (run.timing.settle + run.timing.hold).as_secs()), crate::ember::PlanKind::Confirm => format!("the fleet prior ({} samples) and one neighbour, {} s each", prior.as_ref().map(|p| p.samples).unwrap_or(0), (run.timing.settle + run.timing.hold).as_secs()), crate::ember::PlanKind::Baseline => format!("measuring the card as it runs ({})", control.clone().unwrap_or_default()), + crate::ember::PlanKind::Climb => format!("the hill-climb: memory up, core down, up to {steps} probes of {} s", (run.timing.settle + run.timing.hold).as_secs()), }; self.shared.event("info", &format!("{}: tuning started: {what}", c.name)); self.sweep = Some(run); @@ -2725,7 +2749,7 @@ impl Engine { _ => self.shared.event("ok", &format!("{name}: {line}, {} held", point_words(&row.point))), } if pinned && kind != crate::ember::PlanKind::Baseline { - let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct }).unwrap_or(run.before); + let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz }).unwrap_or(run.before); let w = self.st().mining.cards.get(idx).map(requested_watts).unwrap_or(run.before_w); self.tune_apply(idx, &run.device, &crate::ember::Step { point: p, watts: w, kind: crate::ember::Kind::Confirm }); } @@ -2753,7 +2777,7 @@ impl Engine { (Some(r), _) => (r.card, r.label.clone(), r.device.clone(), r.before, r.before_w, r.forced, r.seq > 0 && r.plan.kind != crate::ember::PlanKind::Baseline), (None, Some((idx, forced))) => { let c = self.st().mining.cards.get(idx).cloned(); - let (label, device, w, p) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct })).unwrap_or_default(); + let (label, device, w, p) = c.map(|c| (format!("card-{idx}"), c.device.clone(), if c.power_limit_w > 0.0 { c.power_limit_w } else { requested_watts(&c) }, crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz })).unwrap_or_default(); (idx, label, device, p, w, forced, false) } _ => return, @@ -4030,6 +4054,9 @@ pub struct TuneProbe { /// AMD: the power offset range in percent from the `tune` line (PC 1's 9070 XT: -30 to 10) pub plimit_min: f64, pub plimit_max: f64, + /// Ember 2, NVIDIA: the memory clock under load and the vendor's maximum (nvidia-smi clocks.mem, clocks.max.mem) + pub mem_default_mhz: u32, + pub mem_max_mhz: u32, } /// One `tune` line of igneum-gpu-telemetry --tune: diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 4292b4de9..5a974a88b 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -118,6 +118,7 @@ pub struct CardState { pub clock_min_mhz: u32, // the vendor's floor for a cap (0 = 60% of the maximum) pub gclk_mhz: f64, // core clock now pub clock_cap_mhz: u32, // the cap in force (0 = unlocked) + pub mem_cap_mhz: u32, // Ember 2: the memory clock set by the tune (0 = the driver's default) pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown) pub driver: String, // the driver version (nvidia-smi, or the worker's race line) pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown diff --git a/docs/bench-log.md b/docs/bench-log.md index 1867820d4..8f11a0762 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1669,6 +1669,18 @@ Branch `ember-tune` (54ff1bc), docs/plans/ember-tune.md. Every card tuned for MH Nothing set; the installed app's miners back after 350 s. Why every earlier run (5 and 6 October, runs 1 to 4 and dry runs 1 and 2) read its copied settings as defaults, measured on PC 1 (collect ember-acl-2): the engine's own start locks its app folder with `icacls /inheritance:r /grant:r <user>:F`; cutting the folder's inheritance propagates down, the non-inheritable grant gives the children nothing, so a file COPIED in before the start (settings.json, machine-id, wallet.json) is left with no access entry and its owner cannot read it (`ReadAllText`: access denied), while the engine's own files written after the lock inherit fine, which hid it for a day. A first fix with `(OI)(CI)F /T` left the file empty too: `/T` re-applies `/inheritance:r` to each file after the propagation and an `(OI)(CI)` entry on a file is inherit-only. The right form is the inheritable grant without `/T` (07d5a72). Consequence for every tier on Windows: nothing changes for the installed app (its files were always its own); any tool that drops files into the app folder before the app starts (an installer's seed, a migration, a support script) was unreadable to the app until now and is readable from 0.3.13 on. +**Run 5, 6 October 2026, 15:28 to 15:39Z (job ember-tune-pc1-5, elevated on Josh's click, PC 1 on 0.3.13, the tune engine = kit ember-kit-5 from 07d5a72, mode=direct):** the first run that set limits. The 5090's power ladder, 75 s a step, the clock unlocked (2,850 MHz core, 13,801 MHz memory), the rate = the worker's STATUS wall rate, the draw = nvidia-smi every 5 s: + +| Cap | Limit | MH/s | W | MH/W | GPU C | +|---|---|---|---|---|---| +| 100% | 575 W | 127.38 | 309.9 | 0.411 | 64 | +| 90% | 518 W | 99.32 | 313.6 | 0.317 | 65 | +| 80% | 460 W | 123.11 | 312.2 | 0.394 | 65 | +| 70% | 403 W | 127.38 | 310.9 | 0.410 | 65 | +| 60% (floor 400 W) | 400 W | 127.38 | 311.3 | 0.409 | 65 | + +Reading: the cap does not bind on this hash (310 to 314 W under every limit, as the 4 October stability line said), so the power knob is flat at 0.41 MH/W on the 5090 and the saving must come from the clocks; the 90% and 80% rows' rate dips at the same draw are stalls inside those holds (a worker restart or a template wait), not the cap. The clock ladder's first step (2,781 MHz at 575 W) was requested at 15:37:47Z and never measured: the playbook's own watchdog killed the live engine at 15:39:03Z (all three cards mining at 177 MH/s) because its idle clause sampled one log line and read "idle" from a missing match; the 4070's and the 9070 XT's plans never ran. Consequences: the 5090 is probably left with its core clock locked at 2,781 MHz (an `-lgc` lock persists until `-rgc` or a reboot) under the 575 W cap, which costs little rate; freeing it needs administrator rights; and the Power Helper was NOT registered by this run (the registration lived only in the installed app's cap path, which a `--sweep` engine skips). Fixed the same hour: the watchdog's idle rule (three consecutive status lines reading 0.00 MH/s and 300 s, never a missing match), the `after` snapshot and the engine-log dump on every exit, and an elevated tune engine registering the task itself before its first step. The decided way out: Josh switches Power control ON in the 0.3.13 app (its one prompt registers the task from the install folder), a job frees the clock through the task (`rgc`), the tune runs unelevated through the task. + Consequence for the tiers: an AMD card is tuned on its power limit alone until its stock core clock is read (a 9070 XT at -30% is the floor the driver allows, 4 steps, 5 minutes); every NVIDIA card's two-knob plan waits on the user's one click on Power control; the re-run on PC 1 is held until the quit's source is named (the event-log collect) and follows the 0.3.11 rollout (the update clears the jobs folder, so the engine and the helper are fetched again), with the scheduler's slot. ## 5 October 2026 (night), read width of the lottery hash: 4, 16 and 64-byte loads, a per-load mix, a written scratch; three cards (gate 1 experiment, cryptographer) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index da492d63c..55dd81480 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -150,6 +150,9 @@ function Forward([string] $line) { # its budget silently again $firstStatusAt = $null $lastMining = $null +$idleSince = $null +$idleSamples = 0 +$lastIdleStatus = '' $lastEngineLine = '' function EngineLogDump([string] $why) { Write-Output ('===== engine log tail (' + $why + ')') @@ -157,29 +160,32 @@ function EngineLogDump([string] $why) { if ($t) { Get-Content -LiteralPath $t.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'status: accepted 0 blocks' } | Select-Object -Last 80 | ForEach-Object { Write-Output (' ' + $_) } } else { Write-Output ' (no app-*.log in the scratch logs folder)' } if (Test-Path -LiteralPath $errFile) { Write-Output '===== engine stderr'; Get-Content -LiteralPath $errFile -ErrorAction SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Output (' ' + $_) } } } +# the newest `status:` line among the engine log's last 60 lines (run 5, 15:39Z: a single sampled tail line missed the +# status lines between TUNE and telemetry lines for 300 s and the watchdog killed a live three-card tune) +function EngineStatus() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $ls = @(Get-Content -LiteralPath $t.FullName -Tail 60 -ErrorAction SilentlyContinue); for ($i = $ls.Count - 1; $i -ge 0; $i--) { if ([string]$ls[$i] -match ' status: ') { return [string]$ls[$i] } } }; return '' } function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' } +function Exit3([string] $why, [string] $reason) { + Write-Output ('RESULT TUNE error=' + $reason + ' last_status_line=' + ((EngineStatus) -replace '\s+', '_') + ' last_log_line=' + ($lastEngineLine -replace '\s+', '_')) + EngineLogDump $why + EndTree $p.Id $why + Snapshot 'after' + Write-Output 'RESULT TUNE error=no_rows' + exit 3 +} while (-not $p.HasExited) { Start-Sleep -Seconds 5 $tailLine = EngineTail if ($tailLine) { $lastEngineLine = $tailLine } - if ($lastEngineLine -match ' status: ') { + $statusLine = EngineStatus + if ($statusLine) { if (-not $firstStatusAt) { $firstStatusAt = Get-Date } - if ($lastEngineLine -match ', mining \|' -or ($lastEngineLine -match '(\d+\.\d+) MH/s' -and [double]$Matches[1] -gt 0)) { $lastMining = Get-Date } - } - if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { - Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_')) - EngineLogDump 'watchdog: not mining' - EndTree $p.Id 'watchdog: not mining' - Write-Output 'RESULT TUNE error=no_rows' - exit 3 - } - if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) { - Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_')) - EngineLogDump 'watchdog: stopped mining' - EndTree $p.Id 'watchdog: stopped mining' - Write-Output 'RESULT TUNE error=no_rows' - exit 3 + $rate = 0.0; if ($statusLine -match '(\d+\.\d+) MH/s') { $rate = [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) } + # idle = three consecutive 10-s samples whose newest status line reads 0.00 MH/s (never a missing match) + if ($rate -gt 0) { $lastMining = Get-Date; $idleSamples = 0; $idleSince = $null } + elseif ($statusLine -ne $lastIdleStatus) { $lastIdleStatus = $statusLine; $idleSamples++; if (-not $idleSince) { $idleSince = Get-Date } } } + if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { Exit3 'watchdog: not mining' 'not_mining reason=no_card_mined_within_120_s_of_the_first_status_line' } + if ($lastMining -and $idleSamples -ge 3 -and $idleSince -and ((Get-Date) - $idleSince).TotalSeconds -gt 300) { Exit3 'watchdog: stopped mining' 'stopped_mining reason=three_status_lines_read_0.00_MH/s_and_300_s_passed' } $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++ @@ -203,6 +209,7 @@ while (-not $p.HasExited) { } else { Write-Output ('RESULT TUNE quit not sent: no URL file at ' + $u + '; killing pid ' + $p.Id) } Start-Sleep -Seconds 20 if (-not $p.HasExited) { EndTree $p.Id 'budget' } + EngineLogDump 'budget' Write-Output 'RESULT TUNE error=budget_exceeded' } } From 8770aa5294d865f8b8c47848052b74484d8819be Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:48:14 +0100 Subject: [PATCH 07/45] The one approved Power control step on PC 1 failed with exit 1 (15:45:21Z, no cap applied, no helper registered): cmd.exe strips the first and last quote of a /c line that starts with one and holds more than two (two NVIDIA cards, or a cap plus the registration script); both launchers now wrap the line in one outer pair (platform::cmd_c_args, host.cpp runElevated), unit-tested; Ember 2: the memory clock set through nvidia-smi -lmc/-rmc (direct and through the helper's lmc/rmc verbs), the goal, the electricity price and the hill-climb switch in Settings and /api/tune/goal, the chosen point's memory clock and the measured curve on the card Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/config.rs | 17 ++++++++++- app/igneum-app/src/engine.rs | 54 ++++++++++++++++++++++++++++++--- app/igneum-app/src/hotplug.rs | 2 ++ app/igneum-app/src/platform.rs | 26 +++++++++++++++- app/igneum-app/src/powertask.rs | 11 +++++++ app/igneum-app/src/server.rs | 8 +++++ app/igneum-app/src/state.rs | 7 +++++ app/igneum-app/src/sweep.rs | 9 ++++++ app/windows/host.cpp | 4 ++- 9 files changed, 131 insertions(+), 7 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 214ced9e5..abd308b89 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -39,6 +39,9 @@ pub struct CardPref { pub sweep_class: String, #[serde(default)] pub sweep_source: String, + /// Ember 2: the memory clock the last tune chose (0 = the driver's default) + #[serde(default)] + pub sweep_mem_mhz: u32, } #[derive(Clone, Serialize, Deserialize)] @@ -90,6 +93,15 @@ pub struct Settings { /// unanswered prompt switches it back off with a notice, no retries. #[serde(default)] pub power_control: bool, + /// Ember 2 (6 October 2026): the tune's goal ("efficiency" = most MH per watt within 10% of the top rate, + /// "balanced" = within 1%, "rate" = the fastest point), the electricity price in pence per kWh for the £/day + /// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders). + #[serde(default = "balanced")] + pub tune_goal: String, + #[serde(default)] + pub power_price_pence: f64, + #[serde(default)] + pub tune_climb: bool, /// When this install first ran (unix s), for the "first hour after install" sweep. #[serde(default)] pub installed_at: u64, @@ -113,13 +125,16 @@ pub struct Settings { fn one() -> u32 { 1 } +fn balanced() -> String { + "balanced".into() +} fn yes() -> bool { true } impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 077b32101..1c08d5241 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -75,6 +75,8 @@ pub enum Cmd { TuneSet(u64, Result<String, String>), /// POST /api/tune-progress from a measurement engine beside this app: the card's live tune state TuneProgress(Value), + /// Ember 2: Settings > goal, electricity price, the hill-climb switch + TuneGoal(Option<String>, Option<f64>, Option<bool>), /// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the /// prover found a host that was not there when the node started RestartNode(String), @@ -121,7 +123,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -1145,6 +1147,28 @@ impl Engine { } } Cmd::TuneProgress(v) => self.tune_progress(&v), + Cmd::TuneGoal(goal, price, climb) => { + { + let mut s = self.shared.settings.lock().unwrap(); + if let Some(g) = &goal { + s.tune_goal = g.clone(); + } + if let Some(p) = price { + s.power_price_pence = p; + } + if let Some(c) = climb { + s.tune_climb = c; + } + } + self.shared.save_settings(); + let s = self.shared.settings.lock().unwrap().clone(); + let mut st = self.st(); + st.settings.tune_goal = s.tune_goal.clone(); + st.settings.power_price_pence = s.power_price_pence; + st.settings.tune_climb = s.tune_climb; + drop(st); + self.shared.event("info", &format!("tune goal: {}{}{}", s.tune_goal, if s.power_price_pence > 0.0 { format!(", electricity {:.1} p/kWh", s.power_price_pence) } else { String::new() }, if s.tune_climb { ", hill-climb on" } else { "" })); + } Cmd::TuneSet(seq, r) => match r { Ok(text) => { self.tune_acked = Some(seq); @@ -2361,11 +2385,19 @@ impl Engine { let key = crate::ember::prior_key(&c.name, &probe.driver, &c.program_class); let prior = crate::ember::prior_of(tuning.as_ref(), &key, ember.min_samples); let full_due = self.tune_full_due.remove(&idx); + let (goal, climb_on) = { + let s = self.shared.settings.lock().unwrap(); + (crate::ember::Goal::parse(&s.tune_goal), s.tune_climb) + }; let plan = if let Some(why) = control.as_ref() { if let Some(cc) = self.st().mining.cards.get_mut(idx) { cc.sweep_note = why.clone(); } crate::ember::Plan::baseline(&limits, before, ember.tolerance_pct) + } else if climb_on || prior.as_ref().map(|p| p.point.mem_mhz > 0).unwrap_or(false) { + // Ember 2: the hill-climb from the fleet prior (or the card's point) toward the goal + let start = prior.as_ref().map(|p| p.point).unwrap_or(before); + crate::ember::Plan::climb(&limits, start, goal, ember.tolerance_pct) } else if let (Some(p), false, 0) = (prior.as_ref(), full_due, c.sweep_pct) { crate::ember::Plan::confirm(&limits, p.point, before, ember.tolerance_pct) } else { @@ -2477,6 +2509,7 @@ impl Engine { let Some(c) = card else { return }; let w = step.watts.round() as u64; let clock = step.point.clock_mhz; + let mem = step.point.mem_mhz; let shared = self.shared.clone(); self.tune_acked = None; match c.vendor.as_str() { @@ -2500,6 +2533,15 @@ impl Engine { ok &= out.contains("All done") || out.to_ascii_lowercase().contains("clocks set") || out.to_ascii_lowercase().contains("reset"); text.push(' '); text.push_str(out.trim()); + // Ember 2: the memory clock, locked to one value (`-lmc m,m`) or reset (`-rmc`) + let out = if mem > 0 { + crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-lmc", &format!("{mem},{mem}")]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into()) + } else { + crate::detect::run_timeout(std::process::Command::new(&smi).args(["-i", &device, "-rmc"]), None, Duration::from_secs(20)).unwrap_or_else(|| "nvidia-smi did not answer".into()) + }; + ok &= out.contains("All done") || out.to_ascii_lowercase().contains("clocks set") || out.to_ascii_lowercase().contains("reset") || out.to_ascii_lowercase().contains("not supported"); + text.push(' '); + text.push_str(out.trim()); shared.send(Cmd::TuneSet(seq, if ok { Ok(text) } else { Err(text) })); }); } @@ -2509,7 +2551,7 @@ impl Engine { return; } let dev = device.to_string(); - let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }); + let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() }); let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd); // the helper polls twice a second and nvidia-smi answers within a second or two std::thread::spawn(move || { @@ -2551,7 +2593,7 @@ impl Engine { } _ => {} } - self.shared.log(&format!("tune: {} MHz, {}% ({w} W) requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct)); + self.shared.log(&format!("tune: {} MHz, {}% ({w} W), memory {} requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct, if mem > 0 { format!("{mem} MHz") } else { "default".into() })); } /// A measurement engine's progress for one card (POST /api/tune-progress, forwarded by the job playbook from the @@ -2704,8 +2746,10 @@ impl Engine { c.tune_steps = 0; c.tune_eta_s = 0; c.tune_clock_mhz = row.point.clock_mhz; + c.tune_mem_mhz = row.point.mem_mhz; c.tune_source = kind.name().into(); c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff); + c.tune_curve = run.rows.iter().map(|r| r.json()).collect(); let control = c.tune_control; if kind == crate::ember::PlanKind::Baseline { c.sweep_note = if control { String::new() } else { c.sweep_note.clone() }; @@ -2714,6 +2758,7 @@ impl Engine { } else { c.power_pct = row.point.power_pct; c.clock_cap_mhz = row.point.clock_mhz; + c.mem_cap_mhz = row.point.mem_mhz; if c.vendor == "nvidia" { c.power_limit_w = row.limit; c.power_applied = true; @@ -2728,6 +2773,7 @@ impl Engine { e.sweep_watts = row.watts; e.sweep_mhs = row.mhs; e.sweep_clock_mhz = row.point.clock_mhz; + e.sweep_mem_mhz = row.point.mem_mhz; e.sweep_driver = c.driver.clone(); e.sweep_class = c.program_class.clone(); e.sweep_source = kind.name().into(); @@ -2738,7 +2784,7 @@ impl Engine { }; let _ = key; self.shared.save_settings(); - self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" })); + self.sweep_say(&format!("TUNE chosen card={} clock={} cap={} mem={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4} plan={}{}", run.label, row.point.clock_mhz, row.point.power_pct, row.point.mem_mhz, row.limit, row.watts, row.mhs, row.eff, kind.name(), if pinned { " pinned=1" } else { "" })); let before = run.rows.first().filter(|_| kind == crate::ember::PlanKind::Full).cloned(); let record = crate::ember::record_json(crate::platform::unix_now_f(), &crate::config::fingerprint8(&self.shared.runtime.machine_id), VERSION, crate::manifest::platform_name(), &name, &vendor, &driver, &class, kind, &run.rows, Some(&row), before.as_ref()); self.sweep_say(&format!("TUNE {record}")); diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs index f26779ba1..588e93e0a 100644 --- a/app/igneum-app/src/hotplug.rs +++ b/app/igneum-app/src/hotplug.rs @@ -163,6 +163,8 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) { c.sweep_at = p.sweep_at as f64; // Ember Tune (src/ember.rs): the clock cap the last tune chose, its plan, and the row's Tuned line c.tune_clock_mhz = p.sweep_clock_mhz; + c.tune_mem_mhz = p.sweep_mem_mhz; + c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz }; c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz }; c.tune_source = p.sweep_source.clone(); if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 { diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index bb41df4ff..01f288c6d 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -430,11 +430,21 @@ pub fn sync_clock() -> Result<String, String> { /// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows. /// Blocking; call from a thread. +/// The cmd.exe argument for one elevated line. cmd's documented rule: when the text after /c starts with a quote +/// and holds more than two quotes (two cards, or a cap plus a script), it strips the FIRST and LAST quote and runs +/// the broken remainder (PC 1, 6 October 2026, 15:45Z: the one approved Power control step ran +/// `"...\nvidia-smi.exe" -i 0 -pl 460 & "...\nvidia-smi.exe" -i 1 -pl 160 & "...\powershell.exe" ... -File "...ps1"`, +/// exit 1, no cap applied, no task registered; a single-card PC, two quotes, was fine, which is why PC 2 never +/// showed it). Wrapping the whole line in one outer pair makes cmd strip exactly those. +pub fn cmd_c_args(cmdline: &str) -> String { + format!("/c \"{cmdline}\"") +} + pub fn run_elevated(cmdline: &str) -> Result<(), String> { #[cfg(windows)] { let cmd = tool("cmd").display().to_string(); - let mut c = elevated_command(&cmd, &format!("/c {cmdline}")); + let mut c = elevated_command(&cmd, &cmd_c_args(cmdline)); let out = c.output().map_err(|e| e.to_string())?; if out.status.success() { Ok(()) @@ -511,6 +521,20 @@ pub fn quiet(cmd: &mut Command) -> &mut Command { cmd } +#[cfg(test)] +mod cmd_tests { + #[test] + fn an_elevated_line_is_wrapped_so_cmd_keeps_every_inner_quote() { + let line = r#""C:\WINDOWS\System32\nvidia-smi.exe" -i 0 -pl 460 & "C:\WINDOWS\System32\nvidia-smi.exe" -i 1 -pl 160 & "C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -File "C:\x\register-power-task.ps1""#; + let a = super::cmd_c_args(line); + assert!(a.starts_with("/c \"\"C:\\WINDOWS"), "{a}"); + assert!(a.ends_with("register-power-task.ps1\"\""), "{a}"); + // the inner line is intact between the outer pair + assert_eq!(&a[4..a.len() - 1], line); + assert_eq!(super::cmd_c_args("echo hi"), "/c \"echo hi\""); + } +} + #[cfg(test)] mod lock_tests { #[test] diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index afff52b8c..6032b84af 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -43,6 +43,9 @@ pub enum HelperCmd { PowerLimit(u64), ClockCap(u64), ClockReset, + /// Ember 2: the memory clock locked to one value (`-lmc m,m`), or reset (`-rmc`) + MemClock(u64), + MemReset, Quit, Remove, } @@ -65,6 +68,8 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> { [_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))), [_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))), [_, "rgc"] => Some((seq, HelperCmd::ClockReset)), + [_, "lmc", m] if digits(m) => Some((seq, HelperCmd::MemClock(m.parse().ok()?))), + [_, "rmc"] => Some((seq, HelperCmd::MemReset)), [_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))), _ => None, } @@ -76,6 +81,8 @@ pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> { HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]), HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]), HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]), + HelperCmd::MemClock(m) => Some(vec!["-i".into(), dev.into(), "-lmc".into(), format!("{m},{m}")]), + HelperCmd::MemReset => Some(vec!["-i".into(), dev.into(), "-rmc".into()]), _ => None, } } @@ -229,6 +236,10 @@ mod tests { assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460)))); assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472)))); assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset))); + assert_eq!(parse_line("10 lmc 14001"), Some((10, HelperCmd::MemClock(14001)))); + assert_eq!(parse_line("11 rmc"), Some((11, HelperCmd::MemReset))); + assert_eq!(smi_args("0", &HelperCmd::MemClock(14001)).unwrap(), vec!["-i", "0", "-lmc", "14001,14001"]); + assert_eq!(smi_args("0", &HelperCmd::MemReset).unwrap(), vec!["-i", "0", "-rmc"]); assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into())))); assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form"); assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit))); diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index e85d949b9..95984e600 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -251,6 +251,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri shared.send(Cmd::ApplyCards(choices)); Ok(json!({ "ok": true })) } + "/api/tune/goal" => { + // Ember 2: the goal, the electricity price (pence per kWh) and the hill-climb switch + let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string()); + let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p)); + let climb = body.get("climb").and_then(|v| v.as_bool()); + shared.send(Cmd::TuneGoal(goal, price, climb)); + Ok(json!({ "ok": true })) + } "/api/settings" => { let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32); let vote = body.get("vote").and_then(|v| v.as_bool()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 5a974a88b..a1293b559 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -132,6 +132,9 @@ pub struct CardState { pub tune_steps: u32, pub tune_eta_s: i64, pub tune_plan: String, + // Ember 2: the memory clock the last tune chose and the measured curve (every row of the last plan) + pub tune_mem_mhz: u32, + pub tune_curve: Vec<serde_json::Value>, // the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose pub variant: String, pub race_mhs: f64, @@ -290,6 +293,10 @@ pub struct SettingsState { /// Ember Tune is paused fleet-wide by the signed manifest's kill switch (tuning.ember.enabled = false) pub tuning_off: bool, pub tuning_note: String, + /// Ember 2: the goal (efficiency | balanced | rate), the electricity price in pence per kWh, the hill-climb switch + pub tune_goal: String, + pub power_price_pence: f64, + pub tune_climb: bool, /// the miner software's dev fee switch (settings; `--dev-fee 0` when off) pub dev_fee: bool, /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) diff --git a/app/igneum-app/src/sweep.rs b/app/igneum-app/src/sweep.rs index 5ae546f3d..92e402eee 100644 --- a/app/igneum-app/src/sweep.rs +++ b/app/igneum-app/src/sweep.rs @@ -375,6 +375,12 @@ while ($true) { } elseif ($op -eq 'rgc') { $out = (& $Smi -i $Device -rgc 2>&1 | Out-String).Trim() "$(Get-Date -Format o) $($p[0]) -rgc : $out" | Out-File -FilePath $log -Append -Encoding utf8 + } elseif ($op -eq 'lmc' -and $v -match '^\d+$') { + $out = (& $Smi -i $Device -lmc "$v,$v" 2>&1 | Out-String).Trim() + "$(Get-Date -Format o) $($p[0]) -lmc $v,$v : $out" | Out-File -FilePath $log -Append -Encoding utf8 + } elseif ($op -eq 'rmc') { + $out = (& $Smi -i $Device -rmc 2>&1 | Out-String).Trim() + "$(Get-Date -Format o) $($p[0]) -rmc : $out" | Out-File -FilePath $log -Append -Encoding utf8 } } } @@ -411,6 +417,8 @@ while true; do pl) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -pl $v : $("$smi" -i "$dev" -pl "$v" 2>&1)" >> "$dir/helper.log";; esac ;; lgc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lgc 0,$v : $("$smi" -i "$dev" -lgc "0,$v" 2>&1)" >> "$dir/helper.log";; esac ;; rgc) echo "$(date -u +%FT%TZ) $1 -rgc : $("$smi" -i "$dev" -rgc 2>&1)" >> "$dir/helper.log" ;; + lmc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lmc $v,$v : $("$smi" -i "$dev" -lmc "$v,$v" 2>&1)" >> "$dir/helper.log";; esac ;; + rmc) echo "$(date -u +%FT%TZ) $1 -rmc : $("$smi" -i "$dev" -rmc 2>&1)" >> "$dir/helper.log" ;; esac done fi @@ -614,6 +622,7 @@ mod tests { for s in [helper_script_windows(), helper_script_unix()] { assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min")); assert!(s.contains("-lgc") && s.contains("-rgc"), "the clock cap and its reset"); + assert!(s.contains("-lmc") && s.contains("-rmc"), "Ember 2: the memory clock and its reset"); } } } diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 84bf99b5e..88328f388 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -112,7 +112,9 @@ static void applyState(const std::string& j) { // Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin. static void runElevated(std::wstring line) { std::thread([line] { - std::wstring params = L"/c " + line; + // cmd strips the first and last quote of a line that starts with one and holds more than two (two cards, or a + // cap plus a script): one outer pair around the whole line is what it may strip (PC 1, 6 October 2026) + std::wstring params = L"/c \"" + line + L"\""; wchar_t sysdir[MAX_PATH]; GetSystemDirectoryW(sysdir, MAX_PATH); std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3) From e9738dd4d5d5c4ea145629e106a427b4d490b2cd Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:48:44 +0100 Subject: [PATCH 08/45] state: tune_period_s on the settings state (the manifest's period), so the UI derives the next tune from sweep_at Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/engine.rs | 3 ++- app/igneum-app/src/state.rs | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 1c08d5241..de76b1011 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -123,7 +123,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -2176,6 +2176,7 @@ impl Engine { let mut st = self.st(); st.settings.tuning_off = !ember.enabled; st.settings.tuning_note = if ember.enabled { String::new() } else { "tuning paused fleet-wide by the signed manifest".into() }; + st.settings.tune_period_s = ember.period_s; } if paused || !ember.enabled { return; diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index a1293b559..61dc5c93a 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -297,6 +297,9 @@ pub struct SettingsState { pub tune_goal: String, pub power_price_pence: f64, pub tune_climb: bool, + /// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at + /// sweep_at + tune_period_s, or at once after a driver major or program-class change + pub tune_period_s: u64, /// the miner software's dev fee switch (settings; `--dev-fee 0` when off) pub dev_fee: bool, /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) From 564bdea1384b47012587a9231b7ab0e882b07b33 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:50:34 +0100 Subject: [PATCH 09/45] =?UTF-8?q?Ember=202:=20the=20climb's=20tests=20(a?= =?UTF-8?q?=20synthetic=20memory-bound=20card=20converges=20in=20five=20pr?= =?UTF-8?q?obes;=20a=20refused=20probe=20backs=20the=20knob=20off=20for=20?= =?UTF-8?q?good;=20each=20goal=20picks=20its=20point;=20the=20=C2=A3/day?= =?UTF-8?q?=20formula)=20and=20the=20fleet=20prior=20carrying=20the=20memo?= =?UTF-8?q?ry=20clock=20(climb=20records=20aggregate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/src/ember.rs | 78 +++++++++++++++++++++++++++++++++++++ relay/lib/ember.mjs | 6 ++- relay/test/ember.test.mjs | 11 +++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index e5a4584b1..9e9bf54cc 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -1180,6 +1180,84 @@ mod tests { assert_eq!(program_class(0, 0), "v2"); } + /// Ember 2: a synthetic memory-latency-bound card. Rate rises 1% per 100 MHz of memory above the default and + /// falls only 0.3% per 100 MHz of core below the maximum; draw falls 8 W per 100 MHz of core and rises 2 W per + /// 100 MHz of memory. The climb must walk memory up and core down and converge in under five probes. + fn synthetic(p: Point) -> Row { + let mem = if p.mem_mhz == 0 { 13801.0 } else { p.mem_mhz as f64 }; + let core = if p.clock_mhz == 0 { 3090.0 } else { p.clock_mhz as f64 }; + let mhs = 127.0 * (1.0 + 0.015 * (mem - 13801.0) / 100.0) * (1.0 - 0.003 * (3090.0 - core) / 100.0); + let watts = 310.0 - 8.0 * (3090.0 - core) / 100.0 + 2.0 * (mem - 13801.0) / 100.0; + Row { point: p, limit: 575.0, watts, mhs, eff: mhs / watts, draws: 12, rates: 6, gclk: core, mclk: mem, tmax: 65.0, faults: 0, mark: Some(Mark::Ok) } + } + + #[test] + fn the_climb_walks_memory_up_and_core_down_and_converges_in_five_probes() { + // a 2 GHz memory range above the default (the headroom a 5090 has in practice; PC 1's driver reports 14,001) + let l = Limits { mem_max_mhz: 15801, ..l5090() }; + let plan = Plan::climb(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Goal::Balanced, 1.0); + assert_eq!(plan.len(), 5); + let mut rows = Vec::new(); + while let Some(step) = plan.next(&rows) { + assert_eq!(step.kind, Kind::Climb); + rows.push(synthetic(step.point)); + } + assert_eq!(rows.len(), 5, "the budget"); + assert_eq!(rows[0].point, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, "it starts at the start point"); + // every probe moved one knob the right way: memory never down, core never up, and both inside the vendor's range + for w in rows.windows(2) { + let (a, b) = (w[0].point, w[1].point); + assert!(b.mem_mhz >= a.mem_mhz || b.mem_mhz == 0, "{a:?} -> {b:?}"); + assert!(b.mem_mhz <= 15801 && (b.clock_mhz == 0 || b.clock_mhz >= 1854), "{b:?}"); + } + let best = choose(&rows, plan.tolerance_pct).unwrap(); + assert!(best.eff > rows[0].eff, "the chosen point beats the start: {:.4} > {:.4}", best.eff, rows[0].eff); + assert!(best.point.mem_mhz > 13801 || (best.point.clock_mhz > 0 && best.point.clock_mhz < 3090), "it moved a knob"); + } + + #[test] + fn a_refused_probe_backs_that_knob_off_for_good() { + let l = Limits { mem_max_mhz: 15801, ..l5090() }; + let plan = Plan::climb(&l, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, Goal::Balanced, 1.0); + let mut rows = vec![synthetic(plan.next(&[]).unwrap().point)]; + // the first probe (memory up) draws a mismatched hash: refused + let probe = plan.next(&rows).unwrap(); + assert!(probe.point.mem_mhz > 13801, "memory first: {:?}", probe.point); + let mut bad = synthetic(probe.point); + bad.faults = 1; + bad.mark = Some(Mark::Faulted); + rows.push(bad); + // from here every probe leaves the memory clock alone + while let Some(step) = plan.next(&rows) { + assert_eq!(step.point.mem_mhz, 0, "memory backed off: {:?}", step.point); + rows.push(synthetic(step.point)); + } + assert!(rows.len() >= 3 && rows.len() <= 5); + assert!(choose(&rows, 1.0).unwrap().point.mem_mhz == 0); + } + + #[test] + fn each_goal_picks_its_point() { + let l = l5090(); + let p = |c: u32, m: u32| Point { clock_mhz: c, power_pct: 100, mem_mhz: m }; + // three points: the fastest (slightly less efficient), the most efficient (4% slower), and the start + let rows = vec![synthetic(p(0, 0)), synthetic(p(0, 14001)), synthetic(p(1854, 0))]; + let eff_plan = Plan::climb(&l, p(0, 0), Goal::Efficiency, 1.0); + let bal_plan = Plan::climb(&l, p(0, 0), Goal::Balanced, 1.0); + let rate_plan = Plan::climb(&l, p(0, 0), Goal::MaxRate, 1.0); + assert_eq!(eff_plan.tolerance_pct, 10.0); + assert_eq!(bal_plan.tolerance_pct, 1.0); + assert_eq!(rate_plan.tolerance_pct, 0.0); + let by = |plan: &Plan| rows.iter().filter(|r| r.usable() && r.mhs >= rows.iter().map(|x| x.mhs).fold(0.0, f64::max) * (1.0 - plan.tolerance_pct / 100.0)).max_by(|a, b| plan.score(a).partial_cmp(&plan.score(b)).unwrap()).unwrap().point; + assert_eq!(by(&rate_plan), p(0, 14001), "maximum rate takes the fastest point"); + assert_eq!(by(&eff_plan), p(1854, 0), "efficiency takes the most MH/W inside 10%"); + assert_eq!(by(&bal_plan), p(0, 14001), "balanced keeps within 1% of the top rate"); + assert_eq!(Goal::parse("efficiency"), Goal::Efficiency); + assert_eq!(Goal::parse("rate"), Goal::MaxRate); + assert_eq!(Goal::parse("anything"), Goal::Balanced); + assert!((pounds_per_day(310.0, 28.5) - 2.1204).abs() < 1e-3, "310 W a day at 28.5 p/kWh = £2.12"); + } + #[test] fn control_reasons_per_vendor() { let l = l5090(); diff --git a/relay/lib/ember.mjs b/relay/lib/ember.mjs index 166b70274..5e4604352 100644 --- a/relay/lib/ember.mjs +++ b/relay/lib/ember.mjs @@ -46,7 +46,7 @@ export function spreadPct(xs) { return Number((median(xs.map(x => Math.abs(x - m))) / m * 100).toFixed(2)); } -const usable = r => r && r.chosen && r.chosen.mark === 'ok' && r.chosen.eff > 0 && (r.plan === 'full' || r.plan === 'confirm'); +const usable = r => r && r.chosen && r.chosen.mark === 'ok' && r.chosen.eff > 0 && (r.plan === 'full' || r.plan === 'confirm' || r.plan === 'climb'); /// Folds records into priors: one per key, from the full and confirm records with a usable chosen point. The point /// is the median clock cap and the median power percent (each rounded to the step the apps use: 10 MHz, 1%), the @@ -80,6 +80,8 @@ export function aggregate(records, { minSamples = 1 } = {}) { const prior = { clock_mhz: Math.round(median(t.map(r => r.chosen.clock_mhz)) / 10) * 10, power_pct: Math.round(median(t.map(r => r.chosen.power_pct))), + // Ember 2: the memory clock (0 = the driver's default); older records carry none + mem_mhz: Math.round(median(t.map(r => r.chosen.mem_mhz || 0)) / 10) * 10, eff: Number(median(effs).toFixed(4)), mhs: Number(median(t.map(r => r.chosen.mhs)).toFixed(2)), watts: Number(median(t.map(r => r.chosen.watts)).toFixed(1)), @@ -120,7 +122,7 @@ export function priorFor(tuning, key, minSamples) { const p = tuning && tuning.priors && tuning.priors[key]; const floor = Number.isFinite(minSamples) ? minSamples : (tuning && tuning.ember && tuning.ember.min_samples) || 5; if (!p || !(p.samples >= floor)) return null; - return { clock_mhz: p.clock_mhz || 0, power_pct: Math.min(100, Math.max(50, p.power_pct || 100)), eff: p.eff, samples: p.samples }; + return { clock_mhz: p.clock_mhz || 0, power_pct: Math.min(100, Math.max(50, p.power_pct || 100)), mem_mhz: p.mem_mhz || 0, eff: p.eff, samples: p.samples }; } /// One text line per prior for the console and the CLI. diff --git a/relay/test/ember.test.mjs b/relay/test/ember.test.mjs index a5d39e96e..c8ed38170 100644 --- a/relay/test/ember.test.mjs +++ b/relay/test/ember.test.mjs @@ -82,7 +82,7 @@ test('the manifest merge keeps the kernel-variant cards and carries the settings assert.deepEqual(mergeTuning(null, {}).cards, {}); // the round trip: canonical JSON (what publish-manifest.sh signs) parses back to the same prior const back = JSON.parse(JSON.stringify(t)); - assert.deepEqual(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16'), { clock_mhz: 2470, power_pct: 100, eff: priors['NVIDIA_GeForce_RTX_5090|581|l128w16'].eff, samples: 5 }); + assert.deepEqual(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16'), { clock_mhz: 2470, power_pct: 100, mem_mhz: 0, eff: priors['NVIDIA_GeForce_RTX_5090|581|l128w16'].eff, samples: 5 }); assert.equal(priorFor(back, 'NVIDIA_GeForce_RTX_5090|581|l128w16', 6), null, 'six wanted, five there'); assert.equal(priorFor(back, 'nothing|0|v2'), null); assert.equal(priorFor(null, 'x'), null); @@ -100,6 +100,15 @@ test('AMD confirm records aggregate by their own key, and the line reads', () => assert.equal(table.length, 1); }); +test('Ember 2: climb records carry the memory clock into the prior', () => { + const climb = (m, ts, mem) => ({ ...rec(m, ts, { ...step(2472, 100, 220, 123.5), mem_mhz: mem }), plan: 'climb' }); + const { priors } = aggregate([climb('a1', 1, 14001), climb('b2', 2, 14001), climb('c3', 3, 13901), climb('d4', 4, 14001), climb('e5', 5, 14001)], { minSamples: 5 }); + const p = priors['NVIDIA_GeForce_RTX_5090|581|l128w16']; + assert.equal(p.mem_mhz, 14000, 'the median memory clock, rounded to 10 MHz'); + assert.equal(p.clock_mhz, 2470); + assert.equal(priorFor({ priors, ember: { min_samples: 5 } }, 'NVIDIA_GeForce_RTX_5090|581|l128w16').mem_mhz, 14000); +}); + test('median and spread', () => { assert.equal(median([3, 1, 2]), 2); assert.equal(median([4, 1, 2, 3]), 2.5); From bc97cba2e5fb4700c8f7c3360e876a2d8288c096 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:52:37 +0100 Subject: [PATCH 10/45] ember-tune-pc1.ps1: ember-kit-6 (the quote fix, the self-registering elevated engine) preferred when present Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- relay/playbooks/ember-tune-pc1.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 55dd81480..547159124 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -47,10 +47,12 @@ $installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out $installedHasEmber = $false if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) } $ember = $null -# a kit is used only when the installed app predates Ember Tune (0.3.13); from 0.3.13 on the installed engine is the -# tune engine, so the Power Helper task it registers points at the install folder (the 15:28Z run on PC 1 registered -# the kit's path because kit-5 was still preferred) -if (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } +# a kit is used when the installed app predates Ember Tune (0.3.13), or when a run carries fixes the installed app +# lacks (ember-kit-6: the cmd quote fix and the self-registering elevated engine of 6 October 2026); a kit engine +# registers the Power Helper on the INSTALLED exe's path (powertask::task_exe), never its own +$k6 = Join-Path $appDir 'jobs\ember-kit-6\igneum-app-ember.exe' +if (Test-Path $k6) { $ember = $k6 } +elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } if ($ember) { Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force Say ("engine: the Ember build from " + $ember) From 4b25760e50a376f1cf341dde0f88360534d6d178 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:54:14 +0100 Subject: [PATCH 11/45] Igneum Miner 0.3.14: the six version files (node-only: the exec layer survives a deep reorg and a moved pruning point) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/Cargo.lock | 2 +- app/igneum-app/Cargo.toml | 2 +- app/igneum-app/resources/igneum-app.rc | 8 ++++---- app/windows/version.h | 4 ++-- packaging/mac/app/Info.plist | 2 +- packaging/windows/Igneum-Miner.iss | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index 557c75108..a1d5e9db0 100644 --- a/app/igneum-app/Cargo.lock +++ b/app/igneum-app/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "igneum-app" -version = "0.3.13" +version = "0.3.14" dependencies = [ "ed25519-dalek", "getrandom", diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index 28d4ac4f8..82a8803ba 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-app" -version = "0.3.13" +version = "0.3.14" edition = "2021" description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1" license = "MIT" diff --git a/app/igneum-app/resources/igneum-app.rc b/app/igneum-app/resources/igneum-app.rc index f581c416d..23b97b1ef 100644 --- a/app/igneum-app/resources/igneum-app.rc +++ b/app/igneum-app/resources/igneum-app.rc @@ -6,8 +6,8 @@ 1 ICON "igneum.ico" 1 VERSIONINFO -FILEVERSION 0,3,13,0 -PRODUCTVERSION 0,3,13,0 +FILEVERSION 0,3,14,0 +PRODUCTVERSION 0,3,14,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS VOS_NT_WINDOWS32 @@ -20,12 +20,12 @@ BEGIN BEGIN VALUE "CompanyName", "Igneum" VALUE "FileDescription", "Igneum Miner engine" - VALUE "FileVersion", "0.3.13" + VALUE "FileVersion", "0.3.14" VALUE "InternalName", "igneum-app" VALUE "LegalCopyright", "Igneum contributors" VALUE "OriginalFilename", "igneum-app.exe" VALUE "ProductName", "Igneum Miner" - VALUE "ProductVersion", "0.3.13" + VALUE "ProductVersion", "0.3.14" END END BLOCK "VarFileInfo" diff --git a/app/windows/version.h b/app/windows/version.h index 0b54bd922..e6d8b433a 100644 --- a/app/windows/version.h +++ b/app/windows/version.h @@ -3,6 +3,6 @@ // packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too. #ifndef IGNEUM_HOST_VERSION_H #define IGNEUM_HOST_VERSION_H -#define IGNEUM_HOST_VERSION_STR "0.3.13" -#define IGNEUM_HOST_VERSION_RC 0,3,13,0 +#define IGNEUM_HOST_VERSION_STR "0.3.14" +#define IGNEUM_HOST_VERSION_RC 0,3,14,0 #endif diff --git a/packaging/mac/app/Info.plist b/packaging/mac/app/Info.plist index d0c82fe68..718b1626e 100644 --- a/packaging/mac/app/Info.plist +++ b/packaging/mac/app/Info.plist @@ -11,7 +11,7 @@ <key>CFBundleVersion</key> <string>VERSION_STAMP</string> <key>CFBundleShortVersionString</key> - <string>0.3.13</string> + <string>0.3.14</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleExecutable</key> diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index c422b75a3..a40dfa1c1 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -9,7 +9,7 @@ #define ArtDir "..\..\brand\icons" #endif #ifndef AppVersion - #define AppVersion "0.3.13" + #define AppVersion "0.3.14" #endif #define AppName "Igneum Miner" #define Publisher "Igneum" From 3d741655e2e22237fb4e780c0995fb4166b881e6 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:54:14 +0100 Subject: [PATCH 12/45] release 0.3.14: the plan skeleton (node-only; the exec layer survives a deep reorg and a moved pruning point) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/plans/release-0.3.14.md | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/plans/release-0.3.14.md diff --git a/docs/plans/release-0.3.14.md b/docs/plans/release-0.3.14.md new file mode 100644 index 000000000..e41f1507a --- /dev/null +++ b/docs/plans/release-0.3.14.md @@ -0,0 +1,41 @@ +# Igneum Miner 0.3.14: node-only, the execution layer survives a deep reorg and a moved pruning point; prepared to the gate, 6 October 2026 + +Release engineer, from 15:55 UTC, on the coordinator's decision of 15:53Z (the "0.3.13.1" name is refused by the ship tooling, so 0.3.14). +Worktree `/Users/joshm/Projects/igneum-wt-ship0314`, branch `release-0.3.14` from master 2cf8851 (the 0.3.13 merge); the fork worktree +`vendor/igneum-node-0314`, branch `release-0.3.14-node`, at bb43e9a8 (the 0.3.13 node) until the proving agent's fix lands on it. The +0.3.13 recipe; igneum-josh commits; times UTC. + +## 1. Why, and what it carries + +After 0.3.13's publish 2 (6 October, `release-0.3.13.md` section 4a): the fresh rule armed at DAA 198,000 inside the rollout window, the +chain ran two-sided for a minute, the hands took a 274-chain-block reorg, and the exec layer on every executing node replayed from genesis +("reorg deeper than the snapshot ring"), overwrote its good snapshot with a tip-0 one, and could not take the restart path again because the +devnet's pruning point had moved from bb45cf0d (DAA 45,537) to eb2a5d70 (DAA 88,763), stranding the anchor 27,276. Every node reads +`eth_blockNumber` 0 and `paidShards` 0 since 15:44:47Z; the chain, the finality locks and the hash are untouched. + +| Change | Where | State | +|---|---|---| +| A deep reorg never replays from genesis on a pruned node: the follower unwinds through its persisted generations, else requests a peer's snapshot (protocol 16); a good snapshot is never overwritten by a tip-0 one | the proving agent's fix branch off bb43e9a8 | (pending) | +| A moved pruning point does not strand the anchor: a node keeps executing from its persisted state; the anchor is for a node with nothing | the same | (pending) | +| The six version files | (the bump commit) | | + +No re-pin of the anchor (the coordinator's decision). If the fix is code-only the object is CARRIED OVER (the thirteen-field one, digest +b18ed271... unchanged) and this is ONE publish; if a field changes, the 0.3.12 two-publish shape. + +## 2. The order at the go + +Runbook: the session scratchpad's `r0314/rollout-0314.sh` (the 0.3.13 one with the names moved; `step_1a_*` is the whole hand swap when the +object is carried over). + +| Step | What | Gate | +|---|---|---| +| 0 | the baseline: `step_check_observer` reads 0 / genesis-or-snapshot-at-0 / 0 shards today | | +| 1 | the observer, node 1 (on its own eth port 26791 now) and the seed on the 0.3.14 binary with the SAME thirteen-field file | each prints b18ed271...; within minutes `eth_blockNumber` climbs to the sink, `igneum_getExecStatus` startedFrom "snapshot" (the persisted generation) or "restart at chain block 27276" (if the walk can meet it) or a peer's snapshot, `blocked null`; `igneum_getProvingStatus` active, paidShards >= 1,482 | +| 2 | the ship (`--from ci`, consensus carried over), then update-now Mac, PC 2, PC 1 (no activation height anywhere near the window) | each node's exec back the same way | +| 3 | the fleet agent's boxes on the new Linux node (handed before the publish), the sweep, the proving agent's fresh join on the rented 4090 | the per-node times | + +## 3. Builds and artefacts (to fill when the fork tip is set) + +## 4. The rollout (to fill at the go) + +## 5. Owed From 5165de79102ab74352ef4d5c5f4763546cc8d7e1 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:53:43 +0100 Subject: [PATCH 13/45] =?UTF-8?q?Ember=202:=20the=20plan's=20section=206b?= =?UTF-8?q?=20(the=20clocks,=20the=20hill-climb,=20the=20goal,=20the=20pri?= =?UTF-8?q?ce,=20the=20curve,=20the=20fleet=20prior,=20what=20is=20owed);?= =?UTF-8?q?=20Settings:=20the=20goal,=20the=20electricity=20price,=20the?= =?UTF-8?q?=20climb=20switch;=20the=20card=20detail:=20=C2=A3=20a=20day=20?= =?UTF-8?q?for=20the=20tuned=20point=20and=20the=20measured=20curve;=20UI?= =?UTF-8?q?=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/ui/app.js | 20 +++++++++++++++++++- app/igneum-app/ui/index.html | 3 +++ app/igneum-app/ui/tune-line.test.mjs | 5 +++++ docs/plans/ember-tune.md | 26 ++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 168fa680d..283ecb467 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -441,7 +441,9 @@ var TuneLine = (function () { return { kind: 'idle', text: cd.sweep_note || 'tuning: not run yet (starts after 120 s of steady mining)' }; } function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; } - return { model: model, point: point, rel: rel }; + // Ember 2: £ a day for a draw at pence per kWh (watts × 24 / 1000 × price / 100) + function pounds(watts, pence) { var v = watts * 24 / 1000 * pence / 100; return '£' + v.toFixed(2); } + return { model: model, point: point, rel: rel, pounds: pounds }; })(); if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; } @@ -630,6 +632,9 @@ if (typeof document !== 'undefined') (function () { $('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); }); $('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); }); $('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on: once after install, then weekly' : 'Ember Tune off'); }); }); + $('s-tune-goal').addEventListener('change', function () { var g = this.value; api('api/tune/goal', { goal: g }).then(function (r) { if (r.ok) toast('Goal: ' + g); }); }); + $('s-power-price').addEventListener('change', function () { var p = parseFloat(this.value); if (isNaN(p)) return; api('api/tune/goal', { price_pence: p }).then(function (r) { if (r.ok) toast('Electricity: ' + p + ' p/kWh'); }); }); + $('s-tune-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (r.ok) toast(on ? 'Ember 2: the clocks climb from the next tune' : 'The ladders run from the next tune'); }); }); $('s-power-control').addEventListener('change', function () { var on = this.checked; api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on: Windows asks for administrator rights once' : 'Power control off; nothing asks'); }); }); $('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); }); $('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); @@ -1177,6 +1182,10 @@ if (typeof document !== 'undefined') (function () { sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', s.settings.sweep); sw('s-trust', s.settings.proof_verify_trust); sw('s-power-control', s.settings.power_control); setText('s-power-note', s.settings.tuning_off ? s.settings.tuning_note : (s.settings.power_note || (s.settings.power_control ? '' : 'Off: NVIDIA cards measure only; AMD cards need no rights.'))); setText('s-devfee-text', View.devFeeText(s)); + if (document.activeElement !== $('s-tune-goal')) $('s-tune-goal').value = (s.settings.tune_goal || 'balanced'); + if (document.activeElement !== $('s-power-price')) $('s-power-price').value = s.settings.power_price_pence > 0 ? s.settings.power_price_pence : ''; + setText('s-power-price-note', s.settings.power_price_pence > 0 ? 'the card rows read in £ a day' : 'set it to see £ a day per card'); + sw('s-tune-climb', s.settings.tune_climb); if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || ''; setText('s-mid', (s.machine_id || '').slice(0, 8)); setText('s-node-dir', 'chain data: ' + s.node_dir); @@ -1200,6 +1209,7 @@ if (typeof document !== 'undefined') (function () { } // Ember Tune's line (TuneLine.model): running, tuned, measured, stopped or not run yet, for every card h += tuneLineHtml(cd); + h += tuneCurveHtml(cd, state && state.settings ? state.settings.power_price_pence : 0); h += '<div class="ctl"><span class="k">Identities</span><p class="help" style="grid-column:2">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>'; return h + '</div>'; } @@ -1211,6 +1221,14 @@ if (typeof document !== 'undefined') (function () { var btn = running ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Tune now</button>' + (cd.pinned ? ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0">Unpin</button>' : ''); return '<div class="line' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>'; } + // Ember 2: £ a day for the tuned point and the measured curve (every row of the last plan) + function tuneCurveHtml(cd, price) { + var h = ''; + if (price > 0 && cd.sweep_watts > 0) h += '<div class="line dim">' + esc(TuneLine.pounds(cd.sweep_watts, price)) + ' a day at this point' + (cd.power_w > 0 && Math.abs(cd.power_w - cd.sweep_watts) > 5 ? ' · ' + esc(TuneLine.pounds(cd.power_w, price)) + ' at the draw now' : '') + '</div>'; + var c = cd.tune_curve || []; + if (c.length > 1) h += '<div class="line dim">curve: ' + c.map(function (r) { return esc((r.clock_mhz ? r.clock_mhz + ' MHz' : 'unlocked') + (r.mem_mhz ? ' mem ' + r.mem_mhz : '') + ' ' + r.power_pct + '%: ' + Number(r.mhs).toFixed(1) + ' MH/s ' + Math.round(r.watts) + ' W ' + Number(r.eff).toFixed(3) + (r.mark && r.mark !== 'ok' ? ' (' + r.mark + ')' : '')); }).join(' · ') + '</div>'; + return h; + } function renderSetCards(s) { var cards = shownCards(s.mining.cards), box = $('s-cards'); if (box.querySelector('input[type=range]:active') || box.contains(document.activeElement)) return; diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 7b423fdf9..4695883e8 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -369,6 +369,9 @@ <div class="set-cards" id="s-cards"><div class="empty">No card yet.</div></div> <label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Ember Tune: tune every card for hashes per watt</span></label> <p class="help">Once after install, then weekly and after a driver or program change: the power limit steps from 100% down to 50%, then the core clock from its maximum down to 60%, 75 s a step on the live program; the memory clock is never touched. The card keeps the point with the most hashes per watt within 1% of its top rate. A step with a rejected hash, a hot GPU or a dragged memory clock is reverted. A card whose model the fleet already knows starts at that point and confirms it in two steps. Every result goes back to the fleet without anything that identifies you. A cap you set by hand is left alone.</p> + <div class="ctl"><span class="k">Goal</span><select id="s-tune-goal" aria-label="tuning goal"><option value="efficiency">Efficiency: the most hashes per watt, within 10% of the top rate</option><option value="balanced">Balanced: the most hashes per watt, within 1% of the top rate</option><option value="rate">Maximum rate</option></select></div> + <div class="ctl"><span class="k">Electricity</span><input type="number" id="s-power-price" min="0" max="500" step="0.1" placeholder="pence per kWh" aria-label="electricity price, pence per kWh"><span class="pv" id="s-power-price-note"></span></div> + <label class="switch"><input type="checkbox" id="s-tune-climb"><span class="track"></span><span>Ember 2: climb the clocks (memory up, core down) instead of the ladders</span></label> <label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span>Power control: let the app set NVIDIA limits</span></label> <p class="help">Windows asks for administrator rights once; the NVIDIA cap and the tune need them. Off, the app never asks and NVIDIA cards measure only. AMD cards need no rights. <span id="s-power-note"></span></p> </div> diff --git a/app/igneum-app/ui/tune-line.test.mjs b/app/igneum-app/ui/tune-line.test.mjs index 18fafce33..ff684f4fd 100644 --- a/app/igneum-app/ui/tune-line.test.mjs +++ b/app/igneum-app/ui/tune-line.test.mjs @@ -75,3 +75,8 @@ test('a card never shows 0 MH/s without a reason word: tuning, held and tuned ro assert.equal(View.tuneEta(30), 'under a minute'); assert.equal(View.tuneEta(700), 'about 12 min'); }); + +test('Ember 2: £ a day from watts and pence per kWh', () => { + assert.equal(mod.exports.TuneLine.pounds(310, 28.5), '£2.12'); + assert.equal(mod.exports.TuneLine.pounds(102.7, 28.5), '£0.70'); +}); diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 352698906..b6c9127c5 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -176,6 +176,32 @@ live MH/s and W, the seconds left and the plan; a POST of state, never a quit, p reads `Tuned: <MH/s> at <W> W (<MH/W>)` with the point and the plan. Tests: `ui/tune-line.test.mjs` (tuning, held, tuned rows; the button; the strip line). +## 6b. Ember 2: the clocks, the hill-climb, the goal (6 October 2026) + +Why: run 5 measured the 5090's power ladder flat (310 to 314 W under every cap from 575 to 400 W, 0.41 MH/W), so +on this memory-latency-bound hash the power cap is a safety, not a lever; the pair that matters is the memory +clock UP and the core clock DOWN. Ember 2 adds both and replaces the fixed ladder with a hill-climb. + +| Piece | What | Where | +|---|---|---| +| The memory knob | NVIDIA: `nvidia-smi -lmc <m>,<m>` locks the memory clock to one value, `-rmc` resets; the range is the driver's own (`clocks.mem` under load = the floor, `clocks.max.mem` = the ceiling; PC 1's 5090: 13,801 to 14,001 MHz); directly from an elevated engine or through the Power Helper's `lmc`/`rmc` verbs. AMD: ADLX on RDNA 4 exposes no memory setter through the helper's path, so an AMD card climbs on core and power only (the row says so). Linux NVIDIA: `-lmc` through pkexec as the cap is | `Point.mem_mhz`, `Limits.mem_default_mhz`, `Limits.mem_max_mhz`, `tune_apply`, `powertask::HelperCmd::MemClock`, `sweep::helper_script_*` | +| The hill-climb | from the start point (the fleet prior for the model, else the card's point): each probe moves memory up by 5% of the range above the default (at least 25 MHz) or core down by 5% of the maximum (at least 25 MHz); a probe that improves the goal's score keeps that direction, else the climb turns to the other knob, then to both; 5 probes of 60 s (10 s settle) converge in under 10 minutes | `Plan::climb`, `Climb`, `Plan::climb_next` | +| The stability guard | a rejected or mismatched hash during a probe marks it `faulted`; a faulted probe backs that knob off for good in this climb (memory never goes above, core never below, the last good point); the hash fingerprint is the worker's own self-test at every pack (a variant that is not bit-exact never serves), so a step that keeps mining with 0 mismatches is stable by the only test that matters | `Row::from_samples`, `climb_next`'s `refused_mem` / `refused_core`, docs/design/miner-tuning.md | +| The goal | Settings > goal: `efficiency` (the most MH/W within 10% of the top rate), `balanced` (within 1%, lever 3's rule), `rate` (the fastest point; MH/W breaks ties); one goal for every card | `Goal`, `Settings.tune_goal`, `POST /api/tune/goal {goal, price_pence, climb}` | +| The price | Settings > electricity, pence per kWh: the card's row reads the chosen point as £ a day (`watts × 24 / 1000 × price / 100`: 310 W at 28.5 p = £2.12) | `pounds_per_day`, `Settings.power_price_pence`, the UI | +| The curve | every row of the last plan on the card (`tune_curve`: point, MH/s, W, MH/W, clocks, hottest reading, mark) so a manual tuner sees the points the climb measured | `CardState.tune_curve`, the card detail | +| The fleet | the record's `steps` already carry the curve; the prior gains `mem_mhz` (the median memory clock, 10 MHz steps); a stranger's card of a known model starts its climb at the prior's point | `relay/lib/ember.mjs`, `ember::prior_of` | +| Re-check | weekly (the manifest's period), after a driver major or program-class change, and when the card's temperature band changes (owed: the band trigger) | `tick_sweep` | + +Tests: `ember::tests::the_climb_walks_memory_up_and_core_down_and_converges_in_five_probes` (a synthetic +memory-bound card: every probe moves memory up or core down inside the range, the chosen point beats the start in +five probes), `a_refused_probe_backs_that_knob_off_for_good`, `each_goal_picks_its_point` (and the £/day formula), +`powertask::tests` (the `lmc`/`rmc` verbs reach nvidia-smi as `-lmc m,m` and `-rmc`), `sweep::tests` (the helper +scripts carry them), `relay/test/ember.test.mjs` (climb records aggregate into a prior with the memory clock). + +Owed: the measured comparison on PC 1 (one climb per card against run 5's ladder rows, no prompt through the +Power Helper), the temperature-band trigger, NVAPI/nvidia-settings for cards whose driver refuses `-lmc`. + ## 7a. One administrator approval, ever (0.3.13; Josh, 6 October 2026, 11:50 UTC) What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a From 32e004f8b95bb8d14c75a4ea9083bb9b068c32e2 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:58:45 +0100 Subject: [PATCH 14/45] restart-hand-nodes.sh: IGNEUMD_EXEC_SNAPSHOT (--igneum-exec-snapshot on both hands) and IGNEUM_EXEC_RESET (the persisted exec files deleted after the stop): the recovery after a genesis replay Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- infra/devnet/restart-hand-nodes.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/infra/devnet/restart-hand-nodes.sh b/infra/devnet/restart-hand-nodes.sh index 0016213c4..2d424e1fe 100755 --- a/infra/devnet/restart-hand-nodes.sh +++ b/infra/devnet/restart-hand-nodes.sh @@ -12,6 +12,12 @@ BIN="${IGNEUMD:-/Users/joshm/Projects/igneum/vendor/igneum-node/target-036/relea # the fork commit the binary must carry (0.3.9: a24ab01a, release-0.3.6 with txgen-export; 2b6d23ef read the switch too) COMMIT="${IGNEUMD_COMMIT:-a24ab01a}" OV=/tmp/igneum-devnet/override-v3.json +# 6 October 2026 (the exec recovery after the 15:44Z reorg): IGNEUMD_EXEC_SNAPSHOT="<path>,<sha256>" adds --igneum-exec-snapshot +# to both nodes (the exec state to start from when the data dir's own file is bad or missing); IGNEUM_EXEC_RESET=1 deletes each +# node's persisted exec files (exec-snapshot.bin, the tip-0 file a genesis replay writes, and exec-snapshot.prev.bin) after the +# stop, so the start takes the flag's file and not the bad one. Both off by default. +SNAP="${IGNEUMD_EXEC_SNAPSHOT:-}"; SNAPARG=""; [ -n "$SNAP" ] && SNAPARG="--igneum-exec-snapshot=$SNAP" +reset_exec() { if [ "${IGNEUM_EXEC_RESET:-0}" = 1 ]; then rm -f "$1/igneum-devnet/datadir/evm/exec-snapshot.bin" "$1/igneum-devnet/datadir/evm/exec-snapshot.prev.bin"; echo "exec files reset under $1"; fi; } echo "$OVJSON" | python3 -c 'import json,sys; o=json.load(sys.stdin); assert isinstance(o, dict) and "fees_v1_activation_daa" in o, "the object must carry fees_v1_activation_daa"; print("override:", json.dumps(o, sort_keys=True))' # grep -c, not grep -q: -q closes the pipe early and pipefail turns strings' SIGPIPE into a refusal of a good binary (5 October 2026, 17:48Z) [ "$(strings "$BIN" | grep -c "$COMMIT")" -gt 0 ] || { echo "$BIN is not the $COMMIT node (a 0.3.5 node refuses an override file with fees_v1_activation_daa)"; exit 1; } @@ -21,12 +27,14 @@ stop() { local p; p=$(pgrep -f "$1" | head -1 || true); if [ -n "$p" ]; then kil lines() { sleep 10; grep -E "Calibrated v1 fees from the override file|Fees on igneum-devnet|Consensus params digest" "$1" | tail -3 || true; } # the observer first stop "igneumd --devnet.*observer-v4" +reset_exec /tmp/igneum-devnet/observer-v4 nohup "$BIN" --devnet --nodnsseed --disable-upnp --appdir=/tmp/igneum-devnet/observer-v4 --rpclisten=127.0.0.1:26640 --rpclisten-json=127.0.0.1:28640 --listen=127.0.0.1:26641 \ - --addpeer=127.0.0.1:26611 --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" --nologfiles --yes >> /tmp/igneum-devnet/observer-v4.out 2>&1 & + --addpeer=127.0.0.1:26611 --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" $SNAPARG --nologfiles --yes >> /tmp/igneum-devnet/observer-v4.out 2>&1 & lines /tmp/igneum-devnet/observer-v4.out # node 1, under caffeinate as it runs today stop "igneumd --devnet.*appdir=/tmp/igneum-devnet/node1 " +reset_exec /tmp/igneum-devnet/node1 nohup caffeinate -dims "$BIN" --devnet --nodnsseed --disable-upnp --enable-unsynced-mining --appdir=/tmp/igneum-devnet/node1 --rpclisten=0.0.0.0:26610 --listen=0.0.0.0:26611 --evm-rpclisten=127.0.0.1:26791 \ - --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" --nologfiles --yes >> /tmp/igneum-devnet/node1.out 2>&1 & + --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file="$OV" $SNAPARG --nologfiles --yes >> /tmp/igneum-devnet/node1.out 2>&1 & lines /tmp/igneum-devnet/node1.out echo "running now:"; ps -o pid=,lstart=,command= -p "$(pgrep -f 'igneumd --devnet' | tr '\n' ',' | sed 's/,$//')" | cut -c1-160 From 9fba504a1f1446cd439a8e80240ed58b07c095da Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:00:09 +0100 Subject: [PATCH 15/45] Ember playbook: the AMD watts source. The script samples the installed app's /api/state power_w for the AMD card every 10 s (a GET), counts the engine's own AMD watts, prints a fallback ladder (median per step, with the step's MH/s and MH/W) when the engine's AMD progress carries no watts, and names the source it used Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- relay/playbooks/ember-tune-pc1.ps1 | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 547159124..0a7fb1ab1 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -174,8 +174,21 @@ function Exit3([string] $why, [string] $reason) { Write-Output 'RESULT TUNE error=no_rows' exit 3 } +# the AMD watts source (coordinator, 6 October 2026 16:00Z): the 3.0 coordinator's job read ZERO watts for the 9070 XT +# from igneum-gpu-telemetry.exe while the installed app had read 198.9 W the day before. The tune engine samples its +# own helper; this script also samples the installed app's /api/state power_w for the AMD card every 10 s (a GET, +# never a write) and, when the engine's AMD progress lines carry no watts, prints a fallback ladder from those +# samples (median per step) and names the source it used +$amdApp = @(); $amdStep = 0; $amdMhs = 0.0; $amdEngNZ = 0; $amdEngZ = 0; $tick = 0; $amdMhsByStep = @{} while (-not $p.HasExited) { Start-Sleep -Seconds 5 + $tick++ + if ($installedUrl -and ($tick % 2 -eq 0)) { + try { + $js = (Invoke-WebRequest -Uri ($installedUrl + 'api/state') -UseBasicParsing -TimeoutSec 3).Content | ConvertFrom-Json + foreach ($c in @($js.mining.cards)) { if ($c.vendor -eq 'amd') { $amdApp += ,@{ step = $amdStep; w = [double]$c.power_w } } } + } catch { } + } $tailLine = EngineTail if ($tailLine) { $lastEngineLine = $tailLine } $statusLine = EngineStatus @@ -191,7 +204,12 @@ while (-not $p.HasExited) { $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++ - if ($l -match '^TUNE progress ') { Forward $l } + if ($l -match '^TUNE progress ') { + Forward $l + if ($l -match ' key=amd' -and $l -match ' step=(\d+)') { $amdStep = [int]$Matches[1] + if ($l -match ' mhs=([\d.]+)') { $amdMhsByStep[$amdStep] = [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) } + if ($l -match ' watts=([\d.]+)' -and [double]::Parse($Matches[1], [Globalization.CultureInfo]::InvariantCulture) -gt 0) { $amdEngNZ++ } else { $amdEngZ++ } } + } elseif ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ }; if ($l -match '^TUNE chosen ') { Forward $l } } elseif ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l) } elseif ($l -match '^(URL|STATE) ') { } @@ -217,6 +235,20 @@ while (-not $p.HasExited) { } $all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) } while ($seen -lt $all.Count) { $l = [string]$all[$seen]; $seen++; if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } } } +$amdNZ = @($amdApp | Where-Object { $_.w -gt 0 }) +Write-Output ('RESULT TUNE amd_watts engine_progress nonzero=' + $amdEngNZ + ' zero=' + $amdEngZ + ' app_state samples=' + $amdApp.Count + ' nonzero=' + $amdNZ.Count) +$amdSource = 'none' +if ($amdEngNZ -gt 0) { $amdSource = 'engine_telemetry' } +elseif ($amdNZ.Count -gt 0) { + $amdSource = 'app_state_fallback' + foreach ($g in ($amdNZ | Group-Object { $_.step } | Sort-Object { [int]$_.Name })) { + $ws = @($g.Group | ForEach-Object { $_.w } | Sort-Object); $med = $ws[[int][math]::Floor($ws.Count / 2)] + $mh = 0.0; if ($amdMhsByStep.ContainsKey([int]$g.Name)) { $mh = $amdMhsByStep[[int]$g.Name] } + $eff = 0.0; if ($med -gt 0) { $eff = $mh / $med } + Write-Output ('RESULT TUNE amd_fallback source=app_state step=' + $g.Name + ' watts=' + ('{0:F1}' -f $med) + ' mhs=' + ('{0:F2}' -f $mh) + ' eff=' + ('{0:F4}' -f $eff) + ' samples=' + $ws.Count) + } +} +Write-Output ('RESULT TUNE amd_watts_source=' + $amdSource) Say ("tune engine exited " + $p.ExitCode + " after " + [int]((Get-Date) - $started).TotalSeconds + " s, " + $rows + " table rows") EndTree $p.Id 'end of run' # Josh, 6 October 2026, 07:25Z: both cards stay on their best MH/W points (the tune pins them); no factory reset here. From 0585b9ece981c78ff49bb9accda6d6b513d5dc0d Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:13:35 +0100 Subject: [PATCH 16/45] restart-seed.sh: IGNEUMD_EXEC_SNAPSHOT_FILE (copied to the seed, the flag in EXTRA_ARGS) and IGNEUM_EXEC_RESET (the persisted exec files deleted while stopped): the recovery after a genesis replay Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- infra/devnet/restart-seed.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/infra/devnet/restart-seed.sh b/infra/devnet/restart-seed.sh index 346ab2c41..46625af70 100755 --- a/infra/devnet/restart-seed.sh +++ b/infra/devnet/restart-seed.sh @@ -14,6 +14,13 @@ WANT="${IGNEUMD_LINUX_SHA256:-7e26e374586ad8ba539e371c6cbbd74f0f77aee1e2e97cd25f echo "$OVJSON" | python3 -c 'import json,sys; o=json.load(sys.stdin); assert isinstance(o, dict) and "fees_v1_activation_daa" in o; print("override:", json.dumps(o, sort_keys=True))' test "$(shasum -a 256 "$SRC" | cut -c1-64)" = "$WANT" || { echo "$SRC is not the 2b6d23ef Linux node ($WANT)"; exit 1; } SSH="ssh -i $HOME/.ssh/igneum_ed25519 -o ConnectTimeout=20 root@188.245.5.161" +# 6 October 2026 (the exec recovery after the 15:44Z reorg): IGNEUMD_EXEC_SNAPSHOT_FILE=<local file> copies that exec snapshot to the +# seed (/root/v4/exec-snapshot.bin) and adds --igneum-exec-snapshot=<path>,0x<sha256> to EXTRA_ARGS in seed-v4.env (replacing an +# earlier one); IGNEUM_EXEC_RESET=1 deletes the seed's persisted exec files (the tip-0 file a genesis replay writes, and the .prev) +# while the unit is stopped. Both off by default. +SNAPF="${IGNEUMD_EXEC_SNAPSHOT_FILE:-}"; SNAPSHA="" +if [ -n "$SNAPF" ]; then SNAPSHA="$(shasum -a 256 "$SNAPF" | cut -c1-64)"; scp -i "$HOME/.ssh/igneum_ed25519" "$SNAPF" root@188.245.5.161:/root/v4/exec-snapshot.bin; fi +RESET="${IGNEUM_EXEC_RESET:-0}" scp -i "$HOME/.ssh/igneum_ed25519" "$SRC" root@188.245.5.161:/root/v4/out/igneumd.039 $SSH "set -e test \"\$(sha256sum /root/v4/out/igneumd.039 | cut -c1-64)\" = $WANT @@ -23,6 +30,8 @@ $SSH "set -e printf '%s\n' '$OVJSON' > /etc/igneum/override-v3.json grep -q '^EXTRA_ARGS=--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env systemctl stop igneumd-v4 + if [ '$RESET' = 1 ]; then rm -f /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.bin /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.prev.bin; echo 'exec files reset on the seed'; fi + if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /root/v4/exec-snapshot.bin | cut -c1-64)\" = $SNAPSHA; sed -i -E 's/ --igneum-exec-snapshot=[^ ]+//' /etc/igneum/seed-v4.env; sed -i -E 's#^EXTRA_ARGS=(.*)\$#EXTRA_ARGS=\1 --igneum-exec-snapshot=/root/v4/exec-snapshot.bin,0x$SNAPSHA#' /etc/igneum/seed-v4.env; grep -E '^EXTRA_ARGS=' /etc/igneum/seed-v4.env | cut -c1-160; fi mv -f /opt/igneum/v4/bin/igneumd.new /opt/igneum/v4/bin/igneumd systemctl start igneumd-v4 sleep 10; systemctl is-active igneumd-v4; systemctl show -p ActiveEnterTimestamp,MainPID igneumd-v4 From db1205bcd6cb14854216b25f5eac2f986211b3dd Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:16:45 +0100 Subject: [PATCH 17/45] restart-seed.sh: EXTRA_ARGS quoted when the snapshot flag is added (the env file is sourced; an unquoted value with a space ran the flag as a command and the unit crash-looped, 16:14Z) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- infra/devnet/restart-seed.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/devnet/restart-seed.sh b/infra/devnet/restart-seed.sh index 46625af70..421db2858 100755 --- a/infra/devnet/restart-seed.sh +++ b/infra/devnet/restart-seed.sh @@ -28,7 +28,7 @@ $SSH "set -e cp /root/v4/out/igneumd.039 /opt/igneum/v4/bin/igneumd.new && chmod +x /opt/igneum/v4/bin/igneumd.new cp -p /etc/igneum/override-v3.json /etc/igneum/override-v3.json.prev-\$(date -u +%Y%m%dT%H%M%SZ) || true printf '%s\n' '$OVJSON' > /etc/igneum/override-v3.json - grep -q '^EXTRA_ARGS=--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env + grep -qE '^EXTRA_ARGS=\"?--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env systemctl stop igneumd-v4 if [ '$RESET' = 1 ]; then rm -f /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.bin /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.prev.bin; echo 'exec files reset on the seed'; fi if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /root/v4/exec-snapshot.bin | cut -c1-64)\" = $SNAPSHA; sed -i -E 's/ --igneum-exec-snapshot=[^ ]+//' /etc/igneum/seed-v4.env; sed -i -E 's#^EXTRA_ARGS=(.*)\$#EXTRA_ARGS=\1 --igneum-exec-snapshot=/root/v4/exec-snapshot.bin,0x$SNAPSHA#' /etc/igneum/seed-v4.env; grep -E '^EXTRA_ARGS=' /etc/igneum/seed-v4.env | cut -c1-160; fi From dd9044ea5bb3952967be9d615747548f049de9a3 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:17:05 +0100 Subject: [PATCH 18/45] restart-seed.sh: the EXTRA_ARGS line rewritten whole and quoted on the seed (python, not sed) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- infra/devnet/restart-seed.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infra/devnet/restart-seed.sh b/infra/devnet/restart-seed.sh index 421db2858..e5e9a0603 100755 --- a/infra/devnet/restart-seed.sh +++ b/infra/devnet/restart-seed.sh @@ -31,7 +31,11 @@ $SSH "set -e grep -qE '^EXTRA_ARGS=\"?--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env systemctl stop igneumd-v4 if [ '$RESET' = 1 ]; then rm -f /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.bin /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.prev.bin; echo 'exec files reset on the seed'; fi - if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /root/v4/exec-snapshot.bin | cut -c1-64)\" = $SNAPSHA; sed -i -E 's/ --igneum-exec-snapshot=[^ ]+//' /etc/igneum/seed-v4.env; sed -i -E 's#^EXTRA_ARGS=(.*)\$#EXTRA_ARGS=\1 --igneum-exec-snapshot=/root/v4/exec-snapshot.bin,0x$SNAPSHA#' /etc/igneum/seed-v4.env; grep -E '^EXTRA_ARGS=' /etc/igneum/seed-v4.env | cut -c1-160; fi + if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /root/v4/exec-snapshot.bin | cut -c1-64)\" = $SNAPSHA; python3 -c 'import sys,re; p=\"/etc/igneum/seed-v4.env\"; t=open(p).read(); out=[] +for l in t.split(\"\\n\"): + if l.startswith(\"EXTRA_ARGS=\"): l=\"EXTRA_ARGS=\\\"--override-params-file=/etc/igneum/override-v3.json --igneum-exec-snapshot=/root/v4/exec-snapshot.bin,0x$SNAPSHA\\\"\" + out.append(l) +open(p,\"w\").write(\"\\n\".join(out))'; grep -E '^EXTRA_ARGS=' /etc/igneum/seed-v4.env | cut -c1-160; fi mv -f /opt/igneum/v4/bin/igneumd.new /opt/igneum/v4/bin/igneumd systemctl start igneumd-v4 sleep 10; systemctl is-active igneumd-v4; systemctl show -p ActiveEnterTimestamp,MainPID igneumd-v4 From 08ddee75adb4168b2a55e77ea5d29c27a55952ab Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:18:26 +0100 Subject: [PATCH 19/45] restart-seed.sh: the snapshot file installed for the igneum user under /var/lib/igneum-v4 (the unit runs as igneum; a file under /root was unreadable and the flag did nothing, silently) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- infra/devnet/restart-seed.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/devnet/restart-seed.sh b/infra/devnet/restart-seed.sh index e5e9a0603..8fc76da52 100755 --- a/infra/devnet/restart-seed.sh +++ b/infra/devnet/restart-seed.sh @@ -19,7 +19,7 @@ SSH="ssh -i $HOME/.ssh/igneum_ed25519 -o ConnectTimeout=20 root@188.245.5.161" # earlier one); IGNEUM_EXEC_RESET=1 deletes the seed's persisted exec files (the tip-0 file a genesis replay writes, and the .prev) # while the unit is stopped. Both off by default. SNAPF="${IGNEUMD_EXEC_SNAPSHOT_FILE:-}"; SNAPSHA="" -if [ -n "$SNAPF" ]; then SNAPSHA="$(shasum -a 256 "$SNAPF" | cut -c1-64)"; scp -i "$HOME/.ssh/igneum_ed25519" "$SNAPF" root@188.245.5.161:/root/v4/exec-snapshot.bin; fi +if [ -n "$SNAPF" ]; then SNAPSHA="$(shasum -a 256 "$SNAPF" | cut -c1-64)"; scp -i "$HOME/.ssh/igneum_ed25519" "$SNAPF" root@188.245.5.161:/root/v4/exec-snapshot.bin && ssh -i "$HOME/.ssh/igneum_ed25519" root@188.245.5.161 "install -o igneum -g igneum -m 644 /root/v4/exec-snapshot.bin /var/lib/igneum-v4/exec-snapshot-recovery.bin"; fi RESET="${IGNEUM_EXEC_RESET:-0}" scp -i "$HOME/.ssh/igneum_ed25519" "$SRC" root@188.245.5.161:/root/v4/out/igneumd.039 $SSH "set -e @@ -31,9 +31,9 @@ $SSH "set -e grep -qE '^EXTRA_ARGS=\"?--override-params-file=/etc/igneum/override-v3.json' /etc/igneum/seed-v4.env systemctl stop igneumd-v4 if [ '$RESET' = 1 ]; then rm -f /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.bin /var/lib/igneum-v4/igneum-devnet/datadir/evm/exec-snapshot.prev.bin; echo 'exec files reset on the seed'; fi - if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /root/v4/exec-snapshot.bin | cut -c1-64)\" = $SNAPSHA; python3 -c 'import sys,re; p=\"/etc/igneum/seed-v4.env\"; t=open(p).read(); out=[] + if [ -n '$SNAPSHA' ]; then test \"\$(sha256sum /var/lib/igneum-v4/exec-snapshot-recovery.bin | cut -c1-64)\" = $SNAPSHA; python3 -c 'import sys,re; p=\"/etc/igneum/seed-v4.env\"; t=open(p).read(); out=[] for l in t.split(\"\\n\"): - if l.startswith(\"EXTRA_ARGS=\"): l=\"EXTRA_ARGS=\\\"--override-params-file=/etc/igneum/override-v3.json --igneum-exec-snapshot=/root/v4/exec-snapshot.bin,0x$SNAPSHA\\\"\" + if l.startswith(\"EXTRA_ARGS=\"): l=\"EXTRA_ARGS=\\\"--override-params-file=/etc/igneum/override-v3.json --igneum-exec-snapshot=/var/lib/igneum-v4/exec-snapshot-recovery.bin,0x$SNAPSHA\\\"\" out.append(l) open(p,\"w\").write(\"\\n\".join(out))'; grep -E '^EXTRA_ARGS=' /etc/igneum/seed-v4.env | cut -c1-160; fi mv -f /opt/igneum/v4/bin/igneumd.new /opt/igneum/v4/bin/igneumd From 14e02301140142fafc9a2960b2fe8b0c01107dcb Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:19:50 +0100 Subject: [PATCH 20/45] release 0.3.14: the recovery on the shipped binary (the snapshot flag, the three traps), the two new items for the fix Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/plans/release-0.3.14.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/plans/release-0.3.14.md b/docs/plans/release-0.3.14.md index e41f1507a..fd3ac0bdb 100644 --- a/docs/plans/release-0.3.14.md +++ b/docs/plans/release-0.3.14.md @@ -17,7 +17,10 @@ devnet's pruning point had moved from bb45cf0d (DAA 45,537) to eb2a5d70 (DAA 88, |---|---|---| | A deep reorg never replays from genesis on a pruned node: the follower unwinds through its persisted generations, else requests a peer's snapshot (protocol 16); a good snapshot is never overwritten by a tip-0 one | the proving agent's fix branch off bb43e9a8 | (pending) | | A moved pruning point does not strand the anchor: a node keeps executing from its persisted state; the anchor is for a node with nothing | the same | (pending) | -| The six version files | (the bump commit) | | +| A flag file (`--igneum-exec-snapshot`) that cannot be read or whose sha does not match fails loudly at start (on the seed, as user igneum, a file under /root was ignored silently and the node replayed genesis, 16:16Z) | the proving agent's branch | (pending) | +| The p2p snapshot path refuses a snapshot at or below the node's own tip, below the restart, or at tip 0 (the seed pulled and loaded a 2,629-byte tip-0 snapshot from a fleet box at 16:16:30Z) | the same | (pending) | +| The hands' and the seed's restart scripts: `IGNEUMD_EXEC_SNAPSHOT` / `IGNEUMD_EXEC_SNAPSHOT_FILE` (the flag; on the seed the file installed for the igneum user under `/var/lib/igneum-v4`, the quoted `EXTRA_ARGS`) and `IGNEUM_EXEC_RESET` (the persisted exec files deleted after the stop); node 1 on its own eth port 26791 | 32e004f, 0585b9e, db1205b, dd9044e, 08ddee7 (this branch; `release-0.3.13` carries the 26791 line only) | in | +| The six version files | 4b25760 | in | No re-pin of the anchor (the coordinator's decision). If the fix is code-only the object is CARRIED OVER (the thirteen-field one, digest b18ed271... unchanged) and this is ONE publish; if a field changes, the 0.3.12 two-publish shape. @@ -38,4 +41,16 @@ object is carried over). ## 4. The rollout (to fill at the go) +## 4a. The recovery on the shipped binary (16:11 to 16:18Z), before this cut + +The proving agent's export of node 1's copy (13:45Z: tip chain block 130,272, 4,612 blocks below the fork point, 114,830,936 bytes, sha256 +ac101f13576179fd7d7f5e8ee902c9a7b6cc47730e3a3c069f389f0ca46d9221) loaded through `--igneum-exec-snapshot=<file>,0x<sha>` after the persisted +tip-0 files were deleted: the observer 16:11:18Z (`startedFrom snapshot`, `eth_blockNumber` 136,415 at 16:12:03Z and climbing, blocked null, +paidShards 1,482, paidWei 1825.699240038 IGN), node 1 16:11:32Z (136,459, on 26791), the seed 16:18:01Z (136,636). Three traps on the way: +the hands script's `pgrep -f` matched the caller's own shell when the pattern's text sat in the command (11 minutes lost; a rule: a +restart script's pattern never appears in the caller's command line, and `pgrep` excludes the caller); the seed's env file is sourced, so an +unquoted `EXTRA_ARGS` with a space ran the flag as a command and the unit crash-looped eight times (the seed off the air 16:14 to 16:16Z); +the unit's user could not read a file under `/root` and the flag did nothing, silently. The PCs and the fleet's boxes stay at 0 until this +cut or the same manual recovery (the fleet agent has the recipe; the PCs' app node takes no flag). + ## 5. Owed From ae88457770396bdaf28ce91b3a94747688b9c546 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:19:53 +0100 Subject: [PATCH 21/45] miner-ui-3: the card row is the product (audit, design, build) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit (docs/plans/miner-ui-3-audit.md, 27 screenshots): no money anywhere, three red n/a per Apple row, tuning split across two pages, 370 px of controls per card, Prove's 95 words and four zeros, Node's eleven numbers, the jobs table on every Updates page, fixes two taps away, no light mode, no layout under 900 px, developer lines on user surfaces. The design (docs/plans/miner-ui-3.md): four sections (Mine, Earnings, Prove, Settings); the card row carries the state word with its reason, MH/s, W with MH/W, £ a day at the user's electricity price, °C, Tune, the switch, and its details under a chevron (cap slider, identities, pin, telemetry, the tune table); the tune line per row (not tuned yet / tuning: step k of n with a bar / tuned N ago · point · next check <weekday> / measured only and why / pinned / stopped / fleet pause); Tune all; the goal (Efficiency, Balanced, Maximum) with its £ a day; Power control as one switch where it is the fix; the node as one line with Details; updates as one card; earnings money first, IGN second; proving as a tier sentence per card; every costly action confirmed in place (quit, change address, show key, trust mode), no dialogs; light and dark; a bottom tab bar under 720 px. The build: index.html, app.css, app.js rewritten on the same engine routes; the pure blocks keep their API and carry ember-tune's tuneNotice, tuneWord, toggle states and tune-line test (e9738dd) so the merge is clean; the update card is named Igneum Miner; the Rust side is untouched. node --test on the four UI files: 35 pass. Screenshots of the result: docs/plans/miner-ui-3/n00 to n26. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- app/igneum-app/ui/app.css | 398 +++++---- app/igneum-app/ui/app.js | 818 ++++++++++++------ app/igneum-app/ui/index.html | 428 +++++---- app/igneum-app/ui/tune-line.test.mjs | 38 + app/igneum-app/ui/update-card.test.mjs | 2 +- app/igneum-app/ui/view.test.mjs | 129 ++- docs/plans/miner-ui-3-audit.md | 283 ++++++ docs/plans/miner-ui-3.md | 277 ++++++ docs/plans/miner-ui-3/00-welcome.png | Bin 0 -> 210716 bytes docs/plans/miner-ui-3/01-setup-gpu.png | Bin 0 -> 90836 bytes docs/plans/miner-ui-3/02-setup-address.png | Bin 0 -> 150044 bytes docs/plans/miner-ui-3/03-setup-key.png | Bin 0 -> 181445 bytes docs/plans/miner-ui-3/04-mine-live.png | Bin 0 -> 246900 bytes docs/plans/miner-ui-3/05-prove-live.png | Bin 0 -> 313935 bytes docs/plans/miner-ui-3/06-rewards-live.png | Bin 0 -> 239554 bytes docs/plans/miner-ui-3/07-node-live.png | Bin 0 -> 260432 bytes docs/plans/miner-ui-3/08-updates-live.png | Bin 0 -> 363854 bytes docs/plans/miner-ui-3/09-settings-live.png | Bin 0 -> 346174 bytes docs/plans/miner-ui-3/10-logs-drawer-live.png | Bin 0 -> 417869 bytes .../plans/miner-ui-3/11-update-card-ready.png | Bin 0 -> 384201 bytes .../12-strip-update-downloading.png | Bin 0 -> 226178 bytes .../plans/miner-ui-3/13-strip-job-running.png | Bin 0 -> 219992 bytes docs/plans/miner-ui-3/14-strip-job-failed.png | Bin 0 -> 238507 bytes docs/plans/miner-ui-3/15-mine-nvidia-rig.png | Bin 0 -> 281739 bytes .../miner-ui-3/16-settings-nvidia-rig.png | Bin 0 -> 329298 bytes docs/plans/miner-ui-3/17-mine-syncing.png | Bin 0 -> 233490 bytes docs/plans/miner-ui-3/18-node-syncing.png | Bin 0 -> 262145 bytes docs/plans/miner-ui-3/19-mine-nodedown.png | Bin 0 -> 235202 bytes docs/plans/miner-ui-3/20-mine-nocards.png | Bin 0 -> 226265 bytes docs/plans/miner-ui-3/21-node-clock.png | Bin 0 -> 289822 bytes docs/plans/miner-ui-3/22-narrow-900-mine.png | Bin 0 -> 166996 bytes .../miner-ui-3/23-narrow-900-settings.png | Bin 0 -> 204113 bytes docs/plans/miner-ui-3/24-phone-mine.png | Bin 0 -> 161410 bytes docs/plans/miner-ui-3/25-phone-settings.png | Bin 0 -> 151675 bytes .../plans/miner-ui-3/26-light-scheme-mine.png | Bin 0 -> 248047 bytes docs/plans/miner-ui-3/n00-welcome.png | Bin 0 -> 175489 bytes docs/plans/miner-ui-3/n01-setup-gpu.png | Bin 0 -> 75343 bytes docs/plans/miner-ui-3/n02-setup-address.png | Bin 0 -> 125465 bytes docs/plans/miner-ui-3/n03-setup-key.png | Bin 0 -> 158858 bytes docs/plans/miner-ui-3/n04-mine-mac.png | Bin 0 -> 229366 bytes docs/plans/miner-ui-3/n05-mine-rig.png | Bin 0 -> 298976 bytes docs/plans/miner-ui-3/n06-mine-rig-tuning.png | Bin 0 -> 273885 bytes .../miner-ui-3/n07-mine-rig-tuned-open.png | Bin 0 -> 318555 bytes docs/plans/miner-ui-3/n08-mine-node-open.png | Bin 0 -> 268944 bytes docs/plans/miner-ui-3/n09-earnings.png | Bin 0 -> 192654 bytes docs/plans/miner-ui-3/n10-earnings-change.png | Bin 0 -> 233804 bytes docs/plans/miner-ui-3/n11-prove.png | Bin 0 -> 225216 bytes docs/plans/miner-ui-3/n12-settings.png | Bin 0 -> 230442 bytes docs/plans/miner-ui-3/n13-settings-lower.png | Bin 0 -> 233203 bytes docs/plans/miner-ui-3/n14-quit-ask.png | Bin 0 -> 245953 bytes docs/plans/miner-ui-3/n15-mine-paused.png | Bin 0 -> 221213 bytes docs/plans/miner-ui-3/n16-mine-syncing.png | Bin 0 -> 227504 bytes docs/plans/miner-ui-3/n17-mine-clock.png | Bin 0 -> 239188 bytes docs/plans/miner-ui-3/n18-update-card.png | Bin 0 -> 354863 bytes docs/plans/miner-ui-3/n19-strip-job.png | Bin 0 -> 236256 bytes docs/plans/miner-ui-3/n20-logs.png | Bin 0 -> 407474 bytes docs/plans/miner-ui-3/n21-narrow-900-mine.png | Bin 0 -> 146461 bytes docs/plans/miner-ui-3/n22-phone-mine.png | Bin 0 -> 127957 bytes docs/plans/miner-ui-3/n23-phone-settings.png | Bin 0 -> 175392 bytes docs/plans/miner-ui-3/n24-light-mine.png | Bin 0 -> 304807 bytes docs/plans/miner-ui-3/n25-light-settings.png | Bin 0 -> 236862 bytes .../miner-ui-3/n26-mine-measured-mac.png | Bin 0 -> 272090 bytes 62 files changed, 1707 insertions(+), 666 deletions(-) create mode 100644 docs/plans/miner-ui-3-audit.md create mode 100644 docs/plans/miner-ui-3.md create mode 100644 docs/plans/miner-ui-3/00-welcome.png create mode 100644 docs/plans/miner-ui-3/01-setup-gpu.png create mode 100644 docs/plans/miner-ui-3/02-setup-address.png create mode 100644 docs/plans/miner-ui-3/03-setup-key.png create mode 100644 docs/plans/miner-ui-3/04-mine-live.png create mode 100644 docs/plans/miner-ui-3/05-prove-live.png create mode 100644 docs/plans/miner-ui-3/06-rewards-live.png create mode 100644 docs/plans/miner-ui-3/07-node-live.png create mode 100644 docs/plans/miner-ui-3/08-updates-live.png create mode 100644 docs/plans/miner-ui-3/09-settings-live.png create mode 100644 docs/plans/miner-ui-3/10-logs-drawer-live.png create mode 100644 docs/plans/miner-ui-3/11-update-card-ready.png create mode 100644 docs/plans/miner-ui-3/12-strip-update-downloading.png create mode 100644 docs/plans/miner-ui-3/13-strip-job-running.png create mode 100644 docs/plans/miner-ui-3/14-strip-job-failed.png create mode 100644 docs/plans/miner-ui-3/15-mine-nvidia-rig.png create mode 100644 docs/plans/miner-ui-3/16-settings-nvidia-rig.png create mode 100644 docs/plans/miner-ui-3/17-mine-syncing.png create mode 100644 docs/plans/miner-ui-3/18-node-syncing.png create mode 100644 docs/plans/miner-ui-3/19-mine-nodedown.png create mode 100644 docs/plans/miner-ui-3/20-mine-nocards.png create mode 100644 docs/plans/miner-ui-3/21-node-clock.png create mode 100644 docs/plans/miner-ui-3/22-narrow-900-mine.png create mode 100644 docs/plans/miner-ui-3/23-narrow-900-settings.png create mode 100644 docs/plans/miner-ui-3/24-phone-mine.png create mode 100644 docs/plans/miner-ui-3/25-phone-settings.png create mode 100644 docs/plans/miner-ui-3/26-light-scheme-mine.png create mode 100644 docs/plans/miner-ui-3/n00-welcome.png create mode 100644 docs/plans/miner-ui-3/n01-setup-gpu.png create mode 100644 docs/plans/miner-ui-3/n02-setup-address.png create mode 100644 docs/plans/miner-ui-3/n03-setup-key.png create mode 100644 docs/plans/miner-ui-3/n04-mine-mac.png create mode 100644 docs/plans/miner-ui-3/n05-mine-rig.png create mode 100644 docs/plans/miner-ui-3/n06-mine-rig-tuning.png create mode 100644 docs/plans/miner-ui-3/n07-mine-rig-tuned-open.png create mode 100644 docs/plans/miner-ui-3/n08-mine-node-open.png create mode 100644 docs/plans/miner-ui-3/n09-earnings.png create mode 100644 docs/plans/miner-ui-3/n10-earnings-change.png create mode 100644 docs/plans/miner-ui-3/n11-prove.png create mode 100644 docs/plans/miner-ui-3/n12-settings.png create mode 100644 docs/plans/miner-ui-3/n13-settings-lower.png create mode 100644 docs/plans/miner-ui-3/n14-quit-ask.png create mode 100644 docs/plans/miner-ui-3/n15-mine-paused.png create mode 100644 docs/plans/miner-ui-3/n16-mine-syncing.png create mode 100644 docs/plans/miner-ui-3/n17-mine-clock.png create mode 100644 docs/plans/miner-ui-3/n18-update-card.png create mode 100644 docs/plans/miner-ui-3/n19-strip-job.png create mode 100644 docs/plans/miner-ui-3/n20-logs.png create mode 100644 docs/plans/miner-ui-3/n21-narrow-900-mine.png create mode 100644 docs/plans/miner-ui-3/n22-phone-mine.png create mode 100644 docs/plans/miner-ui-3/n23-phone-settings.png create mode 100644 docs/plans/miner-ui-3/n24-light-mine.png create mode 100644 docs/plans/miner-ui-3/n25-light-settings.png create mode 100644 docs/plans/miner-ui-3/n26-mine-measured-mac.png diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 49be864f0..bde54a016 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -1,8 +1,9 @@ -/* Igneum Miner dashboard (miner-ui-2). The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, - ash; Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. - One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), one accent (ember), used by every page. - Layout: a rail on the left (six sections), a thin top bar, the status strip under it, the page in the middle, the - log drawer at the bottom. The window lays out from 900 x 600 up (the hosts say the same minimum). +/* Igneum Miner dashboard (miner-ui-3). The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, + ash; Unbounded for the page title, the big button and the hero numbers; IBM Plex Sans for words; IBM Plex Mono for + labels, units and small numbers. Fonts ship in the binary. One type scale (--t-*), one spacing scale (--s-*), one + accent (ember). Dark by default, light under prefers-color-scheme or [data-theme=light]. Layout: a rail on the left + (four sections), a thin top bar, the status strip under it, the page in the middle, the log drawer at the bottom. + The window lays out from 900 x 600 (the hosts' minimum); under 720 px the rail becomes a bottom tab bar. Nothing here is newer than 2022 CSS (the WebView2 host). */ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')} @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')} @@ -14,36 +15,42 @@ @font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')} :root{ - /* colour */ - --obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; - --ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--node-blue:#7FA7C9;--nvidia:#8BE37A;--rail-bg:#111114; + /* colour, dark */ + --obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; + --ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C; /* type scale */ - --t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:16px;--t-2xl:18px;--t-num:26px;--t-h3:16px;--t-h2:32px;--t-h1:52px; + --t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px; /* spacing scale */ - --s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:28px;--s-7:40px; - --gutter:var(--s-6);--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:var(--s-5);--gap-tile:var(--s-3); + --s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px; + --gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4); --sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; --top:60px;--bottom:0px;--drawer-h:260px;--rail:196px} +@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){ + --obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF; + --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}} +:root[data-theme="light"]{ + --obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF; + --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F} *{box-sizing:border-box} html,body{height:100%} -body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-lg);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums} +body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums} .mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums} .dim{color:var(--ash)} h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance} h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em} h2{font-weight:700;font-size:var(--t-h2)} -h3{font-weight:700;font-size:var(--t-h3)} +h3{font-weight:700;font-size:var(--t-xl)} p{margin:0} a{color:inherit} button{font:inherit;color:inherit} input,textarea{font-variant-numeric:tabular-nums} -.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap} +.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.16em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap} .eyebrow.ember{color:var(--ember)} [hidden]{display:none !important} ::selection{background:rgba(242,84,27,.45)} /* buttons */ -.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:var(--t-lg);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:40px;padding:8px 18px;border-radius:10px;font-weight:600;font-size:var(--t-md);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap} .btn:hover{transform:translateY(-1px);border-color:var(--ash)} .btn:active{transform:none} .btn:disabled{opacity:.4;cursor:default;transform:none} @@ -68,9 +75,9 @@ body.mac .rail-brand{padding-top:30px} .rail-nav{display:flex;flex-direction:column;gap:3px} .nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease} .nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease} -.nav:hover{background:rgba(255,255,255,.04);color:var(--bone)} +.nav:hover{background:var(--hover);color:var(--bone)} .nav:hover svg{color:var(--ink-2)} -.nav.on{background:var(--ember-12);border-color:rgba(242,84,27,.28);color:var(--bone);font-weight:600} +.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600} .nav.on svg{color:var(--ember)} .nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)} .nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)} @@ -85,7 +92,7 @@ body.mac .rail-brand{padding-top:30px} .rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em} /* top bar */ -.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} +.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:var(--top-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line);z-index:20;-webkit-app-region:drag} .top button,.top .pill{-webkit-app-region:no-drag} body.mac:not(.has-rail) .top{padding-left:92px} body.has-rail .top{left:var(--rail)} @@ -96,21 +103,17 @@ body.has-rail .top{left:var(--rail)} .page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap} .page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} .top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0} -.top-status{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:380px} -.top-status .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)} .pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center} .pill.on{color:var(--molten);border-color:var(--molten-40)} .pill.warn{color:var(--ember)} .dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px} -.dot.small{width:7px;height:7px;flex-basis:7px} .on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite} -.warn .dot{background:var(--ember);animation:none} +.warn .dot,.dot.bad{background:var(--ember);animation:none} @keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}} @media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}} /* the status strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's - top moves once per change with a 150 ms transition (layoutStrip), never per poll. Tones: default molten (running, - available, done), bad ember (failed, clock block, urgent). */ + top moves once per change with a 150 ms transition (layoutStrip), never per poll. */ .notices{position:fixed;top:var(--top);left:0;right:0;z-index:19} body.has-rail .notices{left:var(--rail)} .notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)} @@ -124,11 +127,18 @@ body.has-rail .notices{left:var(--rail)} .notice-close:hover{color:var(--bone);border-color:var(--line-2)} .notice.update-urgent .notice-close{color:#fff} .notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px} -.notice .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-3px} +.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px} .notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} +/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */ +.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none} +body.has-rail .ask-wrap{left:var(--rail)} +.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease} +.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0} +.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none} + /* screens and pages */ -main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease} +main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease} @media (prefers-reduced-motion:reduce){main{transition:none}} body.has-rail main{left:var(--rail)} body.drawer-open main{bottom:var(--drawer-h)} @@ -140,15 +150,14 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease} /* welcome */ -.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-7) 0 48px} -.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px} -.coin-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite} -.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))} +.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-6) 0 48px} +.mark-wrap{position:relative;width:120px;height:120px;border-radius:26px;background:#0C0C0E;display:flex;align-items:center;justify-content:center;margin-bottom:6px;box-shadow:0 20px 50px rgba(242,84,27,.25)} +.mark-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite;z-index:-1} @keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}} -@media (prefers-reduced-motion:reduce){.coin-wrap::before{animation:none}} -.lead{font-size:var(--t-2xl);color:var(--ink-2);max-width:54ch} -.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left} -.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} +@media (prefers-reduced-motion:reduce){.mark-wrap::before{animation:none}} +.lead{font-size:var(--t-xl);color:var(--ink-2);max-width:54ch} +.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3);width:100%;max-width:860px;margin-top:10px;text-align:left} +.tile{background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;display:flex;flex-direction:column;gap:6px;min-width:0} .tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)} .tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25} .tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45} @@ -162,6 +171,9 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .note{font-size:var(--t-base);color:var(--ash);line-height:1.5} .note.small{font-size:var(--t-sm);word-break:break-all} .help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch} +.line-text{font-size:var(--t-md);color:var(--ink-2);line-height:1.5} +.line-text.ok{color:var(--molten)} +.line-text.bad{color:var(--ember)} .cards{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-2)} .card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} .card.detecting{display:flex;align-items:center;gap:18px} @@ -169,11 +181,13 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .card.detecting .s{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-1)} .spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px} @keyframes spin{to{transform:rotate(360deg)}} +.top-gap{margin-top:var(--s-4)} +.indent{margin-left:52px} /* GPU rows (first run) */ .gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap} .gpu-row.off{opacity:.72} -.badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 44px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} .badge.apple{color:var(--bone)} .badge.nvidia{color:var(--nvidia)} .badge.amd{color:var(--ember)} @@ -183,17 +197,15 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .kind.discrete,.kind.apple{color:var(--molten);border-color:var(--molten-40)} .kind.external{color:var(--bone)} .gpu-row .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} -.gpu-row .meta b{color:var(--ink-2);font-weight:500} .gpu-row .reason{font-size:var(--t-sm);color:var(--ash)} .gpu-row .msg{font-size:var(--t-sm);color:var(--ember)} .gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto} -/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */ -.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5} -.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)} +.gpu-row.removed,.gpu-line.removed{opacity:.5} +.gpu-row.unusable .name,.gpu-line.unusable .name{color:var(--ember)} -/* the Mine page: the big switch and the three numbers */ -.hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)} -.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:var(--tile-r);border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0} +/* the Mine page: the big button and the live total */ +.hero-row{display:grid;grid-template-columns:minmax(220px,1fr) 2fr;gap:var(--s-3)} +.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:14px;border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0} .toggle-big:hover{background:var(--ember-hi);border-color:var(--ember-hi);transform:translateY(-1px)} .toggle-big:disabled{opacity:.45;cursor:default;transform:none} .toggle-big .ring{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:rgba(12,12,14,.18);margin-bottom:6px} @@ -201,64 +213,50 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .toggle-big .tl{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap} .toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} .toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)} -.toggle-big.stop:hover{border-color:var(--ash);background:#1b1b20} +.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)} .toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)} .toggle-big.stop .ts{color:var(--molten);opacity:1} -.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} -.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))} -.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap} -.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em} -.cell.ember .v{color:var(--ember)} -.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em} -.cell .v.state{text-transform:capitalize;font-size:22px} -.cell .v.small{font-size:18px} -.cell .v.dim{color:var(--ash)} -.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.cell.ok .v.state{color:var(--molten)} -.cell.bad .v.state{color:var(--ember)} -.cell.bad .s{color:var(--ember)} -.grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);align-items:start} +.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0} +.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)} +.tot:first-child{padding-left:0;border-left:0} +.tot .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.01em} +.tot .v.ember{color:var(--ember)} +.tot .v.ask-price{font-family:var(--sans);font-size:var(--t-md);font-weight:600;color:var(--molten);text-decoration:underline;text-underline-offset:3px;cursor:pointer;padding:9px 0 8px} +.tot .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;text-transform:uppercase;color:var(--ash);margin-top:4px} +.tot .s{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px} .card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap} -.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)} -.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} -#dag{display:block;width:100%;height:170px;border-radius:12px;background:var(--obsidian)} -.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)} -.legend span{display:inline-flex;align-items:center;gap:var(--s-2)} -.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)} -.sw.ember{background:var(--ember);border-color:var(--ember)} -.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} -.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0} +.head-right{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;min-width:0} +.stats{font-size:var(--t-sm);color:var(--ash)} +#dag{display:block;width:100%;height:110px;border-radius:10px;background:var(--obsidian);margin-bottom:var(--s-3)} .empty{color:var(--ash);font-size:var(--t-base);padding:10px 0} .empty.err{color:var(--ember)} .kv{display:flex;flex-direction:column} -.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)} +.kv>div{display:grid;grid-template-columns:120px auto 1fr;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)} .kv>div:last-child{border-bottom:0} .kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap} -.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right} +.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.kv .v.ok{color:var(--molten)} .kv .v.warn{color:var(--ember)} +.kv .v.dim{color:var(--ash)} +.kv .m{font-size:var(--t-sm);color:var(--ash);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .card .note{margin-top:10px} .card .help+.help{margin-top:6px} -.big-word{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.2;margin:2px 0 8px;word-break:break-word} -.big-word.ok{color:var(--molten)} -.big-word.warn{color:var(--ember)} -.big-word.dim{color:var(--ash)} -.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:260px;overflow:auto} -.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline} +.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2)} +.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:7px 0;align-items:baseline} .feed>div:last-child{border-bottom:0} -.feed>div>span:first-child{min-width:0} +.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)} .feed .k{color:var(--molten);margin-right:6px} -.feed .k.error{color:var(--ember)} -.feed .k.warn{color:var(--ember)} -.feed .k.block{color:var(--ember)} -.feed .k.build{color:var(--ink-2)} +.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)} +.feed .k.build,.feed .k.info{color:var(--ash)} -/* the GPU rows on the Mine page: badge, name, numbers, the switch */ +/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron; + the tune line under; the details under that */ .gpu-list{display:flex;flex-direction:column;gap:var(--s-2)} -.gpu-line{display:grid;grid-template-columns:44px minmax(160px,1.4fr) repeat(3,minmax(84px,.7fr)) 48px;align-items:center;gap:var(--s-4);padding:12px 14px;border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);min-width:0} +.gpu-line{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0} .gpu-line.off{opacity:.62} -.gpu-line .badge{width:44px;height:44px;flex-basis:44px;border-radius:12px} +.gpu-line.open{border-color:var(--line-2)} +.gl-main{display:grid;grid-template-columns:44px minmax(150px,1.2fr) auto auto;align-items:center;gap:var(--s-4);padding:12px 14px} .gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px} .gpu-line .name{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap} .gpu-line .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} @@ -267,46 +265,75 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .gpu-line .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px} .gpu-line .msg{font-size:var(--t-sm);color:var(--ember);line-height:1.4} .gpu-line .msg.dim{color:var(--ash)} -.gpu-line .num{display:flex;flex-direction:column;gap:2px;min-width:0} -.gpu-line .num .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap} -.gpu-line .num .v{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:5px} -.gpu-line .num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em} -.gpu-line .num .v.hot{color:var(--ember)} -.gpu-line .num .v.warm{color:var(--molten)} -.gpu-line .num .v.na{color:var(--ash);font-weight:500;font-size:var(--t-md)} +.nums{display:flex;gap:var(--s-5);justify-content:flex-end;align-items:flex-start;min-width:0;flex-wrap:wrap} +.num{display:flex;flex-direction:column;gap:1px;min-width:56px;align-items:flex-end;text-align:right} +.num .v{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.15;white-space:nowrap;display:flex;align-items:baseline;gap:4px} +.num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.num .k{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap} +.num .v.hot{color:var(--ember)} +.num .v.warm{color:var(--molten)} .gpu-line.on .num.hash .v{color:var(--ember)} -.gpu-line .switch{padding:0;justify-self:end} - -/* the Settings page: per-card controls */ -.set-cards{display:flex;flex-direction:column;gap:var(--s-3);margin-bottom:var(--s-3)} -.set-card{border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-width:0} -.set-card .head{display:flex;align-items:center;gap:10px;flex-wrap:wrap} -.set-card .head .name{font-family:var(--head);font-weight:700;font-size:var(--t-md)} -.set-card .head .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)} -.set-card .ctl{display:grid;grid-template-columns:150px 1fr auto;align-items:center;gap:var(--s-3)} -.set-card .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500} -.set-card .ctl .pv{font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);min-width:110px;text-align:right;white-space:nowrap} -.set-card input[type=range]{width:100%;accent-color:var(--ember);margin:0} -.set-card .ctl .help{grid-column:1 / -1;margin-top:-4px} -.set-card .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center} -.set-card .line b{color:var(--ink-2);font-weight:500} -.set-card .line.ok{color:var(--molten)} -.set-card .line.hot{color:var(--ember)} -.set-card .line.on{color:var(--molten)} -.set-card .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px} +.acts{display:flex;align-items:center;gap:var(--s-3);justify-self:end} +.acts .switch{padding:0} +.chev-btn{width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--ash)} +.chev-btn:hover{border-color:var(--line-2);color:var(--bone)} +.chev{width:9px;height:9px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg) translateY(-2px);transition:transform .15s ease;display:inline-block} +.open .chev,.chev-btn.open .chev{transform:rotate(225deg) translateY(-2px)} +.gl-tune{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);border-top:1px solid var(--line)} +.gl-tune .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +.gl-tune .n{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.gl-tune.running{color:var(--molten)} +.gl-tune.stopped,.gl-tune.paused{color:var(--ember)} +.gl-tune .bar{flex-basis:100%;height:3px;border-radius:2px;background:var(--line);overflow:hidden;display:block} +.gl-tune .bar b{display:block;height:100%;background:var(--molten);transition:width .5s linear} +.gl-details{padding:12px 14px 14px 72px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:10px;user-select:text;-webkit-user-select:text} +.gl-details .ctl{display:grid;grid-template-columns:120px 1fr auto;align-items:center;gap:var(--s-3)} +.gl-details .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500} +.gl-details .ctl .pv{font-size:var(--t-sm);color:var(--ink-2);min-width:100px;text-align:right;white-space:nowrap} +.gl-details input[type=range]{width:100%;accent-color:var(--ember);margin:0} +.gl-details .ctl .help{grid-column:1 / -1;margin-top:-4px} +.gl-details .ctl .help:first-of-type{grid-column:2} +.gl-details .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center} +.gl-details .line b{color:var(--ink-2);font-weight:500} +.gl-details .line.ok{color:var(--molten)} +.gl-details .line.hot{color:var(--ember)} +.gl-details .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px} +.curve{font-size:var(--t-sm);font-family:var(--mono);border-collapse:collapse;width:100%} +.curve th{text-align:left;font-weight:500;color:var(--ash);font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:3px 8px 3px 0} +.curve td{padding:3px 8px 3px 0;border-top:1px solid var(--line);color:var(--ink-2)} +.curve tr.marked td{color:var(--ash);text-decoration:line-through} .ids{display:flex;align-items:center;gap:6px;flex:0 0 auto;justify-self:end} .ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} .ids button:hover{border-color:var(--ash)} .ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:var(--t-base);user-select:text;-webkit-user-select:text} .ids input:focus{outline:none;border-color:var(--ember)} .ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} -.lead-card .lead-row,.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)} -.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px} -.lead-text h3{font-size:var(--t-2xl)} + +/* the tuning card: the goal, its consequence, the schedule, Power control when it is the fix */ +.goal-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap;margin-bottom:var(--s-3)} +.seg{display:inline-flex;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:3px;gap:2px} +.seg-b{border:0;background:transparent;color:var(--ash);font-size:var(--t-base);font-weight:500;padding:6px 14px;border-radius:8px;cursor:pointer;transition:background .15s ease,color .15s ease;white-space:nowrap} +.seg-b:hover{color:var(--bone)} +.seg-b.on{background:var(--graphite);color:var(--bone);box-shadow:0 1px 3px rgba(0,0,0,.25);font-weight:600} +.goal-line{font-size:var(--t-base);color:var(--ink-2);min-width:0} + +/* the node line and the disclosures */ +.disclose{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);width:100%;border:0;background:transparent;padding:0;cursor:pointer;text-align:left;color:var(--bone);font-size:var(--t-md);font-weight:500;min-height:32px} +.disclose:hover{color:var(--bone)} +.disclose .chev{color:var(--ash);flex:0 0 auto;margin-right:4px} +.disclose-right{display:flex;align-items:center;gap:var(--s-3);min-width:0} +.disclose-right .dim{font-size:var(--t-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:52ch} +.node-line{display:inline-flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.node-card.bad .node-line{color:var(--ember)} +.card .disclose+.disclose,.card .details+.disclose,.card .help+.disclose,.card .addr-big+.help+.disclose{margin-top:var(--s-3);padding-top:var(--s-3);border-top:1px solid var(--line)} +.details{padding-top:var(--s-3);display:flex;flex-direction:column;gap:var(--s-3)} .switch-list{display:flex;flex-direction:column;gap:3px;font-size:var(--t-sm);color:var(--ash);margin-top:8px} .switch-list span{display:flex;justify-content:space-between;gap:12px} .switch-list span.past{opacity:.55} .switch-list span.next{color:var(--molten)} +.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)} +.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px} +.lead-text h3{font-size:var(--t-xl)} .addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text;margin-bottom:10px} .addr-big span{flex:1;min-width:0;color:var(--molten)} .card.adv{padding:0} @@ -317,22 +344,35 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .card.adv summary .eyebrow{margin-left:0} .card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)} .card.adv>:last-child{margin-bottom:var(--card-pad)} -.job-history table{margin-top:var(--s-2)} table{border-collapse:collapse;width:100%;font-size:var(--t-base)} -.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase} -.job-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top} -.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)} -.job-history tr.running td{color:var(--molten)} -.clock-card{border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)} -.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)} +#s-jobs-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase} +#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top} +#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)} +#s-jobs-history tr.running td{color:var(--molten)} +.clock-card{border:1px solid var(--ember-40);background:var(--ember-12);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)} +.clock-card.warn{border-color:var(--molten-40);background:var(--molten-10)} .clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45} .clock-card .note{margin-top:0} +/* earnings: money first */ +.money{display:flex;flex-direction:column;margin-bottom:var(--s-2)} +.money-row{display:flex;align-items:baseline;gap:var(--s-3);padding:8px 0;border-bottom:1px solid var(--line);flex-wrap:wrap} +.money-row:last-child{border-bottom:0} +.money-row .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;letter-spacing:-.01em;min-width:200px} +.money-row .v.small{font-size:var(--t-num)} +.money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0} + +/* prove */ +.tier-list{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column;gap:6px;font-size:var(--t-md);color:var(--ink-2)} +.tier-list li{display:flex;gap:10px;align-items:baseline} +.tier-list li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px} +#pv-line{margin-top:var(--s-3)} + /* address options */ .options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px} .option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} .option:hover{border-color:var(--line-2)} -.option.on{border-color:var(--ember);background:#1A1614} +.option.on{border-color:var(--ember);background:var(--ember-12)} .option input[type=radio]{position:absolute;opacity:0;width:0;height:0} .option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative} .option.on .radio{border-color:var(--ember)} @@ -342,15 +382,15 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)} .option .s{font-size:var(--t-md);color:var(--ash);line-height:1.5} .tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid var(--molten-40);border-radius:999px;padding:2px 8px} .addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text} +.addr-input.short{width:100px;flex:0 0 100px} .addr-input:focus{outline:none;border-color:var(--ember)} .option:not(.on) .addr-input{display:none} .err{font-size:var(--t-base);color:var(--ember)} -/* the update card (app.js, UpdateCard; the wallet carries the same block): the mark with its ring, the name, one - line, up to three note lines, the size, Install now and Later. Over the key sheet and the pages. */ -.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease} +/* the update card (app.js, UpdateCard; the wallet carries the same block) */ +.upd-wrap{position:fixed;inset:0;z-index:35;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease} @keyframes fade{from{opacity:0}to{opacity:1}} -.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none} +.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px var(--shadow),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none} .upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none} .upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)} .upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} @@ -381,8 +421,8 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)} @media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} /* sheet (the key) */ -.sheet-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} -.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} +.sheet-wrap{position:fixed;inset:0;z-index:30;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} +.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px var(--shadow);animation:rise .3s ease} .sheet .sub{font-size:var(--t-lg);color:var(--ink-2)} .field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px} .field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} @@ -398,13 +438,13 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)} /* rows, switches */ .row{display:flex;gap:10px;align-items:center;min-width:0} -.row.between{justify-content:space-between} .row.wrap{flex-wrap:wrap} .row .addr-input{margin-top:0;min-width:0} -.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:8px 0 2px;line-height:1.35} +.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4} +.switch+.switch{border-top:1px solid var(--line)} .switch input{position:absolute;opacity:0;width:0;height:0} -.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease} -.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease} +.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px} +.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease} .switch input:checked+.track{background:var(--ember)} .switch input:checked+.track::after{transform:translateX(18px)} .switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px} @@ -412,10 +452,12 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)} .switch.lg .track{width:52px;height:30px;flex-basis:52px} .switch.lg .track::after{width:24px;height:24px} .switch.lg input:checked+.track::after{transform:translateX(22px)} -.switch+.help{margin-bottom:6px} +.sw-text{min-width:0} +.sw-text b{font-weight:600} +.sw-text .dim{font-size:var(--t-base)} /* log drawer */ -.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)} +.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px var(--shadow)} body.has-rail .drawer{left:var(--rail)} body.drawer-open .drawer{height:var(--drawer-h)} body.drawer-drag .drawer{transition:none} @@ -450,8 +492,8 @@ body.drawer-drag main{pointer-events:none} .log-ruler .t1{right:0;border-radius:0 0 0 6px} .log-ruler .mk{position:absolute;top:4px;bottom:4px;width:2px;margin-left:-1px;background:var(--ember);opacity:.9;pointer-events:none} .log-ruler .mk.swap{background:var(--molten)} -.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(244,241,236,.14);border-left:1px solid rgba(244,241,236,.35);border-right:1px solid rgba(244,241,236,.35);min-width:3px;pointer-events:none} -.log-ruler:hover .win{background:rgba(244,241,236,.2)} +.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(127,127,127,.14);border-left:1px solid rgba(127,127,127,.35);border-right:1px solid rgba(127,127,127,.35);min-width:3px;pointer-events:none} +.log-ruler:hover .win{background:rgba(127,127,127,.2)} .log-view{position:relative;flex:1;overflow:auto;overscroll-behavior:contain;user-select:text;-webkit-user-select:text;contain:strict} .log-pad{position:relative;min-height:100%} .log{margin:0;position:absolute;top:0;left:0;right:0;padding:6px var(--gutter);font-size:var(--t-sm);line-height:19px;color:var(--ink-2);white-space:pre;will-change:transform} @@ -459,7 +501,7 @@ body.drawer-drag main{pointer-events:none} .log-view.wrap .log{position:relative;padding-bottom:12px} .log-view.wrap .log .ln{height:auto;white-space:pre-wrap;word-break:break-all;overflow:visible} .log .src{color:var(--ash)} -.log .src.node{color:var(--node-blue)} +.log .src.node{color:#7FA7C9} .log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4,.log .src.miner5,.log .src.miner6,.log .src.miner7,.log .src.miner8{color:var(--molten)} .log .src.app{color:var(--ember)} .log .src.watch{color:var(--ash)} @@ -467,22 +509,18 @@ body.drawer-drag main{pointer-events:none} .log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)} .log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px} .log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center} -.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px} -.log-jump:hover{background:#1c1a18;border-color:var(--molten)} +.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px} +.log-jump:hover{border-color:var(--molten)} -.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center} +.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px var(--shadow);max-width:min(90vw,520px);text-align:center} body.has-rail .toast{left:calc(50% + var(--rail) / 2)} body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} -/* narrow windows (the 900 px floor): the rail folds to icons with small labels, the hero row and the strips fold to - two columns, the two-column grid to one, the GPU rows drop a number */ +/* narrow windows: 1180 the hero wraps; 1000 the rail folds to icons; 900 the row's cells wrap; 720 a bottom tab bar */ @media (max-width:1180px){ - .hero-row{grid-template-columns:1fr 1fr} - .toggle-big{grid-row:span 1} - .strip{grid-template-columns:repeat(2,minmax(0,1fr))} - .strip.three{grid-template-columns:repeat(3,minmax(0,1fr))} - .grid2{grid-template-columns:1fr} - .top-status{max-width:220px} + .hero-row{grid-template-columns:1fr} + .totals{grid-template-columns:repeat(4,minmax(0,1fr))} + .nums{gap:var(--s-4)} } @media (max-width:1000px){ :root{--rail:76px;--gutter:var(--s-5)} @@ -497,29 +535,73 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} .rail-status{display:none} .rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap} .rail-version .chain{display:none} - .gpu-line{grid-template-columns:44px minmax(140px,1.4fr) repeat(2,minmax(80px,.7fr)) 48px} - .gpu-line .num.power{display:none} .page-sub{display:none} - .top-status{display:none} - .strip.three{grid-template-columns:repeat(2,minmax(0,1fr))} - .set-card .ctl{grid-template-columns:120px 1fr auto} + .gl-main{grid-template-columns:44px minmax(140px,1fr) auto} + .nums{grid-column:2;justify-content:flex-start} + .acts{grid-column:3;grid-row:1} + .gl-tune,.gl-details{padding-left:14px} + .kv>div{grid-template-columns:110px auto 1fr} } @media (max-width:860px){ .three{grid-template-columns:1fr} h1{font-size:40px} + .totals{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--s-3)} + .tot:nth-child(3){padding-left:0;border-left:0} + .money-row .v{min-width:0} } -/* short windows (the 600 px floor): tighter paddings, a lower hero, a smaller canvas, so the drawer always has room */ +@media (max-width:720px){ + :root{--rail:0px;--gutter:var(--s-4);--bottom:64px} + body.has-rail .rail{top:auto;bottom:0;left:0;right:0;width:auto;height:64px;flex-direction:row;align-items:center;border-right:0;border-top:1px solid var(--line);padding:0 var(--s-2)} + .rail-brand{display:none} + .rail-nav{flex-direction:row;flex:1;gap:0;justify-content:space-around} + .nav{min-height:56px;padding:6px 2px;font-size:10px;border-radius:10px;flex:1;max-width:120px} + .nav.on::before{display:none} + .rail-foot{margin-top:0;padding-top:0;border-top:0;flex-direction:row;gap:0} + .rail-foot .nav.small{min-height:56px;flex:0 0 auto;padding:6px 8px} + .rail-version{display:none} + body.has-rail .top,body.has-rail .notices,body.has-rail main,body.has-rail .drawer,body.has-rail .ask-wrap{left:0} + body.has-rail main{bottom:var(--bottom)} + body.has-rail .toast{left:50%;bottom:calc(var(--bottom) + 12px)} + body.drawer-open .drawer{bottom:var(--bottom)} + .ask-wrap{bottom:var(--bottom)} + .top{padding:0 var(--s-4)} + .page-title h1{font-size:17px} + .pill{min-width:0} + .hero-row{grid-template-columns:1fr} + .toggle-big{min-height:88px} + .gl-main{grid-template-columns:44px 1fr;gap:var(--s-3);padding:12px} + .nums{grid-column:1 / -1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s-2) var(--s-3);justify-items:start} + .acts{grid-column:1 / -1;grid-row:auto;justify-self:end} + .gpu-line .name{font-size:var(--t-base)} + .num{align-items:flex-start;text-align:left} + .acts{gap:var(--s-2)} + .gl-tune,.gl-details{padding-left:12px;padding-right:12px} + .gl-details .ctl{grid-template-columns:1fr;gap:6px} + .gl-details .ctl .help:first-of-type{grid-column:1} + .ids{justify-self:start} + .kv>div{grid-template-columns:96px 1fr;gap:4px var(--s-3)} + .kv .m{grid-column:1 / -1;white-space:normal} + .goal-row{align-items:flex-start;flex-direction:column} + .seg{width:100%} + .seg-b{flex:1;padding:6px 8px} + .indent{margin-left:0} + .disclose-right .dim{display:none} + .lead-row{flex-direction:column} + .step{padding:32px 0} + .money-row .v{font-size:var(--t-num)} + .drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)} + .log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)} +} +/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */ @media (max-height:700px){ - :root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)} + :root{--card-pad:18px;--gap:var(--s-3)} #screen-dashboard{padding:16px 0 20px} - #dag{height:140px} + #dag{height:90px} .hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)} - .coin-wrap{width:104px;height:104px} - .coin{width:104px;height:104px} + .mark-wrap{width:88px;height:88px} h1{font-size:40px} - .lead{font-size:var(--t-xl)} + .lead{font-size:var(--t-lg)} .step{padding:32px 0 32px} - .feed{max-height:200px} .drawer-head{padding-bottom:6px} .toggle-big{min-height:96px} } diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index f0c0d6c3e..d0453ed7a 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -1,19 +1,12 @@ -/* Igneum Miner dashboard. Talks to the engine on the same origin: api/state every second, api/log while the - drawer is open. Screens: welcome, cards, address (plus the one-time key sheet), then the dashboard with six pages behind a rail - (Mine, Prove, Rewards, Node, Updates, Settings; miner-ui-2). ?screen=<name> forces a screen, ?page=<name> a page (screenshots). Everything is relative to the token path the page was opened on. */ +/* Igneum Miner dashboard (miner-ui-3). Talks to the engine on the same origin: api/state every second, api/log while + the drawer is open. Screens: welcome, cards, address (plus the one-time key sheet), then the dashboard with four + pages behind a rail (Mine, Earnings, Prove, Settings). ?screen=<name> forces a screen, ?page=<name> a page, and + ?update=, ?job=, ?tune= show sample states (screenshots). Everything is relative to the token path the page was + opened on. The pure blocks (Notices, UpdateCard, View, TuneLine) run under node for the tests. */ -/* ---------- notices: what the one strip under the header says (pure; notices.test.mjs loads this block) ---------- - The state (src/state.rs) becomes at most one notice at a time, the most important first. Each notice has a key; - closing it hides that key until the state moves on (a new status, version or job). Levels, lowest number first: - 0 update installing, urgent or failed 1 job failed 2 clock 3 job running - 4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated - Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new - version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job - notice is for the dashboard. A card that appeared or went (hot-plug, src/hotplug.rs) is a level 5 notice for - 5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */ var Notices = (function () { 'use strict'; - var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; + var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, tuning: 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 }; var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300; function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; } @@ -120,8 +113,19 @@ var Notices = (function () { if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); } else { var c = clockNotice(s.clock); if (c) out.push(c); } if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0)); + var tn = tuneNotice(s.mining && s.mining.cards); + if (tn) out.push(tn); return out; } + // the one line while a card is being tuned: which card, which plan, the step, when mining is back + function tuneNotice(cards) { + var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); + if (!l.length) return null; + var c = l[0], plan = c.tune_plan === 'confirm' ? 'confirming the fleet prior' : c.tune_plan === 'baseline' ? 'measuring as it runs' : 'the full tune'; + var step = c.tune_steps > 0 ? ', step ' + (c.tune_step || 1) + ' of ' + c.tune_steps : ''; + var eta = c.tune_eta_s > 0 ? ' Mining again in ' + (c.tune_eta_s < 60 ? 'under a minute' : 'about ' + Math.round(c.tune_eta_s / 60) + ' min') + '.' : ''; + return notice('tuning', 'tuning:' + c.key, 'Tuning ' + c.name + ': ' + plan + step + '.' + eta, { tone: 'info', title: 'Ember Tune' }); + } // the lowest level wins; a dismissed key is skipped, so the next one shows in its place function pick(list, dismissed) { var best = null; @@ -132,11 +136,11 @@ var Notices = (function () { } return best; } - return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; + return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord }; })(); /* ---------- the update card (pure; update-card.test.mjs loads this block) ---------- - A centred card over the window for one update: the mark with a progress ring, "Igneum Ember 0.3.7", one line + A centred card over the window for one update: the mark with a progress ring, "Igneum Miner 0.3.14", one line (is available, is downloading, is ready to install, Installing, did not install), up to three lines of release notes with the rest behind "What changed", the size, Install now and Later. The same card, the same rules and the same words sit in the wallet (app/igneum-wallet/ui/update-card.js). @@ -149,7 +153,7 @@ var Notices = (function () { installing and failed show only on an open card (Install now was pressed here); the strip carries the rest */ var UpdateCard = (function () { 'use strict'; - var NAME = 'Igneum Ember', LINE_MAX = 70, LINES = 3, FIRST_S = 60; + var NAME = 'Igneum Miner', LINE_MAX = 70, LINES = 3, FIRST_S = 60; var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'later', label: 'Later' }; var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true }; function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } @@ -238,24 +242,29 @@ var UpdateCard = (function () { return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, FIRST_S: FIRST_S, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide }; })(); /* ---------- the pages' words (pure; view.test.mjs loads this block) ---------- - What each section says for a given state: the GPU row on Mine, the big start/stop button, the Node page's plain - lines, the next consensus switch, the Prove page's words, the dev-fee line. No DOM, no time of day: the caller - passes `now`. The sections and their order: Mine, Prove, Rewards, Node, Updates, Settings. */ + What each section says for a given state: the card row (every state word, the numbers, the money, the tune line), + the big start/stop button, the node line, the prove tier sentence per card, the goal's consequence, the dev-fee + line, the confirmations' words. No DOM, no time of day: the caller passes `now`. The sections (miner-ui-3): Mine, + Earnings, Prove, Settings. */ var View = (function () { 'use strict'; var PAGES = [ - { id: 'mine', title: 'Mine', sub: 'your GPUs, the hash rate, the blocks you found' }, - { id: 'prove', title: 'Prove', sub: 'turn blocks into proofs and get paid for each one' }, - { id: 'rewards', title: 'Rewards', sub: 'where the blocks pay, and the key that controls it' }, - { id: 'node', title: 'Node', sub: 'the chain on this machine' }, - { id: 'updates', title: 'Updates', sub: 'the app version and the jobs Igneum sent' }, - { id: 'settings', title: 'Settings', sub: 'power, start at login, remote jobs, the dev fee' } + { id: 'mine', title: 'Mine', sub: 'your cards, the rate, what it costs' }, + { 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' } ]; + var DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + var SHARD_GB = 20.4, PROVE_GB = 24; function page(id) { for (var i = 0; i < PAGES.length; i++) if (PAGES[i].id === id) return PAGES[i]; return PAGES[0]; } function withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); } function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; } + // a coarser "ago" for a line that is read, not measured: "just now", "12 min ago", "2 h ago", "3 d ago" + function ago(s) { s = Math.max(0, Math.round(s)); if (s < 60) return 'just now'; if (s < 3600) return Math.floor(s / 60) + ' min ago'; if (s < 86400) return Math.floor(s / 3600) + ' h ago'; return Math.floor(s / 86400) + ' d ago'; } function shortHex(h, head, tail) { h = String(h || ''); head = head || 8; tail = tail || 6; return h.length > head + tail + 2 ? h.slice(0, head) + '…' + h.slice(-tail) : h; } + function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } var kindWord = Notices.kindWord; // hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch // The list is ordered by performance (Josh, 6 October 2026): usable cards first, then by the measured rate since the @@ -282,9 +291,11 @@ var View = (function () { function cardRow(cd) { var removed = cd.removed_at > 0, unusable = !!cd.problem; var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off'); - var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; - var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; - var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; + // the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word + var tuning = st === 'tuning', held = st === 'held'; + var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'held for a remote job') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; + var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; + var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : ''; var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : ''; var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : ''; @@ -297,18 +308,134 @@ var View = (function () { return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) }; } + // "tuning: step 7 of 9 · measuring 220 W" (the live draw; the plan's words when there is no draw yet) + function tuneWord(cd) { + var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : ''; + var w = cd.power_w > 0 ? 'measuring ' + Math.round(cd.power_w) + ' W' : ''; + var rest = [step, w].filter(Boolean).join(' · '); + return rest ? 'tuning: ' + rest : (cd.sweep_note || 'tuning'); + } + // "about 12 min" / "under a minute" from a tune's seconds left + function tuneEta(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? 'under a minute' : 'about ' + Math.round(s / 60) + ' min'; } + // the card a tune is running on, or null + function tuningCard(cards) { var l = (cards || []).filter(function (c) { return c.state === 'tuning' || c.sweep_state === 'running'; }); return l.length ? l[0] : null; } + + // ---- money (miner-ui-3): £ a day from watts at pence per kWh; null when either is unknown ---- + function poundsPerDay(watts, pence) { return watts > 0 && pence > 0 ? watts * 24 / 1000 * pence / 100 : null; } + function money(v) { if (v === null || v === undefined || isNaN(v)) return ''; return '£' + (v >= 100 ? String(Math.round(v)) : v.toFixed(2)); } + // MH per watt: the engine's live figure, else rate over draw + function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; } + function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; } + // the watts drawn now by the present cards + function wattsTotal(cards) { return (cards || []).filter(present).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); } + // why a row has no power or temperature cell: one sentence, once, instead of three n/a + function noReading(cd) { + if (cd.power_w > 0 || cd.temp_gpu > 0) return ''; + if (cd.vendor === 'apple') return 'No power or temperature reading on Apple silicon'; + if (cd.kind === 'integrated') return 'No power or temperature reading on an integrated GPU'; + return ''; + } + // the row's cells: rate, watts with MH/W, £ a day, temperature; a cell with no number is left out, never n/a + function cells(cd, pence) { + var r = cardRow(cd), out = {}; + out.hash = r.hash; out.power = r.power; out.temp = r.temp; out.tempTone = r.tempTone; + out.eff = effText(effOf(cd)); + var ppd = poundsPerDay(cd.power_w, pence); + out.money = ppd === null ? '' : money(ppd); + out.needsPrice = cd.power_w > 0 && !(pence > 0); + out.noReading = noReading(cd); + return out; + } + + // ---- the tune line under a row (miner-ui-3 section 4): cd, s = state, now ---- + function nextCheck(at, period, now) { + if (!(at > 0) || !now) return ''; + period = period > 0 ? period : 604800; + var next = at + period, d = next - now; + if (d <= 0) return 'next check due now'; + if (d < 86400) return 'next check within a day'; + var dt = new Date(next * 1000); + if (d < 6 * 86400) return 'next check ' + DAYS[dt.getDay()]; + return 'next check ' + dt.getDate() + ' ' + MONTHS[dt.getMonth()]; + } + function tunePoint(cd) { + var pct = cd.sweep_pct || cd.power_pct || 0; + if (cd.tune_clock_mhz > 0) return cd.tune_clock_mhz + ' MHz at ' + pct + '%'; + return pct ? pct + '%, clock unlocked' : ''; + } + function reasonOf(note) { return cap(String(note || '').replace(/^measure only /, 'measured only ')); } + function tunable(cd) { return (cd.vendor === 'nvidia' || cd.vendor === 'amd' || cd.vendor === 'apple' || !!cd.sweep_supported) && present(cd) && cd.kind !== 'integrated'; } + // {kind: none|paused|running|measured|tuned|stopped|idle, text, note, button: ''|'tune'|'stop', progress: -1 or 0..1} + function tuneWords(cd, s, now) { + cd = cd || {}; var st = (s && s.settings) || {}; + if (!tunable(cd)) return { kind: 'none', text: '', note: '', button: '', progress: -1 }; + if (st.tuning_off) return { kind: 'paused', text: st.tuning_note || 'Tuning paused fleet-wide by the signed manifest', note: '', button: '', progress: -1 }; + if (cd.state === 'tuning' || cd.sweep_state === 'running') { + var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : (cd.sweep_note ? cd.sweep_note.replace(/^tuning:\s*/, '') : 'running'); + var eta = cd.tune_eta_s > 0 ? ' · ' + tuneEta(cd.tune_eta_s) + ' left' : ''; + var prog = cd.tune_steps > 0 ? Math.min(1, Math.max(0, ((cd.tune_step || 1) - 1) / cd.tune_steps)) : 0; + return { kind: 'running', text: 'Tuning: ' + step + eta, note: '', button: 'stop', progress: prog }; + } + 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*/, ''); + 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) + '% · ' : ''; + var pt = tunePoint(cd); + var stopped = cd.sweep_note && /^tuning stopped/i.test(cd.sweep_note) ? ' · ' + cap(cd.sweep_note) : ''; + return { kind: 'tuned', text: pin + 'Tuned ' + (when || 'before') + src + (pt ? ' · ' + pt : '') + (nc ? ' · ' + nc : '') + stopped, note: nums, button: 'tune', progress: -1 }; + } + if (cd.sweep_note && /^tuning stopped/i.test(cd.sweep_note)) return { kind: 'stopped', text: cap(cd.sweep_note), note: '', button: 'tune', progress: -1 }; + var why = !cd.tune_control && cd.sweep_note ? reasonOf(cd.sweep_note) : ''; + var text = st.sweep === false ? 'Not tuned yet: Ember Tune is off in Settings' : 'Not tuned yet: starts after 2 min of steady mining'; + return { kind: 'idle', text: text, note: why, button: 'tune', progress: -1 }; + } + // the Tuning card's one line: what runs, what ran last, what comes next + function schedule(cards, s, now) { + var st = (s && s.settings) || {}; + if (st.tuning_off) return st.tuning_note || 'Tuning paused fleet-wide by the signed manifest.'; + var list = shownCards(cards).filter(tunable), tc = tuningCard(list); + if (tc) { var step = tc.tune_steps > 0 ? ', step ' + (tc.tune_step || 1) + ' of ' + tc.tune_steps : ''; return 'Tuning now: ' + tc.name + step + (tc.tune_eta_s > 0 ? ', mining again in ' + tuneEta(tc.tune_eta_s) : '') + '.'; } + var last = null; + list.forEach(function (c) { if (c.sweep_at > 0 && (!last || c.sweep_at > last.sweep_at)) last = c; }); + if (last) return 'Last tune ' + ago(now - last.sweep_at) + ' (' + last.name + ') · ' + nextCheck(last.sweep_at, st.tune_period_s, now) + '.'; + if (!list.length) return 'No card here can be tuned.'; + return st.sweep === false ? 'Ember Tune is off. Tune and Tune all still work by hand.' : 'Not tuned yet: the first tune starts 2 min into steady mining.'; + } + // the goal control's consequence: goal = efficiency|balanced|rate, cards, pence + var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' }; + function goalWords(goal, cards, pence) { + goal = GOALS[goal] ? goal : 'balanced'; + var list = (cards || []).filter(present), w = 0; + list.forEach(function (c) { + if (goal === 'efficiency') w += c.sweep_watts > 0 ? c.sweep_watts : (c.power_w > 0 ? c.power_w * 0.9 : 0); + else if (goal === 'rate') w += c.power_default_w > 0 ? c.power_default_w : (c.power_w || 0); + else w += c.power_w > 0 ? c.power_w : 0; + }); + var ppd = poundsPerDay(w, pence); + return { goal: goal, label: GOALS[goal], watts: Math.round(w), consequence: ppd === null ? (w > 0 ? 'about ' + Math.round(w) + ' W' : '') : 'about ' + money(ppd) + ' a day at ' + Math.round(w) + ' W' }; + } + // the big button on Mine: m = state.mining, n = state.node, s = { quitting } function toggle(m, n, s) { var cards = shownCards(m.cards).filter(present); var on = cards.filter(function (c) { return c.enabled; }).length; var mining = cards.filter(function (c) { return c.state === 'mining'; }).length; if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' }; + var tc = tuningCard(cards); + if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' }; + var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length; + if (heldCards && !mining) return { label: 'Held', sub: 'a remote job holds the cards; mining restarts when it ends', cls: 'stop', disabled: true, act: '' }; if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' }; if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }; if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' }; if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' }; return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' }; } + // the row's state word while the miner is paused: the card is on, the miner is not + function rowWord(r, m) { return r.on && m && m.paused && (r.word === 'off' || r.word === 'ready' || r.word === 'waiting for the node') ? 'paused' : r.word; } // the Node page's plain words: n = state.node, clock = state.clock, eta = the sync ETA sentence (may be '') function nodeWords(n, clock, eta) { @@ -327,6 +454,13 @@ var View = (function () { } return { word: n.state || 'starting', line: n.message || '', tone: '' }; } + // the one node line on Mine: "Node synced · 6 peers · 109,859 blocks" + function nodeLine(n, clock, eta) { + var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word]; + if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's')); + if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks'); + return { text: parts.join(' · '), sub: w.line, tone: w.tone }; + } function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; } function peersLine(n) { return n.peers === 0 ? 'none yet: looking for the seed node' : n.peers === 1 ? 'one other node this one talks to' : 'other nodes this one talks to'; } function heightLine(n) { return n.headers > n.blocks + 2 ? 'of ' + withCommas(n.headers) + ' headers seen' : 'blocks this node holds'; } @@ -346,7 +480,7 @@ var View = (function () { // the Prove page: pv = state.proving, enabled = state.settings.prove, synced = state.node.synced function proveWords(pv, enabled, synced) { pv = pv || {}; - if (!enabled) return { word: 'off', sub: 'not proving', tone: 'off', note: 'Off. Switch it on and this machine proves the shards the chain assigns to its keys. On a Mac the CPU prover is slow; a CUDA card is quick.' }; + if (!enabled) return { word: 'off', sub: 'not proving', tone: 'off', note: 'Off. Switch it on and this machine proves the pieces the chain assigns to its keys.' }; if (pv.status === 'setup' || (pv.enabled && !pv.available && pv.setup_hint)) return { word: 'needs setup', sub: 'one-time install', tone: 'bad', note: pv.setup_hint || pv.message || 'The prover is not installed yet.' }; if (!synced || pv.status === 'waiting') return { word: 'waiting', sub: 'for the node to sync', tone: '', note: pv.message || 'Proving starts once the node has every block.' }; if (pv.status === 'proving') return { word: 'proving', sub: pv.current || 'a shard', tone: 'on', note: pv.message || 'A proof takes minutes on a CPU and seconds on a CUDA card.' }; @@ -360,6 +494,20 @@ var View = (function () { if (vm === 'command' && pv.pool_entries) word += ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : ''); return { word: word, tone: vm === 'command' ? 'ok' : (vm === 'off' || vm === 'trust') ? 'warn' : 'dim', needsSetup: vm === 'off' && /WSL2 prover is not installed/.test((pv && pv.verifier_reason) || '') }; } + // the tier sentence per card: can this card prove, and how (the 24 GB rule and the 20.4 GB shard are the app's own + // words since miner-ui-2; the engine's proving.available wins when it disagrees) + function proveTier(cd) { + var gb = cd.vram_mb ? Math.round(cd.vram_mb / 1024) : 0; + if (cd.vendor === 'apple') return cd.name + ': proves on the CPU, slowly.'; + if (cd.vendor === 'nvidia') return gb >= PROVE_GB ? cd.name + ': proves while it mines (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' : cd.name + ': cannot prove a full shard (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).'; + return cd.name + ': cannot prove yet (the prover is CUDA only).'; + } + // the counts in one sentence: "idle · nothing assigned · 0 assigned · 0 proven · 0 paid · 0.0000 IGN" + function proveLine(pv, enabled, synced) { + pv = pv || {}; + var w = proveWords(pv, enabled, synced), ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000'; + return cap(w.word) + (w.sub ? ' · ' + w.sub : '') + ' · ' + (pv.assigned || 0) + ' assigned · ' + (pv.submitted || 0) + ' proven · ' + (pv.paid || 0) + ' paid · ' + ign + ' IGN'; + } // the miner's own dev-fee line, next to the rewards address function devFeeText(s) { @@ -370,26 +518,37 @@ var View = (function () { return 'Dev fee ' + (d.percent || 1) + '% (1 block in 100)' + addr + (d.address ? '' : '; the miner names the address when it starts'); } function devFeeLine(s) { - return s.settings.dev_fee ? 'One block in 100 pays the miner software’s dev fee' + (s.mining.fee_total ? ' (' + withCommas(s.mining.fee_total) + ' so far)' : '') + '. Settings turns it off.' : 'The dev fee is off. Every block pays this address.'; + return s.settings.dev_fee ? 'One block in 100 pays the miner software’s dev fee' + (s.mining.fee_total ? ' (' + withCommas(s.mining.fee_total) + ' so far)' : '') + '. The switch above turns it off.' : 'The dev fee is off. Every block pays this address.'; } - // the remote-jobs line on Updates: j = state.jobs, now = state.now + // the remote-jobs line on Settings: j = state.jobs, now = state.now function jobsNote(j, now, jobTitle) { var parts = []; if (j.account) parts.push(j.account); - if (!j.allowed) parts.push('Off: nothing runs here until Settings allows remote jobs.'); + if (!j.allowed) parts.push('Off: nothing runs here until the switch above allows remote jobs.'); else if (!j.url_set) parts.push('No jobs address in this build.'); else if (j.error) parts.push(j.error + '.'); else if (j.active) parts.push('Running ' + jobTitle(j) + ' (' + j.id + ').'); - else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.'); + else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + ((j.history || []).length ? '; ' + j.history.length + ' job' + (j.history.length === 1 ? '' : 's') + ' ran here' : '') + '.'); return parts.join(' '); } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote }; + // an activity line for a user: a JSON tail goes to the log, a long line is cut at a word + function eventLine(text, max) { + text = String(text || ''); max = max || 140; + var j = text.indexOf('{'); if (j > 0) text = text.slice(0, j).replace(/[:\s]+$/, '') + ' (details in the log)'; + if (text.length > max) { var cut = text.lastIndexOf(' ', max - 2); text = text.slice(0, cut > 40 ? cut : max - 2) + '…'; } + return text; + } + // 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, 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 ("Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" plus the point and when), measure only (Apple, NVIDIA without Power - control: the measured line and why nothing is set), stopped (the reason), or not run yet. */ + control: the measured line and why nothing is set), stopped (the reason), or not run yet. The Settings-page block + of miner-ui-2; miner-ui-3 reads View.tuneWords for the row and keeps this for the details and ember-tune's test. */ var TuneLine = (function () { 'use strict'; function point(cd) { @@ -412,9 +571,11 @@ var TuneLine = (function () { return { kind: 'idle', text: cd.sweep_note || 'tuning: not run yet (starts after 120 s of steady mining)' }; } function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; } - return { model: model, point: point, rel: rel }; + // Ember 2: £ a day for a draw at a price in pence per kWh (the same formula as View.poundsPerDay and ember::pounds_per_day) + function pounds(watts, pence) { return View.money(View.poundsPerDay(watts, pence)); } + return { model: model, point: point, rel: rel, pounds: pounds }; })(); -if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; } +if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; } if (typeof document !== 'undefined') (function () { 'use strict'; @@ -424,7 +585,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|prove|rewards|node|updates|settings (screenshots) + if (params.get('page')) page = params.get('page'); // ?page=mine|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'); @@ -451,11 +612,14 @@ if (typeof document !== 'undefined') (function () { return fetch(path, opt).then(function (r) { return r.json(); }); } function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } - var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord, shownCards = View.shownCards, cardTitle = View.cardTitle; + var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord, shownCards = View.shownCards, cardTitle = View.cardTitle, money = View.money; function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); } function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; } function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); } function setText(id, text) { var el = $(id); if (el && el.textContent !== text) el.textContent = text; } + function setHidden(id, hidden) { var el = $(id); if (el && el.hidden !== !!hidden) el.hidden = !!hidden; } + function store(key, val) { try { if (val === null) localStorage.removeItem(key); else localStorage.setItem(key, String(val)); } catch (e) { } } + function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } } var toastT = null; function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); } function copyText(text, msg) { @@ -470,6 +634,19 @@ if (typeof document !== 'undefined') (function () { if (!t || /not (set|read|printed)/.test(t)) { toast('Nothing to copy yet'); return; } copyText(t); }); + // the electricity price: the engine's setting (ember-tune), else what this page stored; pence per kWh + function priceNow() { var p = state && state.settings && state.settings.power_price_pence; if (p > 0) return p; var s = parseFloat(stored('igneum.price') || ''); return s > 0 ? s : 0; } + function goalNow() { var g = state && state.settings && state.settings.tune_goal; return View.GOALS[g] ? g : (View.GOALS[stored('igneum.goal') || ''] ? stored('igneum.goal') : 'balanced'); } + + // ---------- appearance: system, light or dark (the page's own storage) ---------- + function applyTheme(t) { + t = t === 'light' || t === 'dark' ? t : 'system'; + if (t === 'system') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t); + document.querySelectorAll('#theme-seg .seg-b').forEach(function (b) { var on = b.dataset.theme === t; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); + store('igneum.theme', t === 'system' ? null : t); + } + applyTheme(stored('igneum.theme') || 'system'); + $('theme-seg').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) applyTheme(b.dataset.theme); }); // ---------- screens (setup) and pages (the dashboard) ---------- function show(p) { @@ -497,14 +674,14 @@ 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 === 'settings') fillSettings(); if (page === 'mine') size(); } + if (state) { renderPage(state); if (page === 'mine') 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 $('rail-nav').addEventListener('keydown', function (e) { var items = Array.prototype.slice.call(document.querySelectorAll('#rail-nav .nav')), i = items.indexOf(document.activeElement); if (i < 0) return; - var j = e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : -1; + var j = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : -1; if (j < 0 || j >= items.length) return; e.preventDefault(); items[j].focus(); showPage(items[j].dataset.page); }); @@ -518,7 +695,7 @@ if (typeof document !== 'undefined') (function () { // address options var opts = document.querySelectorAll('.option'); - function pickOption(el) { opts.forEach(function (o) { o.classList.toggle('on', o === el); }); el.querySelector('input').checked = true; if (el.id === 'opt-paste') $('addr-input').focus(); } + function pickOption(el) { opts.forEach(function (o) { o.classList.toggle('on', o === el); }); el.querySelector('input').checked = true; if (el.id === 'opt-paste') $('addr-input').focus(); setText('btn-start', el.id === 'opt-paste' ? 'Start mining' : 'Continue'); } opts.forEach(function (o) { o.addEventListener('click', function (e) { if (e.target.tagName !== 'INPUT' || e.target.type === 'radio') pickOption(o); }); }); $('addr-input').addEventListener('focus', function () { pickOption($('opt-paste')); }); function validAddr(a) { return /^0x[0-9a-fA-F]{40}$/.test((a || '').trim()); } @@ -550,59 +727,133 @@ if (typeof document !== 'undefined') (function () { api('api/key/saved', {}).then(function () { return api('api/start', {}); }).then(function () { $('sheet-key').hidden = true; $('key-private').textContent = ''; show('dashboard'); }); }); - // ---------- Mine: the big button and the GPU switches ---------- + // ---------- disclosures: one chevron per details block ---------- + function setOpen(btn, det, open) { btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.classList.toggle('open', open); det.hidden = !open; } + function disclosure(btnId, detId, onOpen) { + $(btnId).addEventListener('click', function () { var open = $(detId).hidden; setOpen($(btnId), $(detId), open); if (open && onOpen) onOpen(); }); + } + disclosure('node-toggle', 'node-details'); + disclosure('pv-toggle', 'pv-details'); + disclosure('addr-toggle', 'addr-details', function () { $('s-address-input').focus(); }); + disclosure('key-toggle', 'key-details'); + $('s-jobs-history-btn').addEventListener('click', function () { setOpen(this, $('s-jobs-history'), $('s-jobs-history').hidden); $('s-jobs-key').hidden = $('s-jobs-history').hidden; }); + + // ---------- Mine: the big button, the rows, the tune controls ---------- $('btn-toggle').addEventListener('click', function () { if (!state) return; var t = View.toggle(state.mining, state.node, state); if (!t.act) return; api('api/' + t.act, {}).then(function () { toast(t.act === 'pause' ? 'Mining stops; the node keeps running' : 'Mining starts'); }); }); - // the GPU switch on a Mine row: the whole list goes to the engine with that card flipped (identities and the cap - // as they are); pending[key] holds the wanted state until the engine agrees, so the row does not flicker back - var pending = {}; + // a row's switch, slider or stepper sends the whole list to the engine (pending[key] holds a flipped switch until + // the engine agrees, so the row does not flicker back) + var pending = {}, cardsT = null, openCards = {}; + function sendCards(msg) { + clearTimeout(cardsT); + cardsT = setTimeout(function () { + var choices = readCardRows($('d-cards')); + if (choices.length) api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(msg || 'Applied; only the changed worker restarts'); else toast(r.error || 'could not change'); }); + }, 400); + } $('d-cards').addEventListener('change', function (e) { - if (!e.target.matches('.gpu-line .switch input') || !state) return; - var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked; - pending[key] = { on: on, at: Date.now() }; - var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; }); - api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Worker starting on that card' : 'Worker stopped on that card'); else { delete pending[key]; toast(r.error || 'could not change'); } }); + if (!state) return; + if (e.target.matches('.gpu-line > .gl-main .switch input')) { + var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked; + pending[key] = { on: on, at: Date.now() }; + var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; }); + api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Worker starting on that card' : 'Worker stopped on that card'); else { delete pending[key]; toast(r.error || 'could not change'); } }); + } else if (e.target.matches('.ids input')) sendCards('Identities set; that card’s worker restarts'); }); + $('d-cards').addEventListener('input', function (e) { + if (e.target.matches('input[type=range]')) { + var row = e.target.closest('.gpu-line'), cd = cardOf(row.dataset.key), pct = parseInt(e.target.value, 10); + row.querySelector('.pv').textContent = pct + '% · ' + (cd && cd.power_default_w ? Math.round(cd.power_default_w * pct / 100) + ' W' : ''); + sendCards('Power cap set; pinned until you unpin it'); + } + }); + $('d-cards').addEventListener('click', function (e) { + var b = e.target.closest('button'); if (!b) return; + var row = b.closest('.gpu-line'), key = row ? row.dataset.key : ''; + if (b.dataset.open !== undefined) { openCards[key] = !openCards[key]; applyOpen(row); } + else if (b.dataset.tune !== undefined) api('api/sweep/start', { key: key }).then(function (r) { toast(r && r.ok === false ? (r.error || 'could not start the tune') : 'Tune queued for ' + (cardOf(key) || {}).name); }); + else if (b.dataset.stop !== undefined) api('api/sweep/stop', {}).then(function () { toast('Tune stopped; the card is back where it was'); }); + else if (b.dataset.d) { var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; inp.value = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); sendCards('Identities set; that card’s worker restarts'); } + 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(); } + }); + 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; } + $('btn-tune-all').addEventListener('click', function () { + if (!state) return; + var list = shownCards(state.mining.cards).filter(function (c) { return View.tunable(c) && c.enabled; }); + if (!list.length) { toast('No card to tune'); return; } + var n = 0; + list.reduce(function (p, c) { return p.then(function () { return api('api/sweep/start', { key: c.key }).then(function (r) { if (!r || r.ok !== false) n++; }); }); }, Promise.resolve()).then(function () { toast(n === 1 ? 'Tune queued for 1 card' : 'Tune queued for ' + n + ' cards, one at a time'); }); + }); + // the goal (ember-tune: POST api/tune/goal); a build without the route says so and keeps the old value + function setGoal(goal) { + api('api/tune/goal', { goal: goal }).then(function (r) { if (r && r.ok) { store('igneum.goal', goal); toast('Goal: ' + View.GOALS[goal]); } else toast('The goal needs the Ember Tune update'); if (state) renderTuneCard(state); }).catch(function () { toast('The goal needs the Ember Tune update'); }); + } + ['goal-seg', 'goal-seg-2'].forEach(function (id) { $(id).addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) setGoal(b.dataset.goal); }); }); + function powerControl(on) { api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on: Windows asks for administrator rights once' : 'Power control off; nothing asks'); }); } + $('m-power-control').addEventListener('change', function () { powerControl(this.checked); }); + $('s-power-control').addEventListener('change', function () { powerControl(this.checked); }); + $('m-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); + $('btn-open-log').addEventListener('click', function () { setDrawer(true); }); - // ---------- Prove ---------- - $('s-prove').addEventListener('change', function () { var on = this.checked; api('api/prove', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); else { toast(r.error || 'could not change'); $('s-prove').checked = !on; } }); }); - $('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); }); - - // ---------- Rewards ---------- + // ---------- Earnings ---------- + $('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); }); + $('e-price-btn').addEventListener('click', function () { showPage('settings'); $('s-price').focus(); }); + $('t-money-cell').addEventListener('click', function () { if ($('t-money').classList.contains('ask-price')) { showPage('settings'); $('s-price').focus(); } }); $('s-address-save').addEventListener('click', function () { var a = $('s-address-input').value.trim(); if (!validAddr(a)) { $('s-address-err').textContent = 'An address is 0x followed by 40 hex characters.'; $('s-address-err').hidden = false; return; } - api('api/settings', { address: a }).then(function (r) { if (r.ok) { $('s-address-err').hidden = true; $('s-address-input').value = ''; toast('Rewards address changed'); } else { $('s-address-err').textContent = r.error; $('s-address-err').hidden = false; } }); + $('s-address-err').hidden = true; + $('ask-address-text').textContent = View.addressAsk(a); $('ask-address').hidden = false; + }); + $('ask-address-no').addEventListener('click', function () { $('ask-address').hidden = true; }); + $('ask-address-yes').addEventListener('click', function () { + var a = $('s-address-input').value.trim(); + api('api/settings', { address: a }).then(function (r) { $('ask-address').hidden = true; if (r.ok) { $('s-address-input').value = ''; setOpen($('addr-toggle'), $('addr-details'), false); toast('Rewards address changed'); } else { $('s-address-err').textContent = r.error; $('s-address-err').hidden = false; } }); }); $('s-reveal').addEventListener('click', function () { - api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; $('s-hide').hidden = false; $('s-reveal').hidden = true; } else toast(r.error || 'no key'); }); + api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; $('ask-key').hidden = true; } else toast(r.error || 'no key'); }); }); - function hideKey() { $('s-key-box').hidden = true; $('s-key').textContent = ''; $('s-hide').hidden = true; $('s-reveal').hidden = false; } + function hideKey() { $('s-key-box').hidden = true; $('s-key').textContent = ''; $('ask-key').hidden = false; } $('s-hide').addEventListener('click', hideKey); + $('ask-key-no').addEventListener('click', function () { setOpen($('key-toggle'), $('key-details'), false); }); $('r-wallet').addEventListener('click', function () { api('api/open', { url: 'https://igneum.network/wallet' }); }); - // ---------- Node ---------- - $('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); + // ---------- Prove ---------- + $('s-prove').addEventListener('change', function () { var on = this.checked; api('api/prove', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Proving on; the first piece arrives within a minute' : 'Proving off'); else { toast(r.error || 'could not change'); $('s-prove').checked = !on; } }); }); + $('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); }); - // ---------- Updates ---------- + // ---------- Settings ---------- $('s-update').addEventListener('click', function () { api('api/update/check', {}); setText('s-update-note', 'Checking.'); }); $('s-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); }); $('s-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); }); $('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); setText('s-jobs-note', 'Checking.'); }); - - // ---------- Settings ---------- $('s-name-save').addEventListener('click', function () { api('api/settings', { display_name: $('s-name').value }).then(function (r) { if (r.ok) toast('Renamed'); }); }); $('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); }); - $('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); }); $('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); }); $('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); }); - $('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on: once after install, then weekly' : 'Ember Tune off'); }); }); - $('s-power-control').addEventListener('change', function () { var on = this.checked; api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on: Windows asks for administrator rights once' : 'Power control off; nothing asks'); }); }); - $('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); }); + $('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on: once after install, then every 7 days' : 'Ember Tune off'); }); }); + $('s-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (!r || !r.ok) { toast('Needs the Ember Tune update'); $('s-climb').checked = !on; } }); }); + function savePrice() { + var p = parseFloat($('s-price').value); + if (!(p >= 0)) { toast('A price in pence per kWh, for example 28'); return; } + store('igneum.price', p > 0 ? p : null); + api('api/tune/goal', { price_pence: p }).then(function () { toast(p > 0 ? 'Price saved: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); }); + } + $('s-price-save').addEventListener('click', savePrice); + $('s-price').addEventListener('keydown', function (e) { if (e.key === 'Enter') savePrice(); }); + $('s-trust').addEventListener('change', function () { var on = this.checked; $('s-trust').checked = !on; $('ask-trust-text').textContent = View.trustAsk(on); $('ask-trust').hidden = false; $('ask-trust').dataset.on = on ? '1' : ''; }); + $('ask-trust-no').addEventListener('click', function () { $('ask-trust').hidden = true; }); + $('ask-trust-yes').addEventListener('click', function () { + var on = $('ask-trust').dataset.on === '1'; $('ask-trust').hidden = true; + api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) { $('s-trust').checked = on; toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); } else toast(r.error || 'could not change'); }); + }); $('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); $('s-log-open').addEventListener('click', function () { setDrawer(true); }); // the log export: the last lines the engine holds (the ring keeps 3,000; one call returns up to 2,000), to the clipboard @@ -613,41 +864,13 @@ if (typeof document !== 'undefined') (function () { copyText('Igneum Miner ' + (state ? state.version : '') + ' log, ' + lines.length + ' lines\n' + lines.join('\n'), 'Copied ' + lines.length + ' lines'); }).catch(function () { toast('The engine did not answer'); }); }); - // the per-card controls on Settings: the cap slider and the identities stepper send the whole list (debounced), - // the sweep buttons send their own calls - var cardsT = null; - function sendCards(msg) { - clearTimeout(cardsT); - cardsT = setTimeout(function () { - var choices = readCardRows($('s-cards')); - if (choices.length) api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(msg || 'Applied; only the changed worker restarts'); }); - }, 500); - } - $('s-cards').addEventListener('input', function (e) { - if (e.target.matches('input[type=range]')) { - var row = e.target.closest('.set-card'), cd = cardOf(row.dataset.key), pct = parseInt(e.target.value, 10); - row.querySelector('.pv').textContent = pct + '% · ' + (cd && cd.power_default_w ? Math.round(cd.power_default_w * pct / 100) + ' W' : ''); - sendCards('Power cap set; pinned until you unpin it'); - } - }); - $('s-cards').addEventListener('change', function (e) { if (e.target.matches('.ids input')) sendCards('Identities set; that card’s worker restarts'); }); - $('s-cards').addEventListener('click', function (e) { - var b = e.target.closest('button'); if (!b) return; - if (b.dataset.d) { var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; inp.value = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); sendCards('Identities set; that card’s worker restarts'); } - else if (b.dataset.sweepStart) api('api/sweep/start', { key: b.dataset.sweepStart }).then(function () { toast('Tune queued: the power limit and the core clock, 75 s a step'); }); - else if (b.dataset.sweepStop) api('api/sweep/stop', {}).then(function () { toast('Tune stopped; the card is back where it was'); }); - else if (b.dataset.sweepPin) api('api/sweep/pin', { key: b.dataset.sweepPin, pinned: b.dataset.pinned === '1' }).then(function () { toast(b.dataset.pinned === '1' ? 'Cap pinned' : 'The sweep chooses the cap again'); }); - else if (b.dataset.powerRetry) api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); }); - }); - function cardOf(key) { return state ? state.mining.cards.filter(function (c) { return c.key === key; })[0] : null; } - // ---------- rail: logs and quit ---------- + // ---------- rail: logs and quit (the quit question is a strip, never a dialog) ---------- $('btn-logs').addEventListener('click', function () { setDrawer(!document.body.classList.contains('drawer-open')); }); $('log-close').addEventListener('click', function () { setDrawer(false); }); - $('btn-quit').addEventListener('click', function () { - if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return; - api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true; - }); + $('btn-quit').addEventListener('click', function () { $('ask-quit').hidden = false; $('ask-quit-yes').focus(); }); + $('ask-quit-no').addEventListener('click', function () { $('ask-quit').hidden = true; }); + $('ask-quit-yes').addEventListener('click', function () { $('ask-quit').hidden = true; api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true; }); // ---------- log drawer ---------- // logLines: everything fetched this session (newest last, capped at LOG_CAP). view: the lines after the source chip @@ -844,28 +1067,31 @@ if (typeof document !== 'undefined') (function () { } function scheduleLogPoll() { clearTimeout(logTimer); logTimer = setTimeout(function () { pollLog(); if (drawerOpen()) scheduleLogPoll(); }, 1500); } + + // ---------- the blocks strip (your blocks over the last 10 minutes) ---------- // The minute grid never moves (it hangs off "now", which is always the right edge), so it is drawn once per size // into an offscreen layer. A frame is clear + the layer + the rail + the blocks. Blocks drift 1.7 px/s, so the // strip redraws twice a second; while a block flashes (1.2 s after it appears) it runs at the display rate. Nothing // draws while the window is hidden (the hosts hide it to the menu bar or the tray). var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, born = {}, grid = document.createElement('canvas'), gx = grid.getContext('2d'); - var SPAN = 600, S = 18, PAD = 10, RIGHT_PAD = 26; + var SPAN = 600, S = 12, PAD = 10, RIGHT_PAD = 26; + function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function size() { W = c.clientWidth; H = c.clientHeight; if (!W || !H) return; c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0); grid.width = W * dpr; grid.height = H * dpr; gx.setTransform(dpr, 0, 0, dpr, 0, 0); gx.clearRect(0, 0, W, H); - var right = W - RIGHT_PAD, left = PAD, pxs = (right - left) / SPAN; + var right = W - RIGHT_PAD, left = PAD, pxs = (right - left) / SPAN, line = css('--line') || '#2A2A30', ash = css('--ash') || '#9A9A9E', molten = css('--molten') || '#FFB35C'; gx.font = '500 10px IBM Plex Mono, monospace'; gx.textAlign = 'center'; for (var m = 0; m <= 10; m++) { var px = right - m * 60 * pxs; - gx.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; gx.lineWidth = 1; gx.setLineDash(m === 0 ? [] : [3, 6]); - gx.beginPath(); gx.moveTo(px, 14); gx.lineTo(px, H - 18); gx.stroke(); gx.setLineDash([]); - if (px > 30) { gx.fillStyle = 'rgba(154,154,158,0.8)'; gx.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } + gx.strokeStyle = m === 0 ? molten : line; gx.lineWidth = 1; gx.setLineDash(m === 0 ? [] : [3, 6]); + gx.beginPath(); gx.moveTo(px, 10); gx.lineTo(px, H - 18); gx.stroke(); gx.setLineDash([]); + if (px > 30 && (m === 0 || m % 2 === 0)) { gx.fillStyle = ash; gx.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } } - var rail = H * 0.5; - gx.strokeStyle = 'rgba(58,58,66,0.9)'; gx.lineWidth = 1; gx.beginPath(); gx.moveTo(left, rail); gx.lineTo(right, rail); gx.stroke(); + var rail = (H - 12) * 0.5; + gx.strokeStyle = line; gx.lineWidth = 1; gx.beginPath(); gx.moveTo(left, rail); gx.lineTo(right, rail); gx.stroke(); requestDraw(); } window.addEventListener('resize', function () { size(); }); @@ -879,24 +1105,24 @@ if (typeof document !== 'undefined') (function () { var right = W - RIGHT_PAD, pxs = (right - PAD) / SPAN; var nowU = state ? (state.now + (performance.now() - stateAt) / 1000) : Date.now() / 1000; var found = state ? state.mining.found : []; - var rail = H * 0.5, shown = 0, animating = false; + var rail = (H - 12) * 0.5, shown = 0, animating = false; for (var i = 0; i < found.length; i++) { var t = found[i], age = nowU - t; if (age < 0 || age > SPAN) continue; shown++; var key = String(t); if (!born[key]) born[key] = age < 5 ? now : now - 2000; // only a block that just arrived flashes, not the whole strip on first paint var fl = Math.max(0, 1 - (now - born[key]) / 1200); if (fl > 0) animating = true; - var px = right - age * pxs, py = rail + ((i % 3) - 1) * 22; + var px = right - age * pxs, py = rail + ((i % 3) - 1) * 15; if (fl > 0) { var g = x.createRadialGradient(px, py, 0, px, py, S * 2.2); g.addColorStop(0, 'rgba(255,179,92,' + (0.6 * fl) + ')'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.beginPath(); x.arc(px, py, S * 2.2, 0, Math.PI * 2); x.fill(); } - x.fillStyle = '#F2541B'; rrect(px - S / 2, py - S / 2, S, S, 5); x.fill(); - if (fl > 0) { x.strokeStyle = 'rgba(255,179,92,' + fl + ')'; x.lineWidth = 2; rrect(px - S / 2 - 2, py - S / 2 - 2, S + 4, S + 4, 6); x.stroke(); } + x.fillStyle = '#F2541B'; rrect(px - S / 2, py - S / 2, S, S, 3); x.fill(); + if (fl > 0) { x.strokeStyle = 'rgba(255,179,92,' + fl + ')'; x.lineWidth = 2; rrect(px - S / 2 - 2, py - S / 2 - 2, S + 4, S + 4, 4); x.stroke(); } } // forget flashes older than the strip, so the map does not grow for a day-long run if (found.length && Object.keys(born).length > found.length + 50) { var keep = {}; for (var k = 0; k < found.length; k++) if (born[String(found[k])]) keep[String(found[k])] = born[String(found[k])]; born = keep; } if (!shown) { - x.fillStyle = 'rgba(154,154,158,0.9)'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center'; - var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: your blocks appear here as the node accepts them' : state.mining.state === 'paused' ? 'paused' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync'; - x.fillText(msg, W / 2, rail - 14); + x.fillStyle = css('--ash') || '#9A9A9E'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center'; + var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: a block this machine finds appears here' : state.mining.state === 'paused' ? 'paused: no blocks while mining is stopped' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync'; + x.fillText(msg, W / 2, rail - 10); } return animating && !reduce; } @@ -914,6 +1140,7 @@ if (typeof document !== 'undefined') (function () { if (hiddenNow()) { clearTimeout(drawTimer); drawTimer = null; if (drawRaf) { cancelAnimationFrame(drawRaf); drawRaf = 0; } clearTimeout(logTimer); logTimer = null; } else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } } }); + window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { size(); }); requestDraw(); // ---------- render ---------- @@ -930,7 +1157,7 @@ if (typeof document !== 'undefined') (function () { var removed = cd.removed_at > 0, unusable = !!cd.problem; d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : ''); d.dataset.key = cd.key; - var meta = '<span>worker <b>' + esc(cd.worker) + '</b></span>' + (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : ''); + var meta = (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : ''); d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' + '<div class="info"><div class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' + (cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' + @@ -941,12 +1168,12 @@ if (typeof document !== 'undefined') (function () { // what a container's rows say now, for api/cards: a row without a stepper or a slider keeps the engine's value function readCardRows(container) { var out = []; - container.querySelectorAll('.gpu-row, .set-card').forEach(function (r) { + container.querySelectorAll('.gpu-row, .gpu-line').forEach(function (r) { if (r.classList.contains('removed') || r.classList.contains('unusable')) return; var cd = cardOf(r.dataset.key) || {}; var idsInput = r.querySelector('.ids input'), n = idsInput ? parseInt(idsInput.value, 10) : (cd.identities || 1); if (!(n >= 1)) n = 1; if (n > 64) n = 64; - var sw = r.querySelector('.switch input'), pr = r.querySelector('input[type=range]'); + var sw = r.querySelector('.gl-main .switch input, :scope > .switch input'), pr = r.querySelector('input[type=range]'); var item = { key: r.dataset.key, enabled: sw ? sw.checked : !!cd.enabled, identities: n }; if (pr) item.power_pct = parseInt(pr.value, 10); else if (cd.vendor === 'nvidia' && cd.power_default_w > 0) item.power_pct = cd.power_pct || 80; out.push(item); @@ -962,17 +1189,17 @@ if (typeof document !== 'undefined') (function () { if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; } det.hidden = true; var cards = shownCards(s.mining.cards); - var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|'); // cards is already in performance order, so a reorder changes the signature + var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|'); if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); } if (!cards.length) { - $('cards-sub').textContent = 'No GPU this app can drive was found.'; + $('cards-sub').textContent = 'No graphics card this app can drive was found.'; $('cards-note').textContent = (s.detect_message || '') + ' The node can still run on its own; mining needs a supported card.'; $('cards-note').hidden = false; $('cards-help').hidden = true; $('btn-cards-retry').hidden = false; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue without mining'; return; } - $('cards-sub').textContent = cards.length === 1 ? 'One card found. Switch it on to mine with it.' : cards.length + ' GPUs found. Switch on the ones you want to mine with; each gets its own worker.'; + $('cards-sub').textContent = cards.length === 1 ? 'One card found. It mines when it is on.' : cards.length + ' cards found. Switch on the ones you want to mine with.'; $('cards-note').hidden = !s.detect_message; $('cards-note').textContent = s.detect_message || ''; - $('cards-help').hidden = false; + $('cards-help').hidden = !cards.some(function (c) { return c.kind === 'integrated'; }); var nv = cards.filter(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0; }).length; $('cards-power').hidden = !nv; $('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue'; @@ -993,113 +1220,129 @@ if (typeof document !== 'undefined') (function () { // ---------- Mine ---------- var mineRowsSig = '', lastEventsKey = ''; - function gpuLineHtml(r) { - var num = function (cls, k, v, unit, tone) { return '<div class="num ' + cls + '"><span class="k">' + k + '</span><span class="v' + (v ? (tone ? ' ' + tone : '') : ' na') + '">' + (v ? esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') : 'n/a') + '</span></div>'; }; + function numCell(cls, v, unit, k, tone) { return '<div class="num ' + cls + '"><span class="v' + (tone ? ' ' + tone : '') + '">' + esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') + '</span>' + (k ? '<span class="k">' + esc(k) + '</span>' : '') + '</div>'; } + function gpuLineHtml(cd, s, r) { + var cells = View.cells(cd, priceNow()), tw = View.tuneWords(cd, s, s.now), word = View.rowWord(r, s.mining); + var nums = ''; + if (r.hash) nums += numCell('hash', r.hash, 'MH/s', '', ''); + if (cells.power) nums += numCell('power', cells.power.replace(' W', ''), 'W', cells.eff, ''); + 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 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>'; return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' + - '<div class="badge ' + esc(r.vendor) + '">' + badgeHtml({ vendor: r.vendor }) + '</div>' + + '<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>' + - '<span class="st ' + r.tone + '"><i></i>' + esc(r.word) + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' + (r.sub ? '<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>' : '') + '</div>' + - num('hash', 'hash rate', r.hash, 'MH/s', '') + num('temp', 'temperature', r.temp, '', r.tempTone) + num('power', 'power', r.power, '', '') + - '<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></div>'; + '<span class="st ' + r.tone + '"><i></i>' + esc(word) + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' + subs.join('') + '</div>' + + '<div class="nums">' + nums + '</div>' + + '<div class="acts">' + tuneBtn + sw + chev + '</div></div>' + tune + + '<div class="gl-details" hidden>' + detailsHtml(cd, s) + '</div></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 = ''; + if (cd.removed_at > 0) return '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p>'; + if (cd.problem) return '<p class="help">' + esc(cd.reason || 'No worker can drive this card.') + ' Reboot with the card attached; if it persists, reinstall the driver with the card attached.</p>'; + if (nv) { + var want = Math.round(cd.power_default_w * pct / 100); + h += '<div class="ctl"><span class="k">Power cap</span><input type="range" min="50" max="100" step="5" value="' + pct + '" aria-label="Power cap for ' + esc(cd.name) + '"><span class="pv mono">' + pct + '% · ' + want + ' W</span>' + + '<p class="help">Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the tune leaves it. <button class="btn tiny ghost" data-pin="0">Unpin</button>' : 'Ember Tune may move it.') + '</p></div>'; + h += cd.power_applied ? '<div class="line ok">cap applied: <b>' + want + ' W</b>' + (cd.sweep_pct === pct && cd.sweep_pct ? ' <span class="pin">chosen by the tune</span>' : '') + '</div>' : '<div class="line hot">cap not applied: Windows needs the administrator prompt <button class="btn tiny" data-power-retry="1">Retry</button></div>'; + } else if (cd.vendor === 'apple') h += '<p class="help">Apple silicon manages its own power; there is no cap to set.</p>'; + var tl = []; + if (cd.temp_mem > 0) tl.push('memory <b>' + Math.round(cd.temp_mem) + ' °C</b>'); + if (cd.gclk_mhz > 0) tl.push('core clock <b>' + Math.round(cd.gclk_mhz) + ' MHz</b>' + (cd.clock_cap_mhz > 0 ? ' (cap ' + cd.clock_cap_mhz + ')' : '')); + if (cd.mclk_mhz > 0) tl.push('memory clock <b>' + Math.round(cd.mclk_mhz) + ' MHz</b>' + (cd.mem_cap_mhz > 0 ? ' (set ' + cd.mem_cap_mhz + ')' : '')); + 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>'; + } + 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>'; + return h; } function renderMine(s) { var m = s.mining, n = s.node, p = s.program; var t = View.toggle(m, n, s), tb = $('btn-toggle'); tb.className = 'toggle-big' + (t.cls ? ' ' + t.cls : ''); tb.disabled = t.disabled; setText('btn-toggle-text', t.label); setText('btn-toggle-sub', t.sub); - setText('d-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1)); var shown = shownCards(m.cards), presentCards = shown.filter(View.present); var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length; - setText('d-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state); - setText('d-blocks', withCommas(m.accepted_total)); - setText('d-blocks-sub', m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '')); - if (n.daa > 0 && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '')); } - else { setText('d-eta', '--:--'); setText('d-eta-sub', n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'); } - var nowU = s.now; - setText('d-found-10', String(m.found.filter(function (x) { return nowU - x <= 600; }).length)); - setText('d-found-60', String(m.found.length)); - setText('d-fee', withCommas(m.fee_total || 0)); - $('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee'; - setText('d-chain-blocks', compact(n.blocks)); - // the rows: every card, on or off; rebuilt only when their words change + // the live total: rate, watts, £ a day, blocks + setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1)); + setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state); + var watts = View.wattsTotal(m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : ''; + setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now'); + var ppd = View.poundsPerDay(watts, price); + setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh'); + setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '')); + // the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider var rows = shown.map(View.cardRow), now = Date.now(); rows.forEach(function (r) { var pd = pending[r.key]; if (pd) { if (pd.on === r.on || now - pd.at > 8000) delete pending[r.key]; else { r.on = pd.on; r.word = pd.on ? 'starting' : 'stopping'; r.tone = ''; } } }); 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 sig = JSON.stringify(rows); - if (sig !== mineRowsSig) { + 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)); + 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]; })]); + if (sig !== mineRowsSig && !busy) { mineRowsSig = sig; - $('d-cards').innerHTML = rows.length ? rows.map(gpuLineHtml).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No GPU this app can drive was found. The node runs on its own.') + '</div>'; + 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>'; + box.querySelectorAll('.gpu-line').forEach(applyOpen); } - var note = !shown.length && s.detect_message ? s.detect_message : shown.some(function (c) { return c.problem; }) ? 'A card the OS reports a problem on gets no worker. Reboot with the card attached; if it persists, reinstall the driver with the card attached.' : presentCards.some(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0 && !c.power_applied && c.enabled; }) ? 'A power cap is not applied yet: Settings has a Retry for the administrator prompt.' : ''; + var note = !shown.length && s.detect_message ? s.detect_message : ''; $('d-cards-note').hidden = !note; setText('d-cards-note', note); + renderTuneCard(s); + renderNode(s); + // activity: the counts, the strip, the newest 8 events in a user's words + var nowU = s.now, f10 = m.found.filter(function (x) { return nowU - x <= 600; }).length; + setText('d-stats', f10 + ' block' + (f10 === 1 ? '' : 's') + ' in 10 min · ' + m.found.length + ' in the last hour' + (m.fee_total ? ' · ' + withCommas(m.fee_total) + ' to the dev fee' : '')); var key = s.events.length ? s.events[0].t + ':' + s.events.length : ''; if (key !== lastEventsKey) { lastEventsKey = key; - $('d-events').innerHTML = s.events.length ? s.events.slice(0, 14).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(e.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>'; + $('d-events').innerHTML = s.events.length ? s.events.slice(0, 8).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(View.eventLine(e.text)) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>'; } } - - // ---------- Prove ---------- - function renderProve(s) { - var pv = s.proving || {}, enabled = !!(s.settings && s.settings.prove); - if (document.activeElement !== $('s-prove')) $('s-prove').checked = enabled; - var w = View.proveWords(pv, enabled, s.node.synced); - setText('pv-state', w.word + (pv.backend && enabled && pv.available ? ' (' + pv.backend.toUpperCase() + ')' : '')); - setText('pv-state-sub', w.sub); - var cell = $('pv-state').parentNode; cell.classList.toggle('ok', w.tone === 'on' || w.tone === 'ok'); cell.classList.toggle('bad', w.tone === 'bad'); - // proving v1: when off by the install-time default, the default's own line says why (src/provedefault.rs) - setText('pv-note', (!enabled && pv.default_note) ? 'Off. ' + pv.default_note + '.' : w.note); - setText('pv-assigned', String(pv.assigned || 0)); - setText('pv-submitted', String(pv.submitted || 0)); - setText('pv-paid', String(pv.paid || 0)); - setText('pv-paid-sub', pv.paid_wei ? (Number(pv.paid_wei) / 1e18).toFixed(4) + ' IGN earned' : 'shards paid out'); - // proving v1 segments (6 October 2026): whole segments this machine proved, and the segment path's last line - var segLine = ''; - if (pv.segments_submitted) segLine = 'Segments: ' + pv.segments_submitted + ' proven whole, ' + (pv.segments_paid || 0) + ' paid' + (pv.segment_paid_wei && Number(pv.segment_paid_wei) ? ' (' + (Number(pv.segment_paid_wei) / 1e18).toFixed(4) + ' IGN to the aggregator)' : '') + (pv.segment_last_s ? ', the last in ' + Math.round(pv.segment_last_s) + ' s' : '') + (pv.segments_held ? ', ' + pv.segments_held + ' record' + (pv.segments_held > 1 ? 's' : '') + ' held for the chain rule' : '') + '.'; - if (pv.segment_note && enabled) segLine += (segLine ? ' ' : '') + pv.segment_note + '.'; - $('pv-seg-note').hidden = !segLine; setText('pv-seg-note', segLine); - var v = View.verifierWords(pv); - setText('pv-verifier', v.word); $('pv-verifier').className = 'big-word ' + v.tone; - $('pv-verifier-note').hidden = !pv.verifier_note; setText('pv-verifier-note', pv.verifier_note || ''); - $('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || v.needsSetup); - setText('pv-program', pv.program_id || 'not read yet'); - setText('pv-aggregator', pv.aggregator_id || 'not read yet'); + // the Tuning card on Mine and the Tuning rows on Settings share the goal, the schedule and the Power control switch + function renderTuneCard(s) { + var st = s.settings || {}, goal = goalNow(), cards = shownCards(s.mining.cards), gw = View.goalWords(goal, cards, priceNow()); + ['goal-seg', 'goal-seg-2'].forEach(function (id) { document.querySelectorAll('#' + id + ' .seg-b').forEach(function (b) { var on = b.dataset.goal === goal; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); }); + 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'); + 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; } - // ---------- Rewards ---------- - function renderRewards(s) { - var a = s.address || {}, m = s.mining; - setText('r-address', a.display || 'not set'); - setText('r-source', a.source === 'generated' ? 'made on this machine' : a.source === 'pasted' ? 'your own address' : ''); - setText('r-address-help', a.display ? 'Every block this machine finds pays this address.' : 'No address yet. Finish the setup to choose one.'); - setText('r-blocks', withCommas(m.accepted_total)); - setText('r-session', withCommas(m.accepted_session)); - setText('r-session-sub', 'since the app started, ' + uptime(s.uptime_s) + ' ago'); - var gen = a.source === 'generated'; - setText('r-key-help', gen ? 'The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe: anyone with the key can spend what the address holds.' : 'You pasted your own address, so its key lives in your wallet, not here. Nothing to back up on this machine.'); - $('r-key-row').hidden = !gen; if (!gen) hideKey(); - setText('r-key-file', gen && a.wallet_file ? a.wallet_file : ''); - setText('r-devfee-line', View.devFeeLine(s)); - } - - // ---------- Node ---------- + // ---------- the node line and its details ---------- var switchesSig = ''; function renderNode(s) { - var n = s.node, f = s.finality, eta = syncEta(n, s.now); - var w = View.nodeWords(n, s.clock, eta); - setText('d-node', w.word); setText('d-node-sub', w.line); - $('d-node-sub').title = w.line + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''); - var cell = $('n-state-cell'); cell.classList.toggle('ok', w.tone === 'ok'); cell.classList.toggle('bad', w.tone === 'bad'); - setText('n-sync-word', w.word); $('n-sync-word').className = 'big-word ' + (w.tone === 'ok' ? 'ok' : w.tone === 'bad' ? 'warn' : ''); - setText('n-sync-eyebrow', n.state === 'restarting' ? 'restart ' + (n.restarts || 0) : n.starts > 1 ? 'start ' + n.starts : ''); - setText('n-note', w.line + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '')); + var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now); + var nl = View.nodeLine(n, s.clock, eta); + setText('node-line-text', nl.text); setText('node-sub', nl.sub); + $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); + $('node-card').classList.toggle('bad', nl.tone === 'bad'); + setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '')); setText('n-blocks', withCommas(n.blocks)); setText('n-blocks-sub', View.heightLine(n)); setText('n-peers', String(n.peers)); setText('n-peers-sub', View.peersLine(n)); setText('n-version', n.version ? n.version.replace(/^igneumd\s*/, '') : '--'); setText('d-node-net', s.chain || s.network || ''); - setText('n-reading', n.state === 'synced' || n.state === 'syncing' ? 'read every 10 s' : ''); setText('n-headers', withCommas(n.headers)); setText('n-daa', withCommas(n.daa)); setText('n-diff', compact(n.difficulty)); setText('n-tips', String(n.tips)); setText('n-blue', withCommas(n.blue)); + if (n.daa > 0 && p && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', 'until the next hourly mining program' + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '') + (p.message ? ' · ' + p.message : '')); } + else { setText('d-eta', '--:--'); setText('d-eta-sub', n.state === 'syncing' ? 'known after the node is synced' : 'known once the node runs'); } setText('n-digest', n.consensus_digest || (n.state === 'stopped' || n.state === 'failed' ? 'the node is not running' : 'not printed yet')); var sw = View.nextSwitch(n.consensus_switches, n.daa); setText('n-switch', sw ? sw.name + ' at ' + withCommas(sw.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'DAA ' + withCommas(n.consensus_switch_daa) : (n.consensus_switches && n.consensus_switches.length ? 'none ahead' : 'none planned'))); @@ -1109,17 +1352,73 @@ if (typeof document !== 'undefined') (function () { switchesSig = sig; $('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '<span class="' + (sw && x.key === sw.key ? 'next' : x.daa <= n.daa ? 'past' : '') + '"><span>' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '</span><span>' + withCommas(x.daa) + '</span></span>'; }).join(''); } - if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock)); setText('f-age', rel(f.age_s)); setText('f-note', 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the 30-day weight; this machine votes every 30 s.'); } - else { setText('f-lock', 'none yet'); setText('f-age', 'n/a'); setText('f-note', f.message || 'Locks appear once the miner votes on checkpoints.'); } + if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock) + ' · ' + rel(f.age_s)); setText('f-note', 'a point the miners agreed can never be undone; locks at 2/3 of the 30-day weight'); } + else { setText('f-lock', 'none yet'); setText('f-note', f.message || 'locks appear once the miner votes on checkpoints'); } setText('f-votes', withCommas(f.votes)); } - // ---------- Updates ---------- - function renderUpdates(s) { + // ---------- Earnings ---------- + function renderEarnings(s) { + var a = s.address || {}, m = s.mining, pv = s.proving || {}, price = priceNow(); + var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000'; + setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : '')); + setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s)); + var watts = View.wattsTotal(m.cards), ppd = View.poundsPerDay(watts, price); + setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day'); + setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W'); + setHidden('e-price-btn', !(watts > 0 && ppd === null)); + if (document.activeElement !== $('s-devfee')) $('s-devfee').checked = !!(s.settings && s.settings.dev_fee); + setText('s-devfee-text', s.settings.dev_fee ? 'Dev fee: 1 block in 100 pays the miner software’s author' : 'Dev fee off: every block pays your address'); + setText('r-devfee-line', s.settings.dev_fee ? 'The protocol takes nothing; this is the miner software’s fee, the same way every GPU miner takes one' + (m.fee_total ? ' (' + withCommas(m.fee_total) + ' block' + (m.fee_total === 1 ? '' : 's') + ' so far)' : '') + '.' : 'The protocol takes nothing either.'); + setText('r-address', a.display || 'not set'); + setText('r-source', a.source === 'generated' ? 'made on this machine' : a.source === 'pasted' ? 'your own address' : ''); + setText('r-address-help', a.display ? 'Every block this machine finds pays this address.' : 'No address yet. Finish the setup to choose one.'); + var gen = a.source === 'generated'; + setHidden('key-toggle', !gen); if (!gen) { $('key-details').hidden = true; hideKey(); } + setText('r-key-help', 'The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe.'); + setText('r-key-file', gen && a.wallet_file ? a.wallet_file : ''); + } + + // ---------- Prove ---------- + var tiersSig = ''; + function renderProve(s) { + var pv = s.proving || {}, enabled = !!(s.settings && s.settings.prove); + if (document.activeElement !== $('s-prove')) $('s-prove').checked = enabled; + var w = View.proveWords(pv, enabled, s.node.synced); + var cards = shownCards(s.mining.cards).filter(View.present), tiers = cards.map(View.proveTier); + var sig = tiers.join('|'); + if (sig !== tiersSig) { tiersSig = sig; $('pv-tiers').innerHTML = tiers.map(function (t) { return '<li>' + esc(t) + '</li>'; }).join(''); } + setText('pv-line', View.proveLine(pv, enabled, s.node.synced)); + $('pv-line').className = 'line-text ' + (w.tone === 'on' || w.tone === 'ok' ? 'ok' : w.tone === 'bad' ? 'bad' : ''); + setText('pv-note', (!enabled && pv.default_note) ? pv.default_note + '.' : w.note); + var v = View.verifierWords(pv); + setText('pv-verifier', v.word); $('pv-verifier').className = 'v ' + v.tone; + setText('pv-verifier-help', pv.verifier_note || 'the node checks a proof before it counts'); + $('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || v.needsSetup); + var seg = pv.segments_submitted ? pv.segments_submitted + ' proven whole, ' + (pv.segments_paid || 0) + ' paid' + (pv.segment_paid_wei && Number(pv.segment_paid_wei) ? ' (' + (Number(pv.segment_paid_wei) / 1e18).toFixed(4) + ' IGN)' : '') + (pv.segment_last_s ? ', the last in ' + Math.round(pv.segment_last_s) + ' s' : '') + (pv.segments_held ? ', ' + pv.segments_held + ' held for the chain rule' : '') : 'none yet'; + setText('pv-seg', seg); setText('pv-seg-note', pv.segment_note && enabled ? pv.segment_note : 'whole segments this machine proved'); + setText('pv-program', pv.program_id || 'not read yet'); + setText('pv-aggregator', pv.aggregator_id || 'not read yet'); + } + + // ---------- Settings ---------- + var jobsSig = ''; + function renderSettings(s) { + var st = s.settings || {}, sw = function (id, on) { if (document.activeElement !== $(id)) $(id).checked = !!on; }; + sw('s-vote', st.vote); sw('s-login', st.start_at_login); sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', st.sweep); sw('s-trust', st.proof_verify_trust); sw('s-power-control', st.power_control); sw('s-climb', st.tune_climb); + setHidden('s-climb-row', st.tune_climb === undefined); + setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : '')); + if (document.activeElement !== $('s-price')) { var p = priceNow(); $('s-price').value = p > 0 ? String(p) : ''; } + renderTuneCard(s); + if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || ''; + setText('s-mid', 'machine id ' + (s.machine_id || '').slice(0, 8)); + setText('s-node-dir', 'chain data: ' + s.node_dir); + setText('s-log-dir', 'log files: ' + s.log_dir); + $('s-live').hidden = !s.live_page; var u = s.update || {}; setText('s-version', 'Igneum Miner ' + s.version); setText('s-update-note', settingsUpdateNote(u)); - if (document.activeElement !== $('s-auto-update')) $('s-auto-update').checked = !!(s.settings && s.settings.auto_update); + sw('s-auto-update', st.auto_update); $('s-install').hidden = !(u.version && (u.ready || u.downloaded || u.status === 'error') && !/no update manifest configured/.test(u.error || '') && !u.applying); $('nav-updates-dot').hidden = !(u.available && !u.applying); fillJobs(s.jobs || {}, s.now); @@ -1130,81 +1429,24 @@ if (typeof document !== 'undefined') (function () { var h = j.history || [], sig = JSON.stringify(h); if (sig === jobsSig) return; jobsSig = sig; + $('s-jobs-history-btn').hidden = !h.length; $('s-jobs-history').innerHTML = h.length ? '<table><thead><tr><th>job</th><th>kind</th><th>started</th><th>exit</th><th>report</th></tr></thead><tbody>' + h.map(function (r) { return '<tr class="' + esc(r.status) + '"><td class="mono" title="' + esc(r.summary || '') + '">' + esc(r.id) + '</td><td>' + esc(r.kind) + '</td><td>' + (r.started_at ? clock(r.started_at) : '') + '</td><td>' + esc(r.status) + (r.status !== 'running' ? ' (' + r.exit + ')' : '') + '</td><td>' + (r.uploaded ? 'uploaded' : (r.status === 'running' ? 'pending' : 'not uploaded')) + '</td></tr>'; }).join('') + '</tbody></table>' : '<p class="empty">No job has run on this machine yet.</p>'; } - var jobsSig = ''; - - // ---------- Settings ---------- - // the switches follow the state unless that switch has the focus; the per-card block is rebuilt when a card's - // settings change (never while a slider is being dragged) - var setCardsSig = ''; - function fillSettings() { - if (!state) return; - var s = state; - var sw = function (id, on) { if (document.activeElement !== $(id)) $(id).checked = !!on; }; - sw('s-vote', s.settings.vote); sw('s-devfee', s.settings.dev_fee); sw('s-login', s.settings.start_at_login); - sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', s.settings.sweep); sw('s-trust', s.settings.proof_verify_trust); sw('s-power-control', s.settings.power_control); - setText('s-power-note', s.settings.tuning_off ? s.settings.tuning_note : (s.settings.power_note || (s.settings.power_control ? '' : 'Off: NVIDIA cards measure only; AMD cards need no rights.'))); - setText('s-devfee-text', View.devFeeText(s)); - if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || ''; - setText('s-mid', (s.machine_id || '').slice(0, 8)); - setText('s-node-dir', 'chain data: ' + s.node_dir); - setText('s-log-dir', 'log files: ' + s.log_dir); - $('s-live').hidden = !s.live_page; - renderSetCards(s); - } - function setCardHtml(cd) { - var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, removed = cd.removed_at > 0, unusable = !!cd.problem; - var h = '<div class="set-card' + (removed ? ' removed' : '') + (unusable ? ' unusable' : '') + '" data-key="' + esc(cd.key) + '"><div class="head"><span class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + '</span><span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span><span class="meta">' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (removed ? ' · removed' : unusable ? ' · not usable (' + esc(cd.problem) + ')' : cd.enabled ? '' : ' · off') + '</span></div>'; - if (removed) return h + '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p></div>'; - if (unusable) return h + '<p class="help">' + esc(cd.reason || 'No worker can drive this card.') + '</p></div>'; - if (nv) { - h += '<div class="ctl"><span class="k">Power cap</span><input type="range" min="50" max="100" step="5" value="' + pct + '" aria-label="Power cap for ' + esc(cd.name) + '"><span class="pv">' + pct + '% · ' + Math.round(cd.power_default_w * pct / 100) + ' W</span>' + - '<p class="help">Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the tune leaves it.' : 'Ember Tune may move it.') + '</p></div>'; - var want = Math.round(cd.power_default_w * pct / 100); - h += cd.power_applied ? '<div class="line ok">cap applied: <b>' + want + ' W</b>' + (cd.pinned ? ' <span class="pin">pinned</span>' : cd.sweep_pct === pct && cd.sweep_pct ? ' <span class="pin">chosen by the sweep</span>' : '') + '</div>' : '<div class="line hot">cap not applied (needs the administrator prompt) <button class="btn tiny" data-power-retry="1">Retry</button></div>'; - if (cd.telemetry_at > 0) h += '<div class="line">draw <b>' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '</b><span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span><span>memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span><span>efficiency <b>' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '</b></span></div>'; - } else if (cd.vendor === 'apple') { - h += '<p class="help">Apple silicon manages its own power; there is no cap to set. Ember Tune measures the card as it runs.</p>'; - } - // Ember Tune's line (TuneLine.model): running, tuned, measured, stopped or not run yet, for every card - h += tuneLineHtml(cd); - h += '<div class="ctl"><span class="k">Identities</span><p class="help" style="grid-column:2">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>'; - return h + '</div>'; - } - function tuneLineHtml(cd) { - var m = TuneLine.model(cd, state ? state.now : 0); - if (m.kind === 'off') return m.text ? '<div class="line dim">' + esc(m.text) + '</div>' : ''; - var running = m.kind === 'running'; - var t = m.kind === 'tuned' || m.kind === 'measured' ? '<b>' + esc(m.text) + '</b>' + (m.note ? ' <span class="dim">' + esc(m.note) + '</span>' : '') : esc(m.text); - var btn = running ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Tune now</button>' + (cd.pinned ? ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0">Unpin</button>' : ''); - return '<div class="line' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>'; - } - function renderSetCards(s) { - var cards = shownCards(s.mining.cards), box = $('s-cards'); - if (box.querySelector('input[type=range]:active') || box.contains(document.activeElement)) return; - var sig = JSON.stringify(cards.map(function (c) { return [c.key, c.enabled, c.identities, c.power_pct, c.pinned, c.power_applied, c.sweep_state, c.sweep_pct, c.sweep_note, c.tune_line, c.tune_source, c.tune_control, Math.round(c.power_w || 0), Math.round(c.temp_gpu || 0), Math.round(c.temp_mem || 0), c.problem || '', c.removed_at > 0]; })); - if (sig === setCardsSig) return; - setCardsSig = sig; - setText('s-cards-eyebrow', cards.length ? cards.length + ' card' + (cards.length === 1 ? '' : 's') : ''); - box.innerHTML = cards.length ? cards.map(setCardHtml).join('') : '<div class="empty">No card yet. Mine shows what was found.</div>'; - } function renderPage(s) { if (page === 'mine') renderMine(s); + else if (page === 'earnings') renderEarnings(s); else if (page === 'prove') renderProve(s); - else if (page === 'rewards') renderRewards(s); - else if (page === 'node') renderNode(s); - else if (page === 'updates') renderUpdates(s); - else if (page === 'settings') fillSettings(); + else if (page === 'settings') renderSettings(s); } function renderPill(s) { var pill = $('pill'), text; pill.classList.remove('on', 'warn'); if (s.quitting) text = 'stopping'; else if (!s.setup_done) text = s.detecting ? 'detecting' : 'set up'; + else if (View.tuningCard(s.mining.cards)) { text = 'tuning'; pill.classList.add('on'); } else if (s.mining.state === 'mining') { text = s.mining.hash_total.toFixed(1) + ' MH/s'; pill.classList.add('on'); } else if (s.mining.state === 'paused') text = 'paused'; else if (s.node.state === 'syncing' || s.node.state === 'starting') text = 'node ' + s.node.state; @@ -1212,26 +1454,22 @@ if (typeof document !== 'undefined') (function () { else if (s.mining.state === 'idle' && s.node.state === 'synced') { text = 'node synced'; pill.classList.add('on'); } else text = s.mining.state; $('pill-text').textContent = text; - // the top line and the rail foot: the machine, the uptime, where rewards go - var nodeWord = s.node.state === 'synced' ? 'synced' : s.node.state; - var top = phase !== 'dashboard' ? '' : s.quitting ? '<span class="pc">stopping: miners first, then the node</span>' : '<span class="pc">' + esc(s.mining.state === 'mining' ? 'mining' : s.mining.state) + '</span><span class="pc">node ' + esc(nodeWord) + '</span><span class="pc">up ' + uptime(s.uptime_s) + '</span>'; - if (top !== lastTop) { lastTop = top; $('top-status').innerHTML = top; } - var foot = (s.display_name ? '<b title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</b><br>' : '') + (s.address.display ? 'rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet'); + var foot = (s.display_name ? '<b title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</b><br>' : '') + (s.address.display ? 'pays ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet') + '<br>up ' + uptime(s.uptime_s); if (foot !== lastFoot) { lastFoot = foot; $('rail-status').innerHTML = foot; } var fv = 'v' + esc(s.version) + '<span class="chain"> · ' + esc(s.chain || s.network || '') + '</span>'; if (fv !== lastVersion) { lastVersion = fv; $('foot-version').innerHTML = fv; } } - var lastTop = '', lastFoot = '', lastVersion = ''; - // the clock on the Node page (the strip carries it on the setup screens, see Notices) + var lastFoot = '', lastVersion = ''; + // the clock card on Mine (the strip carries it on the setup screens, see Notices) function renderClock(s) { var c = s.clock || { severity: 'none' }, bad = c.severity === 'block', warn = c.severity === 'warn'; - var card = $('n-clock'); + var card = $('m-clock'); card.hidden = !(bad || warn); card.classList.toggle('warn', warn); - setText('n-clock-msg', c.message || ''); - setText('n-clock-hint', c.hint ? 'By hand: ' + c.hint : ''); - setText('n-clock-result', c.sync_result || ''); - $('n-clock-sync').disabled = !!c.syncing; setText('n-clock-sync', c.syncing ? 'Syncing' : 'Sync clock'); + setText('m-clock-msg', c.message || ''); + setText('m-clock-hint', c.hint ? 'By hand: ' + c.hint : ''); + setText('m-clock-result', c.sync_result || ''); + $('m-clock-sync').disabled = !!c.syncing; setText('m-clock-sync', c.syncing ? 'Syncing' : 'Sync clock'); $('btn-start').disabled = bad; $('start-blocked').hidden = !bad; setText('start-blocked', bad ? c.message + ' Use "Sync clock" above.' : ''); @@ -1337,25 +1575,25 @@ if (typeof document !== 'undefined') (function () { else if (act === 'open') api('api/update/open', {}); }); $('upd-more').addEventListener('click', function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); }); - document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { if (!$('upd').hidden) { e.preventDefault(); cardLater(); } else if (!$('ask-quit').hidden) $('ask-quit').hidden = true; } }); - // ---------- over-the-air updates (src/ota.rs): the Updates page's line; the strip is Notices.updateNotice ---------- + // ---------- over-the-air updates (src/ota.rs): the Settings card's line; the strip is Notices.updateNotice ---------- function settingsUpdateNote(u) { var n = Notices.updateNotice(u, state || {}), parts = []; if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); if (n && n.kind !== 'update-installed' && !(u.rolled_back && u.status === 'error')) parts.push(n.text + (n.detail ? ' ' + Notices.endDot(n.detail) : '')); else if (u.status === 'checking') parts.push('Checking.'); - else if (u.status === 'current') parts.push('This is the latest version' + (u.checked_at ? ' (checked ' + rel(state.now - u.checked_at) + ')' : '') + '.'); + else if (u.status === 'current') parts.push('Up to date' + (u.checked_at ? ', checked ' + rel(state.now - u.checked_at) : '') + '.'); else if (/no update manifest configured/.test(u.error || '')) parts.push('This build has no update address; updates come as a new download.'); else if (u.error) parts.push(u.error); else if (!u.status || u.status === 'unknown') parts.push('Not checked yet. The engine checks by itself every hour.'); - if (u.activation_height && !u.urgent) parts.push('Consensus upgrade at height ' + withCommas(u.activation_height) + '.'); + if (u.activation_height && !u.urgent && state && state.node && u.activation_height > state.node.daa) parts.push('A rule switch comes at height ' + withCommas(u.activation_height) + '.'); return parts.join(' '); } function sampleUpdate(kind) { // ?update=<kind>[&auto=0]: the card and the strip for each state, without an engine - var u = { available: true, version: '0.3.7', notes: 'The update card: one centred card, Install now and Later. Worker restarts no longer drop the hourly program. The log drawer wraps long lines and jumps to the last error. Windows Hello is written but untested; Touch ID confirms every send on the Mac.', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: params.get('auto') !== '0', wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' }; + var u = { available: true, version: '0.3.14', notes: 'The miner has four sections: Mine, Earnings, Prove, Settings. Every card is a row with its rate, watts, £ a day and a Tune button. The node is one line. Updates are one card.', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: params.get('auto') !== '0', wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' }; if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)'; if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; } if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); } @@ -1363,13 +1601,13 @@ if (typeof document !== 'undefined') (function () { if (kind === 'waiting') { u.wait = 'hourly program boundary in 97 s; installing after it'; } if (kind === 'applying') { u.status = 'applying'; u.applying = true; } if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission); mining continues'; } - if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.7 now.'; } + if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.activation_height = 120000; u.urgent_text = 'Consensus upgrade at height 120000 (difficulty v2): the node is 1,240 blocks away. Installing 0.3.14 now.'; } if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; } if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; } - if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.6'; u.version = '0.3.7'; } + if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.13'; u.version = '0.3.14'; } return u; } - // ---------- remote jobs (src/jobrun.rs): the Updates page; the strip is Notices.jobNotice ---------- + // ---------- remote jobs (src/jobrun.rs): the Settings row; the strip is Notices.jobNotice ---------- var jobTitle = Notices.jobTitle; function sampleJob(kind, now) { var j = { allowed: true, key_fingerprint: '8f186e37', url_set: true, checked_at: now - 30, error: '', queued: 0, account: '', active: false, id: '', kind: '', title: '', stage: '', message: '', started_at: 0, run_id: '', results: [], last: {}, last_results: [], history: [] }; @@ -1378,10 +1616,18 @@ if (typeof document !== 'undefined') (function () { else if (kind === 'failed') { j.last = { id: 'job-2026-10-05-01', kind: 'build', title: 'build', status: 'failed', started_at: now - 2000, finished_at: now - 900, exit: 1, run_id: 'r0', uploaded: false, summary: 'build exited with code 1' }; j.last_results = ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo` in this scope (src/engine.rs:2082)']; j.history = [j.last]; } return j; } + // ?tune=running|tuned|measured : a sample tune state on the first card (screenshots, no engine) + 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; } + 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) { state = s; stateAt = performance.now(); - if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.7'; s.uptime_s = 20; } } + if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.14'; s.uptime_s = 20; } } if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0); + if (params.get('tune')) sampleTune(s, params.get('tune')); if (params.get('uptime')) s.uptime_s = parseInt(params.get('uptime'), 10) || 0; // the card's first-minute rule, for screenshots renderPill(s); renderClock(s); @@ -1415,9 +1661,9 @@ if (typeof document !== 'undefined') (function () { if (failures >= 4) { $('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; if (phase === 'dashboard') { - setText('d-node', 'no answer'); setText('d-node-sub', 'the engine is not answering; the window reconnects by itself'); $('n-state-cell').classList.add('bad'); + setText('node-line-text', 'Node: no answer'); setText('node-sub', 'the engine is not answering; the window reconnects by itself'); $('node-card').classList.add('bad'); setText('btn-toggle-sub', 'the engine is not answering'); $('btn-toggle').disabled = true; - setText('d-hash-sub', 'no answer from the engine'); + setText('t-hash-sub', 'no answer from the engine'); } } }); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 7b423fdf9..da1cfcee2 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -4,13 +4,14 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Igneum Miner - + - +