diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9879a98b..32aeab56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: - name: igneum-pow tests (release) working-directory: igneum-pow run: cargo test --release + - name: pack loader seed rule (packfile.h on a known-good and a known-mismatched pack) + run: bash proto-cuda/nvrtc/emu/packfile-test.sh - name: igneum-census build (release) working-directory: igneum-census run: cargo build --release @@ -67,6 +69,8 @@ jobs: run: bash tools/ci/no-conflict-markers.sh - name: copied sources are re-stamped before a build run: bash tools/ci/copied-sources-check.sh + - name: the signer is never piped into head + run: bash tools/ci/signer-pipe-check.sh - name: pinned guest programs match their manifest and are built only by pin-guests.sh run: bash tools/ci/pinned-guests-check.sh - name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree) @@ -83,4 +87,4 @@ jobs: - name: relay unit tests (parsers, secret compare, the wake endpoint) run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs - name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows) - run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs + run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index 601d3542..f3a5ca19 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.9" +version = "0.3.10" dependencies = [ "ed25519-dalek", "getrandom", diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index e3304671..bf0f5163 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-app" -version = "0.3.9" +version = "0.3.10" 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 3881fb70..0aadc835 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,9,0 -PRODUCTVERSION 0,3,9,0 +FILEVERSION 0,3,10,0 +PRODUCTVERSION 0,3,10,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.9" + VALUE "FileVersion", "0.3.10" VALUE "InternalName", "igneum-app" VALUE "LegalCopyright", "Igneum contributors" VALUE "OriginalFilename", "igneum-app.exe" VALUE "ProductName", "Igneum Miner" - VALUE "ProductVersion", "0.3.9" + VALUE "ProductVersion", "0.3.10" END END BLOCK "VarFileInfo" diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 1af73c03..cb7c1f62 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -1,6 +1,8 @@ //! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core //! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest -//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs. +//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no +//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither +//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists). use crate::state::CardState; use std::io::Write; @@ -18,6 +20,38 @@ pub struct Bins { pub dir: std::path::PathBuf, } +/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer +/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather +/// than calling them removed (src/hotplug.rs). +#[derive(Clone, Default)] +pub struct Detection { + pub cards: Vec, + pub notes: Vec, + /// duplicate OpenCL platform entries left out (one line each, for the log) + pub dropped: Vec, + pub nvidia_listed: bool, + pub opencl_listed: bool, + pub adapters_listed: bool, + pub metal_listed: bool, +} + +impl Detection { + /// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered. + pub fn listed(&self, c: &CardState) -> bool { + if !c.problem.is_empty() { + return self.adapters_listed; + } + match c.vendor.as_str() { + "apple" => self.metal_listed, + "nvidia" => self.nvidia_listed, + _ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed), + } + } +} + +/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends). +pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached"; + /// Runs a command with a time limit; returns stdout (and stderr appended) or None. pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option { cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -56,7 +90,8 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState { CardState { index, - key: format!("{vendor}:{device}:{name}"), + key: format!("{vendor}:{name}"), + code: name.to_string(), name: name.to_string(), vendor: vendor.into(), worker: worker.into(), @@ -68,14 +103,141 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi } } -/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete). -#[allow(dead_code)] +/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is +/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints +/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026). pub fn looks_integrated(name: &str) -> bool { let n = name.to_ascii_lowercase(); let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"]; - integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") + if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") { + return true; + } + // AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and + // gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036, + // Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here. + let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"]; + let code = n.trim(); + apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} "))) } +/// One row of Windows' adapter list (Win32_VideoController), the part this app reads. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Adapter { + pub name: String, + /// "OK", "Error", "Degraded", ... (the Status property) + pub status: String, + /// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded + pub code: u32, + /// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values) + pub ram_mb: u64, + pub processor: String, + pub pnp_id: String, + /// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them + pub bus: String, +} + +impl Adapter { + /// The PCI device id from the PnP id ("PCI\\VEN_1002&DEV_7550&..." gives 0x7550); 0 when there is none. + pub fn device_id(&self) -> u16 { + let up = self.pnp_id.to_ascii_uppercase(); + up.find("DEV_").and_then(|i| u16::from_str_radix(up.get(i + 4..i + 8)?, 16).ok()).unwrap_or(0) + } + /// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine. + pub fn problem(&self) -> Option { + if self.code != 0 { + return Some(format!("Code {}", self.code)); + } + let st = self.status.trim(); + if !st.is_empty() && !st.eq_ignore_ascii_case("ok") { + return Some(format!("status {st}")); + } + None + } +} + +/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is +/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing). +pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str { + if looks_integrated(name) { + return "integrated"; + } + if let Some(a) = adapter { + // the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon + // Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here + let proc_ = a.processor.to_ascii_lowercase(); + if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) { + return "integrated"; + } + if a.ram_mb > 0 && a.ram_mb < 1024 { + return "integrated"; + } + } + "discrete" +} + +pub fn vendor_of(name: &str) -> &'static str { + let n = name.to_ascii_lowercase(); + if n.contains("nvidia") || n.contains("geforce") { + "nvidia" + } else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") { + "amd" + } else if n.contains("apple") { + "apple" + } else { + "other" + } +} + +/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array). +pub fn parse_adapters(json: &str) -> Vec { + let Ok(v) = serde_json::from_str::(json.trim()) else { return Vec::new() }; + let rows: Vec = match v { + serde_json::Value::Array(a) => a, + o @ serde_json::Value::Object(_) => vec![o], + _ => Vec::new(), + }; + let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string(); + let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::().ok()))).unwrap_or(0); + rows.iter() + .map(|r| { + // DEVPKEY_Device_Address on PCI is (device << 16) | function + let bus = match (r.get("BusNumber").and_then(|x| x.as_u64()), r.get("Address").and_then(|x| x.as_u64())) { + (Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff), + _ => String::new(), + }; + Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus } + }) + .filter(|a| !a.name.is_empty()) + .collect() +} + +/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer. +#[cfg(windows)] +pub fn adapters() -> Option> { + // one object per adapter, with the PCI bus number and address from the PnP properties (they name the card + // the OpenCL worker's "pci" field names); @() keeps a single adapter an array + let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress"; + let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?; + let start = out.find(|c| c == '[' || c == '{')?; + Some(parse_adapters(&out[start..])) +} + +#[cfg(not(windows))] +#[allow(dead_code)] +pub fn adapters() -> Option> { + None +} + +/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime +/// reports gfx codes, which match nothing here and fall back to the name rules). +pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> { + let n = name.trim().to_ascii_lowercase(); + adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n) +} + +/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept). +pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)"; + /// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off /// (1 identity), Apple silicon on with 1. pub fn apply_defaults(c: &mut CardState) { @@ -87,7 +249,7 @@ pub fn apply_defaults(c: &mut CardState) { "integrated" => { c.enabled = false; c.identities = 1; - c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into(); + c.reason = INTEGRATED_REASON.into(); } _ => { c.enabled = true; @@ -148,19 +310,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap) -> Vec { - let mut cards = Vec::new(); +pub fn detect(bins: &Bins) -> Detection { + let mut d = Detection::default(); let Some(metal) = bins.metal.as_ref() else { - notes.push("the Metal worker (igneum-bench) is missing from the app".into()); - return cards; + d.notes.push("the Metal worker (igneum-bench) is missing from the app".into()); + return d; }; // the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1" let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default(); let ready = out.lines().find(|l| l.starts_with("ready ")); let Some(ready) = ready else { - notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output"))); - return cards; + d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output"))); + return d; }; + d.metal_listed = true; let fields: Vec<&str> = ready.split_whitespace().collect(); let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into()); let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1"); @@ -175,58 +338,280 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { } } } - if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { - if let Ok(b) = mem.trim().parse::() { - let gb = b / (1024 * 1024 * 1024); - detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") }; - } + let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::().ok()); + if let Some(b) = mem { + let gb = b / (1024 * 1024 * 1024); + detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") }; } if !prepare { - notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into()); + d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into()); } let mut c = card(0, &name, "apple", "Metal", &detail, ""); c.kind = "apple".into(); c.path = "prebuilt".into(); - if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) { - c.vram_mb = mem.trim().parse::().map(|b| b / (1024 * 1024)).unwrap_or(0); - } + c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0); apply_defaults(&mut c); mark_sweep_support(&mut c); - cards.push(c); - cards + d.cards.push(c); + assign_keys(&mut d.cards); + d } -#[cfg(not(target_os = "macos"))] -pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { - let mut cards: Vec = Vec::new(); - // NVIDIA: nvidia-smi ships with the driver - let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10)); - match smi { +/// AMD gfx codes the OpenCL runtime reports as the device name, with the card names Windows uses, the words for +/// the row when no adapter matches, and the PCI device ids (approximate, from AMD's public ROCm and Linux driver +/// tables; add a line when a card is seen). gfx1036 is the Ryzen desktop iGPU (PC 1: DEV_13C0, 5 October 2026). +const GFX: &[(&str, &str, &[&str], &[u16])] = &[ + ("gfx1201", "Radeon RX 9070 XT / 9070", &["Radeon RX 9070 XT", "Radeon RX 9070"], &[0x7550]), + ("gfx1200", "Radeon RX 9060 XT", &["Radeon RX 9060 XT", "Radeon RX 9060"], &[0x7590]), + ("gfx1100", "Radeon RX 7900 XTX / XT", &["Radeon RX 7900 XTX", "Radeon RX 7900 XT", "Radeon RX 7900 GRE"], &[0x744C]), + ("gfx1101", "Radeon RX 7800 XT / 7700 XT", &["Radeon RX 7800 XT", "Radeon RX 7700 XT"], &[0x747E]), + ("gfx1102", "Radeon RX 7600", &["Radeon RX 7600 XT", "Radeon RX 7600"], &[0x7480]), + ("gfx1030", "Radeon RX 6800 / 6900", &["Radeon RX 6900 XT", "Radeon RX 6950 XT", "Radeon RX 6800 XT", "Radeon RX 6800"], &[0x73BF]), + ("gfx1031", "Radeon RX 6700 XT", &["Radeon RX 6750 XT", "Radeon RX 6700 XT", "Radeon RX 6700"], &[0x73DF]), + ("gfx1032", "Radeon RX 6600", &["Radeon RX 6650 XT", "Radeon RX 6600 XT", "Radeon RX 6600"], &[0x73FF]), + ("gfx1036", "Ryzen integrated Radeon Graphics", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x164E, 0x13C0]), + ("gfx1035", "Radeon 680M (integrated)", &["Radeon 680M", "Radeon 660M"], &[0x1681]), + ("gfx1103", "Radeon 780M (integrated)", &["Radeon 780M", "Radeon 760M"], &[0x15BF, 0x15C8]), + ("gfx1150", "Radeon 890M (integrated)", &["Radeon 890M", "Radeon 880M"], &[0x150E]), + ("gfx90c", "Radeon Graphics (Renoir / Cezanne, integrated)", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x1636, 0x1638]), +]; + +fn gfx_entry(code: &str) -> Option<&'static (&'static str, &'static str, &'static [&'static str], &'static [u16])> { + let c = code.trim().to_ascii_lowercase(); + let c = c.split(|ch: char| ch == ':' || ch == ' ').next().unwrap_or(""); + GFX.iter().find(|e| e.0 == c) +} + +/// The name on the row for a device the tool knows by `code`: Windows' adapter name when one matches (by PCI bus, +/// else by the gfx code's device ids, else by the card names in the table), else the table's words, else the code. +/// `used` holds the adapters already given to another card, so two gfx1036 entries never share one. +pub fn resolve_name(code: &str, vendor: &str, bus: &str, adapters: &[Adapter], used: &mut Vec) -> (String, Option) { + let free = |i: &usize| !used.contains(i); + let fine = |a: &Adapter| a.problem().is_none(); + let vendor_ok = |a: &Adapter| vendor == "other" || vendor_of(&a.name) == vendor; + if !bus.is_empty() { + if let Some(i) = (0..adapters.len()).filter(free).find(|&i| adapters[i].bus == bus && vendor_ok(&adapters[i])) { + used.push(i); + return (adapters[i].name.clone(), Some(i)); + } + } + // the name is already a marketing name (nvidia-smi, Windows): the adapter with the same name + let same: Vec = (0..adapters.len()).filter(free).filter(|&i| adapters[i].name.trim().eq_ignore_ascii_case(code.trim())).collect(); + if same.len() == 1 { + used.push(same[0]); + return (adapters[same[0]].name.clone(), Some(same[0])); + } + let Some(entry) = gfx_entry(code) else { return (code.to_string(), None) }; + let by_id: Vec = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && entry.3.contains(&adapters[i].device_id())).collect(); + if by_id.len() == 1 { + used.push(by_id[0]); + return (adapters[by_id[0]].name.clone(), Some(by_id[0])); + } + let by_name: Vec = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && vendor_ok(&adapters[i]) && { let n = adapters[i].name.to_ascii_lowercase(); entry.2.iter().any(|m| n.contains(&m.to_ascii_lowercase())) }).collect(); + if by_name.len() == 1 { + used.push(by_name[0]); + return (adapters[by_name[0]].name.clone(), Some(by_name[0])); + } + (entry.1.to_string(), None) +} + +/// One device line pair of the OpenCL worker's --list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ClDevice { + pub index: String, + pub name: String, + pub platform: String, + pub platform_version: String, + pub is_gpu: bool, + pub vendor: String, + pub driver: String, + pub units: String, + /// "01:00.0" when the worker printed `pci` (workers from 5 October 2026 on), else empty + pub bus: String, + pub mem_mb: u64, +} + +impl ClDevice { + pub fn vendor_word(&self) -> &'static str { + if self.vendor.contains("NVIDIA") || self.name.contains("NVIDIA") { + "nvidia" + } else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") { + "amd" + } else { + "other" + } + } + fn platform_key(&self) -> String { + format!("{} ({}) driver {}", self.platform, self.platform_version, self.driver) + } +} + +/// Parses `igneum-worker-opencl --list`: `[idx] name | platform (version)` then `GPU, vendor V, driver D, OpenCL C +/// x.y, N compute units, M MHz[, pci bb:dd.f]` then `global N MiB, ...`. The bool says the worker printed its header. +pub fn parse_opencl_list(text: &str) -> (Vec, bool) { + let lines: Vec<&str> = text.lines().collect(); + let listed = lines.iter().any(|l| l.starts_with("OpenCL devices")); + let mut out = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim(); + if !t.starts_with('[') { + continue; + } + let Some(close) = t.find(']') else { continue }; + let rest = &t[close + 1..]; + let mut halves = rest.splitn(2, " |"); + let name = halves.next().unwrap_or("").trim().to_string(); + let plat = halves.next().unwrap_or("").trim(); + // the first " (" opens the version: AMD's version string carries brackets of its own, "OpenCL 2.1 AMD-APP (3617.0)" + let (platform, platform_version) = match plat.find(" (") { + Some(p) if plat.ends_with(')') => (plat[..p].to_string(), plat[p + 2..plat.len() - 1].to_string()), + _ => (plat.to_string(), String::new()), + }; + let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or(""); + let parts: Vec<&str> = info.split(", ").collect(); + let mem_mb = lines.get(i + 2).map(|l| l.trim()).and_then(|l| l.strip_prefix("global ")).and_then(|l| l.split_whitespace().next()).and_then(|n| n.parse::().ok()).unwrap_or(0); + out.push(ClDevice { + index: t[1..close].to_string(), + name, + platform, + platform_version, + is_gpu: info.starts_with("GPU"), + vendor: parts.iter().find_map(|p| p.strip_prefix("vendor ")).unwrap_or("").trim().to_string(), + driver: parts.iter().find_map(|p| p.strip_prefix("driver ")).unwrap_or("").trim().to_string(), + units: parts.iter().find(|p| p.contains("compute units")).unwrap_or(&"").to_string(), + bus: parts.iter().find_map(|p| p.strip_prefix("pci ")).unwrap_or("").trim().to_string(), + mem_mb, + }); + } + (out, listed) +} + +fn version_tuple(s: &str) -> Vec { + s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).map(|p| p.parse::().unwrap_or(0)).collect() +} + +/// One entry per physical card across OpenCL platforms. Two AMD ICDs after a driver upgrade each list every AMD +/// card (PC 1, 5 October 2026: gfx1036 and gfx1201 twice, two workers on one 9070 XT). Per vendor, the fuller +/// platform wins (most GPUs, then the newest driver, then the first listed); a device on another platform is kept +/// only when the winner has no device at the same PCI address (or, without addresses, the same code and ordinal). +/// Returns the kept devices and one note per dropped duplicate. +pub fn dedupe_platforms(devs: Vec) -> (Vec, Vec) { + let gpus: Vec = devs.into_iter().filter(|d| d.is_gpu).collect(); + let mut kept: Vec = Vec::new(); + let mut dropped = Vec::new(); + let mut vendors: Vec<&'static str> = Vec::new(); + for d in &gpus { + let v = d.vendor_word(); + if !vendors.contains(&v) { + vendors.push(v); + } + } + for v in vendors { + let mine: Vec<&ClDevice> = gpus.iter().filter(|d| d.vendor_word() == v).collect(); + let mut plats: Vec = Vec::new(); + for d in &mine { + let k = d.platform_key(); + if !plats.contains(&k) { + plats.push(k); + } + } + let score = |k: &String| { + let count = mine.iter().filter(|d| &d.platform_key() == k).count(); + let driver = mine.iter().find(|d| &d.platform_key() == k).map(|d| version_tuple(&d.driver)).unwrap_or_default(); + (count, driver) + }; + let winner = plats.iter().max_by(|a, b| score(a).cmp(&score(b))).cloned().unwrap_or_default(); + let identity = |d: &ClDevice, ordinal: usize| if d.bus.is_empty() { format!("{}#{ordinal}", d.name.to_ascii_lowercase()) } else { d.bus.clone() }; + let mut have: Vec = Vec::new(); + let mut seen_codes: std::collections::HashMap = std::collections::HashMap::new(); + let mut ordinal = |d: &ClDevice| { + let n = seen_codes.entry(format!("{}|{}", d.platform_key(), d.name.to_ascii_lowercase())).or_insert(0); + *n += 1; + *n + }; + for d in mine.iter().filter(|d| d.platform_key() == winner) { + let o = ordinal(d); + have.push(identity(d, o)); + kept.push((*d).clone()); + } + for d in mine.iter().filter(|d| d.platform_key() != winner) { + let o = ordinal(d); + let id = identity(d, o); + if have.contains(&id) { + dropped.push(format!("[{}] {} on {} ({}) is the same card as the one on {}: no worker", d.index, d.name, d.platform, d.platform_version, winner)); + } else { + have.push(id); + kept.push((*d).clone()); + } + } + } + kept.sort_by_key(|d| d.index.parse::().unwrap_or(u64::MAX)); + (kept, dropped) +} + +/// Keys without an index (it moves when a card arrives): vendor:code, "#2" and up for identical cards in list order. +pub fn assign_keys(cards: &mut [CardState]) { + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for c in cards.iter_mut() { + if c.code.is_empty() { + c.code = c.name.clone(); + } + let base = format!("{}:{}", c.vendor, c.code); + let n = seen.entry(base.clone()).or_insert(0); + *n += 1; + c.key = if *n == 1 { base } else { format!("{base}#{n}") }; + } +} + +/// What one Windows enumeration gathered; `assemble` turns it into the list (pure, so the PC 1 cases are tests). +#[derive(Default)] +pub struct Inputs { + /// `nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id --format=csv,noheader`; None = nvidia-smi did not run + pub nvidia: Option, + pub nvidia_limits: std::collections::HashMap, + pub cuda_worker: bool, + /// the OpenCL worker's --list; None = no worker installed or it did not answer + pub opencl: Option, + pub opencl_installed: bool, + /// Windows' adapter list; None = PowerShell did not answer + pub adapters: Option>, +} + +pub fn assemble(inp: Inputs) -> Detection { + let mut d = Detection::default(); + d.adapters_listed = inp.adapters.is_some(); + let adapters = inp.adapters.unwrap_or_default(); + let mut used: Vec = Vec::new(); + match inp.nvidia { Some(out) => { + d.nvidia_listed = true; for line in out.lines() { let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() { let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::().ok()).map(|v| v as u64).unwrap_or(0); let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() }; - let worker_ok = bins.cuda.is_some(); - let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]); - c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() }; + // nvidia-smi prints 00000000:01:00.0; the worker and Windows say 01:00.0 + let bus = parts.get(3).map(|b| b.trim().to_ascii_lowercase()).map(|b| b.rsplit_once(':').map(|(d, r)| format!("{}:{r}", d.rsplit(':').next().unwrap_or(d))).unwrap_or(b)).unwrap_or_default(); + let (name, adapter) = resolve_name(parts[1], "nvidia", &bus, &adapters, &mut used); + let mut c = card(d.cards.len(), &name, "nvidia", "CUDA", &detail, parts[0]); + c.code = parts[1].to_string(); + c.bus = bus; + c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into(); c.vram_mb = mem_mb; - c.path = if worker_ok { "prebuilt".into() } else { "build".into() }; - if !worker_ok { + c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() }; + if !inp.cuda_worker { c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into(); } apply_defaults(&mut c); - cards.push(c); + d.cards.push(c); } } - if cards.is_empty() { - notes.push("nvidia-smi ran but listed no card".into()); + if d.cards.is_empty() { + d.notes.push("nvidia-smi ran but listed no card".into()); } - let limits = nvidia_power_limits(); - for c in cards.iter_mut() { - if let Some((d, cur, lo, hi)) = limits.get(&c.device) { - c.power_default_w = *d; + for c in d.cards.iter_mut() { + if let Some((dflt, cur, lo, hi)) = inp.nvidia_limits.get(&c.device) { + c.power_default_w = *dflt; c.power_limit_w = *cur; c.power_before_w = *cur; c.power_min_w = *lo; @@ -235,55 +620,84 @@ pub fn detect(bins: &Bins, notes: &mut Vec) -> Vec { mark_sweep_support(c); } } - None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()), + None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()), } - // OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped) - if let Some(cl) = bins.opencl.as_ref() { - if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) { - let lines: Vec<&str> = out.lines().collect(); - for (i, line) in lines.iter().enumerate() { - let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim(); - if !t.starts_with('[') { - continue; - } - let Some(close) = t.find(']') else { continue }; - let idx = &t[1..close]; - let rest = &t[close + 1..]; - let name = rest.split(" |").next().unwrap_or("").trim(); - let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or(""); - let is_gpu = info.starts_with("GPU"); - let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim(); - if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") { - continue; - } - let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" }; - let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string(); - let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx); - c.device = idx.to_string(); - c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() }; - c.path = "prebuilt".into(); - apply_defaults(&mut c); - mark_sweep_support(&mut c); - cards.push(c); - } + if let Some(out) = inp.opencl { + let (devs, listed) = parse_opencl_list(&out); + d.opencl_listed = listed; + let (kept, dropped) = dedupe_platforms(devs.into_iter().filter(|dv| dv.vendor_word() != "nvidia").collect()); + d.dropped = dropped; + for dv in kept { + let vendor = dv.vendor_word(); + let (name, adapter) = resolve_name(&dv.name, vendor, &dv.bus, &adapters, &mut used); + let mut c = card(d.cards.len(), &name, vendor, "OpenCL", &dv.units, &dv.index); + c.code = dv.name.clone(); + c.bus = dv.bus.clone(); + c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver); + c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into(); + c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb }; + c.path = "prebuilt".into(); + apply_defaults(&mut c); + mark_sweep_support(&mut c); + d.cards.push(c); } - } else if cards.is_empty() { - notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); + } else if !inp.opencl_installed && d.cards.is_empty() { + d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into()); } - if cards.is_empty() { + if d.cards.is_empty() { // last resort: the names Windows knows, so the screen can at least say what is in the PC - if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) { - for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { - let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" }; - let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0"); - c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() }; - c.enabled = false; - c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into(); - cards.push(c); - } + for (i, a) in adapters.iter().enumerate().filter(|(_, a)| a.problem().is_none()) { + let vendor = vendor_of(&a.name); + let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", ""); + c.bus = a.bus.clone(); + c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() }; + c.enabled = false; + c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into(); + used.push(i); + d.cards.push(c); } } - cards + // the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven + for (i, a) in adapters.iter().enumerate() { + let Some(problem) = a.problem() else { continue }; + if used.contains(&i) || d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) { + continue; + } + let vendor = vendor_of(&a.name); + let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", ""); + c.bus = a.bus.clone(); + c.kind = classify_kind(&a.name, Some(a)).into(); + mark_unusable(&mut c, &problem); + d.cards.push(c); + } + assign_keys(&mut d.cards); + d +} + +#[cfg(not(target_os = "macos"))] +pub fn detect(bins: &Bins) -> Detection { + // Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and + // processor for the integrated call, the PCI address and the names for the rows + let adapters = adapters(); + // NVIDIA: nvidia-smi ships with the driver + let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10)); + let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() }; + // OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped) + let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15))); + assemble(Inputs { nvidia, nvidia_limits, cuda_worker: bins.cuda.is_some(), opencl, opencl_installed: bins.opencl.is_some(), adapters }) +} + +/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it. +pub fn mark_unusable(c: &mut CardState, problem: &str) { + c.problem = problem.to_string(); + c.enabled = false; + c.identities = 1; + c.state = "unusable".into(); + c.message = format!("not usable ({problem})"); + c.reason = PROBLEM_HINT.into(); + c.sweep_supported = false; + c.sweep_state = "unsupported".into(); + c.sweep_note = c.message.clone(); } /// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle). @@ -322,3 +736,250 @@ pub fn node_version(node: &Path) -> String { .and_then(|o| o.lines().next().map(|l| l.trim().to_string())) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + + // PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box + // while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK | + // ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK". + fn pc1() -> Vec { + parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"}, + {"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"}, + {"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019"}]"#) + } + + #[test] + fn adapters_parse_with_status_code_and_memory() { + let a = pc1(); + assert_eq!(a.len(), 3); + assert_eq!(a[0].name, "AMD Radeon RX 9070 XT"); + assert_eq!(a[0].status, "Error"); + assert_eq!(a[0].code, 43); + assert_eq!(a[0].ram_mb, 0); + assert_eq!(a[0].problem().as_deref(), Some("Code 43")); + assert_eq!(a[1].ram_mb, 2048); + assert_eq!(a[1].problem(), None); + assert_eq!(a[2].problem(), None); + // a single adapter: ConvertTo-Json gives one object, not an array + let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#); + assert_eq!(one.len(), 1); + assert!(parse_adapters("not json").is_empty()); + } + + #[test] + fn problem_without_a_code_is_the_status_word() { + let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() }; + assert_eq!(a.problem().as_deref(), Some("status Degraded")); + let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() }; + assert_eq!(fine.problem(), None); + let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() }; + assert_eq!(code12.problem().as_deref(), Some("Code 12")); + } + + #[test] + fn kind_from_pc1_lines_and_the_mac() { + let a = pc1(); + // the discrete cards, with and without their adapter row + assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete"); + assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete"); + assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete"); + // the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code) + assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated"); + assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated"); + assert_eq!(classify_kind("gfx1036", None), "integrated"); + assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated"); + // a discrete gfx code stays discrete; an Arc card is discrete despite "Intel" + assert_eq!(classify_kind("gfx1201", None), "discrete"); + assert_eq!(classify_kind("gfx1030", None), "discrete"); + assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete"); + // an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count + let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() }; + assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated"); + assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)"); + // shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing + let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() }; + assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated"); + let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() }; + assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete"); + // the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated + assert!(!looks_integrated("Apple M5 Max")); + assert_eq!(vendor_of("Apple M5 Max"), "apple"); + assert_eq!(vendor_of("gfx1036"), "amd"); + assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd"); + assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia"); + } + + // PC 1 after Adrenalin 26.9.2 and a reboot (5 October 2026, evening): all three adapters OK, the Windows names, + // DEV ids and PCI addresses as the coordinator read them (the 5090's bus 01:00.0; the two AMD cards' addresses are + // not in that reading, so this fixture leaves them empty, as an old worker's --list would) + fn pc1_rebooted() -> Vec { + parse_adapters(r#"[{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x13C0)","PNPDeviceID":"PCI\\VEN_1002&DEV_13C0&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041","BusNumber":null,"Address":null}, + {"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019","BusNumber":1,"Address":0}, + {"Name":"AMD Radeon RX 9070 XT","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008","BusNumber":null,"Address":null}]"#) + } + // the 0.3.9 app's five rows came from this shape of --list: two AMD platforms, each listing both AMD GPUs (the old + // 32.0.21042 ICD and the new 32.0.32015 one); the driver strings are the shape AMD's runtime prints, the numbers + // are the Windows driver builds (approximate: the OpenCL CL_DRIVER_VERSION was not captured) + const PC1_LIST: &str = "OpenCL devices (4):\n\ + [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\ + global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\ + [1] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\ + global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\ + [2] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\ + global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\ + [3] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\ + GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\ + global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n"; + const PC1_SMI: &str = "0, NVIDIA GeForce RTX 5090, 32607 MiB, 00000000:01:00.0\n"; + + fn pc1_inputs(list: &str) -> Inputs { + Inputs { nvidia: Some(PC1_SMI.into()), nvidia_limits: Default::default(), cuda_worker: true, opencl: Some(list.into()), opencl_installed: true, adapters: Some(pc1_rebooted()) } + } + + #[test] + fn opencl_list_parses_both_platforms_and_the_pci_field() { + let (devs, listed) = parse_opencl_list(PC1_LIST); + assert!(listed); + assert_eq!(devs.len(), 4); + assert_eq!(devs[1].index, "1"); + assert_eq!(devs[1].name, "gfx1201"); + assert_eq!(devs[1].platform, "AMD Accelerated Parallel Processing"); + assert_eq!(devs[1].platform_version, "OpenCL 2.1 AMD-APP (3617.0)"); + assert_eq!(devs[1].driver, "3617.0 (PAL,HSAIL)"); + assert_eq!(devs[1].units, "32 compute units"); + assert_eq!(devs[1].mem_mb, 16368); + assert_eq!(devs[1].bus, ""); + assert!(devs[1].is_gpu); + assert_eq!(devs[1].vendor_word(), "amd"); + let with_pci = PC1_LIST.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n"); + let (devs, _) = parse_opencl_list(&with_pci); + assert_eq!(devs[1].bus, "05:00.0"); + assert_eq!(devs[3].bus, "05:00.0"); + assert!(!parse_opencl_list("").1); + } + + #[test] + fn two_amd_platforms_give_one_entry_per_card() { + // without PCI addresses: by code and ordinal, the newer driver wins + let (devs, _) = parse_opencl_list(PC1_LIST); + let (kept, dropped) = dedupe_platforms(devs); + assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::>(), vec!["2", "3"]); + assert_eq!(dropped.len(), 2); + assert!(dropped[0].starts_with("[0] gfx1036 on AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0)) is the same card as"), "{}", dropped[0]); + // with PCI addresses: by address; a card the winner does not list (the old ICD still serving a third card) is kept + let text = PC1_LIST + .replace("2 compute units, 2200 MHz\n", "2 compute units, 2200 MHz, pci 0c:00.0\n") + .replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n") + + " [4] gfx1100 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 96 compute units, 2500 MHz, pci 09:00.0\n global 24560 MiB\n"; + let (devs, _) = parse_opencl_list(&text); + let (kept, dropped) = dedupe_platforms(devs); + // the old platform now lists three and wins on count: its three stay, the new platform's two are duplicates + assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::>(), vec!["0", "1", "4"]); + assert_eq!(dropped.len(), 2); + // two real twins on one platform keep both entries (same code, different ordinal or address) + let twins = "OpenCL devices (2):\n [0] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n [1] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n"; + let (kept, dropped) = dedupe_platforms(parse_opencl_list(twins).0); + assert_eq!(kept.len(), 2); + assert!(dropped.is_empty()); + } + + #[test] + fn names_come_from_windows_by_bus_then_device_id_then_the_table() { + let a = pc1_rebooted(); + let mut used = Vec::new(); + assert_eq!(resolve_name("NVIDIA GeForce RTX 5090", "nvidia", "01:00.0", &a, &mut used).0, "NVIDIA GeForce RTX 5090"); + assert_eq!(resolve_name("gfx1201", "amd", "", &a, &mut used).0, "AMD Radeon RX 9070 XT"); + assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "AMD Radeon(TM) Graphics"); + assert_eq!(used.len(), 3); + // every adapter is taken: a second gfx1036 gets the table's words, a code the table lacks stays a code + assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "Ryzen integrated Radeon Graphics"); + assert_eq!(resolve_name("gfx9999", "amd", "", &a, &mut used).0, "gfx9999"); + assert_eq!(resolve_name("gfx1100", "amd", "", &[], &mut Vec::new()).0, "Radeon RX 7900 XTX / XT"); + // by PCI address when both sides have one, before any table + let mut b = pc1_rebooted(); + b[2].bus = "05:00.0".into(); + let mut used = Vec::new(); + assert_eq!(resolve_name("gfx1201", "amd", "05:00.0", &b, &mut used), ("AMD Radeon RX 9070 XT".to_string(), Some(2))); + // the device id alone names a card whose adapter name the table does not know + let mut c = pc1_rebooted(); + c[2].name = "AMD Radeon RX 9070 XT OC Edition".into(); + assert_eq!(resolve_name("gfx1201", "amd", "", &c, &mut Vec::new()).0, "AMD Radeon RX 9070 XT OC Edition"); + assert_eq!(a[2].device_id(), 0x7550); + assert_eq!(a[0].device_id(), 0x13C0); + assert_eq!(a[1].bus, "01:00.0"); + } + + #[test] + fn pc1_five_rows_become_three_cards_named_properly() { + let d = assemble(pc1_inputs(PC1_LIST)); + assert!(d.nvidia_listed && d.opencl_listed && d.adapters_listed); + let rows: Vec<(String, String, String, String, bool, String)> = d.cards.iter().map(|c| (c.name.clone(), c.key.clone(), c.kind.clone(), c.device.clone(), c.enabled, c.code.clone())).collect(); + assert_eq!(rows, vec![ + ("NVIDIA GeForce RTX 5090".into(), "nvidia:NVIDIA GeForce RTX 5090".into(), "discrete".into(), "0".into(), true, "NVIDIA GeForce RTX 5090".into()), + ("AMD Radeon(TM) Graphics".into(), "amd:gfx1036".into(), "integrated".into(), "2".into(), false, "gfx1036".into()), + ("AMD Radeon RX 9070 XT".into(), "amd:gfx1201".into(), "discrete".into(), "3".into(), true, "gfx1201".into()), + ]); + assert_eq!(d.cards[0].bus, "01:00.0"); + assert_eq!(d.cards[1].reason, INTEGRATED_REASON); + assert_eq!(d.cards[1].identities, 1); + assert_eq!(d.cards[2].identities, 8, "16 GB: 8 identities"); + assert_eq!(d.cards[2].vram_mb, 16368); + assert!(d.cards[2].platform.contains("3649.0")); + assert_eq!(d.dropped.len(), 2); + assert!(d.notes.is_empty(), "{:?}", d.notes); + assert_eq!(crate::hotplug::cards_line(&d.cards), "cards: NVIDIA GeForce RTX 5090 [discrete, off] | AMD Radeon(TM) Graphics [integrated, off] | AMD Radeon RX 9070 XT [discrete, off]"); + // the same machine before the eGPU: one platform, the iGPU alone; the keys do not depend on the index + let before = "OpenCL devices (1):\n [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n global 16384 MiB\n"; + let d0 = assemble(pc1_inputs(before)); + assert_eq!(d0.cards[1].key, "amd:gfx1036"); + assert_eq!(d0.cards[1].device, "0"); + // and the diff between the two lists: the iGPU moved (device 0 to 2), the 9070 XT is new, nothing is removed + let diff = crate::hotplug::diff(&d0.cards, &d.cards, &|c| d.listed(c)); + assert_eq!(diff.unchanged, vec![0]); + assert_eq!(diff.moved.len(), 1); + assert_eq!(diff.moved[0].0, 1); + assert_eq!(diff.added.len(), 1); + assert_eq!(diff.added[0].name, "AMD Radeon RX 9070 XT"); + assert!(diff.removed.is_empty()); + } + + #[test] + fn keys_number_identical_cards() { + let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")]; + cards[2].name = "AMD Radeon RX 9070 XT".into(); + assign_keys(&mut cards); + assert_eq!(cards.iter().map(|c| c.key.as_str()).collect::>(), vec!["nvidia:NVIDIA GeForce RTX 5090", "nvidia:NVIDIA GeForce RTX 5090#2", "amd:gfx1201"]); + } + + #[test] + fn unusable_card_row() { + let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", ""); + mark_unusable(&mut c, "Code 43"); + assert!(!c.enabled); + assert_eq!(c.state, "unusable"); + assert_eq!(c.message, "not usable (Code 43)"); + assert_eq!(c.reason, PROBLEM_HINT); + assert!(!c.present()); + } + + #[test] + fn integrated_default_is_off_with_the_row_words() { + let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1"); + c.kind = classify_kind("gfx1036", None).into(); + apply_defaults(&mut c); + assert!(!c.enabled); + assert_eq!(c.identities, 1); + assert_eq!(c.reason, INTEGRATED_REASON); + let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0"); + big.kind = "discrete".into(); + big.vram_mb = 32768; + apply_defaults(&mut big); + assert!(big.enabled); + assert_eq!(big.identities, 8); + } +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 85075432..3ad329d0 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -29,7 +29,8 @@ pub struct CardChoice { pub enum Cmd { Detect, ApplyCards(Vec), - Detected(Vec, Vec), + /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) + Detected(crate::detect::Detection), Start, Pause, Resume, @@ -350,6 +351,40 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A } } +/// The node's consensus params digest out of its own line ("Consensus params digest: <64 hex> (exchanged in the p2p +/// handshake; ...)"). None for any other line, including the WARN lines that quote a peer's digest. +pub fn digest_from_line(line: &str) -> Option { + let rest = line.split("Consensus params digest:").nth(1)?.trim_start(); + let hex: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); + (hex.len() == 64).then(|| hex.to_ascii_lowercase()) +} + +/// The planned rule changes in an override file: every `_activation_daa` key, lowest height first, with plain +/// words for the name ("fees_v1" -> "Fees v1"). Keys that are not activation heights are left out. +pub fn switches_of(v: &Value) -> Vec { + let mut out: Vec = v + .as_object() + .map(|o| { + o.iter() + .filter_map(|(k, val)| { + let stem = k.strip_suffix("_activation_daa")?; + let daa = val.as_u64()?; + let mut words: Vec = stem.split('_').map(|w| w.to_string()).collect(); + if let Some(first) = words.first_mut() { + let mut c = first.chars(); + if let Some(f) = c.next() { + *first = f.to_ascii_uppercase().to_string() + c.as_str(); + } + } + Some(crate::state::ConsensusSwitch { key: k.clone(), name: words.join(" "), daa }) + }) + .collect() + }) + .unwrap_or_default(); + out.sort_by_key(|s| (s.daa, s.key.clone())); + out +} + /// One value out of `key=value` tokens of a log line. fn kv<'a>(line: &'a str, key: &str) -> Option<&'a str> { let pat = format!(" {key}="); @@ -395,6 +430,8 @@ struct MinerSlot { error_at: Option, /// the per-card watchdog (src/watchdog.rs): one restart, then faulted watch: crate::watchdog::CardWatch, + /// pack refusals (src/watchdog.rs): the pack is exported again before the restart, capped per epoch + pack_rebuilds: crate::watchdog::PackRebuilds, } pub struct Engine { @@ -436,6 +473,12 @@ pub struct Engine { wrapper: bool, last_state_print: Instant, detected: bool, + /// hot-plug (src/hotplug.rs): an enumeration thread is running; one more was asked for meanwhile; when the + /// next periodic one is due; when the app log last carried the card list + detect_busy: bool, + detect_again: bool, + detect_next: Instant, + last_cards_line: Instant, clock_samples: Vec, clock_node_at: Option, clock_node_behind: f64, @@ -530,6 +573,10 @@ impl Engine { wrapper, last_state_print: now, detected: false, + detect_busy: false, + detect_again: false, + detect_next: now + Duration::from_secs(crate::hotplug::POLL_S), + last_cards_line: now, clock_samples: Vec::new(), clock_node_at: None, clock_node_behind: 0.0, @@ -635,62 +682,34 @@ impl Engine { fn command(&mut self, c: Cmd) { match c { Cmd::Detect => { - if self.detected { + // the first run, /api/detect, the host's "detect" on a device change, and the minute poll all land + // here; one enumeration at a time, and a request during one runs once more after it + if self.detect_busy { + self.detect_again = true; return; } - self.st().detecting = true; + self.detect_busy = true; + if !self.detected { + self.st().detecting = true; + } let bins = self.bins.clone(); let shared = self.shared.clone(); std::thread::spawn(move || { - let mut notes = Vec::new(); - let cards = crate::detect::detect(&bins, &mut notes); - shared.send(Cmd::Detected(cards, notes)); + let d = crate::detect::detect(&bins); + shared.send(Cmd::Detected(d)); }); } - Cmd::Detected(cards, notes) => { - self.detected = true; - let names: Vec = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).collect(); - self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") })); - for n in ¬es { - self.shared.log(&format!("detection: {n}")); + Cmd::Detected(d) => { + self.detect_busy = false; + self.detect_next = Instant::now() + Duration::from_secs(crate::hotplug::POLL_S); + if self.detected { + self.merge_detection(d); + } else { + self.first_detection(d); } - let prefs = self.shared.settings.lock().unwrap().cards.clone(); - let mut st = self.st(); - st.detecting = false; - st.detect_message = notes.join(". "); - st.mining.cards = cards; - for c in st.mining.cards.iter_mut() { - if let Some(p) = prefs.get(&c.key) { - c.enabled = p.enabled && c.kind != "unknown"; - c.identities = p.identities.clamp(1, 64); - c.reason = String::new(); - if c.vendor == "nvidia" && p.power_pct > 0 { - c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100); - } - c.pinned = p.pinned; - c.sweep_pct = p.sweep_pct; - c.sweep_eff = p.sweep_eff; - c.sweep_watts = p.sweep_watts; - c.sweep_mhs = p.sweep_mhs; - c.sweep_at = p.sweep_at as f64; - } - } - let supported: Vec = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect(); - let unsupported: Vec = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect(); - drop(st); - for u in &unsupported { - self.shared.log(&format!("efficiency sweep {u}")); - } - if self.shared.runtime.sweep_only { - if supported.is_empty() { - self.sweep_say("SWEEP none reason=no_supported_card"); - self.shared.send(Cmd::Quit); - } else { - self.sweep_queue = supported; - } - } - if self.running { - self.plan_miners(); + if self.detect_again { + self.detect_again = false; + self.shared.send(Cmd::Detect); } } Cmd::ApplyCards(choices) => self.apply_cards(choices), @@ -850,7 +869,7 @@ impl Engine { let mut missing = Vec::new(); { let mut st = self.st(); - for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) { + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) { let want = requested_watts(c); let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w); if got > 0.0 { @@ -971,6 +990,147 @@ impl Engine { // ---- start, node ------------------------------------------------------------------------------------------ + /// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes. + fn first_detection(&mut self, d: crate::detect::Detection) { + self.detected = true; + let crate::detect::Detection { cards, notes, dropped, .. } = d; + let names: Vec = cards.iter().map(|c| format!("{} ({}{}{})", c.name, c.worker, if c.code != c.name { format!(", {}", c.code) } else { String::new() }, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect(); + self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") })); + for n in ¬es { + self.shared.log(&format!("detection: {n}")); + } + for n in &dropped { + self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}")); + } + let prefs = self.shared.settings.lock().unwrap().cards.clone(); + let mut st = self.st(); + st.detecting = false; + st.detect_message = notes.join(". "); + st.mining.cards = cards; + for c in st.mining.cards.iter_mut() { + if let Some(p) = crate::hotplug::pref_for(&prefs, c) { + crate::hotplug::apply_pref(c, p); + } + } + let supported: Vec = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect(); + let unsupported: Vec = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect(); + let line = crate::hotplug::cards_line(&st.mining.cards); + drop(st); + self.shared.log(&line); + self.last_cards_line = Instant::now(); + for u in &unsupported { + self.shared.log(&format!("efficiency sweep {u}")); + } + if self.shared.runtime.sweep_only { + if supported.is_empty() { + self.sweep_say("SWEEP none reason=no_supported_card"); + self.shared.send(Cmd::Quit); + } else { + self.sweep_queue = supported; + } + } + if self.running { + self.plan_miners(); + } + } + + /// A later enumeration (src/hotplug.rs): new cards start, lost cards stop, faulty cards are marked; a list + /// that changed nothing touches nothing (no running worker ever restarts because of a re-detection). + fn merge_detection(&mut self, d: crate::detect::Detection) { + let now = crate::platform::unix_now_f(); + let old = self.st().mining.cards.clone(); + let diff = crate::hotplug::diff(&old, &d.cards, &|c| d.listed(c)); + if diff.is_quiet() { + return; + } + for n in &d.dropped { + self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}")); + } + let prefs = self.shared.settings.lock().unwrap().cards.clone(); + let mut new_nvidia = false; + for i in diff.removed.iter().copied() { + let name = old[i].name.clone(); + self.drop_worker(i, "the card was removed"); + if let Some(c) = self.st().mining.cards.get_mut(i) { + crate::hotplug::mark_removed(c, now); + } + self.shared.event("warn", &format!("Card removed: {name}; its worker stopped")); + } + for (i, problem) in diff.errored.iter() { + let name = old[*i].name.clone(); + self.drop_worker(*i, &format!("the card reports {problem}")); + if let Some(c) = self.st().mining.cards.get_mut(*i) { + crate::detect::mark_unusable(c, problem); + c.added_at = now; + } + self.shared.event("warn", &format!("{name}: not usable ({problem}); its worker stopped. {}", crate::detect::PROBLEM_HINT)); + } + for (i, fresh) in diff.moved.iter() { + if let Some(c) = self.st().mining.cards.get_mut(*i) { + self.shared.log(&format!("{}: device {} is now {} (the running worker keeps its device; the next start uses the new one)", c.name, c.device, fresh.device)); + c.device = fresh.device.clone(); + c.key = fresh.key.clone(); + c.bus = fresh.bus.clone(); + c.name = fresh.name.clone(); + c.platform = fresh.platform.clone(); + if fresh.vram_mb > 0 { + c.vram_mb = fresh.vram_mb; + } + if !fresh.detail.is_empty() { + c.detail = fresh.detail.clone(); + } + } + } + let mut placed: Vec<(usize, CardState)> = Vec::new(); + for (i, fresh) in diff.recovered.into_iter().chain(diff.revived.into_iter()) { + placed.push((i, fresh)); + } + let mut next = self.st().mining.cards.len(); + for fresh in diff.added.into_iter() { + placed.push((next, fresh)); + next += 1; + } + for (i, mut fresh) in placed { + let pref = crate::hotplug::pref_for(&prefs, &fresh).cloned(); + crate::hotplug::settle_new(&mut fresh, i, pref.as_ref(), now); + let (kind, text) = crate::hotplug::added_words(&fresh); + new_nvidia |= fresh.vendor == "nvidia" && fresh.enabled; + let mut st = self.st(); + if i < st.mining.cards.len() { + st.mining.cards[i] = fresh; + } else { + st.mining.cards.push(fresh); + } + drop(st); + self.shared.event(kind, &text); + } + let line = crate::hotplug::cards_line(&self.st().mining.cards); + self.shared.log(&line); + self.last_cards_line = Instant::now(); + if self.running { + self.plan_miners(); + if new_nvidia { + self.apply_power_limits("new card"); + } + } + } + + /// Stops the worker on card `idx` (quit, then up to 8 s) and drops its slot; the sweep on it, if any, is aborted. + fn drop_worker(&mut self, idx: usize, why: &str) { + if self.sweep.as_ref().map(|r| r.card == idx).unwrap_or(false) { + self.sweep_abort(why); + } + let Some(pos) = self.miners.iter().position(|m| m.card == idx) else { return }; + let label = self.miners[pos].label.clone(); + if let Some(mut p) = self.miners[pos].proc.take() { + self.shared.log(&format!("stopping miner {label} ({why})")); + p.write_stdin("quit\n"); + p.stop(8); + } + self.miners.remove(pos); + self.stability.remove(&idx); + } + fn start(&mut self) { if self.running { return; @@ -1069,6 +1229,13 @@ impl Engine { let log = self.shared.runtime.log_dir.join(format!("node-{}{seg}.log", self.stamp)); let args = self.node_args(); let verifier = self.node_verifier(); + // the switches the Node page names: from the override file this start uses (none = the network's defaults) + let switches = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::(&t).ok()).map(|v| switches_of(&v)).unwrap_or_default(); + { + let mut st = self.st(); + st.node.consensus_switches = switches; + st.node.consensus_digest = String::new(); + } match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx, &verifier.env) { Ok(p) => { self.shared.log(&format!("igneumd started (pid {}): {}", p.pid(), p.cmdline)); @@ -1143,7 +1310,7 @@ impl Engine { let cards = self.st().mining.cards.clone(); let id8 = self.shared.runtime.id8(); for c in cards.iter() { - if !c.enabled || self.miners.iter().any(|m| m.card == c.index) { + if !c.enabled || !c.present() || self.miners.iter().any(|m| m.card == c.index) { continue; } let prefix = match c.vendor.as_str() { @@ -1172,6 +1339,7 @@ impl Engine { last_status: None, error_at: None, watch: crate::watchdog::CardWatch::new(), + pack_rebuilds: crate::watchdog::PackRebuilds::new(), }); } if self.miners.is_empty() { @@ -1307,7 +1475,7 @@ impl Engine { let mut st = self.st(); let paused = st.mining.paused; for c in st.mining.cards.iter_mut() { - if c.enabled && c.state != "faulted" { + if c.enabled && c.present() && c.state != "faulted" { c.state = if paused { "off".into() } else { "waiting".into() }; c.hash_now = 0.0; c.pid = 0; @@ -1348,6 +1516,9 @@ impl Engine { let mut st = self.st(); for ch in &choices { let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue }; + if !c.present() { + continue; // a removed or faulty card has no switch; its saved choice waits for it + } let identities = ch.identities.clamp(1, 64); let enabled = ch.enabled && c.kind != "unknown"; let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct); @@ -1366,7 +1537,7 @@ impl Engine { if c.enabled != enabled || c.identities != identities { c.enabled = enabled; c.identities = identities; - c.reason = String::new(); + c.reason = if !enabled && c.kind == "integrated" { crate::detect::INTEGRATED_REASON.into() } else { String::new() }; if !enabled { c.state = "off".into(); c.hash_now = 0.0; @@ -1422,7 +1593,7 @@ impl Engine { let mut what = Vec::new(); { let mut st = self.st(); - for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) { + for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) { let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) }; c.power_pct = pct; let watts = requested_watts(c); @@ -1442,7 +1613,7 @@ impl Engine { self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & "))); let line = cmds.join(" & "); let what = what.join(", "); - let want: std::collections::HashMap = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect(); + let want: std::collections::HashMap = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect(); if self.wrapper && cfg!(windows) { // the window host has a UI context: it shows the administrator prompt and reports back on stdin self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now())); @@ -1479,7 +1650,7 @@ impl Engine { .mining .cards .iter() - .filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0) + .filter(|c| c.vendor == "nvidia" && c.present() && c.power_applied && c.power_before_w > 0.0) .map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64)) .collect(); drop(st); @@ -2019,6 +2190,16 @@ impl Engine { self.last_settings_save = now; self.shared.save_settings(); } + // hot-plug (src/hotplug.rs): enumerate again every POLL_S, and keep the card list in the log for the console + if self.detected && !self.detect_busy && !self.quitting && now >= self.detect_next { + self.detect_next = now + Duration::from_secs(crate::hotplug::POLL_S); + self.shared.send(Cmd::Detect); + } + if self.detected && now.duration_since(self.last_cards_line) >= Duration::from_secs(crate::hotplug::CARDS_LINE_S) { + self.last_cards_line = now; + let line = crate::hotplug::cards_line(&self.st().mining.cards); + self.shared.log(&line); + } } /// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what @@ -2282,6 +2463,7 @@ impl Engine { if !p.alive() { let code = p.exit_code.unwrap_or(-1); let tail = tail_of(&p.log_path, 3); + let long_tail = if code == crate::watchdog::PACK_OUT_OF_DATE_CODE { tail_of(&p.log_path, 40) } else { Vec::new() }; self.miners[i].proc = None; let t = self.secs(now); let verdict = self.miners[i].watch.event(t, crate::watchdog::Event::Exited(code)); @@ -2291,6 +2473,37 @@ impl Engine { self.miners[i].needs_rebuild = true; } self.miners[i].restart_at = Some(now); + } else if code == crate::watchdog::PACK_OUT_OF_DATE_CODE { + // The worker refused its program pack and the miner could not rebuild it (or hit its own + // cap): export the pack from the node again before the next start, not a blind restart; + // at most PACK_REBUILD_CAP times per epoch, then the card shows the reason + let why = long_tail.iter().rev().find_map(|l| crate::watchdog::pack_refusal(l)).unwrap_or_else(|| "the worker refused its program pack".into()); + let epoch = std::fs::read_to_string(self.shared.runtime.app_dir.join("packs").join("devnet").join("seeds.txt")).ok().and_then(|s| crate::watchdog::pack_epoch_of(&s)).unwrap_or_default(); + let name = self.st().mining.cards.get(card_idx).map(|c| c.name.clone()).unwrap_or_else(|| self.miners[i].label.clone()); + match self.miners[i].pack_rebuilds.decide(&epoch, &why) { + crate::watchdog::PackAction::Rebuild { n, cap } => { + self.shared.event("build", "program pack out of date, rebuilding"); + self.shared.log(&format!("{name}: program pack out of date, rebuilding (export {n} of {cap} for epoch {epoch}): {why}")); + self.miners[i].prepared = false; // prepare_worker exports the pack again before the start + self.miners[i].restart_at = Some(now); + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + c.state = "restarting".into(); + c.hash_now = 0.0; + c.message = "program pack out of date, rebuilding".into(); + } + } + crate::watchdog::PackAction::GiveUp { n: _, reason } => { + self.shared.event("error", &format!("{name}: {reason}")); + self.shared.log(&format!("{name}: {reason}; next try in 10 minutes or at the next hour")); + self.miners[i].prepared = false; + self.miners[i].restart_at = Some(now + Duration::from_secs(600)); + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + c.state = "failed".into(); + c.hash_now = 0.0; + c.message = reason; + } + } + } } else if verdict != crate::watchdog::Action::None { // exit 43: the miner gave up on its worker; once more, then the card is faulted self.watchdog_verdict(i, verdict, &tail); @@ -2368,6 +2581,25 @@ impl Engine { } } + /// The strip, the log and the card for a program pack the worker refused or the miner found stale: the miner + /// rebuilds the pack and restarts the worker itself (or exits 44 for the app to export it). One strip line per + /// 30 s: the miner prints the refusal on stderr and its own line on stdout. + fn pack_notice(&mut self, i: usize, card: usize, card_name: &str, why: &str) { + let now = Instant::now(); + self.shared.log(&format!("{card_name}: program pack out of date, rebuilding: {why}")); + if now.duration_since(self.last_error_event) >= Duration::from_secs(30) { + self.last_error_event = now; + self.shared.event("build", "program pack out of date, rebuilding"); + } + self.miners[i].error_at = Some(now); + let t = self.secs(now); + self.miners[i].watch.event(t, crate::watchdog::Event::WorkerRestart("program pack out of date, rebuilding")); + if let Some(c) = self.st().mining.cards.get_mut(card) { + c.hash_now = 0.0; + c.message = "program pack out of date, rebuilding".into(); + } + } + /// Applies a watchdog verdict to slot `i` (its process already stopped or gone): a restart now with the reason on /// the card, or the card marked faulted with the reason in the UI and the log while the other cards keep mining. fn watchdog_verdict(&mut self, i: usize, verdict: crate::watchdog::Action, tail: &[String]) { @@ -2444,6 +2676,7 @@ impl Engine { c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 }; } st.mining.hash_total = total; + crate::hotplug::age(&mut st.mining.cards, unix); let cut = unix - 3600.0; st.mining.found.retain(|t| *t > cut); if st.node.daa > 0 { @@ -2503,6 +2736,13 @@ impl Engine { /// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is /// this machine's clock running behind the network (PC 2, 4 October 2026: 60 s slow after a power cut, 0 blocks). fn node_line(&mut self, text: &str) { + if let Some(d) = digest_from_line(text) { + let mut st = self.st(); + if st.node.consensus_digest != d { + st.node.consensus_digest = d; + } + return; + } if !text.contains("too far into the future") { return; } @@ -2681,6 +2921,10 @@ impl Engine { if let Some(c) = self.st().mining.cards.get_mut(card) { c.message = "the node is not answering; the miner retries".into(); } + } else if let Some(why) = crate::watchdog::pack_refusal(text) { + // the worker refused its program pack; the miner rebuilds the pack and restarts the worker itself + // (or exits 44 for us to export it): the strip and the card name the condition in plain words + self.pack_notice(i, card, &card_name, &why); } else if text.contains("WORKER MISMATCH") || text.contains("worker error") || text.contains("worker exited") || text.contains("worker killed by a guard") || text.contains("panicked") || text.contains("CUDA error") || text.contains("submit error") { let now = Instant::now(); if now.duration_since(self.last_error_event) >= Duration::from_secs(30) { @@ -2703,6 +2947,12 @@ impl Engine { } return; } + if text.contains("PACK OUT OF DATE") { + // the miner found its pack stale or refused (stdout): it rebuilds the pack before the restart + let why = crate::watchdog::pack_refusal(text).unwrap_or_else(|| short(text, 160)); + self.pack_notice(i, card, &card_name, &why); + return; + } if text.contains(" WORKER FAULT ") { // the miner's guards (interval, job time, cpu re-check, stall) killed the worker; it restarts it itself let reason = crate::watchdog::fault_reason(text).unwrap_or_else(|| short(text, 160)); @@ -3108,7 +3358,27 @@ pub fn parse_race(body: &str) -> Option { #[cfg(test)] mod tests { - use super::{parse_race, sync_decision, Reading}; + use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading}; + + #[test] + fn digest_comes_from_the_nodes_own_line_only() { + let own = "2026-10-05 17:58:13.001+01:00 [INFO ] Consensus params digest: 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505 (exchanged in the p2p handshake; a peer with another digest is refused)"; + assert_eq!(digest_from_line(own).as_deref(), Some("1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505")); + let peer = "[WARN ] Refusing peer 188.245.5.161:26611: consensus params digest mismatch, local 72532d35 remote a6da35e8 (the peer's override file, environment or build differs)"; + assert_eq!(digest_from_line(peer), None); + assert_eq!(digest_from_line("Consensus params digest: abc"), None); + assert_eq!(digest_from_line("[INFO ] Processed 100 blocks"), None); + } + + #[test] + fn switches_are_the_activation_heights_lowest_first_in_plain_words() { + let v: serde_json::Value = serde_json::from_str(r#"{"difficulty_v2_activation_daa":33000,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"proving_v0_activation_daa":84100,"max_block_mass":500000}"#).unwrap(); + let s = switches_of(&v); + assert_eq!(s.iter().map(|x| (x.name.as_str(), x.daa)).collect::>(), vec![("Difficulty v2", 33000), ("Proving v0", 84100), ("Finality v3", 135200), ("Fees v1", 210000)]); + assert_eq!(s[3].key, "fees_v1_activation_daa"); + assert!(switches_of(&serde_json::json!({})).is_empty()); + assert!(switches_of(&serde_json::json!(null)).is_empty()); + } #[test] fn race_line_parses() { @@ -3192,9 +3462,17 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { (if m <= 2 { y + 1 } else { y }, m, d) } +/// One pack export at a time (5 October 2026). prepare_worker runs on a thread per card, so two cards starting +/// together ran two `igneum-miner export-pack` processes into the same folder; across an epoch change they +/// interleaved and PC 1's packs\devnet was left with one epoch's program.h and the other's seeds.txt, which every +/// OpenCL worker start then refused ("the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT") until the next +/// export. The second export of a pair rewrites the same pack, which is harmless. +static EXPORT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Exports this hour's program pack from the node to \packs\devnet (the prebuilt workers read it with --pack). #[allow(unused_variables)] fn export_pack(shared: &Arc, bins: &Bins) -> Result<(), String> { + let _one_at_a_time = EXPORT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let pack = shared.runtime.app_dir.join("packs").join("devnet"); let _ = std::fs::create_dir_all(&pack); let out = crate::detect::run_timeout(std::process::Command::new(&bins.miner).args(["export-pack", &shared.runtime.rpc_url(), &pack.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default(); @@ -3213,6 +3491,7 @@ fn build_worker_from_source(shared: &Arc, bins: &Bins, vendor: &str) -> #[cfg(windows)] { use std::process::Command; + let _one_at_a_time = EXPORT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let pack = shared.runtime.app_dir.join("packs").join("devnet"); let _ = std::fs::create_dir_all(&pack); let out = crate::detect::run_timeout(Command::new(&bins.miner).args(["export-pack", &shared.runtime.rpc_url(), &pack.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default(); diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs new file mode 100644 index 00000000..aeaaf46f --- /dev/null +++ b/app/igneum-app/src/hotplug.rs @@ -0,0 +1,494 @@ +//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every +//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the +//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went +//! into PC 1 through an eGPU box while the app ran and nothing noticed. +//! +//! Rules: a card is the same card when its key (vendor:code, "#2" for a twin) matches and the PCI addresses do not +//! disagree, or, failing that, when vendor and name match and that pair is unique on both sides (a twin whose +//! ordinal moved because the first one left). The device index is never part of the identity: it moves. A +//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards +//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a +//! removed row after five minutes. + +use crate::config::CardPref; +use crate::state::CardState; + +/// How long a removed card's row says "removed" before it hides. +pub const REMOVED_SHOWN_S: f64 = 300.0; +/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there). +pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 }; +/// How often the app log carries the full card list, seconds (the console reads it from the log tail). +pub const CARDS_LINE_S: u64 = 600; + +#[derive(Default, Debug)] +pub struct Diff { + /// new cards (usable or with a problem), to be appended + pub added: Vec, + /// cards that were marked removed earlier and are listed again: (slot, the fresh entry) + pub revived: Vec<(usize, CardState)>, + /// slots whose card is gone + pub removed: Vec, + /// slots whose card now reports a problem: (slot, "Code 43") + pub errored: Vec<(usize, String)>, + /// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry) + pub recovered: Vec<(usize, CardState)>, + /// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry) + pub moved: Vec<(usize, CardState)>, + pub unchanged: Vec, +} + +impl Diff { + /// Nothing to do: every present card is where it was. + pub fn is_quiet(&self) -> bool { + self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty() + } +} + +fn bus_compat(a: &CardState, b: &CardState) -> bool { + a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus +} + +fn same_identity(a: &CardState, b: &CardState) -> bool { + a.vendor == b.vendor && (a.name.trim().eq_ignore_ascii_case(b.name.trim()) || (!a.code.is_empty() && a.code.eq_ignore_ascii_case(&b.code))) && bus_compat(a, b) +} + +/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could +/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed. +pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff { + let mut out = Diff::default(); + let mut used = vec![false; fresh.len()]; + let mut pair: Vec> = vec![None; old.len()]; + // exact keys first + for (i, o) in old.iter().enumerate() { + if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key && bus_compat(o, f)) { + used[j] = true; + pair[i] = Some(j); + } + } + // then vendor + name, when that pair is unique among what is still unmatched on both sides + for (i, o) in old.iter().enumerate() { + if pair[i].is_some() { + continue; + } + let cands: Vec = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect(); + let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count(); + if cands.len() == 1 && twins == 0 { + used[cands[0]] = true; + pair[i] = Some(cands[0]); + } + } + for (i, o) in old.iter().enumerate() { + match pair[i] { + Some(j) => { + let f = &fresh[j]; + if o.removed_at > 0.0 { + out.revived.push((i, f.clone())); + } else if o.problem.is_empty() && !f.problem.is_empty() { + out.errored.push((i, f.problem.clone())); + } else if !o.problem.is_empty() && f.problem.is_empty() { + out.recovered.push((i, f.clone())); + } else if !o.problem.is_empty() { + // still faulty: the same problem or a new code, nothing to start or stop + if o.problem != f.problem { + out.errored.push((i, f.problem.clone())); + } else { + out.unchanged.push(i); + } + } else if o.device != f.device || o.key != f.key { + out.moved.push((i, f.clone())); + } else { + out.unchanged.push(i); + } + } + None => { + if o.removed_at > 0.0 { + // already removed: stays hidden or shown as removed + out.unchanged.push(i); + } else if listed(o) { + out.removed.push(i); + } else { + out.unchanged.push(i); + } + } + } + } + for (j, f) in fresh.iter().enumerate() { + if !used[j] { + out.added.push(f.clone()); + } + } + out +} + +/// The saved choice for a card: by its key (vendor:code), else a key saved by an app before 0.3.11 (vendor:index:code, +/// vendor:index:name) when exactly one matches; an index that moved never changes the answer. +pub fn pref_for<'a>(prefs: &'a std::collections::HashMap, c: &CardState) -> Option<&'a CardPref> { + if let Some(p) = prefs.get(&c.key) { + return Some(p); + } + if c.key.contains('#') { + return None; // a twin's choice is its own + } + let head = format!("{}:", c.vendor); + for tail in [format!(":{}", c.code), format!(":{}", c.name)] { + if tail.len() <= 1 { + continue; + } + let found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail) && !k.contains('#')).map(|(_, p)| p).collect(); + if found.len() == 1 { + return Some(found[0]); + } + } + None +} + +/// Applies a saved choice to a freshly detected card (the first detection and every later one use this). +pub fn apply_pref(c: &mut CardState, p: &CardPref) { + if !c.problem.is_empty() { + return; + } + c.enabled = p.enabled && c.kind != "unknown"; + c.identities = p.identities.clamp(1, 64); + if c.enabled || c.kind != "integrated" { + c.reason = String::new(); + } + if c.vendor == "nvidia" && p.power_pct > 0 { + c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100); + } + c.pinned = p.pinned; + c.sweep_pct = p.sweep_pct; + c.sweep_eff = p.sweep_eff; + c.sweep_watts = p.sweep_watts; + c.sweep_mhs = p.sweep_mhs; + c.sweep_at = p.sweep_at as f64; +} + +/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it +/// came with; its slot and the time it appeared. +pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) { + c.index = index; + c.added_at = now; + c.removed_at = 0.0; + c.gone = false; + if let Some(p) = pref { + apply_pref(c, p); + } + if c.problem.is_empty() { + c.state = if c.enabled { "waiting".into() } else { "off".into() }; + } +} + +/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched. +pub fn mark_removed(c: &mut CardState, now: f64) { + c.removed_at = now; + c.gone = false; + c.state = "removed".into(); + c.message = "removed".into(); + c.hash_now = 0.0; + c.pid = 0; + c.restart_in_s = 0; + c.ready = false; + c.prepared = false; +} + +/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed. +pub fn age(cards: &mut [CardState], now: f64) -> bool { + let mut changed = false; + for c in cards.iter_mut() { + let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S; + if gone != c.gone { + c.gone = gone; + changed = true; + } + } + changed +} + +/// The event line for a card that appeared: "New card: , mining" and its kind (ok | info | warn). +pub fn added_words(c: &CardState) -> (&'static str, String) { + if !c.problem.is_empty() { + ("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT)) + } else if c.enabled { + ("ok", format!("New card: {}, mining", c.name)) + } else if c.kind == "integrated" { + ("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON)) + } else { + ("info", format!("New card: {}, off (switched off in settings)", c.name)) + } +} + +/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43). +pub fn state_word(c: &CardState) -> String { + if c.removed_at > 0.0 { + "removed".into() + } else if !c.problem.is_empty() { + format!("not usable ({})", c.problem) + } else if !c.enabled { + "off".into() + } else { + c.state.clone() + } +} + +/// The app-log line the console reads (relay/lib/parse.mjs): `cards: [, ] | ...`, hidden rows +/// left out, `cards: none` when nothing is listed. +pub fn cards_line(cards: &[CardState]) -> String { + let parts: Vec = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect(); + if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON}; + + fn card(name: &str, vendor: &str, device: &str) -> CardState { + let mut c = CardState { index: 0, key: format!("{vendor}:{name}"), code: name.into(), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() }; + c.kind = classify_kind(name, None).into(); + crate::detect::apply_defaults(&mut c); + c + } + // PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036) + fn pc1_start() -> Vec { + let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0"); + a.bus = "00000000:01:00.0".into(); + a.state = "mining".into(); + let mut b = card("gfx1036", "amd", "0"); + b.index = 1; + vec![a, b] + } + fn all_listed(_: &CardState) -> bool { + true + } + fn fresh_pc1_with_egpu() -> Vec { + let mut v = pc1_start(); + v[0].state = "off".into(); + let mut e = card("gfx1201", "amd", "1"); + e.index = 2; + v.push(e); + v + } + + #[test] + fn unchanged_list_is_quiet() { + let old = pc1_start(); + let mut fresh = pc1_start(); + fresh[0].state = "off".into(); // runtime state in the fresh list means nothing + let d = diff(&old, &fresh, &all_listed); + assert!(d.is_quiet(), "{d:?}"); + assert_eq!(d.unchanged, vec![0, 1]); + } + + #[test] + fn a_new_usable_card_is_added_and_nothing_else_moves() { + let old = pc1_start(); + let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed); + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].name, "gfx1201"); + assert_eq!(d.added[0].kind, "discrete"); + assert!(d.added[0].enabled); + assert_eq!(d.unchanged, vec![0, 1]); + assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty()); + } + + #[test] + fn a_new_card_with_a_problem_is_added_as_unusable() { + let old = pc1_start(); + let mut fresh = pc1_start(); + let mut bad = card("AMD Radeon RX 9070 XT", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + fresh.push(bad); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].problem, "Code 43"); + assert!(!d.added[0].enabled); + let (kind, text) = added_words(&d.added[0]); + assert_eq!(kind, "warn"); + assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}"); + assert_eq!(state_word(&d.added[0]), "not usable (Code 43)"); + } + + #[test] + fn an_unplugged_card_is_removed_only_when_its_tool_answered() { + let old = fresh_pc1_with_egpu(); + let fresh = pc1_start(); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.removed, vec![2]); + assert_eq!(d.unchanged, vec![0, 1]); + // the OpenCL worker did not answer this round: nothing is called removed + let opencl_dead = |c: &CardState| c.vendor == "nvidia"; + let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::>(), &opencl_dead); + assert!(d2.removed.is_empty(), "{d2:?}"); + assert!(d2.is_quiet()); + } + + #[test] + fn a_card_whose_index_moved_keeps_its_slot() { + // the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0 + let old = pc1_start(); + let mut fresh = pc1_start(); + fresh[1].device = "1".into(); + let mut e = card("gfx1201", "amd", "0"); + e.index = 2; + fresh.push(e); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.moved.len(), 1); + assert_eq!(d.moved[0].0, 1); + assert_eq!(d.moved[0].1.device, "1"); + assert_eq!(d.added.len(), 1); + assert!(d.removed.is_empty()); + } + + #[test] + fn two_identical_cards_are_told_apart_by_key_and_never_swapped() { + let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0"); + let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1"); + b.index = 1; + b.key = "nvidia:NVIDIA GeForce RTX 5090#2".into(); + a.bus = "01:00.0".into(); + b.bus = "02:00.0".into(); + let old = vec![a.clone(), b.clone()]; + // the second twin leaves: the first keeps its slot by key; the missing one is removed, not "moved" + let d = diff(&old, &[a.clone()], &all_listed); + assert_eq!(d.unchanged, vec![0]); + assert_eq!(d.removed, vec![1]); + // the FIRST twin leaves: the survivor is now index 0 with the unsuffixed key, but its bus says which card it + // is, so slot 1 is "moved" (new key and device) and slot 0 is removed; no worker is swapped between cards + let mut survivor = b.clone(); + survivor.device = "0".into(); + survivor.key = "nvidia:NVIDIA GeForce RTX 5090".into(); + let d2 = diff(&old, &[survivor], &all_listed); + assert_eq!(d2.removed, vec![0]); + assert_eq!(d2.moved.len(), 1); + assert_eq!(d2.moved[0].0, 1); + assert_eq!(d2.moved[0].1.key, "nvidia:NVIDIA GeForce RTX 5090"); + // the same two cards again, nothing changed: quiet + let d3 = diff(&old, &old, &all_listed); + assert!(d3.is_quiet(), "{d3:?}"); + } + + #[test] + fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() { + let old = fresh_pc1_with_egpu(); + // the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL + let mut fresh = pc1_start(); + let mut bad = card("gfx1201", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + fresh.push(bad); + let d = diff(&old, &fresh, &all_listed); + assert_eq!(d.errored, vec![(2, "Code 43".to_string())]); + assert!(d.added.is_empty() && d.removed.is_empty()); + // after a reboot with the card attached it is driven again: recovered, same slot + let mut faulty = old.clone(); + mark_unusable(&mut faulty[2], "Code 43"); + faulty[2].device = String::new(); + let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed); + assert_eq!(d2.recovered.len(), 1); + assert_eq!(d2.recovered[0].0, 2); + assert!(d2.recovered[0].1.problem.is_empty()); + // the same problem again next minute: quiet + let d3 = diff(&faulty, &fresh, &all_listed); + assert!(d3.is_quiet(), "{d3:?}"); + } + + #[test] + fn a_removed_card_that_comes_back_is_revived_in_its_slot() { + let mut old = fresh_pc1_with_egpu(); + mark_removed(&mut old[2], 1000.0); + assert_eq!(state_word(&old[2]), "removed"); + // still absent: quiet (and hidden after five minutes) + let d = diff(&old, &pc1_start(), &all_listed); + assert!(d.is_quiet(), "{d:?}"); + assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S)); + assert!(old[2].gone); + assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0)); + // back: revived in slot 2 + let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed); + assert_eq!(d2.revived.len(), 1); + assert_eq!(d2.revived[0].0, 2); + assert!(d2.added.is_empty()); + let mut back = d2.revived[0].1.clone(); + settle_new(&mut back, 2, None, 2000.0); + assert_eq!(back.index, 2); + assert_eq!(back.removed_at, 0.0); + assert!(!back.gone); + assert_eq!(back.added_at, 2000.0); + assert_eq!(back.state, "waiting"); + } + + #[test] + fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() { + let mut prefs = std::collections::HashMap::new(); + // the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0 + prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() }); + // the iGPU comes back as device 1 (the eGPU took index 0): the 0.3.9 key still answers, by vendor and code + let mut igpu = card("gfx1036", "amd", "1"); + assert_eq!(igpu.kind, "integrated"); + assert!(!igpu.enabled); + assert_eq!(igpu.reason, INTEGRATED_REASON); + let p = pref_for(&prefs, &igpu).cloned(); + assert!(p.is_some()); + settle_new(&mut igpu, 1, p.as_ref(), 5.0); + assert!(igpu.enabled); + assert_eq!(igpu.identities, 2); + assert_eq!(igpu.reason, ""); + assert_eq!(igpu.state, "waiting"); + // the user switched it off (saved under the index-free key): the row keeps the integrated words + prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() }); + let mut igpu2 = card("gfx1036", "amd", "1"); + let p2 = pref_for(&prefs, &igpu2).cloned(); + settle_new(&mut igpu2, 1, p2.as_ref(), 6.0); + assert!(!igpu2.enabled); + assert_eq!(igpu2.reason, INTEGRATED_REASON); + assert_eq!(igpu2.state, "off"); + let (kind, text) = added_words(&igpu2); + assert_eq!(kind, "info"); + assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})")); + // no saved choice: the detect default (integrated off, discrete on) + let mut egpu = card("gfx1201", "amd", "0"); + settle_new(&mut egpu, 2, None, 7.0); + assert!(egpu.enabled); + assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string())); + // a pref never switches on a card with a problem + let mut bad = card("AMD Radeon RX 9070 XT", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0); + assert!(!bad.enabled); + assert_eq!(bad.state, "unusable"); + // old keys only, two of them for the same code (the five-row PC 1 list had amd:0:gfx1036 and amd:2:gfx1036): + // ambiguous, so the default applies; the index-free key, once saved, always wins + prefs.remove("amd:gfx1036"); + prefs.insert("amd:2:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() }); + let other = card("gfx1036", "amd", "7"); + assert!(pref_for(&prefs, &other).is_none()); + prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: true, identities: 4, ..Default::default() }); + assert_eq!(pref_for(&prefs, &other).map(|p| p.identities), Some(4)); + // a twin never borrows the first card's choice + let mut twin = card("gfx1201", "amd", "3"); + twin.key = "amd:gfx1201#2".into(); + prefs.insert("amd:gfx1201".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() }); + assert!(pref_for(&prefs, &twin).is_none()); + // the name on the row is the Windows name while the key keeps the code: the 0.3.9 key by code still answers + let mut named = card("gfx1201", "amd", "1"); + named.name = "AMD Radeon RX 9070 XT".into(); + prefs.clear(); + prefs.insert("amd:1:gfx1201".to_string(), CardPref { enabled: false, identities: 2, ..Default::default() }); + assert_eq!(pref_for(&prefs, &named).map(|p| p.identities), Some(2)); + } + + #[test] + fn the_console_line_lists_every_shown_card_with_kind_and_state() { + let mut cards = fresh_pc1_with_egpu(); + cards[0].state = "mining".into(); + cards[2].state = "starting".into(); + let mut bad = card("AMD Radeon RX 9070 XT", "amd", ""); + mark_unusable(&mut bad, "Code 43"); + cards.push(bad); + assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]"); + mark_removed(&mut cards[2], 10.0); + assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]")); + age(&mut cards, 10.0 + REMOVED_SHOWN_S); + assert!(!cards_line(&cards).contains("gfx1201")); + assert_eq!(cards_line(&[]), "cards: none"); + } +} diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index bc833a21..dc7a6773 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -1119,7 +1119,10 @@ fn run_script(shared: &Arc, job: &Job, sink: &Sink, jobs_dir: &Path, dat std::fs::write(&wrapper, [b"\xEF\xBB\xBF".as_slice(), w.as_bytes()].concat()).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&out_file); let inner = format!("-NoProfile -ExecutionPolicy Bypass -File \"{}\"", wrapper.display()); - let ps = format!("$p = Start-Process -FilePath powershell.exe -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode", inner.replace('\'', "''")); + // A refused or unanswered UAC prompt makes Start-Process throw (`$p` stays null) and `exit $p.ExitCode` + // would exit 0: the 5 October 2026 driver job on PC 1 was reported "done" after Windows cancelled its + // prompt at 122 s. The launch failure is exit 251 and says so on stderr. + let ps = format!("try {{ $p = Start-Process -FilePath powershell.exe -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{ Write-Error ('elevated launch failed (UAC refused, cancelled or timed out): ' + $_.Exception.Message); exit 251 }}; if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}; exit $p.ExitCode", inner.replace('\'', "''")); cmd = Command::new(crate::platform::tool("powershell")); cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]); } else if shell == "powershell" { @@ -1145,6 +1148,7 @@ fn run_script(shared: &Arc, job: &Job, sink: &Sink, jobs_dir: &Path, dat fn finish_ran(ran: Ran, what: &str) -> Result { match ran.code { Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }), + Some(251) => Ok(Done { status: "failed".into(), exit: 251, summary: format!("{what} did not start: the administrator prompt was refused, cancelled or timed out (click Yes within 2 minutes)"), extra: json!({}) }), Some(c) => Ok(Done { status: "failed".into(), exit: c as i64, summary: format!("{what} exited with code {c}"), extra: json!({}) }), None if ran.timed_out => Ok(Done { status: "timeout".into(), exit: -1, summary: format!("{what} hit the time cap and was ended"), extra: json!({}) }), None => Err(format!("{what} was ended")), @@ -1259,6 +1263,20 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec, ran: Option mod tests { use super::*; + #[test] + fn a_refused_administrator_prompt_is_a_failure_not_done() { + // 5 October 2026: the elevated launcher exited 0 after Windows cancelled an unanswered UAC prompt + let d = finish_ran(Ran { code: Some(251), timed_out: false }, "script").unwrap(); + assert_eq!((d.status.as_str(), d.exit), ("failed", 251)); + assert!(d.summary.contains("administrator prompt"), "{}", d.summary); + let d = finish_ran(Ran { code: Some(0), timed_out: false }, "script").unwrap(); + assert_eq!(d.status, "done"); + // the launcher string itself: a thrown Start-Process must not fall through to `exit $p.ExitCode` + let src = include_str!("jobrun.rs"); + assert!(src.contains("-Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{")); + assert!(src.contains("if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}")); + } + #[test] fn collect_outcome_follows_the_command_exit() { let d = collect_done(0, 0, vec![], None); diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 126291f0..dc37f2bb 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -16,6 +16,7 @@ mod config; mod detect; mod engine; +mod hotplug; mod keys; mod platform; mod procs; @@ -135,6 +136,8 @@ fn main() { "quit" => shared.send(engine::Cmd::Quit), "pause" => shared.send(engine::Cmd::Pause), "resume" => shared.send(engine::Cmd::Resume), + // the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute + "detect" => shared.send(engine::Cmd::Detect), "elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))), _ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))), _ => {} diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 99ca8b43..c5cfb1ca 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -288,6 +288,44 @@ fn set(shared: &Shared, f: F) { f(&mut st.proving); } +/// The pinned ids out of `igneum-prove-host --mode id` ("RESULT id: pinned guests: shard program id 0x... (...) +/// aggregator id 0x... (...)"): (shard program id, aggregator id). None when the line is not there. +pub fn ids_from_describe(text: &str) -> Option<(String, String)> { + let line = text.lines().find(|l| l.contains("shard program id "))?; + let after = |key: &str| -> Option { + let rest = line.split(key).nth(1)?.trim_start(); + let id: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric()).collect(); + (id.starts_with("0x") && id.len() == 66).then_some(id) + }; + Some((after("shard program id ")?, after("aggregator id ")?)) +} + +/// Reads the pinned ids once (`--mode id` does no key setup, under a second) and puts them on the state. The Prove +/// page shows them with the verifier state, proving on or off. +fn read_ids(shared: &Shared, t: &Tools) { + if !shared.state.lock().unwrap().proving.program_id.is_empty() { + return; + } + // not run_tool: that stops the child while proving is off, and the ids are wanted proving on or off + let out = if t.wsl { + let body = format!("export RUST_LOG=off\nexec {} --mode id", crate::wslhost::sq(&t.host.display().to_string())); + let Ok(file) = crate::wslhost::write_script("prove-ids", &body) else { return }; + crate::detect::run_timeout(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[]), None, Duration::from_secs(60)) + } else { + crate::detect::run_timeout(Command::new(&t.host).args(["--mode", "id"]).env("RUST_LOG", "off"), None, Duration::from_secs(60)) + }; + match ids_from_describe(&out.unwrap_or_default()) { + Some((shard, agg)) => { + shared.log(&format!("prover: pinned shard program id {shard}, aggregator id {agg}")); + set(shared, |p| { + p.program_id = shard; + p.aggregator_id = agg; + }); + } + None => shared.log("prover: --mode id printed no pinned ids (an older host)"), + } +} + static BIN_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); /// Starts the prover thread. It idles while the setting is off or the node is not synced. @@ -306,6 +344,13 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new(); let mut last_verifier_read = Instant::now() - Duration::from_secs(600); let mut asked_restart = false; + // macOS and Linux: the host sits next to the engine, so its pinned ids are read at once, proving on or off + // (Windows runs the host inside WSL2, which is probed only once proving is on) + if !cfg!(windows) { + if let Ok(t) = find_tools(&bin_dir) { + read_ids(&shared, &t); + } + } loop { std::thread::sleep(Duration::from_secs(10)); let enabled = shared.settings.lock().unwrap().prove; @@ -345,6 +390,7 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { asked_restart = true; shared.send(crate::engine::Cmd::RestartNode("the WSL2 prover is installed now; the node restarts to verify proof records".into())); } + read_ids(&shared, &t); tools = Some(t); } Err(e) => { @@ -544,6 +590,16 @@ pub fn setup(shared: &Shared) -> Result { mod tests { use super::*; + #[test] + fn pinned_ids_come_out_of_the_hosts_id_line() { + let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n"; + let (shard, agg) = ids_from_describe(out).unwrap(); + assert_eq!(shard, "0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a"); + assert_eq!(agg, "0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896"); + assert_eq!(ids_from_describe("RESULT setup: 1.2 s"), None); + assert_eq!(ids_from_describe("shard program id 0xabc aggregator id 0xdef"), None); + } + #[test] fn chooses_the_newest_unpaid_assigned_shard_not_yet_attempted() { let v: Value = serde_json::from_str(r#"[ diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index faaa8199..356b0cf8 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -24,12 +24,26 @@ pub struct NodeState { /// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none pub consensus_switch_daa: u64, pub override_restart_wait: String, + /// the node's "Consensus params digest: <64 hex>" line (exchanged in the p2p handshake); empty until the node prints it + pub consensus_digest: String, + /// every activation height in the override file the node was started with (miner-ui-2): the Node page names the next one + pub consensus_switches: Vec, } -#[derive(Clone, Serialize, Default)] +/// One planned rule change the node applies by itself at a DAA score (from the override file's `*_activation_daa` keys). +#[derive(Clone, Serialize, Default, PartialEq, Debug)] +pub struct ConsensusSwitch { + pub key: String, // the override key, for example fees_v1_activation_daa + pub name: String, // plain words: Fees v1 + pub daa: u64, +} + +#[derive(Clone, Serialize, Default, Debug)] pub struct CardState { pub index: usize, - pub key: String, // stable id for the saved preference: vendor:device:name + pub key: String, // stable id for the saved preference: vendor:code, "#2" and up for a second identical card (no index: it moves) + pub code: String, // the tool's own device name (AMD's OpenCL runtime says "gfx1201"); the key and the tooltip carry it + pub platform: String, // the OpenCL platform and driver the worker opens it through (the tooltip) pub kind: String, // apple | discrete | integrated | external | unknown pub vram_mb: u64, // 0 when unknown pub reason: String, // why it is off by default, if it is @@ -40,7 +54,7 @@ pub struct CardState { pub detail: String, // memory, cores pub device: String, // the worker's --device value (Windows) pub enabled: bool, - pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) + pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged) pub hash_now: f64, // MH/s, the last interval pub hash_avg: f64, // MH/s since the start pub accepted: u64, @@ -57,6 +71,15 @@ pub struct CardState { pub pid: u32, pub last_status_age_s: f64, pub message: String, + /// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it + pub problem: String, + /// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved + pub bus: String, + /// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it + /// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides) + pub added_at: f64, + pub removed_at: f64, + pub gone: bool, /// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count pub faults: u64, /// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line) @@ -92,6 +115,14 @@ pub struct CardState { pub race_variants: u32, } +impl CardState { + /// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the + /// list so the other cards' indices stay put). + pub fn present(&self) -> bool { + self.removed_at == 0.0 && self.problem.is_empty() + } +} + #[derive(Clone, Serialize, Default)] pub struct MiningState { pub state: String, // idle | waiting | mining | paused | stopped @@ -162,6 +193,9 @@ pub struct ProvingState { pub pool_entries: u64, pub pool_verified: u64, pub pool_failed: u64, + /// the pinned guests' ids (`igneum-prove-host --mode id`): the shard program and the aggregator; empty until read + pub program_id: String, + pub aggregator_id: String, } #[derive(Clone, Serialize, Default)] diff --git a/app/igneum-app/src/watchdog.rs b/app/igneum-app/src/watchdog.rs index 27f30c83..369ad822 100644 --- a/app/igneum-app/src/watchdog.rs +++ b/app/igneum-app/src/watchdog.rs @@ -27,6 +27,12 @@ pub const HEALTHY_RESET_S: f64 = 300.0; pub const MINER_GAVE_UP_CODE: i32 = 43; /// No `watch` reading from our node for this long: restart it. pub const NODE_SILENT_S: f64 = 120.0; +/// `igneum-miner` exit code when its worker refused the program pack it was started with and the miner could not +/// rebuild it (or rebuilt it `PACK_REBUILD_CAP` times this epoch): the app exports the pack from the node again +/// before the next start, at most `PACK_REBUILD_CAP` times per epoch, then shows the card. +pub const PACK_OUT_OF_DATE_CODE: i32 = 44; +/// Pack rebuilds per epoch seed before the app stops restarting the miner on it. +pub const PACK_REBUILD_CAP: u32 = 3; /// Node restarts inside this window grow the delay before the next one. pub const NODE_WINDOW_S: f64 = 600.0; @@ -280,10 +286,114 @@ fn kv_f64(line: &str, key: &str) -> Option { kv(line, key)?.trim_end_matches('s').parse().ok() } +/// The decision after a pack refusal (exit `PACK_OUT_OF_DATE_CODE`, or a `PACK OUT OF DATE` / `error 0 pack` line +/// from the miner): rebuild the pack before the restart, or stop for this epoch. 5 October 2026: the app restarted +/// two workers every 20 to 40 s for an hour on a pack neither it nor the miner had re-exported in between. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PackAction { + /// Export the pack from the node again (the n-th time this epoch) and start the miner on it now. + Rebuild { n: u32, cap: u32 }, + /// The cap for this epoch is reached: show the card with this reason and wait for the next epoch (or a reset). + GiveUp { n: u32, reason: String }, +} + +/// Pack rebuilds per epoch seed (the epoch the exported pack names in its seeds.txt). +#[derive(Debug, Default)] +pub struct PackRebuilds { + epoch: Option, + n: u32, +} + +impl PackRebuilds { + pub fn new() -> Self { + Self::default() + } + + /// The miner exited over its pack while the exported pack is for `epoch`; `why` is the miner's reason. + pub fn decide(&mut self, epoch: &str, why: &str) -> PackAction { + if self.epoch.as_deref() != Some(epoch) { + self.epoch = Some(epoch.to_string()); + self.n = 0; + } + if self.n >= PACK_REBUILD_CAP { + return PackAction::GiveUp { n: self.n, reason: format!("the program pack still fails after {} rebuilds this epoch ({why}); the worker and the pack disagree", self.n) }; + } + self.n += 1; + PackAction::Rebuild { n: self.n, cap: PACK_REBUILD_CAP } + } + + pub fn count(&self) -> u32 { + self.n + } +} + +/// A miner line that names a pack refusal: the worker's own `error 0 pack : ` (relayed as +/// `worker error: ...`) or the miner's `PACK OUT OF DATE : `. Returns the reason, in plain words. +pub fn pack_refusal(text: &str) -> Option { + if let Some(i) = text.find("PACK OUT OF DATE") { + let rest = text[i + "PACK OUT OF DATE".len()..].trim_start_matches(':').trim(); + return Some(rest.split_once(": ").map(|(_, why)| why).unwrap_or(rest).to_string()); + } + if let Some(i) = text.find("error 0 pack ") { + let rest = &text[i + "error 0 pack ".len()..]; + let (_, why) = rest.split_once(": ")?; + return Some(why.trim().to_string()); + } + None +} + +/// The epoch seed hex an exported pack names (`epoch_seed_hex ` in its seeds.txt), 16 chars. +pub fn pack_epoch_of(seeds_txt: &str) -> Option { + seeds_txt.lines().find_map(|l| l.strip_prefix("epoch_seed_hex ")).map(|h| h.trim().chars().take(16).collect()) +} + #[cfg(test)] mod tests { use super::*; + // The refusal as PC 1 and PC 2 logged it on 5 October 2026 (epoch 34), the miner's own line, and non-refusals. + const REFUSAL: &str = "! 1791224840.037 worker error: error 0 pack packs\\devnet: the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT (wrong seeds.txt for this pack?)"; + const MINER_LINE: &str = "1791224840.100 PACK OUT OF DATE packs\\devnet: the worker refused its program pack (the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT); rebuilding the program pack before the restart"; + + #[test] + fn pack_refusal_reads_both_lines_and_nothing_else() { + assert_eq!(pack_refusal(REFUSAL).unwrap(), "the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT (wrong seeds.txt for this pack?)"); + assert!(pack_refusal(MINER_LINE).unwrap().starts_with("the worker refused its program pack")); + assert_eq!(pack_refusal("! 1791224651.247 worker error: error 5838 epoch seed mismatch: this worker holds epoch bed7ab62cbece66c"), None); + assert_eq!(pack_refusal(STATUS_OK), None); + assert_eq!(pack_epoch_of("epoch_seed_hex 009858237e118f69abc8d096e9b1af21c24539eaecdfd1b896588825660a69ec\nday_seed_hex 69676e65\n").as_deref(), Some("009858237e118f69")); + assert_eq!(pack_epoch_of("day_seed_hex 69676e65\n"), None); + } + + /// Known-good: a refusal rebuilds the pack, once per refusal, and a new epoch starts the count again. + #[test] + fn pack_rebuilds_known_good() { + let mut p = PackRebuilds::new(); + assert_eq!(p.decide("009858237e118f69", "x"), PackAction::Rebuild { n: 1, cap: PACK_REBUILD_CAP }); + assert_eq!(p.decide("009858237e118f69", "x"), PackAction::Rebuild { n: 2, cap: PACK_REBUILD_CAP }); + assert_eq!(p.decide("5e5d0c3b2a19f8e7", "x"), PackAction::Rebuild { n: 1, cap: PACK_REBUILD_CAP }); + assert_eq!(p.count(), 1); + } + + /// Known-mismatched: a pack the worker refuses after every rebuild stops at the cap with the reason in plain + /// words, and stays stopped for that epoch. + #[test] + fn pack_rebuilds_known_mismatched_gives_up_at_the_cap() { + let mut p = PackRebuilds::new(); + for n in 1..=PACK_REBUILD_CAP { + assert_eq!(p.decide("009858237e118f69", "the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT"), PackAction::Rebuild { n, cap: PACK_REBUILD_CAP }); + } + match p.decide("009858237e118f69", "the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT") { + PackAction::GiveUp { n, reason } => { + assert_eq!(n, PACK_REBUILD_CAP); + assert_eq!(reason, "the program pack still fails after 3 rebuilds this epoch (the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT); the worker and the pack disagree"); + } + a => panic!("{a:?}"), + } + assert!(matches!(p.decide("009858237e118f69", "x"), PackAction::GiveUp { .. })); + assert_eq!(p.decide("5e5d0c3b2a19f8e7", "x"), PackAction::Rebuild { n: 1, cap: PACK_REBUILD_CAP }); + } + // Lines as igneum-miner 0.3.x prints them (devnet v4, 4 October 2026; identities and labels shortened). const STATUS_OK: &str = "1791138616.597 STATUS 'win-1' [worker]: 70s jobs=486 accepted=3 rejected=0 mismatched=0 extra=0 rate=0.04 blocks/s hash=123.90 MH/s wall (124.20 MH/s inside jobs) now=124.10 MH/s wall (124.30 MH/s inside jobs, 70 jobs, seed walk 0 calls) template_age=0.31s synced=true idle=0.2% (last 10s: 0.1%) queued=2 restarts=0 faults=0 identities=8 accepted_by_identity=1/0/1/0/0/1/0/0"; const STATUS_ZERO: &str = "1791138626.597 STATUS 'win-1' [worker]: 80s jobs=486 accepted=3 rejected=0 mismatched=0 extra=0 rate=0.04 blocks/s hash=108.41 MH/s wall (124.20 MH/s inside jobs) now=0.00 MH/s wall (0.00 MH/s inside jobs, 0 jobs, seed walk 0 calls) template_age=0.31s synced=true idle=12.5% (last 10s: 100.0%) queued=2 restarts=0 faults=0 identities=8 accepted_by_identity=1/0/1/0/0/1/0/0"; diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 07eb252e..49be864f 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -1,6 +1,9 @@ -/* Igneum Miner dashboard. 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), used by every screen. */ +/* 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). + 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')} @font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')} @@ -13,14 +16,14 @@ :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; + --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; /* 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; /* 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); --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:52px;--drawer-h:260px} + --top:60px;--bottom:0px;--drawer-h:260px;--rail:196px} *{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} @@ -53,21 +56,48 @@ input,textarea{font-variant-numeric:tabular-nums} .btn.ghost:hover{border-color:var(--line-2);color:var(--bone)} .btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)} .btn.danger:hover{color:var(--ember);border-color:var(--ember)} -.btn .chev{transition:transform .2s ease;color:var(--ash)} -.btn.on .chev{transform:rotate(180deg);color:var(--molten)} -.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1} -.icon-btn:hover{color:var(--bone);border-color:var(--ash)} :focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} -@media (prefers-reduced-motion:reduce){.btn,.btn .chev{transition:none}} +@media (prefers-reduced-motion:reduce){.btn{transition:none}} + +/* the rail */ +.rail{position:fixed;top:0;left:0;bottom:0;width:var(--rail);background:var(--rail-bg);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:22;padding:14px 12px 14px;-webkit-app-region:drag} +.rail button{-webkit-app-region:no-drag} +.rail-brand{display:flex;align-items:center;gap:10px;padding:6px 10px 18px;min-width:0} +body.mac .rail-brand{padding-top:30px} +.rail-brand .word{font-family:var(--head);font-weight:900;font-size:17px;letter-spacing:.06em} +.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 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 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)} +.nav.small svg{width:16px;height:16px;flex-basis:16px} +.nav.danger:hover{color:var(--ember)} +.nav.danger:hover svg{color:var(--ember)} +.nav.on.logs{color:var(--molten)} +.nav-dot{position:absolute;right:12px;top:50%;width:7px;height:7px;margin-top:-3px;border-radius:50%;background:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} +.rail-foot{margin-top:auto;display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:1px solid var(--line)} +.rail-status{font-size:var(--t-xs);color:var(--ash);padding:0 12px 10px;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rail-status b{color:var(--ink-2);font-weight:500} +.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 button,.top .pill{-webkit-app-region:no-drag} -body.mac .top{padding-left:92px} +body.mac:not(.has-rail) .top{padding-left:92px} +body.has-rail .top{left:var(--rail)} .brand{display:flex;align-items:center;gap:10px;min-width:0} .brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em} .brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px} -.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto} +.page-title{display:flex;align-items:baseline;gap:12px;min-width:0} +.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)} @@ -78,11 +108,12 @@ body.mac .top{padding-left:92px} @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 notice strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's +/* 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). Nothing here is newer than 2022 CSS (WebView2 host). */ + available, done), bad ember (failed, clock block, urgent). */ .notices{position:fixed;top:var(--top);left:0;right:0;z-index:19} -.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-md);line-height:1.4;color:var(--bone)} +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)} .notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)} .notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)} .notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} @@ -95,25 +126,18 @@ body.mac .top{padding-left:92px} .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 i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} -.job-history table{margin-top:var(--s-2)} -.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0} -.job-history td{padding:5px 6px 5px 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{margin-top:var(--s-3);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)} -.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45} -.clock-card .note{margin-top:0} -/* screens */ +/* 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} @media (prefers-reduced-motion:reduce){main{transition:none}} -body.has-bottom main{bottom:var(--bottom)} -body.drawer-open main{bottom:calc(var(--bottom) + var(--drawer-h))} +body.has-rail main{left:var(--rail)} +body.drawer-open main{bottom:var(--drawer-h)} .screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease} body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} @keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} -@media (prefers-reduced-motion:reduce){.screen{animation:none}} +@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}} +#screen-dashboard{padding:22px 0 32px} +.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} @@ -128,7 +152,6 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .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} -.tile .h{font-family:var(--head);font-weight:700;font-size:var(--t-h3);margin-bottom:var(--s-2)} .cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px} .seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em} @@ -138,6 +161,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .step .cta{justify-content:flex-start;margin-top:var(--s-2)} .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} .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} @@ -145,27 +169,14 @@ 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)}} -.gpu{display:flex;align-items:center;gap:18px} -.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} -.gpu .badge.apple{color:var(--bone)} -.gpu .badge.nvidia{color:var(--nvidia)} -.gpu .badge.amd{color:var(--ember)} -.gpu .name{font-family:var(--head);font-weight:700;font-size:var(--t-2xl);line-height:1.2} -.gpu .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} -.gpu .meta b{color:var(--ink-2);font-weight:500} -.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px} -.gpu.off .tick{background:var(--line-2)} -.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards} -@keyframes draw{to{stroke-dashoffset:0}} -.gpu .msg{font-size:var(--t-sm);color:var(--ember);margin-top:var(--s-1)} -/* GPU rows (first run and settings) */ +/* 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} -.gpu-row .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)} -.gpu-row .badge.apple{color:var(--bone)} -.gpu-row .badge.nvidia{color:var(--nvidia)} -.gpu-row .badge.amd{color:var(--ember)} +.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.apple{color:var(--bone)} +.badge.nvidia{color:var(--nvidia)} +.badge.amd{color:var(--ember)} .gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)} .gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} .kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap} @@ -175,56 +186,147 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .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)} -.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} -.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)} +.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)} + +/* 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} +.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} +.toggle-big .ring svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round} +.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 .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} +.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} +.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: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.warn{color:var(--ember)} +.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>div:last-child{border-bottom:0} +.feed>div>span:first-child{min-width:0} +.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)} + +/* the GPU rows on the Mine page: badge, name, numbers, the switch */ +.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.off{opacity:.62} +.gpu-line .badge{width:44px;height:44px;flex-basis:44px;border-radius:12px} +.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} +.gpu-line .st.on{color:var(--molten)} +.gpu-line .st.bad{color:var(--ember)} +.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)} +.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} +.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} -.gpu-row.off .ids{opacity:.4;pointer-events:none} -.gpu-row .switch{padding:0;flex:0 0 auto} -.cards.compact .gpu-row{padding:12px 14px;gap:var(--s-3);border-radius:14px} -.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px} -.cards.compact .gpu-row .name{font-size:var(--t-md)} -.cards.compact .gpu-row .info{flex-basis:calc(100% - 50px);min-width:120px} -.cards.compact .gpu-row .power{margin-left:50px} -.cards.compact .gpu-row .switch{margin-left:auto} -.cards.compact .ids .k{display:none} - -.power{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto} -.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.power input[type=range]{width:110px;accent-color:var(--ember)} -.power .pv{font-size:var(--t-sm);color:var(--ink-2);min-width:84px} -.power .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} -.gpu-tile .msg.sweep{color:var(--ash)} -.gpu-tile .msg.sweep b{color:var(--ink-2);font-weight:500} -.gpu-tile .msg.sweep.on{color:var(--molten)} -.cards.compact .power .k{display:none} -.cards.compact .power input[type=range]{width:80px} -.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)} -.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)} -.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)} -.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)} -.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle} -.gpu-tile .msg.warm{color:var(--molten)} -.gpu-tile .msg.hot{color:var(--ember)} - -/* per-card tiles on the dashboard */ -.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)} -.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0} -.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap} -.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums} -.gpu-tile .h .unit{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);font-weight:500;letter-spacing:.08em} -.gpu-tile.idle .h{color:var(--ash)} -.gpu-tile .m{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} -.gpu-tile .m b{color:var(--ink-2);font-weight:500} -.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)} -.gpu-tile .st.mining{color:var(--molten)} -.gpu-tile .st.bad{color:var(--ember)} -.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -.gpu-tile .msg{font-size:var(--t-sm);color:var(--ash)} -.gpu-off{margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash)} +.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)} +.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)} +.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} +.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)} +.card.adv summary::-webkit-details-marker{display:none} +.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto} +.card.adv[open] summary::after{content:"\2212"} +.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)} +.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45} +.clock-card .note{margin-top:0} /* address options */ .options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px} @@ -244,63 +346,8 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .option:not(.on) .addr-input{display:none} .err{font-size:var(--t-base);color:var(--ember)} -/* dashboard */ -#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)} -body[data-phase="dashboard"] #screen-dashboard{display:flex} -.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} -.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 .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)} -.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start} -.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0} -.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:190px;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} -.tbl{overflow-x:auto} -table{border-collapse:collapse;width:100%;font-size:var(--t-base)} -th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} -th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500} -td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)} -tr:last-child td{border-bottom:0} -td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)} -td .st.mining{color:var(--molten)} -td .st.bad{color:var(--ember)} -td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);white-space:normal;max-width:280px} -.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0} -.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: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.warn{color:var(--ember)} -.card .note{margin-top:10px} -.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:300px;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>div:last-child{border-bottom:0} -.feed>div>span:first-child{min-width:0} -.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.block{color:var(--ember)} -.feed .k.build{color:var(--ink-2)} - /* 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 settings panel. */ + 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} @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} @@ -334,7 +381,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(-- @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,.panel-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-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 .sub{font-size:var(--t-lg);color:var(--ink-2)} .field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px} @@ -347,41 +394,29 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(-- .check input{width:18px;height:18px;accent-color:var(--ember);margin:0} .check.small input{width:15px;height:15px} .sheet .cta{justify-content:flex-start} +@media (prefers-reduced-motion:reduce){.sheet{animation:none}} -/* settings panel */ -.panel-wrap{justify-content:flex-end;padding:0} -.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease} -@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} -@media (prefers-reduced-motion:reduce){.panel,.sheet{animation:none}} -.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line);flex:0 0 auto} -.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px;overscroll-behavior:contain} +/* 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} -.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:var(--t-md);user-select:text;-webkit-user-select:text} -.num:focus{outline:none;border-color:var(--ember)} -.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:6px 0;line-height:1.35} +.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 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 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} - -/* bottom bar */ -.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} -.bottom .left,.bottom .right{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto} -.bottom .mid{font-size:var(--t-sm);color:var(--ash);display:flex;align-items:center;justify-content:center;gap:0;min-width:0;flex:1;overflow:hidden;white-space:nowrap} -.bottom .mid .pc{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} -.bottom .mid .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)} -.bottom .mid .nm{color:var(--ink-2);max-width:220px;flex-shrink:1} -.bottom .mid .ad{flex-shrink:0} -.bottom .right .mono{font-size:var(--t-sm)} -@media (max-width:1100px){.bottom .mid .up{display:none}.bottom .mid .nm{max-width:140px}.log-tools .at{display:none}.log-search{flex-basis:120px;max-width:200px}} -@media (max-width:960px){.bottom .mid .ad{display:none}} +.switch input:disabled+.track{opacity:.4} +.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} /* log drawer */ -.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;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 rgba(0,0,0,.35)} +body.has-rail .drawer{left:var(--rail)} body.drawer-open .drawer{height:var(--drawer-h)} body.drawer-drag .drawer{transition:none} body.drawer-drag{cursor:row-resize} @@ -435,28 +470,56 @@ body.drawer-drag main{pointer-events:none} .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)} -.toast{position:fixed;left:50%;bottom:calc(var(--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} -body.drawer-open .toast{bottom:calc(var(--bottom) + var(--drawer-h) + 16px)} +.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} +body.has-rail .toast{left:calc(50% + var(--rail) / 2)} +body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} -/* narrow windows: the strip folds to two columns, the side column drops under the main one */ -@media (max-width:860px){ +/* 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 */ +@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))} - .grid{grid-template-columns:1fr} + .strip.three{grid-template-columns:repeat(3,minmax(0,1fr))} + .grid2{grid-template-columns:1fr} + .top-status{max-width:220px} +} +@media (max-width:1000px){ + :root{--rail:76px;--gutter:var(--s-5)} + .rail{padding:12px 8px} + .rail-brand{justify-content:center;padding:6px 0 14px} + .rail-brand .word{display:none} + .nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center} + .nav.on{font-weight:500} + .nav.on::before{left:-9px} + .nav.small{min-height:44px} + .nav-dot{right:8px;top:8px;margin:0} + .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} +} +@media (max-width:860px){ .three{grid-template-columns:1fr} h1{font-size:40px} } -/* short windows (under 700 px): tighter paddings, a lower hero, a smaller canvas, so the bottom bar and the drawer - always have room */ +/* short windows (the 600 px floor): tighter paddings, a lower hero, 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)} #screen-dashboard{padding:16px 0 20px} - #dag{height:150px} + #dag{height:140px} .hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)} .coin-wrap{width:104px;height:104px} .coin{width:104px;height:104px} h1{font-size:40px} .lead{font-size:var(--t-xl)} .step{padding:32px 0 32px} - .feed{max-height:220px} + .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 3d9b9128..92b396ea 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -1,6 +1,6 @@ /* 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), dashboard. ?screen= forces - a screen (screenshots). Everything is relative to the token path the page was opened on. */ + 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= forces a screen, ?page= a page (screenshots). Everything is relative to the token path the page was opened on. */ /* ---------- 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; @@ -9,11 +9,12 @@ 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. */ + 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: , mining", "New card: , 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, 'update-installed': 6 }; - var JOB_DONE_S = 300, INSTALLED_S = 60; + 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 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; } function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; } @@ -91,6 +92,26 @@ var Notices = (function () { return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] }); } + // cards = state.mining.cards; now = state.now: every card a re-detection added or lost in the last 5 minutes + // (added_at / removed_at are 0 for the cards found at start). The newest first, so pick() shows it. + function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; } + function cardNotices(cards, now) { + var out = []; + (cards || []).forEach(function (c) { + if (c.removed_at > 0) { + if (now - c.removed_at <= CARD_S) out.push(notice('card-removed', 'card:removed:' + c.key + ':' + Math.floor(c.removed_at), 'Card removed: ' + c.name + '. Its worker stopped.', { tone: 'warn', at: c.removed_at })); + return; + } + if (!(c.added_at > 0) || now - c.added_at > CARD_S) return; + // a new card with a problem, or a card that was mining and now reports one (both carry added_at): the row's words + if (c.problem) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), c.name + ': not usable (' + c.problem + '). No worker runs on it.', { tone: 'warn', detail: c.reason || '', at: c.added_at })); + else if (c.enabled) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', mining.', { at: c.added_at })); + else out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', off (' + kindWord(c.kind).toLowerCase() + '). Settings switches it on.', { at: c.added_at })); + }); + out.sort(function (a, b) { return b.at - a.at; }); + return out; + } + // every notice the state carries for this screen; pick() chooses the one to show function gather(s, where) { where = where || {}; @@ -98,6 +119,7 @@ var Notices = (function () { if (u) out.push(u); 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)); return out; } // the lowest level wins; a dismissed key is skipped, so the next one shows in its place @@ -110,7 +132,7 @@ var Notices = (function () { } return best; } - return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot }; + 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 }; })(); /* ---------- the update card (pure; update-card.test.mjs loads this block) ---------- @@ -215,16 +237,152 @@ 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 }; })(); -if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; } +/* ---------- 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. */ +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' } + ]; + 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'; } + 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; } + 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 + function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); } + // listed, driver fine, not unplugged: a worker can run on it + function present(c) { return !(c.removed_at > 0) && !c.problem; } + // the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address + function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== undefined && cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); } + + // one GPU row on the Mine page: cd = state.mining.cards[i] + 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)) : ''; + 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' : ''; + var cap = cd.power_default_w > 0 ? Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) : 0; + var sub = unusable ? (cd.reason || '') : removed ? 'unplugged; its worker stopped. The row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || ''; + var meta = []; + if (cd.vram_mb) { var gb = cd.vram_mb / 1024; meta.push((gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : '')); } + if (on && cd.accepted) meta.push(cd.accepted + ' block' + (cd.accepted === 1 ? '' : 's')); + if (on && cd.prepared) meta.push('next program ready'); + 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) }; + } + + // 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: '' }; + 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 Node page's plain words: n = state.node, clock = state.clock, eta = the sync ETA sentence (may be '') + function nodeWords(n, clock, eta) { + var bad = clock && clock.severity === 'block'; + if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' }; + switch (n.state) { + case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' }; + case 'syncing': + var pct = n.headers > n.blocks && n.headers > 0 ? Math.floor(n.blocks / n.headers * 100) : 0; + var line = n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + pct + '%)' : (n.message || 'caught up, waiting for the next block'); + return { word: 'syncing', line: line, tone: '' }; + case 'starting': return { word: 'starting', line: n.message || 'opening the database', tone: '' }; + case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' }; + case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' }; + case 'stopped': return { word: 'stopped', line: n.message || 'the node is not running', tone: 'bad' }; + } + return { word: n.state || 'starting', line: n.message || '', 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'; } + + // the next planned rule change: switches = state.node.consensus_switches, daa = state.node.daa + function nextSwitch(switches, daa) { + var list = (switches || []).slice().sort(function (a, b) { return a.daa - b.daa; }); + for (var i = 0; i < list.length; i++) if (list[i].daa > daa) return { name: list[i].name, daa: list[i].daa, away: list[i].daa - daa, key: list[i].key }; + return null; + } + function switchLine(sw, daa) { + if (!sw) return (daa > 0 ? 'Every planned switch is behind this node. ' : '') + 'A switch is a planned rule change. The node applies it by itself when the chain reaches that height.'; + var blocks = daa > 0 ? withCommas(sw.away) + ' blocks away, about ' + (sw.away < 3600 ? Math.max(1, Math.round(sw.away / 60)) + ' min' : sw.away < 86400 ? Math.floor(sw.away / 3600) + ' h ' + Math.round(sw.away % 3600 / 60) + ' min' : Math.floor(sw.away / 86400) + ' d') + ' at one block a second.' : 'the node applies it by itself at that height.'; + return sw.name + ' at DAA ' + withCommas(sw.daa) + ': ' + blocks; + } + + // 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 (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.' }; + if (pv.status === 'submitted') return { word: 'submitted', sub: 'waiting to be paid', tone: 'on', note: pv.message || 'The node checks the proof, then the chain pays it.' }; + if (pv.status === 'idle') return { word: 'idle', sub: 'nothing assigned', tone: 'ok', note: pv.message || 'On. Nothing is assigned to your keys right now; the next shard arrives within a minute.' }; + return { word: pv.status || 'on', sub: pv.backend ? pv.backend.toUpperCase() : '', tone: '', note: pv.message || '' }; + } + function verifierWords(pv) { + var vm = (pv && pv.verifier_mode) || 'unknown'; + var word = { command: 'verifying', trust: 'trusting (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm; + 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 miner's own dev-fee line, next to the rewards address + function devFeeText(s) { + var d = s.dev_fee || {}; + var addr = d.address ? ' to ' + d.address.slice(0, 8) + '…' + d.address.slice(-4) : ''; + if (!s.settings.dev_fee) return 'Dev fee off (--dev-fee 0); the default is 1% (1 block in 100)' + addr; + if (d.line && !d.on && !d.address) return 'Dev fee: ' + d.line; + 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.'; + } + + // the remote-jobs line on Updates: 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.'); + 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' : '') + '.'); + 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 }; +})(); +if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; } if (typeof document !== 'undefined') (function () { 'use strict'; var $ = function (id) { return document.getElementById(id); }; var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; var dpr = Math.min(2, window.devicePixelRatio || 1); - var state = null, phase = 'welcome', forced = null, cardsSeen = false, keyShownThisRun = false; + 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) 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'); @@ -250,13 +408,12 @@ if (typeof document !== 'undefined') (function () { var opt = body === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; return fetch(path, opt).then(function (r) { return r.json(); }); } - function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//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 withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } - 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'; } + function esc(s) { return String(s == null ? '' : s).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; 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; } 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) { @@ -267,21 +424,48 @@ if (typeof document !== 'undefined') (function () { function fallbackCopy(text, msg) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast(msg || 'Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } document.addEventListener('click', function (e) { var b = e.target.closest('[data-copy]'); if (!b) return; - copyText($(b.getAttribute('data-copy')).textContent); + var t = $(b.getAttribute('data-copy')).textContent; + if (!t || /not (set|read|printed)/.test(t)) { toast('Nothing to copy yet'); return; } + copyText(t); }); - // ---------- screens ---------- + // ---------- screens (setup) and pages (the dashboard) ---------- function show(p) { phase = p; document.body.setAttribute('data-phase', p); var dash = p === 'dashboard'; - $('bottom').hidden = !dash; - $('btn-settings').hidden = !dash; - document.body.classList.toggle('has-bottom', dash); + document.body.classList.toggle('has-rail', dash); + $('rail').hidden = !dash; + $('top-brand').hidden = dash; + $('page-title').hidden = !dash; if (!dash && drawerOpen()) setDrawer(false); if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } + if (dash) showPage(page); api('api/phase', { phase: p }); } + function showPage(id) { + var meta = View.page(id); + page = meta.id; + document.body.setAttribute('data-page', page); + document.querySelectorAll('.page').forEach(function (el) { el.hidden = el.dataset.page !== page; }); + document.querySelectorAll('#rail-nav .nav').forEach(function (b) { + var on = b.dataset.page === page; + b.classList.toggle('on', on); + if (on) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); + }); + 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(); } + } + $('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; + if (j < 0 || j >= items.length) return; + e.preventDefault(); items[j].focus(); showPage(items[j].dataset.page); + }); $('btn-begin').addEventListener('click', function () { show('cards'); }); $('btn-cards-next').addEventListener('click', function () { var choices = readCardRows($('cards-list')); @@ -324,83 +508,99 @@ 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'); }); }); - // ---------- settings ---------- - $('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; }); - $('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; }); - $('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); }); - function fillSettings() { + // ---------- Mine: the big button and the GPU switches ---------- + $('btn-toggle').addEventListener('click', function () { if (!state) return; - $('s-address').textContent = state.address.display || 'not set'; - renderCardRows($('s-cards'), state.mining.cards); - state.mining.cards.forEach(function (cd) { - if (cd.vendor !== 'nvidia' || !(cd.power_default_w > 0)) return; - var row = $('s-cards').querySelector('.gpu-row[data-key="' + cd.key.replace(/"/g, '\\"') + '"] .info'); - if (!row) return; - var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100); - var d = document.createElement('div'); d.className = cd.power_applied ? 'msg ok' : 'msg hot'; - d.innerHTML = cd.power_applied ? 'power cap: ' + want + ' W applied' : 'power cap NOT applied (needs the administrator prompt) '; - row.appendChild(d); - }); - $('s-name').value = state.display_name || ''; - $('s-mid').textContent = (state.machine_id || '').slice(0, 8); - $('s-vote').checked = state.settings.vote; - $('s-devfee').checked = state.settings.dev_fee; - $('s-devfee-text').textContent = devFeeText(state); - $('s-login').checked = state.settings.start_at_login; - $('s-version').textContent = 'Igneum Miner ' + state.version; - $('s-node-dir').textContent = 'chain data: ' + state.node_dir; - $('s-log-dir').textContent = 'logs: ' + state.log_dir; - $('s-reveal').hidden = state.address.source !== 'generated'; - $('s-live').hidden = !state.live_page; - var u = state.update; - $('s-auto-update').checked = !!state.settings.auto_update; - fillJobsSettings(state.jobs || {}); - $('s-install').hidden = !((u.ready || u.downloaded || u.status === 'error') && !u.applying); - $('s-update-note').textContent = settingsUpdateNote(u); - } + 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 = {}; + $('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'); } }); + }); + + // ---------- 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 ---------- $('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-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; } 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; $('s-hide').hidden = false; $('s-reveal').hidden = true; } else toast(r.error || 'no key'); }); }); - $('s-name-save').addEventListener('click', function () { - api('api/settings', { display_name: $('s-name').value }).then(function (r) { if (r.ok) toast('Renamed'); }); - }); - $('s-cards-save').addEventListener('click', function () { - var choices = readCardRows($('s-cards')); - api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast('Applied; only the changed workers restart'); }); - }); - $('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'); }); }); - // the miner's own start line, next to the rewards address: "dev fee 1% (1 block in 100) to 0x...; --dev-fee 0 turns it off" - function devFeeText(s) { - var d = s.dev_fee || {}; - var addr = d.address ? ' to ' + d.address.slice(0, 8) + '\u2026' + d.address.slice(-4) : ''; - if (!s.settings.dev_fee) return 'Dev fee off (--dev-fee 0); the default is 1% (1 block in 100)' + addr; - if (d.line && !d.on && !d.address) return 'Dev fee: ' + d.line; - return 'Dev fee ' + (d.percent || 1) + '% (1 block in 100)' + addr + (d.address ? '' : '; the miner names the address when it starts'); - } - $('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-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); }); + function hideKey() { $('s-key-box').hidden = true; $('s-key').textContent = ''; $('s-hide').hidden = true; $('s-reveal').hidden = false; } + $('s-hide').addEventListener('click', hideKey); + $('r-wallet').addEventListener('click', function () { api('api/open', { url: 'https://igneum.network/wallet' }); }); + + // ---------- Node ---------- + $('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); + + // ---------- Updates ---------- + $('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-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); }); - $('s-prove').addEventListener('change', function () { api('api/prove', { on: this.checked }).then(function (r) { if (r.ok) toast(r.ok && $('s-prove').checked ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); }); }); - $('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; } }); }); - $('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')); }); }); - $('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); setTimeout(fillSettings, 800); }); - $('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); }); - $('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); $('s-jobs-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); }); + $('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); setText('s-jobs-note', 'Checking.'); }); - // ---------- bottom bar ---------- - $('btn-pause').addEventListener('click', function () { - if (!state) return; - api(state.mining.paused ? 'api/resume' : 'api/pause', {}); + // ---------- 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 ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); }); + $('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 }); }); + $('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 + $('s-log-copy').addEventListener('click', function () { + api('api/log?after=0&limit=2000').then(function (r) { + var lines = (r.lines || []).map(function (l) { return clock(l.t) + ' ' + l.src.padEnd(6) + ' ' + l.text; }); + if (!lines.length) { toast('Nothing logged yet'); return; } + 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('Sweep queued: 100% down to 50%, 75 s a step'); }); + else if (b.dataset.sweepStop) api('api/sweep/stop', {}).then(function () { toast('Sweep stopped; cap restored'); }); + 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 ---------- $('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; @@ -421,7 +621,7 @@ if (typeof document !== 'undefined') (function () { function drawerOpen() { return document.body.classList.contains('drawer-open'); } function setDrawer(open) { document.body.classList.toggle('drawer-open', open); - var b = $('btn-logs'); b.classList.toggle('on', open); b.setAttribute('aria-expanded', open ? 'true' : 'false'); + var b = $('btn-logs'); b.classList.toggle('on', open); b.classList.toggle('logs', open); b.setAttribute('aria-expanded', open ? 'true' : 'false'); $('btn-logs-text').textContent = open ? 'Close logs' : 'Logs'; if (open) { applyDrawerHeight(); pollLog(); scheduleLogPoll(); viewDirty = true; renderLog(true); } else if (logTimer) { clearTimeout(logTimer); logTimer = null; } @@ -662,7 +862,7 @@ if (typeof document !== 'undefined') (function () { drawRaf = 0; clearTimeout(drawTimer); drawTimer = null; if (hiddenNow()) return; var fast = false; - if (phase === 'dashboard') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; } + if (phase === 'dashboard' && page === 'mine') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; } if (fast) drawRaf = requestAnimationFrame(frame); else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500); } @@ -674,60 +874,52 @@ if (typeof document !== 'undefined') (function () { requestDraw(); // ---------- render ---------- - // ---------- GPU rows: one per card, with a switch and an identities stepper ---------- function badgeHtml(cd) { if (cd.vendor === 'apple') return ''; return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU'; } - function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; } function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); } + // ---------- the first-run GPU rows: one per card, with a switch ---------- function renderCardRows(container, cards) { container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); }); - cards.forEach(function (cd) { + shownCards(cards).forEach(function (cd) { var d = document.createElement('div'); - d.className = 'gpu-row' + (cd.enabled ? '' : ' off'); + 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 = 'worker ' + esc(cd.worker) + '' + (vramText(cd) ? '' + esc(vramText(cd)) + '' : '') + (cd.detail && cd.kind !== 'apple' ? '' + esc(cd.detail) + '' : '') + (cd.detail && cd.kind === 'apple' ? '' + esc(cd.detail.split(',')[0]) + '' : '') + (cd.device ? 'device ' + esc(cd.device) + '' : ''); d.innerHTML = '
' + badgeHtml(cd) + '
' + - '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + meta + '
' + - (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
' + - (cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '
power cap' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W' + (cd.pinned ? 'pinned' : cd.sweep_pct ? 'sweep ' + cd.sweep_pct + '%' : '') + '
' : '') + - '
identities
' + - ''; + '
' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' ' + kindWord(cd.kind) + '
' + meta + '
' + + (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message && !unusable && !removed ? '
' + esc(cd.message) + '
' : '') + '
' + + ''; container.appendChild(d); }); } + // 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').forEach(function (r) { - var n = parseInt(r.querySelector('.ids input').value, 10); if (!(n >= 1)) n = 1; if (n > 64) n = 64; - var pr = r.querySelector('.power input'), item = { key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n }; - if (pr) item.power_pct = parseInt(pr.value, 10); + container.querySelectorAll('.gpu-row, .set-card').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 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); }); return out; } - document.addEventListener('click', function (e) { - var b = e.target.closest('.ids button'); if (!b) return; - var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; - v = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); inp.value = v; - }); document.addEventListener('change', function (e) { if (e.target.matches('.gpu-row .switch input')) { var r = e.target.closest('.gpu-row'); r.classList.toggle('off', !e.target.checked); } }); - document.addEventListener('input', function (e) { - if (!e.target.matches('.power input')) return; - var row = e.target.closest('.gpu-row'), cd = state && state.mining.cards.filter(function (c) { return c.key === row.dataset.key; })[0]; - var 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' : ''); - }); var cardsRendered = ''; function renderCards(s) { var list = $('cards-list'), det = $('cards-detecting'); 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 = s.mining.cards; - var sig = cards.map(function (c) { return c.key; }).join('|'); + 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('|'); if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); } if (!cards.length) { $('cards-sub').textContent = 'No GPU this app can drive was found.'; @@ -735,54 +927,14 @@ if (typeof document !== 'undefined') (function () { $('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; } - var on = cards.filter(function (c) { return c.enabled; }).length; $('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-note').hidden = !s.detect_message; $('cards-note').textContent = s.detect_message || ''; $('cards-help').hidden = false; 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 = on ? 'Continue' : 'Continue'; + $('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue'; } - // power draw, the cap and the temperatures (NVIDIA); memory over 90 C amber, over 95 C red - function telemetryHtml(cd) { - if (cd.vendor !== 'nvidia' || !(cd.telemetry_at > 0 || cd.power_default_w > 0)) return ''; - var memCls = cd.temp_mem > 95 ? 'hot' : cd.temp_mem > 90 ? 'warm' : ''; - var h = '
draw ' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '' + (cd.power_limit_w ? ' / cap ' + Math.round(cd.power_limit_w) + ' W' : '') + '' + - 'GPU ' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '' + - 'memory ' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '' + - 'eff ' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '
'; - if (memCls) h += '
memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk
'; - if (cd.power_default_w > 0) { - var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100); - if (cd.power_applied) h += '
power cap: ' + want + ' W (' + (cd.power_pct || 80) + '%)' + (cd.pinned ? ' pinned' : cd.sweep_pct === (cd.power_pct || 80) && cd.sweep_pct ? ' chosen by the sweep' : '') + '
'; - else h += '
power cap NOT applied (needs the administrator prompt)' + (cd.power_note && cd.power_note.indexOf('wanted') > 0 ? ' · ' + esc(cd.power_note.replace(/^.*: card reports/, 'card reports')) : '') + '
'; - } - return h + sweepHtml(cd); - } - // the efficiency sweep line: running (the phase), the last result (best cap and MH/W), or why it cannot run - function sweepHtml(cd) { - if (!cd.sweep_supported) return cd.sweep_note ? '
' + esc(cd.sweep_note) + '
' : ''; - var running = cd.sweep_state === 'running'; - var t = ''; - if (running) t = esc(cd.sweep_note || 'sweep: running'); - else if (cd.sweep_pct) t = 'sweep: best ' + cd.sweep_pct + '% · ' + cd.sweep_eff.toFixed(3) + ' MH/W (' + cd.sweep_mhs.toFixed(1) + ' MH/s at ' + Math.round(cd.sweep_watts) + ' W' + (cd.sweep_at ? ', ' + rel(state.now - cd.sweep_at) + ' ago' : '') + ')' + (cd.sweep_note && cd.sweep_note.indexOf('stopped') === 0 ? ' · ' + esc(cd.sweep_note) : ''); - else t = esc(cd.sweep_note || 'sweep: not run yet'); - var btn = running ? '' : ''; - if (!running && cd.pinned) btn += ' '; - return '
' + t + ' ' + btn + '
'; - } - document.addEventListener('click', function (e) { - var b = e.target.closest('[data-sweep-start],[data-sweep-stop],[data-sweep-pin]'); if (!b) return; - if (b.dataset.sweepStart) api('api/sweep/start', { key: b.dataset.sweepStart }).then(function () { toast('Sweep queued: 100% down to 50%, 75 s a step'); }); - else if (b.dataset.sweepStop) api('api/sweep/stop', {}).then(function () { toast('Sweep stopped; cap restored'); }); - else 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'); }); - }); - document.addEventListener('click', function (e) { - var b = e.target.closest('[data-power-retry]'); if (!b) return; - api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); }); - }); - var lastEventsKey = '', lastFoot = ''; - function skewWord(sk) { sk = Math.round(Math.abs(sk)); 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'; } + // sync ETA from the block rate over the last minute of readings: "about 2 h 10 min left" var syncSamples = []; function syncEta(n, now) { @@ -795,90 +947,205 @@ if (typeof document !== 'undefined') (function () { var word = left < 60 ? 'under a minute' : left < 3600 ? 'about ' + Math.round(left / 60) + ' min' : left < 86400 ? 'about ' + Math.floor(left / 3600) + ' h ' + Math.round(left % 3600 / 60) + ' min' : 'over a day'; return word + ' left at ' + Math.round(rate) + ' blocks/s'; } - function renderDashboard(s) { - var m = s.mining, n = s.node, p = s.program, f = s.finality; - $('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1); - var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length; - $('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state; - $('d-blocks').textContent = withCommas(m.accepted_total); - $('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''); - var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state; - $('d-node').textContent = nodeWord; - // the live numbers always show, so a stuck label is visible as stuck - var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's'); - var eta = syncEta(n, s.now); - var syncing = n.state === 'syncing' && n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + Math.floor(n.blocks / n.headers * 100) + '%)' : ''; - $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + skewWord(s.clock.skew_s) + ' ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': sync it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : syncing || (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live; - $('d-node-sub').title = $('d-node-sub').textContent + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''); - $('d-hash-sub').title = $('d-hash-sub').textContent; $('d-blocks-sub').title = $('d-blocks-sub').textContent; - var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block')); - if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); } - else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'; } - $('d-eta-sub').title = $('d-eta-sub').textContent; + + // ---------- Mine ---------- + var mineRowsSig = '', lastEventsKey = ''; + function gpuLineHtml(r) { + var num = function (cls, k, v, unit, tone) { return '
' + k + '' + (v ? esc(v) + (unit ? '' + unit + '' : '') : 'n/a') + '
'; }; + return '
' + + '
' + badgeHtml({ vendor: r.vendor }) + '
' + + '
' + esc(r.name) + ' ' + esc(r.kindWord) + '
' + + '' + esc(r.word) + (r.meta ? ' · ' + esc(r.meta) : '') + '' + (r.sub ? '
' + esc(r.sub) + '
' : '') + '
' + + num('hash', 'hash rate', r.hash, 'MH/s', '') + num('temp', 'temperature', r.temp, '', r.tempTone) + num('power', 'power', r.power, '', '') + + '
'; + } + 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; - $('d-found-10').textContent = m.found.filter(function (t) { return nowU - t <= 600; }).length; - $('d-found-60').textContent = m.found.length; - $('d-fee').textContent = withCommas(m.fee_total || 0); + 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'; - $('d-chain-blocks').textContent = compact(n.blocks); - // per-card tiles (enabled cards), the rest in one muted line - var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; }); - $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : ''); - var tb = $('d-cards'); - if (!on.length) tb.innerHTML = '
' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; - else tb.innerHTML = on.map(function (cd) { - var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : ''; - var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state; - var extra = cd.ids.length ? 'id ' + esc(cd.ids.join(' ')) + '' : ''; - return '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + - '
' + cd.hash_now.toFixed(1) + 'MH/s
' + - '
blocks ' + cd.accepted + '' + (cd.rejected ? ' / ' + cd.rejected + ' rejected' : '') + 'avg ' + cd.hash_avg.toFixed(1) + 'identities ' + cd.identities + '' + extra + (cd.faults ? 'worker faults ' + cd.faults + '' : '') + (cd.mismatched ? 're-check misses ' + cd.mismatched + '' : '') + '
' + - telemetryHtml(cd) + - '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
'; - }).join(''); - $('d-cards-off').hidden = !off.length; - $('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. Settings switches them on.' : ''; - // node card - $('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : ''); - $('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers; - $('n-daa').textContent = withCommas(n.daa); $('n-diff').textContent = compact(n.difficulty); $('n-tips').textContent = n.tips; - var consensus = n.consensus_switch_daa > 0 ? ' Consensus switch at DAA ' + withCommas(n.consensus_switch_daa) + (n.daa > 0 && n.consensus_switch_daa > n.daa ? ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : '.') + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '') : ''; - $('n-note').textContent = (n.state === 'synced' ? ('Reading every 10 s' + (n.last_reading_age_s >= 0 ? ', last ' + Math.round(n.last_reading_age_s) + ' s ago' : '') + '.') : (n.message ? n.message + '.' : '')) + consensus; - // finality - if (f.last_lock > 0) { $('f-lock').textContent = '#' + withCommas(f.last_lock); $('f-age').textContent = rel(f.age_s); $('f-note').textContent = 'Checkpoints lock at 2/3 of the 30-day weight. Votes are sent by this miner.'; } - else { $('f-lock').textContent = 'none yet'; $('f-age').textContent = 'n/a'; $('f-note').textContent = f.message || 'Locks appear once the miner votes on checkpoints.'; } - // proving - var pv = s.proving || {}; - var pvWord = { off: 'off', setup: 'needs setup', waiting: 'waiting', idle: 'idle', proving: 'proving', submitted: 'submitted' }[pv.status] || (pv.status || 'off'); - $('pv-state').textContent = pvWord + (pv.status === 'proving' && pv.current ? ' ' + pv.current : '') + (pv.backend && pv.enabled && pv.available ? ' (' + pv.backend.toUpperCase() + ')' : ''); - $('pv-assigned').textContent = String(pv.assigned || 0); - $('pv-submitted').textContent = String(pv.submitted || 0); - $('pv-paid').textContent = String(pv.paid || 0) + (pv.paid_wei ? ' (' + (Number(pv.paid_wei) / 1e18).toFixed(4) + ' IGN)' : ''); - $('pv-note').textContent = pv.enabled ? (pv.message || '') : 'Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.'; - // the node's proof verifier (spec 7.7 item 4): a node without one relays records and never includes them - var vm = pv.verifier_mode || 'unknown'; - var vWord = { command: 'verifying', trust: 'trust (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm; - $('pv-verifier').textContent = vWord + (vm === 'command' && pv.pool_entries ? ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '') : ''); - $('pv-verifier').classList.toggle('warn', vm === 'off' || vm === 'trust'); - $('pv-verifier-note').textContent = pv.verifier_note || ''; - $('pv-verifier-note').hidden = !pv.verifier_note; - $('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || (vm === 'off' && /WSL2 prover is not installed/.test(pv.verifier_reason || ''))); - $('f-votes').textContent = withCommas(f.votes); - // events + setText('d-chain-blocks', compact(n.blocks)); + // the rows: every card, on or off; rebuilt only when their words change + 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) { + mineRowsSig = sig; + $('d-cards').innerHTML = rows.length ? rows.map(gpuLineHtml).join('') : '
' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No GPU this app can drive was found. The node runs on its own.') + '
'; + } + 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.' : ''; + $('d-cards-note').hidden = !note; setText('d-cards-note', note); 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 '
' + esc(e.kind) + '' + esc(e.text) + '' + clock(e.t) + '
'; }).join('') : '
No events yet.
'; + $('d-events').innerHTML = s.events.length ? s.events.slice(0, 14).map(function (e) { return '
' + esc(e.kind) + '' + esc(e.text) + '' + clock(e.t) + '
'; }).join('') : '
No events yet. The node and the miner report here as they start.
'; } - // bottom - $('btn-pause').textContent = m.paused ? 'Resume' : 'Pause'; - // the bottom line: the machine name truncates first, the address is always short, the rest never wraps - var foot = s.quitting ? 'stopping: miners first, then the node' : - (s.display_name ? '' + esc(s.display_name) + '' : '') + - '' + esc(m.state === 'mining' ? 'mining' : m.state) + 'node ' + esc(nodeWord) + 'up ' + uptime(s.uptime_s) + '' + - (s.address.display ? 'rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) + '' : ''); - if (foot !== lastFoot) { lastFoot = foot; $('foot-status').innerHTML = foot; } - $('foot-version').textContent = 'v' + s.version; + } + + // ---------- 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'); + setText('pv-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'); + 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'); + } + + // ---------- 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 ---------- + 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 + '.' : '')); + 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)); + 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'))); + setText('n-switch-help', sw ? View.switchLine(sw, n.daa) : (n.consensus_switch_daa > 0 && n.consensus_switch_daa > n.daa ? 'The update manifest announces a switch at DAA ' + withCommas(n.consensus_switch_daa) + ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : View.switchLine(null, n.daa))); + var sig = JSON.stringify([n.consensus_switches, sw && sw.key]); + if (sig !== switchesSig) { + switchesSig = sig; + $('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '' + withCommas(x.daa) + ''; }).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.'); } + setText('f-votes', withCommas(f.votes)); + } + + // ---------- Updates ---------- + function renderUpdates(s) { + 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); + $('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); + } + function fillJobs(j, now) { + setText('s-jobs-key', j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : ''); + setText('s-jobs-note', View.jobsNote(j, now, jobTitle)); + var h = j.history || [], sig = JSON.stringify(h); + if (sig === jobsSig) return; + jobsSig = sig; + $('s-jobs-history').innerHTML = h.length ? '' + h.map(function (r) { + return ''; + }).join('') + '
jobkindstartedexitreport
' + esc(r.id) + '' + esc(r.kind) + '' + (r.started_at ? clock(r.started_at) : '') + '' + esc(r.status) + (r.status !== 'running' ? ' (' + r.exit + ')' : '') + '' + (r.uploaded ? 'uploaded' : (r.status === 'running' ? 'pending' : 'not uploaded')) + '
' : '

