diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea5ce045..f4418e8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,4 +78,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/src/engine.rs b/app/igneum-app/src/engine.rs index 85075432..6d0c36f7 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -350,6 +350,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}="); @@ -1069,6 +1103,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)); @@ -2503,6 +2544,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; } @@ -3108,7 +3156,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() { 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..20586787 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -24,6 +24,18 @@ 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, +} + +/// 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)] @@ -162,6 +174,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/ui/app.css b/app/igneum-app/ui/app.css index 07eb252e..ca9b2ccb 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,143 @@ 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} + +/* 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.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 +342,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 +377,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 +390,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 +466,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..bab140a2 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; @@ -215,16 +215,144 @@ 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; } + function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; } + + // one GPU row on the Mine page: cd = state.mining.cards[i] + function cardRow(cd) { + var on = !!cd.enabled, st = cd.state || 'off'; + var word = !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 = !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 = 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, canToggle: cd.kind !== 'unknown', word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · ') }; + } + + // the big button on Mine: m = state.mining, n = state.node, s = { quitting } + function toggle(m, n, s) { + var on = (m.cards || []).filter(function (c) { return c.enabled; }).length; + var mining = (m.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: m.cards && m.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, 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 +378,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; 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 +394,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 +478,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 = state.mining.cards.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 +591,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 +832,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,13 +844,12 @@ 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) { @@ -691,36 +860,27 @@ if (typeof document !== 'undefined') (function () { 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
' + - ''; + ''; 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) { + 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'); @@ -735,54 +895,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 +915,202 @@ 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); + + // ---------- 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 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; + setText('d-hash-sub', 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); + 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 = m.cards.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' : !m.cards.length ? 'none found' : on + ' of ' + m.cards.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 = !m.cards.length && s.detect_message ? s.detect_message : m.cards.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; + var h = '
' + esc(cd.name) + '' + kindWord(cd.kind) + '' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (cd.enabled ? '' : ' · off') + '
'; + 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 = 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)]; })); + 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 +1124,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 +1251,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 +1259,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 +1281,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 +1300,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 +1313,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/view.test.mjs b/app/igneum-app/ui/view.test.mjs new file mode 100644 index 00000000..2f1979e4 --- /dev/null +++ b/app/igneum-app/ui/view.test.mjs @@ -0,0 +1,151 @@ +// 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'); +}); 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..a05da8a3 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -284,6 +284,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; diff --git a/docs/plans/miner-ui-2.md b/docs/plans/miner-ui-2.md new file mode 100644 index 00000000..e64cba3d --- /dev/null +++ b/docs/plans/miner-ui-2.md @@ -0,0 +1,117 @@ +# 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. Ships as 0.3.11 after the project lead has seen the screenshots. 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 branch + +| Commit | What | +|---|---| +| 4d35201, amended | 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) | 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