No job has run on this machine yet.

'; + } + 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); + 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 = '
' + esc(cd.name) + '' + kindWord(cd.kind) + '' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (removed ? ' · removed' : unusable ? ' · not usable (' + esc(cd.problem) + ')' : cd.enabled ? '' : ' · off') + '
'; + if (removed) return h + '

The card was unplugged; its worker stopped. The row goes in five minutes.

'; + if (unusable) return h + '

' + esc(cd.reason || 'No worker can drive this card.') + '

'; + if (nv) { + h += '
Power cap' + pct + '% · ' + Math.round(cd.power_default_w * pct / 100) + ' W' + + '

Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the sweep leaves it.' : 'The sweep may move it.') + '

'; + var want = Math.round(cd.power_default_w * pct / 100); + h += cd.power_applied ? '
cap applied: ' + want + ' W' + (cd.pinned ? ' pinned' : cd.sweep_pct === pct && cd.sweep_pct ? ' chosen by the sweep' : '') + '
' : '
cap not applied (needs the administrator prompt)
'; + if (cd.telemetry_at > 0) h += '
draw ' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + 'GPU ' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + 'memory ' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + 'efficiency ' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '
'; + var running = cd.sweep_state === 'running', t; + if (!cd.sweep_supported) t = esc(cd.sweep_note || 'sweep not available on this card'); + else if (running) t = esc(cd.sweep_note || 'sweep running'); + else if (cd.sweep_pct) t = 'sweep: best ' + cd.sweep_pct + '% · ' + cd.sweep_eff.toFixed(3) + ' MH/W (' + cd.sweep_mhs.toFixed(1) + ' MH/s at ' + Math.round(cd.sweep_watts) + ' W' + (cd.sweep_at ? ', ' + rel(state.now - cd.sweep_at) : '') + ')'; + else t = esc(cd.sweep_note || 'sweep: not run yet'); + var btn = !cd.sweep_supported ? '' : running ? '' : '' + (cd.pinned ? ' ' : ''); + h += '
' + t + ' ' + btn + '
'; + } else if (cd.vendor === 'apple') { + h += '

Apple silicon manages its own power; there is no cap to set.

'; + } + h += '
Identities

Each identity votes and is paid on its own. 8 suits a big card, 2 a small one, 1 an integrated GPU. Changing it restarts that card’s worker.

'; + 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; + 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, 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('') : '
No card yet. Mine shows what was found.
'; + } + + function renderPage(s) { + if (page === 'mine') renderMine(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(); } function renderPill(s) { var pill = $('pill'), text; @@ -892,25 +1159,33 @@ 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 ? 'stopping: miners first, then the node' : '' + esc(s.mining.state === 'mining' ? 'mining' : s.mining.state) + 'node ' + esc(nodeWord) + 'up ' + uptime(s.uptime_s) + ''; + if (top !== lastTop) { lastTop = top; $('top-status').innerHTML = top; } + var foot = (s.display_name ? '' + esc(s.display_name) + '
' : '') + (s.address.display ? 'rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet'); + if (foot !== lastFoot) { lastFoot = foot; $('rail-status').innerHTML = foot; } + var fv = 'v' + esc(s.version) + ' · ' + esc(s.chain || s.network || '') + ''; + if (fv !== lastVersion) { lastVersion = fv; $('foot-version').innerHTML = fv; } } - // the clock on the dashboard: the node card (the strip carries it on the setup screens, see Notices) + var lastTop = '', lastFoot = '', lastVersion = ''; + // the clock on the Node page (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'); card.hidden = !(bad || warn); card.classList.toggle('warn', warn); - $('n-clock-msg').textContent = c.message || ''; - $('n-clock-hint').textContent = c.hint ? 'By hand: ' + c.hint : ''; - $('n-clock-result').textContent = c.sync_result || ''; - $('n-clock-sync').disabled = !!c.syncing; $('n-clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock'; + 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'); $('btn-start').disabled = bad; $('start-blocked').hidden = !bad; - $('start-blocked').textContent = bad ? c.message + ' Use "Sync clock" above.' : ''; + setText('start-blocked', bad ? c.message + ' Use "Sync clock" above.' : ''); $('btn-key-done').disabled = bad || !$('key-ack').checked; } - $('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); }); - // ---------- the notice strip (Notices, top of this file): one notice, its actions, the close control ---------- + // ---------- the status strip (Notices, top of this file): one notice, its actions, the close control ---------- // dismissed: the keys closed this session; a notice with a closed key stays hidden until its key changes var dismissed = {}, noticeSig = '', stripH = 0; function renderNotices(s) { @@ -1011,7 +1286,7 @@ if (typeof document !== 'undefined') (function () { $('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(); } }); - // ---------- over-the-air updates (src/ota.rs): the settings note; the strip is Notices.updateNotice ---------- + // ---------- over-the-air updates (src/ota.rs): the Updates page'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 + '.'); @@ -1019,7 +1294,9 @@ if (typeof document !== 'undefined') (function () { 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 (/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) + '.'); return parts.join(' '); } @@ -1039,34 +1316,15 @@ if (typeof document !== 'undefined') (function () { 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'; } return u; } - // ---------- remote jobs (src/jobrun.rs): the settings block; the strip is Notices.jobNotice ---------- + // ---------- remote jobs (src/jobrun.rs): the Updates page; 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: [] }; - if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; } - else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; } - 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)']; } + if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; j.history = [{ id: j.id, kind: j.kind, title: j.title, status: 'running', started_at: j.started_at, finished_at: 0, exit: 0, run_id: 'r2', uploaded: false, summary: '' }]; } + else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; j.history = [j.last]; } + 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; } - function fillJobsSettings(j) { - $('s-jobs-allow').checked = !!j.allowed; - $('s-prove').checked = !!(state.settings && state.settings.prove); - $('s-trust').checked = !!(state.settings && state.settings.proof_verify_trust); - $('s-sweep').checked = !!(state.settings && state.settings.sweep); - $('s-jobs-key').textContent = j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : ''; - var parts = []; - if (j.account) parts.push(j.account); - if (!j.allowed) parts.push('Off: nothing runs here until it is switched on.'); - 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((state.now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.'); - $('s-jobs-note').textContent = parts.join(' '); - var h = j.history || []; - $('s-jobs-history').innerHTML = h.length ? '' + h.map(function (r) { - return ''; - }).join('') + '
jobkindstartedexitreport
' + esc(r.id) + '' + esc(r.kind) + '' + (r.started_at ? clock(r.started_at) : '') + '' + esc(r.status) + (r.status !== 'running' ? ' (' + r.exit + ')' : '') + '' + (r.uploaded ? 'uploaded' : (r.status === 'running' ? 'pending' : 'not uploaded')) + '
' : '

No job has run on this machine yet.

'; - } 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; } } @@ -1077,7 +1335,7 @@ if (typeof document !== 'undefined') (function () { renderNotices(s); renderUpdateCard(s); if (phase === 'cards') renderCards(s); - if (phase === 'dashboard') renderDashboard(s); + if (phase === 'dashboard') renderPage(s); if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } } var failures = 0, first = true, pollTick = 0; @@ -1090,17 +1348,25 @@ if (typeof document !== 'undefined') (function () { if (first) { first = false; var start = forced || (s.setup_done ? 'dashboard' : 'welcome'); - if (start === 'settings') { show('dashboard'); setTimeout(function () { fillSettings(); $('settings').hidden = false; }, 300); } + if (start === 'settings') { page = 'settings'; show('dashboard'); } else if (start === 'key') { show('address'); showKeySheet({ address: s.address.value || '0x0000000000000000000000000000000000000000', display: s.address.display || '0x0000000000000000000000000000000000000000', private_key: '0x' + '0'.repeat(64), wallet_file: s.address.wallet_file || (s.node_dir.replace(/[^\/\\]+$/, '') + 'app/wallet.json') }); } else show(start); if (start === 'cards') renderCards(s); + if (params.get('logs') === '1' && start === 'dashboard') setDrawer(true); // screenshots: the log drawer open } render(s); perf.render += performance.now() - a; perf.polls++; if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); } }).catch(function () { failures++; - if (failures >= 4) { $('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; if (phase === 'dashboard') { $('d-node').textContent = 'no answer'; $('d-node-sub').textContent = 'the engine is not answering; the window reconnects by itself'; $('d-node-cell').classList.add('bad'); } } + 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('btn-toggle-sub', 'the engine is not answering'); $('btn-toggle').disabled = true; + setText('d-hash-sub', 'no answer from the engine'); + } + } }); } poll(); setInterval(poll, 1000); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index ab1f1625..ec32a963 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -8,24 +8,47 @@ - + + + +
-
+
IGNEUMMINER
+
+
starting
-
- + - - + +
@@ -128,64 +151,168 @@
- +
-
-
-
hash rate
-
0.0MH/s
-
waiting for the worker
-
-
-
blocks found
-
0
-
accepted by the node
-
-
-
node
-
starting
-
opening the database
-
-
-
next program
-
--:--
-
waiting for the node
-
-
-
-
-
-
-
your blocks
-
last 10 min 0last hour 0dev fee 0chain 0
-
- -
block this machine foundjust acceptedone minute
+ +
+
+ +
+
hash rate
+
0.0MH/s
+
waiting for the worker
-
-

Cards

1 worker
-
No card yet.
- +
+
blocks found
+
0
+
accepted by the node
+
+
+
next program
+
--:--
+
waiting for the node
-
+ +
+

Your GPUs

detecting
+
Waiting for the engine.
+ +
+ +
+
+

Your blocks

+
last 10 min 0last hour 0dev fee 0chain 0
+
+ +
block this machine foundjust acceptedone minute
+
+ +
+

Activity

newest first
+
No events yet.
+
+
+ + + + + + + + +
+
+ + + + + + @@ -252,7 +445,7 @@
private key
-

Stored at , readable by your user only. Settings can show it again.

+

Stored at , readable by your user only. Rewards can show it again.

On devnet the vote keys are test keys derived from the miner's label. Mainnet vote keys will be random and stored like this wallet.

@@ -261,75 +454,6 @@
- - -
@@ -357,6 +481,7 @@ +
@@ -367,18 +492,6 @@ -
-
- - -
-
-
- - -
-
- diff --git a/app/igneum-app/ui/notices.test.mjs b/app/igneum-app/ui/notices.test.mjs index 251ae8f2..22cd67ab 100644 --- a/app/igneum-app/ui/notices.test.mjs +++ b/app/igneum-app/ui/notices.test.mjs @@ -11,7 +11,7 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js') const mod = { exports: {} }; new Function('module', src)(mod); const N = mod.exports; -const { updateNotice, jobNotice, clockNotice, gather, pick } = N; +const { updateNotice, jobNotice, clockNotice, cardNotices, gather, pick } = N; const NOW = 1_800_000_000; const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over }); @@ -139,3 +139,36 @@ test('clock: the engine words, Sync clock, the hint; gather() keeps it off the d assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock'); assert.deepEqual(gather({}, { dashboard: true }), []); }); + +// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen +const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over }); +test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => { + const start = cardOf(); + assert.deepEqual(cardNotices([start], NOW), []); + const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' }); + const [a] = cardNotices([start, added], NOW); + assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30)); + const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 }); + const [b] = cardNotices([bad], NOW); + assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/); + const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 }); + assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.'); + const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 }); + const [r] = cardNotices([removed], NOW); + assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn'); + // the newest first; both go after CARD_S + const two = cardNotices([added, removed], NOW); + assert.equal(two[0].kind, 'card-added'); + assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []); +}); + +test('card notices sit under a running job and show on the setup screens too', () => { + const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 }); + const withJob = s({ jobs: run(), mining: { cards: [added] } }); + assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running'); + const quiet = s({ mining: { cards: [added] } }); + assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added'); + assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added'); + // closed: stays closed for that key; a later event on the same card has a new key + assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null); +}); diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs new file mode 100644 index 00000000..e14a05ce --- /dev/null +++ b/app/igneum-app/ui/view.test.mjs @@ -0,0 +1,182 @@ +// node --test app/igneum-app/ui/view.test.mjs (no dependencies; CI runs it in the site job) +// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only +// the pure blocks execute) and checks the words each page shows for a given state (miner-ui-2). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8'); +const mod = { exports: {} }; +new Function('module', src)(mod); +const V = mod.exports.View; + +const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over }); + +test('the six sections and their order', () => { + assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'prove', 'rewards', 'node', 'updates', 'settings']); + assert.equal(V.page('node').title, 'Node'); + assert.equal(V.page('nonsense').id, 'mine'); +}); + +test('a GPU row: name, kind, the numbers where known, the switch', () => { + const r = V.cardRow(card()); + assert.equal(r.name, 'NVIDIA GeForce RTX 5090'); + assert.equal(r.kindWord, 'Discrete'); + assert.equal(r.integrated, false); + assert.equal(r.on, true); + assert.equal(r.word, 'mining'); + assert.equal(r.tone, 'on'); + assert.equal(r.hash, '124'); + assert.equal(r.temp, '61 °C'); + assert.equal(r.tempTone, ''); + assert.equal(r.power, '410 W'); + assert.equal(r.cap, 460); + assert.equal(r.meta, '32 GB · 3 blocks · next program ready'); + assert.equal(r.canToggle, true); +}); + +test('a GPU row: temperatures turn amber then red; unknown numbers are empty', () => { + assert.equal(V.cardRow(card({ temp_mem: 92 })).tempTone, 'warm'); + assert.equal(V.cardRow(card({ temp_mem: 96 })).tempTone, 'hot'); + assert.equal(V.cardRow(card({ temp_gpu: 93 })).tempTone, 'hot'); + const apple = V.cardRow(card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072, power_w: 0, power_default_w: 0, temp_gpu: 0, temp_mem: 0, telemetry_at: 0, hash_now: 7.4, accepted: 0, prepared: false })); + assert.equal(apple.kindWord, 'Apple silicon'); + assert.equal(apple.hash, '7.4'); + assert.equal(apple.temp, ''); + assert.equal(apple.power, ''); + assert.equal(apple.cap, 0); + assert.equal(apple.meta, '128 GB unified'); +}); + +test('an integrated GPU is shown as integrated and off, with its reason', () => { + const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 })); + assert.equal(r.integrated, true); + assert.equal(r.kindWord, 'Integrated'); + assert.equal(r.on, false); + assert.equal(r.word, 'off'); + assert.equal(r.tone, 'off'); + assert.equal(r.hash, ''); + assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory'); + assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false); + assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s'); + assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad'); + assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node'); +}); + +test('the big button: start when paused, stop when mining, disabled with no card on', () => { + const m = (over) => ({ state: 'mining', paused: false, cards: [card()], ...over }); + assert.deepEqual(V.toggle(m(), { synced: true }, {}), { label: 'Stop mining', sub: 'mining on 1 of 1 card', cls: 'stop', disabled: false, act: 'pause' }); + assert.deepEqual(V.toggle(m({ state: 'paused', paused: true }), { synced: true }, {}), { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }); + const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {}); + assert.equal(none.disabled, true); + assert.equal(none.act, ''); + assert.equal(none.sub, 'switch a GPU on below first'); + assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive'); + assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync'); + assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true); +}); + +test('the node words: synced, syncing with a percentage, failed, a blocked clock', () => { + const n = (over) => ({ state: 'synced', blocks: 135200, headers: 135200, peers: 3, daa: 140000, last_reading_age_s: 4, message: '', restart_in_s: 0, ...over }); + const ok = V.nodeWords(n(), { severity: 'none' }, ''); + assert.equal(ok.word, 'synced'); + assert.equal(ok.tone, 'ok'); + assert.match(ok.line, /every block/); + const sy = V.nodeWords(n({ state: 'syncing', blocks: 50000, headers: 100000 }), { severity: 'none' }, 'about 3 min left at 300 blocks/s'); + assert.equal(sy.word, 'syncing'); + assert.equal(sy.line, 'about 3 min left at 300 blocks/s · 50,000 of 100,000 (50%)'); + assert.equal(V.nodeWords(n({ state: 'failed', message: 'igneumd could not start' }), { severity: 'none' }, '').tone, 'bad'); + const blocked = V.nodeWords(n(), { severity: 'block', skew_s: -75 }, ''); + assert.equal(blocked.tone, 'bad'); + assert.match(blocked.line, /clock is off by 75 s/); + assert.equal(V.peersLine(n({ peers: 0 })), 'none yet: looking for the seed node'); + assert.equal(V.peersLine(n({ peers: 1 })), 'one other node this one talks to'); + assert.equal(V.heightLine(n({ state: 'syncing', blocks: 10, headers: 500 })), 'of 500 headers seen'); + assert.equal(V.heightLine(n()), 'blocks this node holds'); +}); + +test('the next consensus switch is the first height above the DAA score, in plain words', () => { + const sw = [ + { key: 'fees_v1_activation_daa', name: 'Fees v1', daa: 210000 }, + { key: 'difficulty_v2_activation_daa', name: 'Difficulty v2', daa: 33000 }, + { key: 'finality_v3_activation_daa', name: 'Finality v3', daa: 135200 } + ]; + const next = V.nextSwitch(sw, 140000); + assert.equal(next.name, 'Fees v1'); + assert.equal(next.away, 70000); + assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.'); + assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2'); + assert.equal(V.nextSwitch(sw, 300000), null); + assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/); + assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/); + assert.equal(V.nextSwitch([], 5), null); +}); + +test('the prove words follow the switch, the setup, the node and the status', () => { + const pv = (over) => ({ enabled: true, available: true, setup_hint: '', backend: 'cuda', status: 'idle', message: '', current: '', verifier_mode: 'command', pool_entries: 4, pool_verified: 4, pool_failed: 0, ...over }); + assert.equal(V.proveWords(pv(), false, true).word, 'off'); + assert.equal(V.proveWords(pv({ status: 'setup', available: false, setup_hint: 'proving needs the WSL2 setup' }), true, true).word, 'needs setup'); + assert.equal(V.proveWords(pv(), true, false).word, 'waiting'); + assert.equal(V.proveWords(pv({ status: 'proving', current: 'block 59199 shard 0' }), true, true).sub, 'block 59199 shard 0'); + assert.equal(V.proveWords(pv({ status: 'submitted' }), true, true).tone, 'on'); + assert.equal(V.proveWords(pv(), true, true).word, 'idle'); + assert.equal(V.verifierWords(pv()).word, 'verifying · pool 4/4'); + assert.equal(V.verifierWords(pv()).tone, 'ok'); + assert.equal(V.verifierWords(pv({ verifier_mode: 'off', pool_entries: 0 })).tone, 'warn'); + assert.equal(V.verifierWords(pv({ verifier_mode: 'off', verifier_reason: 'the WSL2 prover is not installed' })).needsSetup, true); + assert.equal(V.verifierWords({}).word, 'not read yet'); +}); + +test('the dev-fee lines name the share and where Settings turns it off', () => { + const s = (on, fee) => ({ settings: { dev_fee: on }, mining: { fee_total: fee }, dev_fee: { on, percent: on ? 1 : 0, address: '0x1234567890abcdef1234567890abcdef12345678', line: '' } }); + assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). Settings turns it off.'); + assert.equal(V.devFeeLine(s(false, 0)), 'The dev fee is off. Every block pays this address.'); + assert.equal(V.devFeeText(s(true, 0)), 'Dev fee 1% (1 block in 100) to 0x123456…5678'); + assert.match(V.devFeeText(s(false, 0)), /^Dev fee off/); +}); + +test('the remote-jobs line and the helpers', () => { + const title = (j) => j.title || j.kind; + assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until Settings allows remote jobs.'); + assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.'); + assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).'); + assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.'); + assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a'); + assert.equal(V.shortHex('0xabc'), '0xabc'); + assert.equal(V.withCommas(1234567), '1,234,567'); + assert.equal(V.compact(2500000), '2.50M'); + assert.equal(V.rel(90), '1 min ago'); +}); + +test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as such, with no switch, and leave the counts', () => { + const gone = card({ key: 'amd:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: 1000, added_at: 0, gone: false, hash_now: 0 }); + const r = V.cardRow(gone); + assert.equal(r.removed, true); + assert.equal(r.on, false); + assert.equal(r.canToggle, false); + assert.equal(r.word, 'removed'); + assert.equal(r.tone, 'off'); + assert.equal(r.hash, ''); + assert.match(r.sub, /^unplugged; its worker stopped/); + const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' }); + const b = V.cardRow(bad); + assert.equal(b.unusable, true); + assert.equal(b.canToggle, false); + assert.equal(b.word, 'not usable (Code 43)'); + assert.equal(b.tone, 'bad'); + assert.match(b.sub, /^reboot with the card attached/); + assert.equal(b.title, 'gfx1201 · CUDA device 1 · AMD Accelerated Parallel Processing · bus 0000:03:00.0'); + assert.equal(V.cardRow(card()).title, 'CUDA device undefined'.replace(' device undefined', '') === '' ? '' : V.cardRow(card()).title); + // a row that is gone (five minutes after removal) is not shown at all; the big button counts only present cards + assert.deepEqual(V.shownCards([card(), card({ key: 'x', gone: true })]).map((c) => c.key), ['nvidia:0:RTX 5090']); + assert.equal(V.present(card()), true); + assert.equal(V.present(gone), false); + assert.equal(V.present(bad), false); + const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {}); + assert.equal(t.disabled, true); + assert.equal(t.sub, 'no GPU this app can drive'); + const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {}); + assert.equal(t2.sub, 'mining on 1 of 1 card'); +}); diff --git a/app/mac/IgneumMiner.swift b/app/mac/IgneumMiner.swift index 8f92680c..fbd48cf5 100644 --- a/app/mac/IgneumMiner.swift +++ b/app/mac/IgneumMiner.swift @@ -116,7 +116,7 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele window.titleVisibility = .hidden window.isMovableByWindowBackground = true window.backgroundColor = obsidian - window.minSize = NSSize(width: 900, height: 620) + window.minSize = NSSize(width: 900, height: 600) window.center() window.delegate = self window.isReleasedWhenClosed = false diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 83d08178..84bf99b5 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -16,6 +16,7 @@ #endif #include #include +#include #include #include #include @@ -284,6 +285,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_SIZE: if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); } return 0; + case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same + ((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600; + return 0; case WM_ENGINE_LINE: { if (wp == 1) { g_exited = true; @@ -324,6 +328,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } } return 0; + case WM_DEVICECHANGE: + // a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates + // the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration + if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect"); + return TRUE; case WM_TRAY: if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); } else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu(); diff --git a/app/windows/version.h b/app/windows/version.h index f973e572..4c841424 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.9" -#define IGNEUM_HOST_VERSION_RC 0,3,9,0 +#define IGNEUM_HOST_VERSION_STR "0.3.10" +#define IGNEUM_HOST_VERSION_RC 0,3,10,0 #endif diff --git a/docs/bench-log.md b/docs/bench-log.md index 9c0171fc..890bc1ee 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1451,6 +1451,32 @@ The adopted fee table (spec 05 section 5.11) reaches the devnet by `fees_v1_acti Block rate for H: DAA 111,230 at 15:23Z, 112,227 at 15:40:13Z, 0.965 blocks/s; H = 210,000 is 24 h ahead of a publish before about 19:50Z on 5 October (the runbook moves it otherwise). +## 5 October 2026 (night), the C4 fix: certificate-driven reorg + +Owner: the consensus engineer and cryptographer agent, worktrees `igneum-wt-c4` (branch `c4-fix`) and `vendor/igneum-node-c4` (fork branch `c4-fix` on release-0.3.6 a24ab01a). Harness `tools/finality-attacks/c4.mjs` on the fast-time 3-node network (100-ms proxied links), node built on the Mac in `vendor/igneum-node/target-c4` from the fork worktree (an APFS clone of `target-036`), suites on PC 2 through `tools/build-job.mjs`. The Mac carried two other builds and the M20 live sync throughout; every figure is a count, an index or a second from the harness clock. + +**The cause, in the code.** `processes/finality.rs`: `ingest_certificate` verified a certificate only when its block was the node's own determination at that index (`cp.hash == cert.checkpoint`); any other block went to `hold_pending`, and nothing ever tried the pending certificate against the table at its own block. `fork_choice_lock` reads `state.locks`, which only `evaluate` filled, and `evaluate` only ever ran over the node's own determination. So a certified checkpoint off the node's chain never became a lock and never constrained the sink search, whatever spec 3.5 says. Second cause, found tonight on the harness: `protocol/flows/src/v10/blockrelay/flow.rs` skips a relayed block whose blue work is under the virtual's merge-depth root ("hence we are skipping it"), and the certified chain is lighter by construction, so the node on the heavier side never received the certified chain's blocks at all: in the first runs on the fixed consensus n0 held B's certificates by gossip for the whole heal window and B's blocks never arrived (n0's log shows only its own blocks "via submit block" after the reconnect). + +**The fix.** Fork: `ingest_off_chain` (verifies against `voters_at` of the certificate's own block, Q3 and Q5 by `quorum_at` from that block's past, the lock chain by `off_lock_chain`, then LOCKED with a `FinalityLock` notification and a `VirtualStateProcessingMessage::Resolve` nudge so the sink moves without waiting for a block); `retry_pending_off_chain` on every virtual change; the lock-chain guard in `evaluate` (a determination off the chain through the node's nearest locks never locks and never aggregates); `fork_choice_lock` reports a lock beyond the depth-based finality point once; `wants_unknown_certified_block` and the relay-flow bypass of the merge-depth skip while a pending certificate names a block the node lacks (`finality_wants_blocks` through `ConsensusApi` and the session). Not gated on `finality_v3_activation_daa`: rule v2 took the same pending path. + +**Unit tests (PC 2, job build-20261005-180827, 18:09 UTC): `kaspa-consensus` 97 passed, 0 failed, 3 ignored; `kaspa-consensus-core` 101 passed.** New: `a_lighter_certified_chain_wins_and_a_heavier_uncertified_one_does_not_override_it` (main chain 77 blocks locks to 13, a 7-block side chain's index-14 certificate is adopted, the sink moves to the side tip with no new block, ten more main-chain blocks do not move it back, a second certificate at 14 over the main block is CONFLICTING and the lock stands, the side chain then locks 15), `the_certificate_driven_reorg_holds_under_rule_v2` (the same at `finality_v3_activation_daa` never), `a_chain_that_misses_an_adopted_lock_never_locks_here` (the evaluate guard and the off-lock conflict). `reorg_past_an_unlocked_checkpoint_re_determines_it_and_verifies_the_pending_certificate` rewritten for the new behaviour (pending while the block is unknown, adopted when it arrives). `kaspa-p2p-flows` lib tests do not compile on release-0.3.6 before or after this change (nine `epoch_seed_headers` errors in the pruning-proof message tests; the M20 job build-20261005-172340 hit the same nine an hour earlier). Six PC 2 jobs were lost tonight to two tooling faults, both fixed in the class: `igneum-ota-sign embedded | head -1` under `pipefail` (SIGPIPE panic, four scripts, `tools/ci/signer-pipe-check.sh`) and the one shared `build-inputs.zip` in the downloads folder (a job published while another agent's pack landed pinned that agent's sources, three times; `build-job.mjs` now names every job's zip). + +**Harness, weight against work (B four keys and 70% of the weight, A two keys and 30%; at the cut A mines 0.6 and B 0.4 blocks/s; `WINDOW` = weight window, ban and `min_daa` at fast time).** The 120-DAA window of the earlier runs turns every long split into F21's partition-longer-than-a-window shape once the p2p reconnect is added: n0 dials the proxy again on the connection manager's backoff, 84 to 114 s after the heal in every run tonight (the original sweep's 6 s was a short cut), so A's chain is 130 + 84 s = 128 DAA past the cut before any certificate can reach it, past its 120-DAA frozen table (v3) or its own two-thirds share of a sliding table (v2, 126 DAA at W 240 and s 0.3), and A locks alone first. With `WINDOW=240` and `WARM=320` the bound is 400 s (v3) or 210 s (v2) after the cut. + +| Run | Node | Rule, W, split | B locks during the split | n0 reconnected | n0 adopted off-chain | Final chain | Conflicting | Disagreeing | Verdict | +|---|---|---|---|---|---|---|---|---|---| +| on 90 s (the sweep's framing) | c4 consensus fix, no sync hook | v3, 120, 90 s | 0 (36 blue blocks for B, a new index needs 50) | 6 s | 0 | A, all three (no certificate to follow) | 0 | 0 | not the C4 shape | +| v2 90 s | same | v2, 120, 90 s | 1 (index 8) | 6 s | 0 | apart | 5 / 5 / 5 | 2 | n0 locked 10 alone at 18:32:42, B's certificate for 8 reached it at 18:32:43: F21's bound (63 DAA of A's own chain) crossed before the heal | +| on 130 s | same | v3, 120, 130 s | 1 (index 9) | 84 s | 0 | apart | 9 / 3 / 3 | 2 | n0 locked 12 alone at DAA 359, one window after lock 8 at 239, 6 s before the reconnect | +| off 150 s (control) | same | no certificate, 150 s | 0 | 96 s | 0 | A (heavier), all three; B's nodes re-determined 2 indices | 0 | 0 | PASS, as in the sweep | +| on 130 s, W 240 | same | v3, 240, 130 s | 2 (10, 11) | 114 s | 0 (certificates 13 and 14 pending, blocks unknown) | apart | 0 | 0 | the sync gap: n0 never received a B block | +| v2 130 s, W 240 | same | v2, 240, 130 s | 2 (10, 11) | 114 s | 0 | apart, n0 locked 16 alone at 293 s | 0 / 1 / 1 | 0 | the sync gap again (n0 reconnected after v2's 210-s bound) | +| v2 130 s, W 240 | c4 fix with the sync hook | v2, 240, 130 s | 1 (index 12) | 84 s | 3 (12, 13, 14 within 2 s of the first B block; 11 re-determined) | B, all three, A's split tip abandoned | 0 | 0 | PASS | +| on 130 s, W 240 | same | v3, 240, 130 s | 0 (Poisson: 52 blue blocks, the index fell just short) | 84 s | n1 1, n2 2 (B's nodes adopted A's post-heal certificates and moved before IBD) | A, all three | 0 | 0 | the mirror case; not the C4 shape | +| on 140 s, W 240, addPeer at the heal | same | v3, 240, 140 s | 2 (11, 12, first at 12 s) | 3 s (the harness now dials through `addPeer`; the address goes as `{ip, port}`) | 1 (12 by certificate; 11 verified on the new chain) | B, all three, A's split tip abandoned | 0 | 0 | PASS | + +Reading. With the consensus fix and the sync hook, a node on the heavier chain that receives a certificate for a chain it has never seen fetches that chain, verifies the certificate at its own block, locks it, moves its sink to the lighter certified chain and re-determines its own records onto it (the v2 W 240 row: 0 conflicts, 0 disagreements, every node on B's chain, which is the spec's F1 and the design's Fork choice items 1 to 4). The same holds under rule v3 with the frozen table on (the last row: B certified 11 and 12 during a 140-s split, n0 reconnected 3 s after the heal once the harness dialled through `addPeer`, adopted 12 by certificate and ended on B's chain with the other two, 0 conflicts, 0 disagreements). The fix does not and cannot cover a partition that outlasts the bound before the certificate arrives (rows 2, 3 and 6): there the node has already locked alone and 3.11.4 keeps that lock, the late certificate is CONFLICTING for the operator. On the live devnet (W 7,200 DAA, two hours) the bound is two hours after a side's last lock, so every partition under that heals by certificate. Raw: `scratchpad c4-results-*.md`, node logs `c4-*-n0.log`. + ## 5 October 2026 (evening), FUD ledger sweep round 6 Owner: the consensus engineer and cryptographer agent, worktree `igneum-wt-fud-a` (branch `fud-a`), 15:45 to 16:40 UTC. The Mac was loaded throughout (two cargo builds, a txgen run and a fee-switch simnet by other agents; load average over 100), so every figure below is a count, an index, a byte or a number from another machine; the only millisecond figures are the browser verifier's, taken as ratios and labelled. Live reads through the Mac node's wRPC (`ws://127.0.0.1:28640`) and the log intake (Neon HTTP SQL, lines split server-side), never a restart. @@ -1524,3 +1550,39 @@ What is measured: one BLS12-381 aggregate signature over 16 summed G1 keys plus | on, split 90 s | v3 | 0 / 2 | none / 3 | 278 / 265 | apart | none | 3 on n0 | 2 (n0 reconnected 6 s after the heal, A's chain at about 58 DAA, inside the table) | Reading (the NEW finding, ledger C4). With the module off GHOSTDAG alone converges on the heavier chain and the losing side's records re-determine (F24 works when the chain moves). With the module on the overlay holds during the split (A, with 30% of the frozen table, locks nothing; B locks 7 and 8) and then fails at the heal in the shipped node: B's certificates for blocks off n0's chain are "kept pending until the chain decides (no lock at this index)", n0's chain never decides because GHOSTDAG keeps its heavier tip and nothing turns the certificate into a fork-choice constraint, and once n0's last lock (index 7, DAA 209) is one window old (DAA 329) the frozen table stops applying on A's chain ("no frozen table (no lock on this chain inside the window)"), A's two keys are 100% of A's own window (B's post-cut blocks are red there) and n0 locks 10, 11, 12 alone; B's certificates for 10 and 11 then log CONFLICTING on n0 (n0 log, 17:27:04 to 17:29:54 BST). A finality fork from a 96-s honest partition, no attacker, table intact at the heal; the 150-s run and the v2 control end the same way. The spec's fork choice ("GHOSTDAG among tips through all certified checkpoints", 3.5) is therefore implemented only for certificates over blocks already on the node's chain. Fix named in the ledger entry: verify an off-chain certificate against the table at its own block and let it constrain fork choice (a certificate-driven reorg), then re-determine. Raw: `scratchpad fud-a/c4-results-*.md`, node logs `c4-on90-tmp/`, `c4-v2-control-tmp/`. + +## 5 October 2026 (evening), EVM transaction relay: three nodes in a chain, every transaction sent to one end included by the other two miners (execution and networking engineer) + +Until this change the node did not relay EVM transactions to its peers, so a transaction sent to one node was only ever included by that node's own templates (this file, "5 October 2026 (afternoon), live devnet: real transactions": 3,794 transfers, all in the Mac's blocks; execution-layer ledger item 9). Fork branch `tx-gossip` (worktree `vendor/igneum-node-txgossip`, from release-0.3.6 a24ab01a, commit e242acd0), main repo branch `tx-gossip`. Design in `docs/design/execution-layer.md` 1.4 "Relay"; the hand-out cooldown of its 10.2 table is gone with it (row "Mempool hold"). + +What was built. Three p2p messages after Kaspa's own transaction relay (`protocol/flows/src/v10/txrelay/flow.rs`): an inventory of admitted hashes, a request for the unknown ones, one answer with the raw bytes (`protocol/p2p/proto/p2p.proto`, payload numbers 72 to 74). Two flows per peer (`protocol/flows/src/v10/evmrelay.rs`), a pump that announces the mempool's admitted hashes every 250 ms, a sink trait the execution layer implements (`kaspa_consensus_core::evm::EvmTxSink`, `igneum/exec/src/service.rs EvmTxRelaySink`), and the mempool's side: every admitted hash queued for gossip, executed, evicted and invalid hashes remembered (65,536) so a second announcement is not requested, a 50,000-transaction cap, and the hold on block-added in place of the 4-second cooldown (the executor subscribes to consensus `BlockAdded`; a transaction leaves the templates when any DAG block carries it and comes back if a chain block skipped it). Limits per peer in the table. + +| Limit | Value | Over it | +|---|---|---| +| Hashes announced to us, or requested from us | 2,000 per second, burst 8,192 | the surplus of the message is dropped (the sender paid as much as we did) | +| Hashes per inventory or request message | 4,096 | disconnect | +| Bytes per answer / per transaction | 4 MiB / 128 KiB | disconnect | +| Transaction failing a state-free rule (malformed, signature, chain id, type 3 or 4) | | disconnect, hash remembered | +| State-dependent refusal (nonce more than 16 ahead, fee cap under the base fee, funds, 64 queued per sender, pool full) | | dropped quietly, hash not remembered | + +Protocol version. 13 to 14. An Igneum node drops a connection on a payload it cannot decode (`protocol/p2p/src/core/router.rs route_to_flow`: prost leaves the oneof empty, the router returns "empty payload", the connection closes), so the three messages go only to peers that advertised 14 or later, exactly as the finality (12) and proof-record (13) messages did. A 14 node registers the 13 flows for a 13 peer and never announces to it. The consensus params digest does not cover the protocol version: a scratch node on the devnet profile from the shipped 0.3.6 binary (`target-036`) and from this build printed the same digest, `9409dedac4bf9f0f20a54fb169b52a75a2903364909fe9ffd6fc5cdcd9d95d38`, so a 14 node and a 13 node still peer. Rollout: during the mixed fleet a transaction reaches the 14 nodes connected to the node it was sent to, and whatever a 13 node mines carries only what its own RPC received, as today; the relay is complete when the last miner is on 14. No fresh chain, no activation height. + +Unit tests (PC 2, job build-20261005-173606, `igneum-exec` 15 of 15 in 0.01 s, `kaspa-p2p-flows` 33 of 33 in 0.19 s, 24 s for both): the pool queues an admitted hash for gossip once and answers "known" for the duplicate; wrong chain id, a signature above the curve order and truncated bytes are refused and remembered by hash, a nonce beyond the gap and a fee cap under the base fee are refused and not remembered; a transaction stays in every template until a block carries it, is held then, comes back when a chain block skips it and leaves (hash remembered) when one executes it; the wire messages round-trip through prost and the router's payload type, hash lists of the wrong length or over 4,096 are refused, the per-peer bucket grants the burst then the rate. The first PC 2 run of the suites (build-20261005-173013) failed on the signature case: a flipped low bit of `s` recovers a different signer (a funds refusal), not a fault; the test now sets `s` above the curve order. Found on the way: the `kaspa-p2p-flows` test target had not compiled since M20 added the epoch-seed headers to the pruning proof messages (`ibd/proof.rs` tests), fixed in the same commit. + +The 3-node run (`tools/txgen/relay-net.mjs`, new; this Mac, load 7 to 8 at the end of the run after the other agents' harnesses finished, every number a count or an inclusion latency, not a timing of the node). Fast-time profile (`infra/fast-time/override-60x.json`, proof of work skipped), ports 29700+, data `/tmp/igneum-txrelay`. Chain A - B - C: B dials A and C (a harness node that dials accepts no inbound, and `--connect` takes one address per flag; both found by the first two runs, which are not numbers). A mines nothing. One vmine on B and one on C at 0.5 blocks/s each, paid to throwaway keys made for the run; B's rewards funded 16 generator wallets (2 IGN each) 33 s after start. The generator (`tools/txgen/run.mjs`) sent to A's EVM RPC only, 2 transfers a second for 120 s, so every inclusion is by a block B or C built from a pool the relay fed; C is two hops from A. Result files `docs/benchmarks/evm-relay-2026-10-05/{relay-report,txgen-summary}.json`. + +| Measured, 3-node fast-time run (17:49 to 17:52 UTC) | Value | +|---|---| +| Sent to A / included / pending at the end / failures | 240 / 240 / 0 / 0 (0 nonce retries, 0 deferred, 0 throttled) | +| Included per second over the send span | 1.98 (target 2) | +| Inclusion latency p50 / p90 / p99 / max | 1,545 / 3,058 / 5,033 / 6,017 ms (mean 1,859) | +| Chain blocks in the window / executed transfers / skipped copies | 149 / 256 (240 transfers and 16 funding) / 0 | +| Included by miner B (one hop): blocks / with transactions / executed | 79 / 59 / 151 | +| Included by miner C (two hops): blocks / with transactions / executed | 70 / 42 / 105 | +| Pool depth, sampled every 5 s on A, B and C | equal on all three at 27 of 27 samples (0 to 6 pending), peak 6 | +| Sinks agree at the end | yes | +| First funding transfer, sent to A, included | 2.0 s after the send (block 37, mined by B or C) | + +Reading. Every transaction given to A was mined by B or C within 6 s, two thirds of them within 3 s, with no skipped copy: the hold on block-added kept B's and C's parallel blocks from carrying the same transfer. The afternoon run on the live devnet, through one node with the cooldown, had p50 40.7 s and p90 110.8 s with 50-s quiet stretches; here the 1.5 s p50 is one fast-time block plus the relay and the executor's lag. The pool depth matching on all three nodes at every sample is the convergence. Not measured here: a transaction flood above the per-peer rate (the bucket is unit-tested only), a 13 peer in the fleet (the digest check and the version gate are the evidence), and the hold's 30-s expiry on a block that never reaches the chain (not seen in 149 chain blocks). + +Commands: `IGNEUMD=vendor/igneum-node/target-txgossip/release/igneumd IGNEUM_MINER=vendor/igneum-node/target-txgossip/release/igneum-miner tools/lock/with-lock.sh run node tools/txgen/relay-net.mjs --rate 2 --duration 120 --wallets 16 --fund 2`; the Mac binaries from the fork worktree with `CARGO_TARGET_DIR=vendor/igneum-node/target-txgossip cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` under the build lock (an APFS clone of `target-036`, 2 min 15 s to clone, 5 min 06 s to build); the suites with `node tools/build-job.mjs run --target 1ccfe586 --node vendor/igneum-node-txgossip --targets linux --node-tests "igneum-exec kaspa-p2p-flows" --no-app`. diff --git a/docs/benchmarks/evm-relay-2026-10-05/relay-report.json b/docs/benchmarks/evm-relay-2026-10-05/relay-report.json new file mode 100644 index 00000000..c1c24c32 --- /dev/null +++ b/docs/benchmarks/evm-relay-2026-10-05/relay-report.json @@ -0,0 +1,301 @@ +{ + "tool": "tools/txgen/relay-net.mjs", + "node": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-txgossip/release/igneumd", + "topology": "A - B - C (B dials A and C); generator on A; vmine on B and C", + "params": { + "rate_per_s": 2, + "duration_s": 120, + "wallets": 16, + "fund_ign": 2, + "fast_time": true + }, + "chain_blocks_in_window": 149, + "executed": 256, + "skipped": 0, + "by_miner": { + "B": { + "blocks": 79, + "blocks_with_txs": 59, + "txs_carried": 151, + "executed": 151, + "skipped": 0 + }, + "C": { + "blocks": 70, + "blocks_with_txs": 42, + "txs_carried": 105, + "executed": 105, + "skipped": 0 + } + }, + "generator": { + "sent": 240, + "included": 240, + "pending_at_end": 0, + "included_per_s": 1.975, + "latency_ms": { + "p50": 1545, + "p90": 3058, + "p99": 5033, + "max": 6017, + "mean": 1859 + }, + "blocks_with_content": 100, + "errors": {}, + "stop_reason": "duration" + }, + "pools": { + "samples": [ + { + "t": 33.5, + "A": 0, + "A_chain": 37, + "B": 0, + "B_chain": 37, + "C": 0, + "C_chain": 37 + }, + { + "t": 38.5, + "A": 6, + "A_chain": 38, + "B": 6, + "B_chain": 38, + "C": 6, + "C_chain": 38 + }, + { + "t": 43.5, + "A": 2, + "A_chain": 44, + "B": 2, + "B_chain": 44, + "C": 2, + "C_chain": 44 + }, + { + "t": 48.5, + "A": 2, + "A_chain": 50, + "B": 2, + "B_chain": 50, + "C": 2, + "C_chain": 50 + }, + { + "t": 53.5, + "A": 1, + "A_chain": 58, + "B": 1, + "B_chain": 58, + "C": 1, + "C_chain": 58 + }, + { + "t": 58.5, + "A": 2, + "A_chain": 66, + "B": 2, + "B_chain": 66, + "C": 2, + "C_chain": 66 + }, + { + "t": 63.6, + "A": 4, + "A_chain": 71, + "B": 4, + "B_chain": 71, + "C": 4, + "C_chain": 71 + }, + { + "t": 68.6, + "A": 2, + "A_chain": 77, + "B": 2, + "B_chain": 77, + "C": 2, + "C_chain": 77 + }, + { + "t": 73.6, + "A": 0, + "A_chain": 80, + "B": 0, + "B_chain": 80, + "C": 0, + "C_chain": 80 + }, + { + "t": 78.6, + "A": 3, + "A_chain": 83, + "B": 3, + "B_chain": 83, + "C": 3, + "C_chain": 83 + }, + { + "t": 83.6, + "A": 0, + "A_chain": 92, + "B": 0, + "B_chain": 92, + "C": 0, + "C_chain": 92 + }, + { + "t": 88.6, + "A": 0, + "A_chain": 96, + "B": 0, + "B_chain": 96, + "C": 0, + "C_chain": 96 + }, + { + "t": 93.6, + "A": 2, + "A_chain": 101, + "B": 2, + "B_chain": 101, + "C": 2, + "C_chain": 101 + }, + { + "t": 98.6, + "A": 1, + "A_chain": 114, + "B": 1, + "B_chain": 114, + "C": 1, + "C_chain": 114 + }, + { + "t": 103.6, + "A": 5, + "A_chain": 117, + "B": 5, + "B_chain": 117, + "C": 5, + "C_chain": 117 + }, + { + "t": 108.6, + "A": 4, + "A_chain": 121, + "B": 4, + "B_chain": 121, + "C": 4, + "C_chain": 121 + }, + { + "t": 113.6, + "A": 2, + "A_chain": 125, + "B": 2, + "B_chain": 125, + "C": 2, + "C_chain": 125 + }, + { + "t": 118.6, + "A": 1, + "A_chain": 128, + "B": 1, + "B_chain": 128, + "C": 1, + "C_chain": 128 + }, + { + "t": 123.6, + "A": 0, + "A_chain": 135, + "B": 0, + "B_chain": 135, + "C": 0, + "C_chain": 135 + }, + { + "t": 128.6, + "A": 3, + "A_chain": 140, + "B": 3, + "B_chain": 140, + "C": 3, + "C_chain": 140 + }, + { + "t": 133.6, + "A": 0, + "A_chain": 144, + "B": 0, + "B_chain": 144, + "C": 0, + "C_chain": 144 + }, + { + "t": 138.6, + "A": 1, + "A_chain": 155, + "B": 1, + "B_chain": 155, + "C": 1, + "C_chain": 155 + }, + { + "t": 143.6, + "A": 3, + "A_chain": 163, + "B": 3, + "B_chain": 163, + "C": 3, + "C_chain": 163 + }, + { + "t": 148.6, + "A": 2, + "A_chain": 167, + "B": 2, + "B_chain": 167, + "C": 2, + "C_chain": 167 + }, + { + "t": 153.6, + "A": 1, + "A_chain": 175, + "B": 1, + "B_chain": 175, + "C": 1, + "C_chain": 175 + }, + { + "t": 158.6, + "A": 0, + "A_chain": 179, + "B": 0, + "B_chain": 179, + "C": 0, + "C_chain": 179 + }, + { + "t": 163.6, + "A": 0, + "A_chain": 184, + "B": 0, + "B_chain": 184, + "C": 0, + "C_chain": 184 + } + ], + "max": { + "A": 6, + "B": 6, + "C": 6 + } + }, + "sinks_agree": true, + "wall_s": 168.8 +} diff --git a/docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json b/docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json new file mode 100644 index 00000000..d681ca3a --- /dev/null +++ b/docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json @@ -0,0 +1,269 @@ +{ + "tool": "tools/txgen/run.mjs", + "rpc": "http://127.0.0.1:29703", + "chain_id": 4463, + "started": "2026-10-05T17:49:40.914Z", + "ended": "2026-10-05T17:51:44.556Z", + "stop_reason": "duration", + "params": { + "wallets": 16, + "rate_per_s": 2, + "duration_s": 120, + "fund_ign": "2.000000", + "cap_ign": "74.000000", + "gas": 59650, + "stale_s": 60 + }, + "fees_last": { + "base_gwei": "100.00", + "proving_base_gwei": "10000.00", + "tip_gwei": "1.00", + "node_quote_gwei": "243.86", + "fee_cap_gwei": "243.86" + }, + "counts": { + "sent": 240, + "included": 240, + "failed": 0, + "dropped": 0, + "skipped": 0, + "reverted": 0, + "nonceRetries": 0, + "deferred": 0, + "throttled": 0, + "fundingTx": 16, + "pending_at_end": 0 + }, + "throughput": { + "included_per_s": 1.975, + "send_span_s": 121.5, + "sent_per_s_target": 2 + }, + "latency_ms": { + "p50": 1545, + "p90": 3058, + "p99": 5033, + "max": 6017, + "mean": 1859 + }, + "blocks": { + "with_content": 100, + "first": 38, + "last": 176, + "max_tx_in_one": 10, + "per_block": { + "38": 8, + "39": 1, + "40": 2, + "42": 3, + "44": 5, + "45": 3, + "46": 1, + "48": 1, + "50": 2, + "51": 1, + "52": 3, + "54": 2, + "57": 3, + "58": 2, + "59": 1, + "61": 2, + "62": 2, + "63": 1, + "65": 1, + "66": 3, + "68": 4, + "70": 1, + "71": 5, + "72": 1, + "73": 2, + "74": 2, + "75": 2, + "77": 2, + "79": 10, + "80": 1, + "81": 4, + "82": 2, + "83": 3, + "84": 2, + "85": 3, + "87": 1, + "88": 2, + "89": 1, + "91": 1, + "92": 4, + "93": 1, + "94": 3, + "95": 2, + "96": 2, + "97": 1, + "98": 5, + "101": 2, + "103": 1, + "104": 1, + "105": 2, + "106": 2, + "109": 1, + "110": 1, + "112": 1, + "114": 3, + "116": 3, + "117": 8, + "119": 2, + "120": 1, + "121": 6, + "122": 1, + "123": 3, + "124": 2, + "125": 6, + "126": 1, + "127": 4, + "128": 3, + "129": 1, + "130": 4, + "132": 1, + "133": 1, + "134": 1, + "135": 2, + "138": 4, + "139": 1, + "140": 4, + "141": 6, + "142": 3, + "146": 1, + "147": 1, + "148": 3, + "151": 2, + "152": 1, + "153": 1, + "155": 4, + "157": 1, + "158": 1, + "160": 1, + "162": 1, + "163": 5, + "164": 1, + "165": 4, + "166": 1, + "167": 5, + "168": 1, + "169": 1, + "172": 3, + "174": 1, + "175": 3, + "176": 2 + } + }, + "spend_ign": { + "funding": "32.000000", + "fees_actual": "0.542976", + "fees_max_committed": "38.769773", + "value_moved_between_wallets": "0.132776", + "cap": "74.000000", + "cap_hit": false + }, + "wallets": [ + { + "index": 0, + "sent": 15, + "balance_ign": "1.921973", + "nonce": 15 + }, + { + "index": 1, + "sent": 15, + "balance_ign": "1.922278", + "nonce": 15 + }, + { + "index": 2, + "sent": 15, + "balance_ign": "1.923568", + "nonce": 15 + }, + { + "index": 3, + "sent": 15, + "balance_ign": "1.924152", + "nonce": 15 + }, + { + "index": 4, + "sent": 15, + "balance_ign": "1.920937", + "nonce": 15 + }, + { + "index": 5, + "sent": 15, + "balance_ign": "1.922478", + "nonce": 15 + }, + { + "index": 6, + "sent": 15, + "balance_ign": "1.924611", + "nonce": 15 + }, + { + "index": 7, + "sent": 15, + "balance_ign": "1.930741", + "nonce": 15 + }, + { + "index": 8, + "sent": 15, + "balance_ign": "1.923430", + "nonce": 15 + }, + { + "index": 9, + "sent": 15, + "balance_ign": "1.926285", + "nonce": 15 + }, + { + "index": 10, + "sent": 15, + "balance_ign": "1.922495", + "nonce": 15 + }, + { + "index": 11, + "sent": 15, + "balance_ign": "1.918612", + "nonce": 15 + }, + { + "index": 12, + "sent": 15, + "balance_ign": "1.921628", + "nonce": 15 + }, + { + "index": 13, + "sent": 15, + "balance_ign": "1.921379", + "nonce": 15 + }, + { + "index": 14, + "sent": 15, + "balance_ign": "1.923181", + "nonce": 15 + }, + { + "index": 15, + "sent": 15, + "balance_ign": "1.923212", + "nonce": 15 + } + ], + "funder": { + "balance_ign": "203.751501" + }, + "lost": [], + "pending_at_end": [], + "errors": {} +} \ No newline at end of file diff --git a/docs/design/execution-layer.md b/docs/design/execution-layer.md index dbcbd553..f0466d16 100644 --- a/docs/design/execution-layer.md +++ b/docs/design/execution-layer.md @@ -79,6 +79,8 @@ Worked example. Sender S has nonce 5. Miner A's block carries S:5, S:6. Miner B' Consequence for users. A transaction can be skipped in one block and execute in a later one without being re-broadcast, as long as a miner includes it again; the node's mempool re-queues a skipped transaction once (then drops it). `eth_getTransactionReceipt` returns null until the executing copy lands, as on Ethereum for a pending transaction. +Relay (implemented 5 October 2026, fork `tx-gossip`, protocol version 14). A node's mempool is no longer only what its own RPC received. Every admitted hash is announced to every relay-aware peer within 250 ms (`IgneumEvmTxInvMessage`, the inventory pattern of Kaspa's `InvTransactions`); a peer requests the hashes it does not know (`IgneumRequestEvmTxsMessage`) and the holder answers one `IgneumEvmTxsMessage` with the raw bytes it still has; the receiver runs the same admission as `eth_sendRawTransaction` (signature, chain id, nonce window of 16, fee cap at or above the execution base fee, funds, 64 queued per sender, the pgas estimate) and a transaction the node already executed is refused without re-admission, so the pools converge and the chain's own blocks carry a transaction once. Dedup is by hash: the pool answers "known" for what it holds, executed or refused as invalid in the last 65,536 hashes, and one request per hash is outstanding across all peers. Per peer, 2,000 hashes a second with a burst of 8,192 are accepted in and served out; 4,096 hashes per message and 4 MiB per answer, over which the peer is dropped. A state-free fault (malformed, bad signature, wrong chain id, a refused type) disconnects the relaying peer, since every node refuses it the same way; a state-dependent refusal (nonce beyond the window, fee cap under the base fee, funds, queue depth, the 50,000-transaction pool cap) is dropped quietly, because the peer's tip may differ. Relay is off while the node is out of sync. Code: `protocol/flows/src/v10/evmrelay.rs`, the pump in `protocol/flows/src/service.rs`, the sink in `igneum/exec/src/service.rs` (`EvmTxRelaySink`). + Alternative. Per-block nonces or sequence-independent nonces (Sui-style objects). Rejected: every wallet assumes Ethereum nonces. ### 1.5 Invalid transactions are skipped by rule @@ -460,7 +462,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite, | Units | 1 sompi = 1e10 wei; 1 IGN = 1e18 wei | Subsidies come from `igneum::block_subsidy` in 8-decimal sompi; the EVM is 18-decimal. The open "8 or 18 decimals" decision is unchanged; this is the fixed scaling at the bridge named there | | Rewards | 80% of every blue block's subsidy to its miner, 20% to the proving pool escrow `0x...0220`, both credited in the segment that merges the block; reds unpaid | Design 4.4, with the pool held in a keyless account until proof records exist | | Simnet | Devnet block rate and depths (1 BPS, k 18, mergeset 180, merge depth 3,600) with proof of work skipped; chain id 4463 shared with the devnet | A CPU test network of the devnet DAG shape | -| Mempool hand-out | A transaction handed to a template is not offered again for 4 s unless a chain block skipped it; a transaction skipped twice is dropped | Kaspa removes a block's transactions on block-added; the cooldown is the stand-in until the executor listens to block-added | +| Mempool hold | A transaction stays in every template until a block carrying it is added to the DAG (any block, this node's or a peer's: the executor subscribes to consensus `BlockAdded`); then it is held for 30 s or until the executor removes it (executed) or offers it again (a chain block skipped it); a transaction skipped twice is dropped | Kaspa's own rule (`mining/src/manager.rs`, `handle_new_block_transactions`). Replaced the 4-second hand-out cooldown on 5 October 2026 (fork `tx-gossip`, `pool.rs IN_BLOCK_HOLD`, `service.rs listen_block_added`): the cooldown made a sender mineable 1 s in 5 and inclusion came in 50-s bursts (bench-log, 5 October 2026 afternoon); with the hold a transaction is offered to every template until a block has it | | Reorgs | Post-segment states for the last 64 chain blocks; deeper reorgs replay from genesis | Observed depth on the test network: 1 to 3 with Poisson-paced miners. A fixed per-template hold had made the three stub miners mine in lockstep rounds, and with equal work per block the GHOSTDAG hash tie-break then kept two equal-work chains alive from genesis (flips 48 deep every few seconds); `igneum-miner --hold-ms` is exponential now | | Block tags | `pending`, `safe` and `finalized` all resolve to the executed tip | The virtual's segment is not executed eagerly and no certified checkpoint exists on this branch; the RPC does not pretend otherwise | @@ -474,7 +476,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite, 6. The virtual's segment is not executed eagerly (design 1.2 "about one second after inclusion"); the executor runs about one chain block behind the sink. `pending` tags resolve to the executed tip. 7. `eth_subscribe`, `debug_traceTransaction`, `trace_block`, `eth_getProof`, `eth_getUncle*`, `IgneumInfo`: not implemented. 8. The chain follower polls `get_virtual_chain_from_block` every 100 ms instead of subscribing to virtual-chain-changed notifications. -9. Mempool: no p2p relay of EVM transactions between nodes (each node's pool is what its RPC received), no eviction by age, no fee-based replacement beyond the 10% rule. +9. Mempool: p2p relay of EVM transactions implemented 5 October 2026 (section 1.4 "Relay", protocol version 14; measured on a 3-node fast-time chain A - B - C in the bench-log of that day: every transaction sent to A was included by B's and C's blocks). Still missing: eviction by age and fee-based replacement beyond the 10% rule. 10. Differential rows 1 (ethereum/tests), 3 (independent linearizer), 4 (Python oracle), 5 and 6 are not built; the harness here is the balance and receipt comparison of the acceptance criteria. ### 10.4 Merge plan with the finality branch diff --git a/docs/fud-ledger.md b/docs/fud-ledger.md index bb157ed3..dc9e4e3f 100644 --- a/docs/fud-ledger.md +++ b/docs/fud-ledger.md @@ -621,6 +621,8 @@ Evidence: design doc Finality v2, Fork choice items 1 to 4; `sim/results.md` fin Sweep (5 October 2026, evening): the module-on against module-off comparison of O-3.8, run on the fast-time harness with the live node line (`tools/finality-attacks/c4.mjs`, fork 2b6d23ef, 3 nodes, 100-ms proxied links; raw tables in `docs/bench-log.md`, "FUD ledger sweep round 6", C4). The scenario separates weight from work: side B (n1, n2, four keys) holds 70% of the weight table and side A (n0, two keys) 30% when the link is cut; from the cut A mines at 0.6 blocks/s and B at 0.4, so A's chain is the heavier one by blue work while only B can certify under rule v3 (A holds 30% of the frozen table). Module off (`min_daa` never, so no certificate can form, fork choice bare GHOSTDAG): after a 150-s split the three nodes converged on A's heavier chain within 36 s of the heal, B's nodes re-determined their two split-time checkpoints onto it (F24), 0 conflicts. Module on (rule v3 from checkpoint DAA 0), 90-s split, n0 back on the link 6 s after the heal, A's chain at about 58 DAA of its own time, well inside the 120-DAA frozen table: during the split A locked nothing and B locked indices 7 and 8 on its own blocks, as designed; after the heal n0 did not switch. Its log: B's certificates for 8 and 9 arrived and were "kept pending until the chain decides (no lock at this index)" (the F24 path), n0's chain never changed because GHOSTDAG prefers its heavier tip and nothing in the node turns a verified certificate over an off-chain block into a fork-choice constraint, and one window after n0's last lock (index 7 at DAA 209, so from DAA 329) the frozen table no longer applied on A's chain ("no frozen table (no lock on this chain inside the window)"), A's two keys were 100% of A's own window table (B's post-cut blocks are red there and earn nothing), and n0 locked 10, 11 and 12 alone; B's certificates for 10 and 11 then logged CONFLICTING on n0, and B's nodes kept their certified chain. End state: sinks apart, 2 locked indices disagreeing across the nodes, a finality fork from a 96-s honest partition with no attacker and the frozen table intact at the heal; the same shape with a 150-s split (the table expired at the heal) and under rule v2 (the control: 1 conflict, sinks apart). So the answer to the critic is sharper than conceded: the overlay is specified to override blue work (spec 3.5, "GHOSTDAG among tips through all certified checkpoints") but the shipped node applies a certificate only to a block on its own chain, holds the rest pending a reorg that GHOSTDAG alone never produces, and after one window the heavier side certifies its own chain. Two honest views never reconcile. What closes it: a verified certificate over a block the node does not have on its selected chain must verify against the weight table at THAT block (its signers' weight there) and, when valid, constrain fork choice to tips through it, forcing the reorg (a certificate-driven reorg, bounded by the finality depth), with the node's own unlocked records re-determined on the new chain (F24); until then the exchange guidance of 3.9 (a node partitioned for more than a minute treats its locks as proof of work until it has seen the network's certificates agree with its own) is the only protection, and the 3.11.7 row for this case ("a certificate over a chain the node is not on") is missing. On the live devnet the window is 7,200 DAA (two hours) and the cliff is two hours after a side's last lock; a miner who joins with more hashrate than the weight table credits is the realistic work-majority side. The trace-driven adversary of O-3.8 is still owed. Spec rows: 3.5, 3.11.4, 3.11.7; node: `processes/finality.rs` (`ingest_certificate`'s pending branch, `fork_choice_lock`). Decision owner: the project lead (gate 3; a rule change to the node's fork choice). +Fix (5 October 2026, night): the certificate-driven reorg, built, unit-tested and measured; fork branch `c4-fix` on release-0.3.6 (a24ab01a), main branch `c4-fix`. The cause in the code: `processes/finality.rs` `ingest_certificate` verified a certificate only over the node's own determination and sent every other block to `hold_pending`; `fork_choice_lock` reads `state.locks`, which only `evaluate` filled over the node's own chain; so a certified block off the chain never became a lock. A second cause only the harness showed: the block relay (`protocol/flows/src/v10/blockrelay/flow.rs`) skips a relayed block lighter than the virtual's merge-depth root, and the certified chain is the lighter one by construction, so the heavier side never even received it. The fix: `ingest_off_chain` verifies a certificate against the voter table at its own block (canonical list, aggregate BLS, 2/3 of active and of total there, the frozen table under v3, the first-month gate), checks the block lies on the chain through the node's nearest locks (else CONFLICTING, 3.11.4, no lock withdrawn), locks the index on that block and asks the virtual processor to resolve (`VirtualStateProcessingMessage::Resolve`), so the sink search keeps only tips through it, whatever the blue work and whatever the merge depth (finality outranks merge depth; the depth-based finality point still bounds it, Kaspa's pruning safety, logged once); pending certificates over blocks the node lacks are retried on every virtual change; `evaluate` locks the node's own determination only on the chain through its locks; and while a pending certificate names a block the node lacks (`finality_wants_blocks`), the relay takes the lighter block, which orphans, falls out of range and triggers IBD of the certified chain. Not gated on v3: the live devnet's rule v2 took the same pending path (unit test `the_certificate_driven_reorg_holds_under_rule_v2`). Spec 3.5 carries the rule in one paragraph, 3.2 C4 and the 3.10 rows C4 and F1/F2 the implementation. Measured (bench-log "the C4 fix", `c4.mjs` with `WINDOW=240` so a 130-s split plus the 84-s p2p reconnect stays inside the window; the sweep's 120-DAA framing crosses F21's bound before any certificate can arrive once the real reconnect time is counted): under rule v2 side B locked index 12 during the split, n0 took B's first relayed block through the hook, locked 12, 13 and 14 by certificate within 2 s, re-determined 11, and all three nodes ended on B's certified chain with 0 CONFLICTING and 0 disagreeing locked indices (was: sinks apart, 5 conflicts on each node, 2 disagreeing); the module-off control is unchanged (heavier chain, 0 conflicts); under rule v3 (frozen table on, 140-s split, n0 reconnected 3 s after the heal) B locked 11 and 12 during the split, n0 adopted 12 by certificate and verified 11 on the new chain, all three nodes on B's chain, 0 CONFLICTING, 0 disagreeing; the mirror case (B certified nothing, A certified after the heal) had B's nodes adopt A's certificates and move before IBD. Unit tests on PC 2: `kaspa-consensus` 97 passed, `kaspa-consensus-core` 101 passed. Still owed: the live-devnet partition test of O-3.6 and the trace-driven adversary of O-3.8. What the fix does not cover, by design: a partition that outlasts the bound before the certificate arrives (the side has locked alone, 3.11.4 keeps it, the late certificate is CONFLICTING for the operator), which on the devnet means over two hours. Rollout: a consensus-behaviour change in the node with no params-digest change; a mixed fleet disagrees only in the state the old node already got wrong (an old node holds the certificate pending and stays on its heavier chain while new nodes move), and converges once every node is new; ship in the next node release with every node restarted on it. + ### C5. vs Ethereum: you compare inclusion to finality "'Included in about one second, against twelve on Ethereum.' Inclusion in a DAG is not confirmation. Ethereum's twelve seconds is a slot, its finality is about thirteen minutes, and you compare your two-minute lock to that as if a two-minute lock by a pool committee were the same thing." @@ -1255,6 +1257,8 @@ Sweep (5 October 2026, evening): the two options, with their measured cost. Recommendation: Option B, which spec 3.11.4 already states and O-3.17 names; it is Kaspa's rule for a finality conflict (`vendor/rusty-kaspa/consensus/notify/src/notification.rs`, `FinalityConflict`), it is the only reading under which an exchange can credit on a lock, and its cost falls on a state that needs a 34% equivocator or a 30-day partition. What it needs: the 3.5 paragraph replaced by 3.11.4's text, `finality_conflict` and the `finality_active` clear in the node, and the forced-double-certificate devnet test of 3.11.7. Decision owner: the project lead (gate 3). +What the C4 fix changes for option B (5 October 2026, night): the honest-partition row above is gone. Before the fix a 96-s partition with the table intact put two certified chains on the network with no equivocator (C4: the work-majority side held the other side's certificates pending, then certified its own chain), and option B would have paused finality on every node of that side for an operator. With the certificate-driven reorg (spec 3.5, `ingest_off_chain`) a node that receives a valid certificate for a chain it is not on adopts it and moves, so after a heal shorter than a window there is one chain of locks and nothing to withdraw: the module-on harness ended with 0 conflicting certificates and 0 disagreeing locked indices on all three nodes, under rule v3 and under v2 (bench-log "the C4 fix"). What remains for option B is exactly the states 3.11.4 names: an equivocator at one third or more, and a partition longer than a window (both sides certify their own chain before the heal; the node then holds a lock at a higher index on the other chain, `off_lock_chain`, and the late certificate is CONFLICTING, kept for the operator, no lock withdrawn). The node still does not clear `finality_active` or expose `finality_conflict` (O-3.17). + Answer: Correct as the proposal stands. For an exchange "locked" must be irrevocable or it is a confirmation count. The alternative is Kaspa's: a verified certificate is never re-evaluated; two certificates at one index are a chain split that halts `finality_active` until an operator intervenes, and the node never reports a lock it may withdraw. Equivocation costing history and not coins (F6) means the attacker who caused the split keeps the deposit either way. Decision at gate 3; the devnet partition-and-heal test of O-3.6 measures whichever rule is chosen. Evidence: spec 3.5, 3.9. Review id R3.17. diff --git a/docs/plans/miner-ui-2.md b/docs/plans/miner-ui-2.md new file mode 100644 index 00000000..e1768229 --- /dev/null +++ b/docs/plans/miner-ui-2.md @@ -0,0 +1,171 @@ +# Igneum Miner UI 2: sections behind a rail, 5 October 2026 + +the project lead, 18:40 UTC: "can any updates be made to the UI of the miner? different sections for different features. Made +super simple for users, all settings super easy and simple and everything looking gorgeous, as close to shippable +as we can get right now." Worktree `/Users/joshm/Projects/igneum-wt-miner-ui`, branch `miner-ui-2` from master +a93199a. the project lead approved the redesign from the screenshots (20:1x UTC) and moved it into 0.3.10: the 0.3.10 shipper merges +`miner-ui-2` last and rebuilds the app. Times are UTC. + +## 1. What changed + +| Where | What | +|---|---| +| `app/igneum-app/ui/index.html` | One page became a rail with six sections: Mine, Prove, Rewards, Node, Updates, Settings. The welcome, GPU and address screens and the one-time key sheet stay as they were. The settings panel, the bottom bar and the proving tile are gone; every control they held is placed on a section. | +| `app/igneum-app/ui/app.css` | Rewritten around the rail, the thin top bar, the status strip and the pages. Same tokens as the site (obsidian, graphite, ember, molten, bone, ash; Unbounded, IBM Plex Sans, IBM Plex Mono). One accent. Lays out from 900 x 600: under 1000 px the rail folds to icons, the number strips to two columns, the two-column grid to one. Focus rings on every control (`:focus-visible`, the switch tracks). | +| `app/igneum-app/ui/app.js` | New pure block `View` (the words every section shows for a state), tested by `view.test.mjs`. The Notices and UpdateCard blocks, the log drawer and the blocks canvas are unchanged. `?page=` forces a section and `?logs=1` opens the drawer (screenshots). | +| `app/igneum-app/ui/view.test.mjs` | 10 tests: the GPU row (names, kinds, integrated off, temperatures amber then red, unknown numbers blank), the big button, the node words, the next switch, the prove words, the dev-fee lines, the jobs line. Added to `.github/workflows/ci.yml`. | +| `app/igneum-app/src/state.rs`, `engine.rs` | Engine addition 1: `node.consensus_digest`, the node's own "Consensus params digest" line (`digest_from_line`, tested; a peer's digest in a WARN line is never taken). Addition 2: `node.consensus_switches`, every `*_activation_daa` in the override file the node was started with, lowest first, in plain words (`switches_of`, tested). | +| `app/igneum-app/src/prover.rs` | Engine addition 3: `proving.program_id` and `proving.aggregator_id` from `igneum-prove-host --mode id` (`ids_from_describe`, tested), read once at thread start on macOS and Linux and when the WSL2 host is found on Windows, proving on or off. | +| `app/mac/IgneumMiner.swift` | Window minimum 900 x 600 (was 900 x 620). | +| `app/windows/host.cpp` | One `WM_GETMINMAXINFO` case: minimum 900 x 600 (there was no minimum). | + +The API contract is otherwise as it was: the UI calls the same routes (`api/state`, `api/cards`, `api/pause`, +`api/resume`, `api/prove`, `api/settings`, `api/update/*`, `api/jobs/*`, `api/sweep/*`, `api/key/*`, `api/log`, +`api/open`, `api/quit`, `api/clock/sync`, `api/power/apply`, `api/detect`, `api/phase`, `api/setup`, `api/start`). + +## 2. The sections + +| Section | What it holds | Where it was | +|---|---|---| +| Mine | One big Start mining / Stop mining button (pause and resume), hash rate, blocks found, next program; one row per GPU with its real name, kind tag (Integrated shown as such and off by default, the engine's rule), on/off switch (applies at once; the other cards keep their identities and caps), hash rate, temperature and power where the card reports them; the blocks strip; Activity | the dashboard strip, the Cards card, the Pause button, the Events card | +| Prove | The proving switch with three plain sentences on what proving is; state, assigned, proven, paid (IGN when paid); the verifier's state with one line; the shard program id and the aggregator id with Copy; Set up when the WSL2 host is missing | the Proving tile and the Settings switch | +| Rewards | The address with one Copy; blocks found, this run, balance; the Save your key card (two lines, Show my key, Hide) or the "you pasted your own address" line; Use another address; the dev-fee line; the wallet page | the Settings address block | +| Node | Node state, height, peers, version, each with a plain line; the chain numbers; the consensus digest with Copy; the next switch with its height and how far away; every switch with the applied ones dimmed; finality; the clock card when the clock is off | the Node and Finality cards | +| Updates | The version, Check now, Install now when a download is ready, the auto-update switch; remote jobs: Check now, the state line, the history table, the signing key | the Settings version and remote-jobs blocks | +| Settings | Per card: power cap slider with the watt number (NVIDIA; Apple silicon says it manages its own power), identities stepper, sweep line with Sweep now / Stop / Unpin, the cap-applied or Retry line, draw and temperatures; the sweep switch; start at login; remote jobs allowed; vote on checkpoints; the machine name; the dev fee stated plainly; Copy the log and Show the log, the log and chain folders; Advanced (collapsed): the devnet trust switch, the live page | the Settings panel | +| Status strip | Unchanged (Notices): updates, remote jobs, the clock, one at a time, under the top bar | the same place | +| Rail foot | Machine name, rewards address, Logs (the drawer), Quit, version and chain | the bottom bar | + +Every control in Settings is one switch, one slider or one field with one line of help under it. Nothing was +removed: the identities stepper, the sweep buttons, the power Retry, the trust switch, the live page, the machine +rename, the key reveal, the log drawer with its chips, search, ruler and jump controls all still exist. + +Empty, loading and error states: Mine says "Asking the graphics cards to report in" then "No GPU this app can drive +was found" (red) with the detect message; the big button is disabled with a reason while no card is on, the engine +is away or the app quits; Prove says off, needs setup, waiting, proving, submitted or idle, each with a sentence; +Node says starting, syncing with a percentage and the ETA, synced, restarting, failed or stopped, and the digest +box says "not printed yet" or "the node is not running"; Updates says "Not checked yet" or "This build has no update +address"; the jobs table says "No job has run on this machine yet"; Settings says "No card yet"; a lost engine +turns the pill to "engine away" and the Mine and Node words to "no answer". + +## 3. Proof + +The engine was built from this worktree on the Mac under the build lock (`with-lock.sh build nice -n 19 cargo build +--release -j 4`, rustup's cargo 1.99; Homebrew's cargo 1.69 in PATH cannot read the lock file) and run on its own +port and data directory in devnet v4 mode (`IGNEUM_APP_BIN` = the installed 0.3.9 bundle's `Resources/bin`, +`IGNEUM_APP_DATA`, `IGNEUM_APP_LOGS`, `IGNEUM_APP_NODE_DIR` under the session scratchpad, RPC 27610, P2P 27611, +a scratch `igneum-app.json` next to the binary with the devnet override params, no update manifest) through +`with-lock.sh run`. The live Mac app's engine, node 1 and the observer were not touched; the test node peered with +the seed and node 1 and synced 123,559 blocks in about 12 minutes, then the M5 Max mined on it (22 MH/s, digest +1f4b4425..., the devnet's). The setup flow was walked in the built-in browser pane (Get started, the GPU switch, +Make me an address, the key sheet, Start mining) and every section was clicked through, the rail driven with the +arrow keys, and the window checked at 900 x 600. + +Tests: `node --test notices.test.mjs update-card.test.mjs view.test.mjs` = 20 pass, 0 fail; `cargo test --release +--bin igneum-app -- digest_comes switches_are pinned_ids` = 3 pass (the three new engine tests, run on the Mac +under the build lock; the full suites go to PC 2 with the 0.3.11 cut as usual). + +The PNGs below were taken with the installed Mac host's snapshot mode (`"Igneum Miner" --snapshot --url --size 1200x780`, a real WKWebView, Retina 2400 x 1560), in `docs/plans/miner-ui-2/`. + +| File | Shows | Placeholder or sample | +|---|---|---| +| `00-welcome.png` | The welcome screen (unchanged) | none | +| `01-setup-gpu.png` | Step 1, the M5 Max row with its switch | none | +| `02-setup-address.png` | Step 2, make an address or paste one | none | +| `03-setup-key.png` | The one-time key sheet | the key is zeros (`?screen=key` sample) | +| `04-mine.png` | Mine while mining: the big button, 23 MH/s, the GPU row, the blocks strip | temperature and power say n/a on Apple silicon (the engine has no reading for it) | +| `05-prove-off.png` | Prove with proving off, the verifier verifying, both pinned ids | none | +| `06-prove-on.png` | Prove with proving on: Proving (CPU), block 35507 shard 0 | none (the Mac CPU prover was switched on for 25 s, then off) | +| `07-rewards.png` | Rewards: the address, 343 lifetime blocks, Save your key, Use another address | the balance cell says "--, shown in the wallet, not here yet" (no API) | +| `08-node.png` | Node synced: height, 2 peers, version 2.1.0, the digest, next switch Fees v1 at 210,000, finality | none | +| `09-updates.png` | Updates: 0.3.9, Check now, auto-update, remote jobs | this build has no manifest, so "no update address" and "no jobs address" | +| `10-settings.png` | Settings: the card block, the sweep switch, this machine, dev fee, logs, Advanced collapsed | the NVIDIA power slider is not in the shot (no NVIDIA card on this Mac); its markup is in `setCardHtml` | +| `11-logs-drawer.png` | Mine with the log drawer open (chips, search, ruler, follow, close) | none | +| `12-strip-update.png` | The status strip with "Igneum Miner 0.3.7 is available", Install now, Later | `?update=available` sample | +| `13-strip-job.png` | Updates with a running remote job in the strip and the table | `?job=running` sample | +| `14-node-syncing.png` | Node while syncing: 59,602 of 123,559 (48%), the next switch from that height | none | +| `15-mine-syncing.png` | Mine while the node syncs: the button says Stop mining, waiting for the node to sync | taken before the blank marker in the GPU row became `n/a` (it shows a dash) | +| `16-narrow-900-mine.png` | Mine at 900 x 620 (the installed host's minimum; the new hosts say 600): the icon rail, two-column numbers, the power column dropped | none | +| `17-narrow-900-settings.png` | Settings at 900 x 620 | none | +| `18-narrow-900-node.png` | Node at 900 x 620 | none | + +## 4. Shippable now, placeholder, follow-ups + +Shippable now: the six sections, the rail, the strip, the drawer, every setting, the three engine fields, the +hosts' minimum size. The Windows host line is untested on Windows (one `WM_GETMINMAXINFO` case; the 0.3.11 cut +builds it on the runner as usual). The NVIDIA power slider, telemetry and sweep lines on Settings and the +temperature and power columns on Mine are rendered from the same state fields the old tiles used, but no NVIDIA +card was on this Mac, so they are unseen in these shots: the PC screenshot is the first thing to look at after +the cut. + +Placeholder: the balance cell on Rewards ("shown in the wallet, not here yet"). + +Follow-ups (engine work, not built here; the budget was three small additions): + +| Follow-up | What it needs | +|---|---| +| Balance on Rewards | the engine reads `eth_getBalance` for the rewards address from the node's EVM RPC every 30 s (`update.rs` has the curl shape) and puts `address.balance_wei` on the state | +| "Pause while I use the machine" | does not exist in the engine: an idle-input reading per platform and a pause/resume on it; the Settings switch is one line once the field exists | +| Log export as a file | today Copy the log puts the last 2,000 ring lines on the clipboard and the folder path is shown; a `/api/log/export` that writes the ring to the log folder and reveals it in Finder or Explorer needs a `platform::reveal_path` | +| Temperature and power on Apple silicon | the engine reads no telemetry for Metal; `powermetrics` needs root, so this stays blank unless a non-root source is found | +| A per-card "pause this card" without restarting the others | exists already through the row switch (only that card's worker restarts); nothing to do unless the project lead wants a timer | + +## 5. The 0.3.10 merge: gpu-hotplug (4d122e1) resolved on the new UI + +The branch was merged with `gpu-hotplug` 4d122e1 (the 0.3.10 tree carries it). The engine, relay and tooling files +merged by themselves; `app.js` and `app.css` were taken from this branch and the hot-plug UI ported by hand: + +| Hot-plug state | On the six-section UI | +|---|---| +| The strip notices (card added, mining; new card not usable (Code 43); card removed) | the Notices block is gpu-hotplug's, unchanged; `notices.test.mjs` (12) passes | +| A card the OS reports a problem on (`problem`, "Code 43") | Mine row: name in ember, the word "not usable (Code 43)", the reboot hint under it, the switch disabled; the Mine note repeats the hint; first-run row and Settings card say the same, with no controls | +| A removed card (`removed_at`) | Mine row dimmed, the word "removed", "unplugged; its worker stopped. The row goes in five minutes."; no switch; Settings card says the same; after five minutes (`gone`) the row is not shown | +| The counts and the big button | only present cards count (a removed or faulty card never makes the button say "Stop mining") | +| The name tooltip | the tool's code (gfx1201), the device index, the OpenCL platform, the PCI address (`View.cardTitle`) | +| The card list sent on a switch | removed and faulty cards are left out, as gpu-hotplug's `readCardRows` does | + +`view.test.mjs` has an eleventh test for the two states. The whole app crate's unit tests pass on the merged tree +(99, on the Mac under the build lock, `cargo test --release --bin igneum-app`). `relay/test/parse.test.mjs` +passes. The Mac engine builds and runs (the same scratch instance as section 3). + +Build tooling for the Windows compile: `packaging/windows/push-build-inputs.sh --no-node` and +`node tools/build-job.mjs run --no-node` pack and build the app engine only (no node source, no node build, no node +tests), so an app-only change compiles on PC 1 in a fraction of the full job. + +PC 1 job (20:21Z, from this tree at d62b445, `node tools/build-job.mjs run --target ae432dc7 --no-node --targets +windows --no-tests --no-place`): extract 167 files, `windows app/igneum-app build exit 0 6 s` (the PC's target dir +was warm), `igneum-app.exe` 2,970,112 bytes sha256 62ce96a9..., PE check ok, coin icon and version block ok, done +after 13 s. The exe carries the new UI and engine strings ("Prove shards on this machine" x2, `consensus_switches` +x6, "Consensus params digest:" x1, `card:removed:` x1), so the 6 s was a real compile of the merged sources, with +gpu-hotplug's Windows-only `detect::adapters` path and this branch's prover change in it. Downloads under the session +scratchpad (`pc1-app/`), not placed. + +The ship tool runs no node tests of its own (`tools/ship-app.mjs --self-test` is the version bump), so the only +list to carry `view.test.mjs` is `.github/workflows/ci.yml` (done in 83a293f). + +What the PC job does not compile: `app/windows/host.cpp` (the window host with the `WM_GETMINMAXINFO` and +`WM_DEVICECHANGE` cases) is built by the GitHub runner (`windows.yml`) at the shipper's push, never on a PC; the +`WM_GETMINMAXINFO` case is three lines of plain Win32 (`MINMAXINFO`, `ptMinTrackSize`) and reads at the runner's log. + +## 6. The changelog paragraph for the 0.3.10 manifest notes (what the user sees) + +The miner has six sections behind a rail: Mine, Prove, Rewards, Node, Updates, Settings. Mine has one big Start +mining / Stop mining button, the hash rate, the blocks found, and one row per graphics card with its name, an on/off +switch, its hash rate, temperature and power. Prove says in plain words what proving is and shows the shards +assigned, proven and paid, the verifier and the program ids. Rewards shows the address with one Copy and keeps the +key backup in its own card. Node shows the sync, height, peers and version with a plain line under each, the +consensus digest, and the list of planned rule switches with the next one and its height. Updates holds the +version, Check now and the remote jobs. Settings has one switch or slider per setting with one line of help under +each: the power cap per card with the watt number, identities, start at login, remote jobs, voting, the dev fee +stated plainly, the log. The window lays out from 900 x 600. Not in this release: the balance on Rewards (shown in +the wallet), a "pause while I use the machine" switch, log export as a file, temperature and power on Apple +silicon. + +## 7. The branch + +| Commit | What | +|---|---| +| 83a293f | the UI pass, the three engine additions with tests, the hosts' minimum size, this plan and the 19 screenshots (the blank marker in a GPU row is `n/a`, not a dash: copy law) | +| 364feef | this plan's hash line | +| d62b445 | Merge gpu-hotplug 4d122e1: the hot-plug states on the new UI, `--no-node` for the build tools | diff --git a/docs/plans/miner-ui-2/00-welcome.png b/docs/plans/miner-ui-2/00-welcome.png new file mode 100644 index 00000000..ca9b6956 Binary files /dev/null and b/docs/plans/miner-ui-2/00-welcome.png differ diff --git a/docs/plans/miner-ui-2/01-setup-gpu.png b/docs/plans/miner-ui-2/01-setup-gpu.png new file mode 100644 index 00000000..52091366 Binary files /dev/null and b/docs/plans/miner-ui-2/01-setup-gpu.png differ diff --git a/docs/plans/miner-ui-2/02-setup-address.png b/docs/plans/miner-ui-2/02-setup-address.png new file mode 100644 index 00000000..24be979a Binary files /dev/null and b/docs/plans/miner-ui-2/02-setup-address.png differ diff --git a/docs/plans/miner-ui-2/03-setup-key.png b/docs/plans/miner-ui-2/03-setup-key.png new file mode 100644 index 00000000..9b99cbb9 Binary files /dev/null and b/docs/plans/miner-ui-2/03-setup-key.png differ diff --git a/docs/plans/miner-ui-2/04-mine.png b/docs/plans/miner-ui-2/04-mine.png new file mode 100644 index 00000000..d4683fe2 Binary files /dev/null and b/docs/plans/miner-ui-2/04-mine.png differ diff --git a/docs/plans/miner-ui-2/05-prove-off.png b/docs/plans/miner-ui-2/05-prove-off.png new file mode 100644 index 00000000..df7c3945 Binary files /dev/null and b/docs/plans/miner-ui-2/05-prove-off.png differ diff --git a/docs/plans/miner-ui-2/06-prove-on.png b/docs/plans/miner-ui-2/06-prove-on.png new file mode 100644 index 00000000..f21d07c8 Binary files /dev/null and b/docs/plans/miner-ui-2/06-prove-on.png differ diff --git a/docs/plans/miner-ui-2/07-rewards.png b/docs/plans/miner-ui-2/07-rewards.png new file mode 100644 index 00000000..a5e8b9c7 Binary files /dev/null and b/docs/plans/miner-ui-2/07-rewards.png differ diff --git a/docs/plans/miner-ui-2/08-node.png b/docs/plans/miner-ui-2/08-node.png new file mode 100644 index 00000000..640ace7f Binary files /dev/null and b/docs/plans/miner-ui-2/08-node.png differ diff --git a/docs/plans/miner-ui-2/09-updates.png b/docs/plans/miner-ui-2/09-updates.png new file mode 100644 index 00000000..cd5b522c Binary files /dev/null and b/docs/plans/miner-ui-2/09-updates.png differ diff --git a/docs/plans/miner-ui-2/10-settings.png b/docs/plans/miner-ui-2/10-settings.png new file mode 100644 index 00000000..f79072fc Binary files /dev/null and b/docs/plans/miner-ui-2/10-settings.png differ diff --git a/docs/plans/miner-ui-2/11-logs-drawer.png b/docs/plans/miner-ui-2/11-logs-drawer.png new file mode 100644 index 00000000..07d5c613 Binary files /dev/null and b/docs/plans/miner-ui-2/11-logs-drawer.png differ diff --git a/docs/plans/miner-ui-2/12-strip-update.png b/docs/plans/miner-ui-2/12-strip-update.png new file mode 100644 index 00000000..d914c3c8 Binary files /dev/null and b/docs/plans/miner-ui-2/12-strip-update.png differ diff --git a/docs/plans/miner-ui-2/13-strip-job.png b/docs/plans/miner-ui-2/13-strip-job.png new file mode 100644 index 00000000..cb2a5cec Binary files /dev/null and b/docs/plans/miner-ui-2/13-strip-job.png differ diff --git a/docs/plans/miner-ui-2/14-node-syncing.png b/docs/plans/miner-ui-2/14-node-syncing.png new file mode 100644 index 00000000..e7e3ac62 Binary files /dev/null and b/docs/plans/miner-ui-2/14-node-syncing.png differ diff --git a/docs/plans/miner-ui-2/15-mine-syncing.png b/docs/plans/miner-ui-2/15-mine-syncing.png new file mode 100644 index 00000000..8f98fe72 Binary files /dev/null and b/docs/plans/miner-ui-2/15-mine-syncing.png differ diff --git a/docs/plans/miner-ui-2/16-narrow-900-mine.png b/docs/plans/miner-ui-2/16-narrow-900-mine.png new file mode 100644 index 00000000..ae0555e7 Binary files /dev/null and b/docs/plans/miner-ui-2/16-narrow-900-mine.png differ diff --git a/docs/plans/miner-ui-2/17-narrow-900-settings.png b/docs/plans/miner-ui-2/17-narrow-900-settings.png new file mode 100644 index 00000000..b9859878 Binary files /dev/null and b/docs/plans/miner-ui-2/17-narrow-900-settings.png differ diff --git a/docs/plans/miner-ui-2/18-narrow-900-node.png b/docs/plans/miner-ui-2/18-narrow-900-node.png new file mode 100644 index 00000000..083a2940 Binary files /dev/null and b/docs/plans/miner-ui-2/18-narrow-900-node.png differ diff --git a/docs/plans/release-0.3.10.md b/docs/plans/release-0.3.10.md new file mode 100644 index 00000000..dda7f443 --- /dev/null +++ b/docs/plans/release-0.3.10.md @@ -0,0 +1,487 @@ +# Igneum Miner 0.3.10: the certificate-driven reorg, transaction gossip, the pack loader fix, the six-section miner and the PC-built Windows node, 5 October 2026 + +Shipped: manifest 0.3.10 published 21:32:40Z, every reachable machine on 0.3.10 by 21:49:41Z, the hand nodes and the seed on 21d4c73c by +21:50:15Z, one digest (1f4b4425...) on every node that restarted; PC 37ba0461 (the owner's laptop) mid-install and Sam's Mac quit at the +time of writing. The cut ran from 16:39Z to 21:5xZ, two hours of it GitHub's hosted-runner outage. + +Release engineer, from 16:39 UTC. Worktree `/Users/joshm/Projects/igneum-wt-ship0310`, branch `release-0.3.10` from master +after the 0.3.9 merge. Fork worktree `vendor/igneum-node-0310`, branch `release-0.3.10` from a24ab01a (the 0.3.9 fork tip). +Every Mac build through `/Users/joshm/Projects/igneum/tools/lock/with-lock.sh build` at `nice -n 19` with `-j 4`; every PC +job through the main checkout's `tools/build-job.mjs` and `packaging/ota/publish-jobs.sh` (the signed envelope). Times are +UTC. `release-0.3.9.md` and `release-0.3.8.md` are the template; `release-0.3.6.md` section 10 for the Windows acceptance. +The rollout waited for 0.3.9's (shipped about 17:00Z, fee switch published about 17:50Z): nothing of 0.3.10 moved on the +network before every node's digest was ab8847da... + +## 1. What 0.3.10 carries + +| Change | Where | Fork commit | +|---|---|---| +| A. The windows-gnu node links the C++ runtime statically (`database/build.rs`, `rocks-probe`), so the PC-built Windows node runs (release-0.3.6 plan section 10) | fork `housekeeping` 4fb32865 | merged 2f88a82f | +| B. `TestConsensus` sets the unbounded PoW cache build queue, so the five node suites pass in parallel on PC 2 | fork `housekeeping` 7003055b | merged 2f88a82f | +| C. The certificate-driven reorg (ledger C4, measured in FUD round 6, 75c6658: a certificate over an off-chain block stayed pending and the heavier side locked alone): a verified certificate over a block off the node's selected chain now locks it there and moves the virtual to the heaviest tip through it (spec 3.5 F1 and F2); the block relay takes a lighter block a pending certificate names. A consensus-behaviour change with NO digest change: it converges when every node is new (the c4 agent's rollout note, section 10) | fork `c4-fix` e18f1e0e (its message records PC 2 job build-20261005-180827: kaspa-consensus 97 passed, kaspa-consensus-core 101 passed, three new tests), main `c4-fix` 68f14b9 (spec 3.5, 3.2, 3.10, 3.11.7, ledger, bench-log, c4.mjs v2 mode; the signer-pipe-check CI script and per-job build-inputs zip names) | fork: merged 21d4c73c; main: 0cbaf3a and the tip merge | + +Changelog line for C (the coordinator's words): "A node that comes back from a partition now follows the certified checkpoints, even when its own chain is heavier." +| D. EVM transaction relay between nodes: three p2p messages after Kaspa's transaction inv/request/answer, PROTOCOL_VERSION 13 to 14 (13 peers still connect: a v14 node never sends the new messages to an older peer), the mempool caps and faults, the block-added hold instead of the 4-s hand-out cooldown; the digest is unchanged, so no activation height and no fresh chain | fork `tx-gossip` e242acd0 (its message records PC 2: igneum-exec 15 of 15, kaspa-p2p-flows 33 of 33), main `tx-gossip` 0166e94 (the relay-net harness `tools/txgen/relay-net.mjs`, the design note); acceptance here: `node tools/txgen/relay-net.mjs --rate 2 --duration 120 --wallets 16 --fund 2` on the 0.3.10 Mac node, expected 240 of 240 included | fork: merged 21babaa7; main: (pending the worktree) | + +Changelog line for D (the coordinator's words): "Transactions now travel between nodes; a miner on an older node still carries only what was sent to it directly." +| E. GPU hot-plug (coordinator, 17:5xZ): cards re-enumerated every 60 s and on `WM_DEVICECHANGE`, new cards mine by default, Code 43 cards marked unusable, vanished cards dropped without shifting slots, the Ryzen gfx1036 iGPU classified integrated and off by default, a `cards:` line the relay parses (machines API `gpus`/`hotplug`). 14 files: `app/igneum-app/src/{detect,engine,hotplug,main,state}.rs`, `ui/{app.css,app.js,notices.test.mjs}`, `app/windows/host.cpp`, `relay/{api/console.mjs,lib/parse.mjs,test/parse.test.mjs,ui.html}`, `tools/console.mjs`; no proving, packaging or workflow file. Its Windows-only paths (`detect::adapters`, `host.cpp` WM_DEVICECHANGE) were never compiled on the Mac: PC 1's Windows app build and the GitHub runner's host build are their first compile and are read for errors | main `gpu-hotplug` 4517004 (`igneum-wt-hotplug`, on master 22ffc02); its tests on the branch: igneum-app 91 pass, notices, update-card and parse node tests pass | merged after C and D | +| F. Elevated jobs that never started (coordinator, 18:0xZ): an elevated job whose UAC prompt is refused, cancelled or times out fails with exit 251 and the summary "did not start: the administrator prompt was refused, cancelled or timed out"; before, `Start-Process` threw, `$p` stayed null and `exit $p.ExitCode` exited 0, so the PC 1 driver job at 17:58Z read as done. One file, `app/igneum-app/src/jobrun.rs`, plus its unit test | main `elevated-exit` 9816d58 (`igneum-wt-elevated`) | merged after E | +| G. The miner UI redesign (the project lead, through the coordinator at 19:0xZ: into 0.3.10, not 0.3.11): six sections (Mine, Prove, Rewards, Node, Updates, Settings), one switch per card, one line of help per setting, the node's next switch shown with its height; three engine additions with tests (`state.rs` `node.consensus_digest` and `node.consensus_switches`, `engine.rs`, `prover.rs` program ids via `--mode id`), a Mac window minimum of 900 x 600, one `WM_GETMINMAXINFO` case in `app/windows/host.cpp`, `view.test.mjs` in CI | main `miner-ui-2` (`igneum-wt-miner-ui`, 83a293f and 364feef, then the commit its agent reports after resolving it against gpu-hotplug's card-row states) | merged LAST, after F, at the agent's final commit; the node stays 21d4c73c, so the app, the DMG, CI and the ship files are rebuilt (section 7a) | +| H. The pack loader fix (the outage of 18:31Z, root cause found by the coordinator's agent): the generator retries a rejected candidate program with `seed || k` (epoch 34's attempt 0 was rejected on the saturation rule, attempt 1 accepted) and the workers' pack loader (`proto-cuda/nvrtc/packfile.h`, shared by the OpenCL worker) derived the expected seed words from attempt 0, so every worker started after the boundary refused a correct pack. Changes `packfile.h`, `worker.cpp`, `proto-opencl/host.c`, the relay's parse and tests, an emu test. It changes the Windows WORKER binaries: since 0.3.6 the payload has carried only the worker sources (`host.cu`, `host.c`, `build.bat`) and the PCs built the workers themselves, so the fix reaches the PCs only as exes: both workers cross-built on this Mac (`proto-cuda/nvrtc/build-windows.sh`, mingw, the staged redist) and carried by `push-inputs.sh` into the installer; a hot-fix copy is already on both PCs under `packs\\workers-fix`. The CI builds no worker (`windows.yml` copies the two worker exes and the `nvrtc*.dll` files from the signed inputs only), so the rebuilt pair reaches the installer through `push-inputs.sh` and nowhere else; the engine takes a bundled worker before its own PC-built one (`detect.rs` `Bins`, `engine.rs` `build_worker_from_source` only "when no prebuilt worker ships"), so the fix applies at the first start after the update | main `pack-loop` af983a7 (`igneum-wt-pack-loop`; the tip at merge time) | merged after G | +| I. The export lock: `engine.rs` serialises `igneum-miner export-pack` behind `EXPORT_LOCK` around both call sites (two per-card export threads interleaved into one folder across an epoch change on PC 1, a second way to a wrong pack) | main `opencl-rdna4` a08c371: the lock hunk is the part that must ship; the rest (host.c's duplicate-platform fold, `--readback select`, `--memprobe`, `detect.rs parse_opencl_list`) only if it merges cleanly with hotplug and the OpenCL worker cross-builds without error, else the hunk alone (coordinator, 19:2xZ) | (decided at the merge, section 2) | +| NOT in 0.3.10 unless the coordinator says so: `job-console` d33a266 (one hidden-console builder for every elevated launch, the spawn check in CI, PC 1 console watchers; its agent's message at 18:5xZ) and `opencl-rdna4` a08c371 (its agent's message at 19:1xZ: the OpenCL worker folds the duplicate AMD platform out of `--list`, a GPU select pass, `--memprobe`, `detect.rs` parsing with tests, and `engine.rs` serialises `export-pack` behind `EXPORT_LOCK` because PC 1's `packs\devnet` has held a mismatched `program.h` and `seeds.txt` since 19:07Z: the pack-export class the rollout is held for; passed to the coordinator as a candidate) | the coordinator's rule: a late branch goes in only if it lands before the final tree; the final tree b958493 was pushed at 18:36Z with CI green at 18:42Z. job-console also moves elevated-exit's exit-251 launcher text to `platform.rs`, so its `include_str!` test is repointed at its own merge to master after 0.3.10 | next cut | +| The app engine otherwise | unchanged except the version (0.3.10 in the six files); the signed jobs envelope client is master's (housekeeping C, 318a2de) | | + +Changelog line for G (the coordinator's words): "The miner is now six sections: Mine, Prove, Rewards, Node, Updates, Settings. One switch per card, every setting with one line of help, the node's next switch shown with its height." + +Changelog line for F (the coordinator's words): "A remote job that needs administrator rights now reports when the prompt was not accepted instead of claiming success." + +Changelog line for E (the coordinator's words): "New cards are picked up while the miner runs; a card that goes away is released; integrated GPUs stay off unless you switch them on. +| The pinned guest | UNCHANGED unless C or D touches `proving/igneum-prove/core` or `elf/` (checked at the merge: section 2) | | + +## 2. The branch + +| Commit | What | +|---|---| +| 9268b64 | `origin/master` at the worktree's creation (18:00Z: the 0.3.9 merge, fast-forwarded) | +| 2e943a8 | Merge `tx-gossip` (0166e94): the relay design note, rows 463 and 9, the 3-node relay harness and its evidence; no conflict | +| 824059b | `Igneum Miner 0.3.10: the six version files` (`node tools/ship-app.mjs --check`: 0.3.10 in all 6) | +| 9997ef1 | `packaging/mac/packaged-config.sh`: the four-field override object in the packaged line, the self-test fix (section 3) | +| 0cbaf3a | Merge `c4-fix` (3072919, 18:20Z). One conflict, `docs/bench-log.md`: both entries kept (the fee-table entry, then the C4 entry). Brings `tools/ci/signer-pipe-check.sh` (`igneum-ota-sign ... \| head -1` under pipefail panicked the signer with SIGPIPE on a loaded Mac and killed a publish; now `sed -n 1p` in `publish-jobs.sh`, `publish-manifest.sh`, `push-inputs.sh`) and one `build-inputs-