Merge miner-ui-2 (a3d9f2d) into release-0.3.10: the miner UI in six sections (Mine, Prove, Rewards, Node, Updates, Settings), the node's switches and digest in the state, the prover's program ids, the 900 x 600 window minimum, view.test.mjs in CI

# Conflicts:
#	packaging/windows/push-build-inputs.sh
This commit is contained in:
igneum-josh 2026-10-05 20:23:33 +01:00
commit 7f9618a7e8
32 changed files with 1525 additions and 616 deletions

View file

@ -80,4 +80,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

View file

@ -351,6 +351,40 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A
}
}
/// The node's consensus params digest out of its own line ("Consensus params digest: <64 hex> (exchanged in the p2p
/// handshake; ...)"). None for any other line, including the WARN lines that quote a peer's digest.
pub fn digest_from_line(line: &str) -> Option<String> {
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 `<name>_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<crate::state::ConsensusSwitch> {
let mut out: Vec<crate::state::ConsensusSwitch> = 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<String> = 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}=");
@ -1193,6 +1227,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::<Value>(&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));
@ -2641,6 +2682,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;
}
@ -3246,7 +3294,27 @@ pub fn parse_race(body: &str) -> Option<RaceParsed> {
#[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<_>>(), 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() {

View file

@ -288,6 +288,44 @@ fn set<F: FnOnce(&mut crate::state::ProvingState)>(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<String> {
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<PathBuf> = 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<Shared>, 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<Shared>, 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<Value, String> {
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#"[

View file

@ -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<ConsensusSwitch>,
}
/// One planned rule change the node applies by itself at a DAA score (from the override file's `*_activation_daa` keys).
#[derive(Clone, Serialize, Default, PartialEq, Debug)]
pub struct ConsensusSwitch {
pub key: String, // the override key, for example fees_v1_activation_daa
pub name: String, // plain words: Fees v1
pub daa: u64,
}
#[derive(Clone, Serialize, Default, Debug)]
@ -181,6 +193,9 @@ pub struct ProvingState {
pub pool_entries: u64,
pub pool_verified: u64,
pub pool_failed: u64,
/// the pinned guests' ids (`igneum-prove-host --mode id`): the shard program and the aggregator; empty until read
pub program_id: String,
pub aggregator_id: String,
}
#[derive(Clone, Serialize, Default)]

View file

@ -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,60 +186,147 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.gpu-row .meta b{color:var(--ink-2);font-weight:500}
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
.gpu-row.removed{opacity:.5}
.gpu-row.removed .ids,.gpu-row.unusable .ids{opacity:.3;pointer-events:none}
.gpu-row.unusable .name{color:var(--ember)}
.gpu-tile .st.removed{color:var(--ash)}
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto}
.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)}
.gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto}
/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */
.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5}
.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)}
/* the Mine page: the big switch and the three numbers */
.hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)}
.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:var(--tile-r);border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0}
.toggle-big:hover{background:var(--ember-hi);border-color:var(--ember-hi);transform:translateY(-1px)}
.toggle-big:disabled{opacity:.45;cursor:default;transform:none}
.toggle-big .ring{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:rgba(12,12,14,.18);margin-bottom:6px}
.toggle-big .ring svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round}
.toggle-big .tl{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap}
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
.toggle-big.stop:hover{border-color:var(--ash);background:#1b1b20}
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
.toggle-big.stop .ts{color:var(--molten);opacity:1}
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em}
.cell.ember .v{color:var(--ember)}
.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em}
.cell .v.state{text-transform:capitalize;font-size:22px}
.cell .v.small{font-size:18px}
.cell .v.dim{color:var(--ash)}
.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cell.ok .v.state{color:var(--molten)}
.cell.bad .v.state{color:var(--ember)}
.cell.bad .s{color:var(--ember)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);align-items:start}
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)}
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
#dag{display:block;width:100%;height:170px;border-radius:12px;background:var(--obsidian)}
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)}
.legend span{display:inline-flex;align-items:center;gap:var(--s-2)}
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
.sw.ember{background:var(--ember);border-color:var(--ember)}
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
.empty.err{color:var(--ember)}
.kv{display:flex;flex-direction:column}
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
.kv>div:last-child{border-bottom:0}
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
.kv .v.warn{color:var(--ember)}
.card .note{margin-top:10px}
.card .help+.help{margin-top:6px}
.big-word{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.2;margin:2px 0 8px;word-break:break-word}
.big-word.ok{color:var(--molten)}
.big-word.warn{color:var(--ember)}
.big-word.dim{color:var(--ash)}
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:260px;overflow:auto}
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
.feed>div:last-child{border-bottom:0}
.feed>div>span:first-child{min-width:0}
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error{color:var(--ember)}
.feed .k.warn{color:var(--ember)}
.feed .k.block{color:var(--ember)}
.feed .k.build{color:var(--ink-2)}
/* the GPU rows on the Mine page: badge, name, numbers, the switch */
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
.gpu-line{display:grid;grid-template-columns:44px minmax(160px,1.4fr) repeat(3,minmax(84px,.7fr)) 48px;align-items:center;gap:var(--s-4);padding:12px 14px;border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);min-width:0}
.gpu-line.off{opacity:.62}
.gpu-line .badge{width:44px;height:44px;flex-basis:44px;border-radius:12px}
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
.gpu-line .name{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.gpu-line .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.gpu-line .st.on{color:var(--molten)}
.gpu-line .st.bad{color:var(--ember)}
.gpu-line .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px}
.gpu-line .msg{font-size:var(--t-sm);color:var(--ember);line-height:1.4}
.gpu-line .msg.dim{color:var(--ash)}
.gpu-line .num{display:flex;flex-direction:column;gap:2px;min-width:0}
.gpu-line .num .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.gpu-line .num .v{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:5px}
.gpu-line .num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em}
.gpu-line .num .v.hot{color:var(--ember)}
.gpu-line .num .v.warm{color:var(--molten)}
.gpu-line .num .v.na{color:var(--ash);font-weight:500;font-size:var(--t-md)}
.gpu-line.on .num.hash .v{color:var(--ember)}
.gpu-line .switch{padding:0;justify-self:end}
/* the Settings page: per-card controls */
.set-cards{display:flex;flex-direction:column;gap:var(--s-3);margin-bottom:var(--s-3)}
.set-card{border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-width:0}
.set-card .head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.set-card .head .name{font-family:var(--head);font-weight:700;font-size:var(--t-md)}
.set-card .head .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
.set-card .ctl{display:grid;grid-template-columns:150px 1fr auto;align-items:center;gap:var(--s-3)}
.set-card .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500}
.set-card .ctl .pv{font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);min-width:110px;text-align:right;white-space:nowrap}
.set-card input[type=range]{width:100%;accent-color:var(--ember);margin:0}
.set-card .ctl .help{grid-column:1 / -1;margin-top:-4px}
.set-card .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}
.set-card .line b{color:var(--ink-2);font-weight:500}
.set-card .line.ok{color:var(--molten)}
.set-card .line.hot{color:var(--ember)}
.set-card .line.on{color:var(--molten)}
.set-card .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto;justify-self:end}
.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1}
.ids button:hover{border-color:var(--ash)}
.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:var(--t-base);user-select:text;-webkit-user-select:text}
.ids input:focus{outline:none;border-color:var(--ember)}
.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.gpu-row.off .ids{opacity:.4;pointer-events:none}
.gpu-row .switch{padding:0;flex:0 0 auto}
.cards.compact .gpu-row{padding:12px 14px;gap:var(--s-3);border-radius:14px}
.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px}
.cards.compact .gpu-row .name{font-size:var(--t-md)}
.cards.compact .gpu-row .info{flex-basis:calc(100% - 50px);min-width:120px}
.cards.compact .gpu-row .power{margin-left:50px}
.cards.compact .gpu-row .switch{margin-left:auto}
.cards.compact .ids .k{display:none}
.power{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
.power input[type=range]{width:110px;accent-color:var(--ember)}
.power .pv{font-size:var(--t-sm);color:var(--ink-2);min-width:84px}
.power .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
.gpu-tile .msg.sweep{color:var(--ash)}
.gpu-tile .msg.sweep b{color:var(--ink-2);font-weight:500}
.gpu-tile .msg.sweep.on{color:var(--molten)}
.cards.compact .power .k{display:none}
.cards.compact .power input[type=range]{width:80px}
.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)}
.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)}
.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)}
.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)}
.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle}
.gpu-tile .msg.warm{color:var(--molten)}
.gpu-tile .msg.hot{color:var(--ember)}
/* per-card tiles on the dashboard */
.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)}
.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0}
.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums}
.gpu-tile .h .unit{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);font-weight:500;letter-spacing:.08em}
.gpu-tile.idle .h{color:var(--ash)}
.gpu-tile .m{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px}
.gpu-tile .m b{color:var(--ink-2);font-weight:500}
.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
.gpu-tile .st.mining{color:var(--molten)}
.gpu-tile .st.bad{color:var(--ember)}
.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
.gpu-tile .msg{font-size:var(--t-sm);color:var(--ash)}
.gpu-off{margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash)}
.lead-card .lead-row,.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
.lead-text h3{font-size:var(--t-2xl)}
.switch-list{display:flex;flex-direction:column;gap:3px;font-size:var(--t-sm);color:var(--ash);margin-top:8px}
.switch-list span{display:flex;justify-content:space-between;gap:12px}
.switch-list span.past{opacity:.55}
.switch-list span.next{color:var(--molten)}
.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text;margin-bottom:10px}
.addr-big span{flex:1;min-width:0;color:var(--molten)}
.card.adv{padding:0}
.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)}
.card.adv summary::-webkit-details-marker{display:none}
.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto}
.card.adv[open] summary::after{content:"\2212"}
.card.adv summary .eyebrow{margin-left:0}
.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)}
.card.adv>:last-child{margin-bottom:var(--card-pad)}
.job-history table{margin-top:var(--s-2)}
table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase}
.job-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)}
.job-history tr.running td{color:var(--molten)}
.clock-card{border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)}
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
.clock-card .note{margin-top:0}
/* address options */
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
@ -248,64 +346,8 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.option:not(.on) .addr-input{display:none}
.err{font-size:var(--t-base);color:var(--ember)}
/* dashboard */
#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)}
body[data-phase="dashboard"] #screen-dashboard{display:flex}
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em}
.cell.ember .v{color:var(--ember)}
.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em}
.cell .v.state{text-transform:capitalize;font-size:22px}
.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cell.ok .v.state{color:var(--molten)}
.cell.bad .v.state{color:var(--ember)}
.cell.bad .s{color:var(--ember)}
.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start}
.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0}
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)}
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)}
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)}
.legend span{display:inline-flex;align-items:center;gap:var(--s-2)}
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
.sw.ember{background:var(--ember);border-color:var(--ember)}
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
.tbl{overflow-x:auto}
table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
tr:last-child td{border-bottom:0}
td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
td .st.mining{color:var(--molten)}
td .st.bad{color:var(--ember)}
td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);white-space:normal;max-width:280px}
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
.kv{display:flex;flex-direction:column}
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
.kv>div:last-child{border-bottom:0}
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
.kv .v.warn{color:var(--ember)}
.card .note{margin-top:10px}
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:300px;overflow:auto}
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
.feed>div:last-child{border-bottom:0}
.feed>div>span:first-child{min-width:0}
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
.feed .k{color:var(--molten);margin-right:6px}
.feed .k.error{color:var(--ember)}
.feed .k.warn{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}
@ -339,7 +381,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
/* sheet (the key) */
.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
.sheet-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease}
.sheet .sub{font-size:var(--t-lg);color:var(--ink-2)}
.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px}
@ -352,41 +394,29 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
.check input{width:18px;height:18px;accent-color:var(--ember);margin:0}
.check.small input{width:15px;height:15px}
.sheet .cta{justify-content:flex-start}
@media (prefers-reduced-motion:reduce){.sheet{animation:none}}
/* settings panel */
.panel-wrap{justify-content:flex-end;padding:0}
.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease}
@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}}
@media (prefers-reduced-motion:reduce){.panel,.sheet{animation:none}}
.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line);flex:0 0 auto}
.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px;overscroll-behavior:contain}
/* rows, switches */
.row{display:flex;gap:10px;align-items:center;min-width:0}
.row.between{justify-content:space-between}
.row.wrap{flex-wrap:wrap}
.row .addr-input{margin-top:0;min-width:0}
.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:var(--t-md);user-select:text;-webkit-user-select:text}
.num:focus{outline:none;border-color:var(--ember)}
.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:6px 0;line-height:1.35}
.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:8px 0 2px;line-height:1.35}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease}
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease}
.switch input:checked+.track{background:var(--ember)}
.switch input:checked+.track::after{transform:translateX(18px)}
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
/* bottom bar */
.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21}
.bottom .left,.bottom .right{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
.bottom .mid{font-size:var(--t-sm);color:var(--ash);display:flex;align-items:center;justify-content:center;gap:0;min-width:0;flex:1;overflow:hidden;white-space:nowrap}
.bottom .mid .pc{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
.bottom .mid .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)}
.bottom .mid .nm{color:var(--ink-2);max-width:220px;flex-shrink:1}
.bottom .mid .ad{flex-shrink:0}
.bottom .right .mono{font-size:var(--t-sm)}
@media (max-width:1100px){.bottom .mid .up{display:none}.bottom .mid .nm{max-width:140px}.log-tools .at{display:none}.log-search{flex-basis:120px;max-width:200px}}
@media (max-width:960px){.bottom .mid .ad{display:none}}
.switch input:disabled+.track{opacity:.4}
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
.switch.lg .track::after{width:24px;height:24px}
.switch.lg input:checked+.track::after{transform:translateX(22px)}
.switch+.help{margin-bottom:6px}
/* log drawer */
.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)}
.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)}
body.has-rail .drawer{left:var(--rail)}
body.drawer-open .drawer{height:var(--drawer-h)}
body.drawer-drag .drawer{transition:none}
body.drawer-drag{cursor:row-resize}
@ -440,28 +470,56 @@ body.drawer-drag main{pointer-events:none}
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px}
.log-jump:hover{background:#1c1a18;border-color:var(--molten)}
.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center}
body.drawer-open .toast{bottom:calc(var(--bottom) + var(--drawer-h) + 16px)}
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center}
body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
/* narrow windows: the strip folds to two columns, the side column drops under the main one */
@media (max-width:860px){
/* narrow windows (the 900 px floor): the rail folds to icons with small labels, the hero row and the strips fold to
two columns, the two-column grid to one, the GPU rows drop a number */
@media (max-width:1180px){
.hero-row{grid-template-columns:1fr 1fr}
.toggle-big{grid-row:span 1}
.strip{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid{grid-template-columns:1fr}
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid2{grid-template-columns:1fr}
.top-status{max-width:220px}
}
@media (max-width:1000px){
:root{--rail:76px;--gutter:var(--s-5)}
.rail{padding:12px 8px}
.rail-brand{justify-content:center;padding:6px 0 14px}
.rail-brand .word{display:none}
.nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center}
.nav.on{font-weight:500}
.nav.on::before{left:-9px}
.nav.small{min-height:44px}
.nav-dot{right:8px;top:8px;margin:0}
.rail-status{display:none}
.rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap}
.rail-version .chain{display:none}
.gpu-line{grid-template-columns:44px minmax(140px,1.4fr) repeat(2,minmax(80px,.7fr)) 48px}
.gpu-line .num.power{display:none}
.page-sub{display:none}
.top-status{display:none}
.strip.three{grid-template-columns:repeat(2,minmax(0,1fr))}
.set-card .ctl{grid-template-columns:120px 1fr auto}
}
@media (max-width:860px){
.three{grid-template-columns:1fr}
h1{font-size:40px}
}
/* short windows (under 700 px): tighter paddings, a lower hero, a smaller canvas, so the bottom bar and the drawer
always have room */
/* short windows (the 600 px floor): tighter paddings, a lower hero, a smaller canvas, so the drawer always has room */
@media (max-height:700px){
:root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)}
#screen-dashboard{padding:16px 0 20px}
#dag{height:150px}
#dag{height:140px}
.hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)}
.coin-wrap{width:104px;height:104px}
.coin{width:104px;height:104px}
h1{font-size:40px}
.lead{font-size:var(--t-xl)}
.step{padding:32px 0 32px}
.feed{max-height:220px}
.feed{max-height:200px}
.drawer-head{padding-bottom:6px}
.toggle-big{min-height:96px}
}

View file

@ -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=<name> 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=<name> forces a screen, ?page=<name> 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;
@ -237,16 +237,152 @@ var UpdateCard = (function () {
}
return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, FIRST_S: FIRST_S, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; }
/* ---------- the pages' words (pure; view.test.mjs loads this block) ----------
What each section says for a given state: the GPU row on Mine, the big start/stop button, the Node page's plain
lines, the next consensus switch, the Prove page's words, the dev-fee line. No DOM, no time of day: the caller
passes `now`. The sections and their order: Mine, Prove, Rewards, Node, Updates, Settings. */
var View = (function () {
'use strict';
var PAGES = [
{ id: 'mine', title: 'Mine', sub: 'your GPUs, the hash rate, the blocks you found' },
{ id: 'prove', title: 'Prove', sub: 'turn blocks into proofs and get paid for each one' },
{ id: 'rewards', title: 'Rewards', sub: 'where the blocks pay, and the key that controls it' },
{ id: 'node', title: 'Node', sub: 'the chain on this machine' },
{ id: 'updates', title: 'Updates', sub: 'the app version and the jobs Igneum sent' },
{ id: 'settings', title: 'Settings', sub: 'power, start at login, remote jobs, the dev fee' }
];
function page(id) { for (var i = 0; i < PAGES.length; i++) if (PAGES[i].id === id) return PAGES[i]; return PAGES[0]; }
function withCommas(n) { return String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
function compact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; var a = Math.abs(n); if (a >= 1e12) return (n / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return (n / 1e9).toFixed(2) + 'G'; if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k'; return String(Math.round(n)); }
function rel(s) { s = Math.max(0, Math.round(s)); if (s < 60) return s + ' s ago'; var m = Math.floor(s / 60); if (m < 60) return m + ' min ago'; var h = Math.floor(m / 60); if (h < 24) return h + ' h ' + (m % 60) + ' min ago'; return Math.floor(h / 24) + ' d ago'; }
function shortHex(h, head, tail) { h = String(h || ''); head = head || 8; tail = tail || 6; return h.length > head + tail + 2 ? h.slice(0, head) + '…' + h.slice(-tail) : h; }
var kindWord = Notices.kindWord;
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); }
// listed, driver fine, not unplugged: a worker can run on it
function present(c) { return !(c.removed_at > 0) && !c.problem; }
// the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address
function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== undefined && cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); }
// one GPU row on the Mine page: cd = state.mining.cards[i]
function cardRow(cd) {
var removed = cd.removed_at > 0, unusable = !!cd.problem;
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : '';
var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : '';
var cap = cd.power_default_w > 0 ? Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) : 0;
var sub = unusable ? (cd.reason || '') : removed ? 'unplugged; its worker stopped. The row goes in five minutes.' : cd.message || (!on && cd.reason ? cd.reason : '') || '';
var meta = [];
if (cd.vram_mb) { var gb = cd.vram_mb / 1024; meta.push((gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : '')); }
if (on && cd.accepted) meta.push(cd.accepted + ' block' + (cd.accepted === 1 ? '' : 's'));
if (on && cd.prepared) meta.push('next program ready');
return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) };
}
// the big button on Mine: m = state.mining, n = state.node, s = { quitting }
function toggle(m, n, s) {
var cards = shownCards(m.cards).filter(present);
var on = cards.filter(function (c) { return c.enabled; }).length;
var mining = cards.filter(function (c) { return c.state === 'mining'; }).length;
if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' };
}
// the Node page's plain words: n = state.node, clock = state.clock, eta = the sync ETA sentence (may be '')
function nodeWords(n, clock, eta) {
var bad = clock && clock.severity === 'block';
if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' };
switch (n.state) {
case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' };
case 'syncing':
var pct = n.headers > n.blocks && n.headers > 0 ? Math.floor(n.blocks / n.headers * 100) : 0;
var line = n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + pct + '%)' : (n.message || 'caught up, waiting for the next block');
return { word: 'syncing', line: line, tone: '' };
case 'starting': return { word: 'starting', line: n.message || 'opening the database', tone: '' };
case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' };
case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' };
case 'stopped': return { word: 'stopped', line: n.message || 'the node is not running', tone: 'bad' };
}
return { word: n.state || 'starting', line: n.message || '', tone: '' };
}
function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; }
function peersLine(n) { return n.peers === 0 ? 'none yet: looking for the seed node' : n.peers === 1 ? 'one other node this one talks to' : 'other nodes this one talks to'; }
function heightLine(n) { return n.headers > n.blocks + 2 ? 'of ' + withCommas(n.headers) + ' headers seen' : 'blocks this node holds'; }
// the next planned rule change: switches = state.node.consensus_switches, daa = state.node.daa
function nextSwitch(switches, daa) {
var list = (switches || []).slice().sort(function (a, b) { return a.daa - b.daa; });
for (var i = 0; i < list.length; i++) if (list[i].daa > daa) return { name: list[i].name, daa: list[i].daa, away: list[i].daa - daa, key: list[i].key };
return null;
}
function switchLine(sw, daa) {
if (!sw) return (daa > 0 ? 'Every planned switch is behind this node. ' : '') + 'A switch is a planned rule change. The node applies it by itself when the chain reaches that height.';
var blocks = daa > 0 ? withCommas(sw.away) + ' blocks away, about ' + (sw.away < 3600 ? Math.max(1, Math.round(sw.away / 60)) + ' min' : sw.away < 86400 ? Math.floor(sw.away / 3600) + ' h ' + Math.round(sw.away % 3600 / 60) + ' min' : Math.floor(sw.away / 86400) + ' d') + ' at one block a second.' : 'the node applies it by itself at that height.';
return sw.name + ' at DAA ' + withCommas(sw.daa) + ': ' + blocks;
}
// the Prove page: pv = state.proving, enabled = state.settings.prove, synced = state.node.synced
function proveWords(pv, enabled, synced) {
pv = pv || {};
if (!enabled) return { word: 'off', sub: 'not proving', tone: 'off', note: 'Off. Switch it on and this machine proves the shards the chain assigns to its keys. On a Mac the CPU prover is slow; a CUDA card is quick.' };
if (pv.status === 'setup' || (pv.enabled && !pv.available && pv.setup_hint)) return { word: 'needs setup', sub: 'one-time install', tone: 'bad', note: pv.setup_hint || pv.message || 'The prover is not installed yet.' };
if (!synced || pv.status === 'waiting') return { word: 'waiting', sub: 'for the node to sync', tone: '', note: pv.message || 'Proving starts once the node has every block.' };
if (pv.status === 'proving') return { word: 'proving', sub: pv.current || 'a shard', tone: 'on', note: pv.message || 'A proof takes minutes on a CPU and seconds on a CUDA card.' };
if (pv.status === 'submitted') return { word: 'submitted', sub: 'waiting to be paid', tone: 'on', note: pv.message || 'The node checks the proof, then the chain pays it.' };
if (pv.status === 'idle') return { word: 'idle', sub: 'nothing assigned', tone: 'ok', note: pv.message || 'On. Nothing is assigned to your keys right now; the next shard arrives within a minute.' };
return { word: pv.status || 'on', sub: pv.backend ? pv.backend.toUpperCase() : '', tone: '', note: pv.message || '' };
}
function verifierWords(pv) {
var vm = (pv && pv.verifier_mode) || 'unknown';
var word = { command: 'verifying', trust: 'trusting (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm;
if (vm === 'command' && pv.pool_entries) word += ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '');
return { word: word, tone: vm === 'command' ? 'ok' : (vm === 'off' || vm === 'trust') ? 'warn' : 'dim', needsSetup: vm === 'off' && /WSL2 prover is not installed/.test((pv && pv.verifier_reason) || '') };
}
// the miner's own dev-fee line, next to the rewards address
function devFeeText(s) {
var d = s.dev_fee || {};
var addr = d.address ? ' to ' + d.address.slice(0, 8) + '…' + d.address.slice(-4) : '';
if (!s.settings.dev_fee) return 'Dev fee off (--dev-fee 0); the default is 1% (1 block in 100)' + addr;
if (d.line && !d.on && !d.address) return 'Dev fee: ' + d.line;
return 'Dev fee ' + (d.percent || 1) + '% (1 block in 100)' + addr + (d.address ? '' : '; the miner names the address when it starts');
}
function devFeeLine(s) {
return s.settings.dev_fee ? 'One block in 100 pays the miner software’s dev fee' + (s.mining.fee_total ? ' (' + withCommas(s.mining.fee_total) + ' so far)' : '') + '. Settings turns it off.' : 'The dev fee is off. Every block pays this address.';
}
// the remote-jobs line on Updates: j = state.jobs, now = state.now
function jobsNote(j, now, jobTitle) {
var parts = [];
if (j.account) parts.push(j.account);
if (!j.allowed) parts.push('Off: nothing runs here until Settings allows remote jobs.');
else if (!j.url_set) parts.push('No jobs address in this build.');
else if (j.error) parts.push(j.error + '.');
else if (j.active) parts.push('Running ' + jobTitle(j) + ' (' + j.id + ').');
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.');
return parts.join(' ');
}
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; }
if (typeof document !== 'undefined') (function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var dpr = Math.min(2, window.devicePixelRatio || 1);
var state = null, phase = 'welcome', forced = null, cardsSeen = false, keyShownThisRun = false;
var state = null, phase = 'welcome', page = 'mine', forced = null, cardsSeen = false, keyShownThisRun = false;
var params = new URLSearchParams(location.search);
if (params.get('screen')) forced = params.get('screen');
if (params.get('page')) page = params.get('page'); // ?page=mine|prove|rewards|node|updates|settings (screenshots)
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
var forcedJob = params.get('job'); // a sample remote job for screenshots: running|done|failed
if (params.get('host') === 'mac') document.body.classList.add('mac');
@ -272,13 +408,12 @@ if (typeof document !== 'undefined') (function () {
var opt = body === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
return fetch(path, opt).then(function (r) { return r.json(); });
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
var compact = View.compact, withCommas = View.withCommas, rel = View.rel, kindWord = View.kindWord, shownCards = View.shownCards, cardTitle = View.cardTitle;
function hms(s) { s = Math.max(0, Math.round(s)); var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' : '') + (h ? String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }
function uptime(s) { var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : m + ' min'; }
function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); }
function setText(id, text) { var el = $(id); if (el && el.textContent !== text) el.textContent = text; }
var toastT = null;
function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); }
function copyText(text, msg) {
@ -289,21 +424,48 @@ if (typeof document !== 'undefined') (function () {
function fallbackCopy(text, msg) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast(msg || 'Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); }
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-copy]'); if (!b) return;
copyText($(b.getAttribute('data-copy')).textContent);
var t = $(b.getAttribute('data-copy')).textContent;
if (!t || /not (set|read|printed)/.test(t)) { toast('Nothing to copy yet'); return; }
copyText(t);
});
// ---------- screens ----------
// ---------- screens (setup) and pages (the dashboard) ----------
function show(p) {
phase = p;
document.body.setAttribute('data-phase', p);
var dash = p === 'dashboard';
$('bottom').hidden = !dash;
$('btn-settings').hidden = !dash;
document.body.classList.toggle('has-bottom', dash);
document.body.classList.toggle('has-rail', dash);
$('rail').hidden = !dash;
$('top-brand').hidden = dash;
$('page-title').hidden = !dash;
if (!dash && drawerOpen()) setDrawer(false);
if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); }
if (dash) showPage(page);
api('api/phase', { phase: p });
}
function showPage(id) {
var meta = View.page(id);
page = meta.id;
document.body.setAttribute('data-page', page);
document.querySelectorAll('.page').forEach(function (el) { el.hidden = el.dataset.page !== page; });
document.querySelectorAll('#rail-nav .nav').forEach(function (b) {
var on = b.dataset.page === page;
b.classList.toggle('on', on);
if (on) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current');
});
setText('page-title-text', meta.title); setText('page-sub', meta.sub);
$('main').scrollTop = 0;
if (state) { renderPage(state); if (page === 'settings') fillSettings(); if (page === 'mine') size(); }
}
$('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); });
// the rail answers the arrow keys and Home/End, so the sections are reachable without a mouse
$('rail-nav').addEventListener('keydown', function (e) {
var items = Array.prototype.slice.call(document.querySelectorAll('#rail-nav .nav')), i = items.indexOf(document.activeElement);
if (i < 0) return;
var j = e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : -1;
if (j < 0 || j >= items.length) return;
e.preventDefault(); items[j].focus(); showPage(items[j].dataset.page);
});
$('btn-begin').addEventListener('click', function () { show('cards'); });
$('btn-cards-next').addEventListener('click', function () {
var choices = readCardRows($('cards-list'));
@ -346,85 +508,99 @@ if (typeof document !== 'undefined') (function () {
api('api/key/saved', {}).then(function () { return api('api/start', {}); }).then(function () { $('sheet-key').hidden = true; $('key-private').textContent = ''; show('dashboard'); });
});
// ---------- settings ----------
$('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; });
$('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; });
$('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); });
var settingsCardsSig = '';
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';
settingsCardsSig = shownCards(state.mining.cards).map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : ''); }).join('|');
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) <button class="btn tiny" data-power-retry="1">Retry</button>';
row.appendChild(d);
});
$('s-name').value = state.display_name || '';
$('s-mid').textContent = (state.machine_id || '').slice(0, 8);
$('s-vote').checked = state.settings.vote;
$('s-devfee').checked = state.settings.dev_fee;
$('s-devfee-text').textContent = devFeeText(state);
$('s-login').checked = state.settings.start_at_login;
$('s-version').textContent = 'Igneum Miner ' + state.version;
$('s-node-dir').textContent = 'chain data: ' + state.node_dir;
$('s-log-dir').textContent = 'logs: ' + state.log_dir;
$('s-reveal').hidden = state.address.source !== 'generated';
$('s-live').hidden = !state.live_page;
var u = state.update;
$('s-auto-update').checked = !!state.settings.auto_update;
fillJobsSettings(state.jobs || {});
$('s-install').hidden = !((u.ready || u.downloaded || u.status === 'error') && !u.applying);
$('s-update-note').textContent = settingsUpdateNote(u);
}
var t = View.toggle(state.mining, state.node, state);
if (!t.act) return;
api('api/' + t.act, {}).then(function () { toast(t.act === 'pause' ? 'Mining stops; the node keeps running' : 'Mining starts'); });
});
// the GPU switch on a Mine row: the whole list goes to the engine with that card flipped (identities and the cap
// as they are); pending[key] holds the wanted state until the engine agrees, so the row does not flicker back
var pending = {};
$('d-cards').addEventListener('change', function (e) {
if (!e.target.matches('.gpu-line .switch input') || !state) return;
var key = e.target.closest('.gpu-line').dataset.key, on = e.target.checked;
pending[key] = { on: on, at: Date.now() };
var choices = shownCards(state.mining.cards).filter(View.present).map(function (c) { var it = { key: c.key, enabled: c.key === key ? on : c.enabled, identities: c.identities || 1 }; if (c.vendor === 'nvidia' && c.power_default_w > 0) it.power_pct = c.power_pct || 80; return it; });
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(on ? 'Worker starting on that card' : 'Worker stopped on that card'); else { delete pending[key]; toast(r.error || 'could not change'); } });
});
// ---------- Prove ----------
$('s-prove').addEventListener('change', function () { var on = this.checked; api('api/prove', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); else { toast(r.error || 'could not change'); $('s-prove').checked = !on; } }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
// ---------- Rewards ----------
$('s-address-save').addEventListener('click', function () {
var a = $('s-address-input').value.trim();
if (!validAddr(a)) { $('s-address-err').textContent = 'An address is 0x followed by 40 hex characters.'; $('s-address-err').hidden = false; return; }
api('api/settings', { address: a }).then(function (r) { if (r.ok) { $('s-address-err').hidden = true; $('s-address-input').value = ''; toast('Rewards address changed'); } else { $('s-address-err').textContent = r.error; $('s-address-err').hidden = false; } });
});
$('s-reveal').addEventListener('click', function () {
api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; } else toast(r.error || 'no key'); });
api('api/key/reveal', {}).then(function (r) { if (r.ok) { $('s-key').textContent = r.private_key; $('s-key-box').hidden = false; $('s-hide').hidden = false; $('s-reveal').hidden = true; } else toast(r.error || 'no key'); });
});
$('s-name-save').addEventListener('click', function () {
api('api/settings', { display_name: $('s-name').value }).then(function (r) { if (r.ok) toast('Renamed'); });
});
$('s-cards-save').addEventListener('click', function () {
var choices = readCardRows($('s-cards'));
api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast('Applied; only the changed workers restart'); });
});
$('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
$('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
// the miner's own start line, next to the rewards address: "dev fee 1% (1 block in 100) to 0x...; --dev-fee 0 turns it off"
function devFeeText(s) {
var d = s.dev_fee || {};
var addr = d.address ? ' to ' + d.address.slice(0, 8) + '\u2026' + d.address.slice(-4) : '';
if (!s.settings.dev_fee) return 'Dev fee off (--dev-fee 0); the default is 1% (1 block in 100)' + addr;
if (d.line && !d.on && !d.address) return 'Dev fee: ' + d.line;
return 'Dev fee ' + (d.percent || 1) + '% (1 block in 100)' + addr + (d.address ? '' : '; the miner names the address when it starts');
}
$('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); });
$('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
function hideKey() { $('s-key-box').hidden = true; $('s-key').textContent = ''; $('s-hide').hidden = true; $('s-reveal').hidden = false; }
$('s-hide').addEventListener('click', hideKey);
$('r-wallet').addEventListener('click', function () { api('api/open', { url: 'https://igneum.network/wallet' }); });
// ---------- Node ----------
$('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
// ---------- Updates ----------
$('s-update').addEventListener('click', function () { api('api/update/check', {}); setText('s-update-note', 'Checking.'); });
$('s-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); });
$('s-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); });
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('s-prove').addEventListener('change', function () { api('api/prove', { on: this.checked }).then(function (r) { if (r.ok) toast(r.ok && $('s-prove').checked ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); }); });
$('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); setTimeout(fillSettings, 800); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); });
$('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); $('s-jobs-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
$('s-jobs-check').addEventListener('click', function () { api('api/jobs/check', {}); setText('s-jobs-note', 'Checking.'); });
// ---------- bottom bar ----------
$('btn-pause').addEventListener('click', function () {
if (!state) return;
api(state.mining.paused ? 'api/resume' : 'api/pause', {});
// ---------- Settings ----------
$('s-name-save').addEventListener('click', function () { api('api/settings', { display_name: $('s-name').value }).then(function (r) { if (r.ok) toast('Renamed'); }); });
$('s-vote').addEventListener('change', function () { api('api/settings', { vote: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
$('s-devfee').addEventListener('change', function () { api('api/settings', { dev_fee: this.checked }).then(function (r) { if (r.ok) toast(r.restart ? 'Applied; the miner restarts' : 'Applied'); }); });
$('s-login').addEventListener('change', function () { var on = this.checked; api('api/settings', { start_at_login: on }).then(function (r) { if (!r.ok) { toast(r.error || 'could not change'); $('s-login').checked = !on; } }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); });
$('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); });
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('s-log-open').addEventListener('click', function () { setDrawer(true); });
// the log export: the last lines the engine holds (the ring keeps 3,000; one call returns up to 2,000), to the clipboard
$('s-log-copy').addEventListener('click', function () {
api('api/log?after=0&limit=2000').then(function (r) {
var lines = (r.lines || []).map(function (l) { return clock(l.t) + ' ' + l.src.padEnd(6) + ' ' + l.text; });
if (!lines.length) { toast('Nothing logged yet'); return; }
copyText('Igneum Miner ' + (state ? state.version : '') + ' log, ' + lines.length + ' lines\n' + lines.join('\n'), 'Copied ' + lines.length + ' lines');
}).catch(function () { toast('The engine did not answer'); });
});
// the per-card controls on Settings: the cap slider and the identities stepper send the whole list (debounced),
// the sweep buttons send their own calls
var cardsT = null;
function sendCards(msg) {
clearTimeout(cardsT);
cardsT = setTimeout(function () {
var choices = readCardRows($('s-cards'));
if (choices.length) api('api/cards', { cards: choices }).then(function (r) { if (r.ok) toast(msg || 'Applied; only the changed worker restarts'); });
}, 500);
}
$('s-cards').addEventListener('input', function (e) {
if (e.target.matches('input[type=range]')) {
var row = e.target.closest('.set-card'), cd = cardOf(row.dataset.key), pct = parseInt(e.target.value, 10);
row.querySelector('.pv').textContent = pct + '% · ' + (cd && cd.power_default_w ? Math.round(cd.power_default_w * pct / 100) + ' W' : '');
sendCards('Power cap set; pinned until you unpin it');
}
});
$('s-cards').addEventListener('change', function (e) { if (e.target.matches('.ids input')) sendCards('Identities set; that card’s worker restarts'); });
$('s-cards').addEventListener('click', function (e) {
var b = e.target.closest('button'); if (!b) return;
if (b.dataset.d) { var inp = b.parentNode.querySelector('input'), v = parseInt(inp.value, 10) || 1; inp.value = Math.max(1, Math.min(64, v + parseInt(b.dataset.d, 10))); sendCards('Identities set; that card’s worker restarts'); }
else if (b.dataset.sweepStart) api('api/sweep/start', { key: b.dataset.sweepStart }).then(function () { toast('Sweep queued: 100% down to 50%, 75 s a step'); });
else if (b.dataset.sweepStop) api('api/sweep/stop', {}).then(function () { toast('Sweep stopped; cap restored'); });
else if (b.dataset.sweepPin) api('api/sweep/pin', { key: b.dataset.sweepPin, pinned: b.dataset.pinned === '1' }).then(function () { toast(b.dataset.pinned === '1' ? 'Cap pinned' : 'The sweep chooses the cap again'); });
else if (b.dataset.powerRetry) api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); });
});
function cardOf(key) { return state ? state.mining.cards.filter(function (c) { return c.key === key; })[0] : null; }
// ---------- rail: logs and quit ----------
$('btn-logs').addEventListener('click', function () { setDrawer(!document.body.classList.contains('drawer-open')); });
$('log-close').addEventListener('click', function () { setDrawer(false); });
$('btn-quit').addEventListener('click', function () {
if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return;
api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true;
@ -445,7 +621,7 @@ if (typeof document !== 'undefined') (function () {
function drawerOpen() { return document.body.classList.contains('drawer-open'); }
function setDrawer(open) {
document.body.classList.toggle('drawer-open', open);
var b = $('btn-logs'); b.classList.toggle('on', open); b.setAttribute('aria-expanded', open ? 'true' : 'false');
var b = $('btn-logs'); b.classList.toggle('on', open); b.classList.toggle('logs', open); b.setAttribute('aria-expanded', open ? 'true' : 'false');
$('btn-logs-text').textContent = open ? 'Close logs' : 'Logs';
if (open) { applyDrawerHeight(); pollLog(); scheduleLogPoll(); viewDirty = true; renderLog(true); }
else if (logTimer) { clearTimeout(logTimer); logTimer = null; }
@ -686,7 +862,7 @@ if (typeof document !== 'undefined') (function () {
drawRaf = 0; clearTimeout(drawTimer); drawTimer = null;
if (hiddenNow()) return;
var fast = false;
if (phase === 'dashboard') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
if (phase === 'dashboard' && page === 'mine') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; }
if (fast) drawRaf = requestAnimationFrame(frame);
else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500);
}
@ -698,17 +874,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 '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU';
}
var kindWord = Notices.kindWord;
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); }
// the tooltip on a card's name: what the tool calls it (gfx1201), its device index, the OpenCL platform, the PCI address
function cardTitle(cd) { var t = []; if (cd.code && cd.code !== cd.name) t.push(cd.code); if (cd.device !== '') t.push(cd.worker + ' device ' + cd.device); if (cd.platform) t.push(cd.platform); if (cd.bus) t.push('bus ' + cd.bus); return t.join(' · '); }
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(); });
shownCards(cards).forEach(function (cd) {
@ -720,37 +891,28 @@ if (typeof document !== 'undefined') (function () {
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
'<div class="info"><div class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
(cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '<div class="power"><span class="k">power cap</span><input type="range" min="50" max="100" step="5" value="' + (cd.power_pct || 80) + '"><span class="pv mono">' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W</span>' + (cd.pinned ? '<span class="pin" title="set by hand; the sweep records but does not change it">pinned</span>' : cd.sweep_pct ? '<span class="pin" title="chosen by the efficiency sweep">sweep ' + cd.sweep_pct + '%</span>' : '') + '</div>' : '') +
'<div class="ids"><span class="k">identities</span><button type="button" data-d="-1" aria-label="fewer">&minus;</button><input type="number" min="1" max="64" value="' + cd.identities + '"><button type="button" data-d="1" aria-label="more">+</button></div>' +
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(cd.name) + '"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
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) {
container.querySelectorAll('.gpu-row, .set-card').forEach(function (r) {
if (r.classList.contains('removed') || r.classList.contains('unusable')) return;
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);
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');
@ -765,54 +927,14 @@ if (typeof document !== 'undefined') (function () {
$('cards-note').hidden = false; $('cards-help').hidden = true; $('btn-cards-retry').hidden = false; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue without mining';
return;
}
var on = cards.filter(function (c) { return c.enabled; }).length;
$('cards-sub').textContent = cards.length === 1 ? 'One card found. Switch it on to mine with it.' : cards.length + ' GPUs found. Switch on the ones you want to mine with; each gets its own worker.';
$('cards-note').hidden = !s.detect_message; $('cards-note').textContent = s.detect_message || '';
$('cards-help').hidden = false;
var nv = cards.filter(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0; }).length;
$('cards-power').hidden = !nv;
$('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = on ? 'Continue' : 'Continue';
$('btn-cards-retry').hidden = true; $('btn-cards-next').disabled = false; $('btn-cards-next').textContent = 'Continue';
}
// power draw, the cap and the temperatures (NVIDIA); memory over 90 C amber, over 95 C red
function telemetryHtml(cd) {
if (cd.vendor !== 'nvidia' || !(cd.telemetry_at > 0 || cd.power_default_w > 0)) return '';
var memCls = cd.temp_mem > 95 ? 'hot' : cd.temp_mem > 90 ? 'warm' : '';
var h = '<div class="m tele"><span>draw <b>' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '</b>' + (cd.power_limit_w ? ' <span class="dim">/ cap ' + Math.round(cd.power_limit_w) + ' W</span>' : '') + '</span>' +
'<span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span>' +
'<span class="' + memCls + '">memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span>' +
'<span>eff <b>' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '</b></span></div>';
if (memCls) h += '<div class="msg ' + memCls + '">memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk</div>';
if (cd.power_default_w > 0) {
var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100);
if (cd.power_applied) h += '<div class="msg ok">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' : '') + '</div>';
else h += '<div class="msg hot">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')) : '') + ' <button class="btn tiny" data-power-retry="1">Retry</button></div>';
}
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 ? '<div class="msg dim sweep">' + esc(cd.sweep_note) + '</div>' : '';
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 <b>' + cd.sweep_pct + '%</b> · <b>' + cd.sweep_eff.toFixed(3) + ' MH/W</b> (' + 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 ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Sweep now</button>';
if (!running && cd.pinned) btn += ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0" title="let the sweep choose the cap again">Unpin</button>';
return '<div class="msg sweep' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>';
}
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) {
@ -825,93 +947,205 @@ if (typeof document !== 'undefined') (function () {
var word = left < 60 ? 'under a minute' : left < 3600 ? 'about ' + Math.round(left / 60) + ' min' : left < 86400 ? 'about ' + Math.floor(left / 3600) + ' h ' + Math.round(left % 3600 / 60) + ' min' : 'over a day';
return word + ' left at ' + Math.round(rate) + ' blocks/s';
}
function renderDashboard(s) {
var m = s.mining, n = s.node, p = s.program, f = s.finality;
$('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1);
var shown = shownCards(m.cards), present = shown.filter(function (c) { return !(c.removed_at > 0) && !c.problem; });
var mining = present.filter(function (c) { return c.state === 'mining'; }).length;
$('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + present.length + ' card' + (present.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state;
$('d-blocks').textContent = withCommas(m.accepted_total);
$('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '');
var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state;
$('d-node').textContent = nodeWord;
// the live numbers always show, so a stuck label is visible as stuck
var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's');
var eta = syncEta(n, s.now);
var syncing = n.state === 'syncing' && n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + Math.floor(n.blocks / n.headers * 100) + '%)' : '';
$('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + skewWord(s.clock.skew_s) + ' ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': sync it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : syncing || (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live;
$('d-node-sub').title = $('d-node-sub').textContent + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : '');
$('d-hash-sub').title = $('d-hash-sub').textContent; $('d-blocks-sub').title = $('d-blocks-sub').textContent;
var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block'));
if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); }
else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'; }
$('d-eta-sub').title = $('d-eta-sub').textContent;
// ---------- Mine ----------
var mineRowsSig = '', lastEventsKey = '';
function gpuLineHtml(r) {
var num = function (cls, k, v, unit, tone) { return '<div class="num ' + cls + '"><span class="k">' + k + '</span><span class="v' + (v ? (tone ? ' ' + tone : '') : ' na') + '">' + (v ? esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') : 'n/a') + '</span></div>'; };
return '<div class="gpu-line' + (r.on ? ' on' : ' off') + (r.removed ? ' removed' : '') + (r.unusable ? ' unusable' : '') + '" data-key="' + esc(r.key) + '">' +
'<div class="badge ' + esc(r.vendor) + '">' + badgeHtml({ vendor: r.vendor }) + '</div>' +
'<div class="who"><div class="name" title="' + esc(r.title) + '">' + esc(r.name) + ' <span class="kind ' + esc(r.kind) + '">' + esc(r.kindWord) + '</span></div>' +
'<span class="st ' + r.tone + '"><i></i>' + esc(r.word) + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' + (r.sub ? '<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>' : '') + '</div>' +
num('hash', 'hash rate', r.hash, 'MH/s', '') + num('temp', 'temperature', r.temp, '', r.tempTone) + num('power', 'power', r.power, '', '') +
'<label class="switch" title="' + (r.on ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox" aria-label="Mine on ' + esc(r.name) + '"' + (r.on ? ' checked' : '') + (r.canToggle ? '' : ' disabled') + '><span class="track"></span></label></div>';
}
function renderMine(s) {
var m = s.mining, n = s.node, p = s.program;
var t = View.toggle(m, n, s), tb = $('btn-toggle');
tb.className = 'toggle-big' + (t.cls ? ' ' + t.cls : ''); tb.disabled = t.disabled;
setText('btn-toggle-text', t.label); setText('btn-toggle-sub', t.sub);
setText('d-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
var shown = shownCards(m.cards), presentCards = shown.filter(View.present);
var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length;
setText('d-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state);
setText('d-blocks', withCommas(m.accepted_total));
setText('d-blocks-sub', m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
if (n.daa > 0 && p.eta_s >= 0) { setText('d-eta', hms(p.eta_s)); setText('d-eta-sub', p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : '')); }
else { setText('d-eta', '--:--'); setText('d-eta-sub', n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'); }
var nowU = s.now;
$('d-found-10').textContent = m.found.filter(function (t) { return nowU - t <= 600; }).length;
$('d-found-60').textContent = m.found.length;
$('d-fee').textContent = withCommas(m.fee_total || 0);
setText('d-found-10', String(m.found.filter(function (x) { return nowU - x <= 600; }).length));
setText('d-found-60', String(m.found.length));
setText('d-fee', withCommas(m.fee_total || 0));
$('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee';
$('d-chain-blocks').textContent = compact(n.blocks);
// per-card tiles (enabled cards), the rest in one muted line
// a card that was removed keeps its tile saying "removed" for five minutes (then the row goes); a faulty one is in the off line
var on = shown.filter(function (c) { return c.enabled && !c.problem; }), off = shown.filter(function (c) { return !c.enabled || c.problem; });
var workers = on.filter(function (c) { return !(c.removed_at > 0); }).length;
$('d-cards-eyebrow').textContent = workers + ' worker' + (workers === 1 ? '' : 's') + (present.length > workers ? ' of ' + present.length + ' GPU' + (present.length === 1 ? '' : 's') : '');
var tb = $('d-cards');
if (!on.length) tb.innerHTML = '<div class="empty">' + (shown.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
else tb.innerHTML = on.map(function (cd) {
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : cd.state === 'removed' ? 'removed' : '';
var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state;
var extra = cd.ids.length ? '<span>id <b>' + esc(cd.ids.join(' ')) + '</b></span>' : '';
return '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
'<div class="h">' + cd.hash_now.toFixed(1) + '<span class="unit">MH/s</span></div>' +
'<div class="m"><span>blocks <b>' + cd.accepted + '</b>' + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + ' rejected</span>' : '') + '</span><span>avg <b>' + cd.hash_avg.toFixed(1) + '</b></span><span>identities <b>' + cd.identities + '</b></span>' + extra + (cd.faults ? '<span class="dim">worker faults <b>' + cd.faults + '</b></span>' : '') + (cd.mismatched ? '<span class="dim">re-check misses <b>' + cd.mismatched + '</b></span>' : '') + '</div>' +
telemetryHtml(cd) +
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message && cd.state !== 'removed' ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
}).join('');
$('d-cards-off').hidden = !off.length;
$('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.problem ? c.name + ': not usable (' + c.problem + ')' : c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. ' + (off.some(function (c) { return !c.problem; }) ? 'Settings switches them on.' : 'Reboot with the card attached; if it persists, reinstall the driver with the card attached.') : '';
// node card
$('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : '');
$('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers;
$('n-daa').textContent = withCommas(n.daa); $('n-diff').textContent = compact(n.difficulty); $('n-tips').textContent = n.tips;
var consensus = n.consensus_switch_daa > 0 ? ' Consensus switch at DAA ' + withCommas(n.consensus_switch_daa) + (n.daa > 0 && n.consensus_switch_daa > n.daa ? ' (' + withCommas(n.consensus_switch_daa - n.daa) + ' blocks away).' : '.') + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '') : '';
$('n-note').textContent = (n.state === 'synced' ? ('Reading every 10 s' + (n.last_reading_age_s >= 0 ? ', last ' + Math.round(n.last_reading_age_s) + ' s ago' : '') + '.') : (n.message ? n.message + '.' : '')) + consensus;
// finality
if (f.last_lock > 0) { $('f-lock').textContent = '#' + withCommas(f.last_lock); $('f-age').textContent = rel(f.age_s); $('f-note').textContent = 'Checkpoints lock at 2/3 of the 30-day weight. Votes are sent by this miner.'; }
else { $('f-lock').textContent = 'none yet'; $('f-age').textContent = 'n/a'; $('f-note').textContent = f.message || 'Locks appear once the miner votes on checkpoints.'; }
// proving
var pv = s.proving || {};
var pvWord = { off: 'off', setup: 'needs setup', waiting: 'waiting', idle: 'idle', proving: 'proving', submitted: 'submitted' }[pv.status] || (pv.status || 'off');
$('pv-state').textContent = pvWord + (pv.status === 'proving' && pv.current ? ' ' + pv.current : '') + (pv.backend && pv.enabled && pv.available ? ' (' + pv.backend.toUpperCase() + ')' : '');
$('pv-assigned').textContent = String(pv.assigned || 0);
$('pv-submitted').textContent = String(pv.submitted || 0);
$('pv-paid').textContent = String(pv.paid || 0) + (pv.paid_wei ? ' (' + (Number(pv.paid_wei) / 1e18).toFixed(4) + ' IGN)' : '');
$('pv-note').textContent = pv.enabled ? (pv.message || '') : 'Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.';
// the node's proof verifier (spec 7.7 item 4): a node without one relays records and never includes them
var vm = pv.verifier_mode || 'unknown';
var vWord = { command: 'verifying', trust: 'trust (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm;
$('pv-verifier').textContent = vWord + (vm === 'command' && pv.pool_entries ? ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '') : '');
$('pv-verifier').classList.toggle('warn', vm === 'off' || vm === 'trust');
$('pv-verifier-note').textContent = pv.verifier_note || '';
$('pv-verifier-note').hidden = !pv.verifier_note;
$('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || (vm === 'off' && /WSL2 prover is not installed/.test(pv.verifier_reason || '')));
$('f-votes').textContent = withCommas(f.votes);
// events
setText('d-chain-blocks', compact(n.blocks));
// the rows: every card, on or off; rebuilt only when their words change
var rows = shown.map(View.cardRow), now = Date.now();
rows.forEach(function (r) { var pd = pending[r.key]; if (pd) { if (pd.on === r.on || now - pd.at > 8000) delete pending[r.key]; else { r.on = pd.on; r.word = pd.on ? 'starting' : 'stopping'; r.tone = ''; } } });
var on = rows.filter(function (r) { return r.on; }).length;
setText('d-cards-eyebrow', s.detecting ? 'detecting' : !shown.length ? 'none found' : on + ' of ' + presentCards.length + ' on');
var sig = JSON.stringify(rows);
if (sig !== mineRowsSig) {
mineRowsSig = sig;
$('d-cards').innerHTML = rows.length ? rows.map(gpuLineHtml).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No GPU this app can drive was found. The node runs on its own.') + '</div>';
}
var note = !shown.length && s.detect_message ? s.detect_message : shown.some(function (c) { return c.problem; }) ? 'A card the OS reports a problem on gets no worker. Reboot with the card attached; if it persists, reinstall the driver with the card attached.' : presentCards.some(function (c) { return c.vendor === 'nvidia' && c.power_default_w > 0 && !c.power_applied && c.enabled; }) ? 'A power cap is not applied yet: Settings has a Retry for the administrator prompt.' : '';
$('d-cards-note').hidden = !note; setText('d-cards-note', note);
var key = s.events.length ? s.events[0].t + ':' + s.events.length : '';
if (key !== lastEventsKey) {
lastEventsKey = key;
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 14).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(e.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet.</div>';
$('d-events').innerHTML = s.events.length ? s.events.slice(0, 14).map(function (e) { return '<div><span><span class="k ' + esc(e.kind) + '">' + esc(e.kind) + '</span>' + esc(e.text) + '</span><span class="t">' + clock(e.t) + '</span></div>'; }).join('') : '<div class="empty">No events yet. The node and the miner report here as they start.</div>';
}
// 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 ? '<span class="pc">stopping: miners first, then the node</span>' :
(s.display_name ? '<span class="pc nm" title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</span>' : '') +
'<span class="pc">' + esc(m.state === 'mining' ? 'mining' : m.state) + '</span><span class="pc">node ' + esc(nodeWord) + '</span><span class="pc up">up ' + uptime(s.uptime_s) + '</span>' +
(s.address.display ? '<span class="pc ad" title="rewards to ' + esc(s.address.display) + '">rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) + '</span>' : '');
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 '<span class="' + (sw && x.key === sw.key ? 'next' : x.daa <= n.daa ? 'past' : '') + '"><span>' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '</span><span>' + withCommas(x.daa) + '</span></span>'; }).join('');
}
if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock)); setText('f-age', rel(f.age_s)); setText('f-note', 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the 30-day weight; this machine votes every 30 s.'); }
else { setText('f-lock', 'none yet'); setText('f-age', 'n/a'); setText('f-note', f.message || 'Locks appear once the miner votes on checkpoints.'); }
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 ? '<table><thead><tr><th>job</th><th>kind</th><th>started</th><th>exit</th><th>report</th></tr></thead><tbody>' + h.map(function (r) {
return '<tr class="' + esc(r.status) + '"><td class="mono" title="' + esc(r.summary || '') + '">' + esc(r.id) + '</td><td>' + esc(r.kind) + '</td><td>' + (r.started_at ? clock(r.started_at) : '') + '</td><td>' + esc(r.status) + (r.status !== 'running' ? ' (' + r.exit + ')' : '') + '</td><td>' + (r.uploaded ? 'uploaded' : (r.status === 'running' ? 'pending' : 'not uploaded')) + '</td></tr>';
}).join('') + '</tbody></table>' : '<p class="empty">No job has run on this machine yet.</p>';
}
var jobsSig = '';
// ---------- Settings ----------
// the switches follow the state unless that switch has the focus; the per-card block is rebuilt when a card's
// settings change (never while a slider is being dragged)
var setCardsSig = '';
function fillSettings() {
if (!state) return;
var s = state;
var sw = function (id, on) { if (document.activeElement !== $(id)) $(id).checked = !!on; };
sw('s-vote', s.settings.vote); sw('s-devfee', s.settings.dev_fee); sw('s-login', s.settings.start_at_login);
sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', s.settings.sweep); sw('s-trust', s.settings.proof_verify_trust);
setText('s-devfee-text', View.devFeeText(s));
if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || '';
setText('s-mid', (s.machine_id || '').slice(0, 8));
setText('s-node-dir', 'chain data: ' + s.node_dir);
setText('s-log-dir', 'log files: ' + s.log_dir);
$('s-live').hidden = !s.live_page;
renderSetCards(s);
}
function setCardHtml(cd) {
var nv = cd.vendor === 'nvidia' && cd.power_default_w > 0, pct = cd.power_pct || 80, removed = cd.removed_at > 0, unusable = !!cd.problem;
var h = '<div class="set-card' + (removed ? ' removed' : '') + (unusable ? ' unusable' : '') + '" data-key="' + esc(cd.key) + '"><div class="head"><span class="name" title="' + esc(cardTitle(cd)) + '">' + esc(cd.name) + '</span><span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span><span class="meta">' + esc(cd.worker) + (vramText(cd) ? ' · ' + esc(vramText(cd)) : '') + (removed ? ' · removed' : unusable ? ' · not usable (' + esc(cd.problem) + ')' : cd.enabled ? '' : ' · off') + '</span></div>';
if (removed) return h + '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p></div>';
if (unusable) return h + '<p class="help">' + esc(cd.reason || 'No worker can drive this card.') + '</p></div>';
if (nv) {
h += '<div class="ctl"><span class="k">Power cap</span><input type="range" min="50" max="100" step="5" value="' + pct + '" aria-label="Power cap for ' + esc(cd.name) + '"><span class="pv">' + pct + '% · ' + Math.round(cd.power_default_w * pct / 100) + ' W</span>' +
'<p class="help">Lower is cooler and quieter; most cards hash nearly as fast at 70%. ' + (cd.pinned ? 'Set by hand, so the sweep leaves it.' : 'The sweep may move it.') + '</p></div>';
var want = Math.round(cd.power_default_w * pct / 100);
h += cd.power_applied ? '<div class="line ok">cap applied: <b>' + want + ' W</b>' + (cd.pinned ? ' <span class="pin">pinned</span>' : cd.sweep_pct === pct && cd.sweep_pct ? ' <span class="pin">chosen by the sweep</span>' : '') + '</div>' : '<div class="line hot">cap not applied (needs the administrator prompt) <button class="btn tiny" data-power-retry="1">Retry</button></div>';
if (cd.telemetry_at > 0) h += '<div class="line">draw <b>' + (cd.power_w ? Math.round(cd.power_w) + ' W' : 'n/a') + '</b><span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span><span>memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span><span>efficiency <b>' + (cd.eff_mhw ? cd.eff_mhw.toFixed(3) + ' MH/W' : 'n/a') + '</b></span></div>';
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 <b>' + cd.sweep_pct + '%</b> · <b>' + cd.sweep_eff.toFixed(3) + ' MH/W</b> (' + 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 ? '<button class="btn tiny" data-sweep-stop="1">Stop</button>' : '<button class="btn tiny" data-sweep-start="' + esc(cd.key) + '">Sweep now</button>' + (cd.pinned ? ' <button class="btn tiny" data-sweep-pin="' + esc(cd.key) + '" data-pinned="0">Unpin</button>' : '');
h += '<div class="line' + (running ? ' on' : '') + '">' + t + ' ' + btn + '</div>';
} else if (cd.vendor === 'apple') {
h += '<p class="help">Apple silicon manages its own power; there is no cap to set.</p>';
}
h += '<div class="ctl"><span class="k">Identities</span><p class="help" style="grid-column:2">Each identity votes and is paid on its own. 8 suits a big card, 2 a small one, 1 an integrated GPU. Changing it restarts that card’s worker.</p><div class="ids"><button type="button" data-d="-1" aria-label="fewer identities">&minus;</button><input type="number" min="1" max="64" value="' + (cd.identities || 1) + '" aria-label="Identities on ' + esc(cd.name) + '"><button type="button" data-d="1" aria-label="more identities">+</button></div></div>';
return h + '</div>';
}
function renderSetCards(s) {
var cards = shownCards(s.mining.cards), box = $('s-cards');
if (box.querySelector('input[type=range]:active') || box.contains(document.activeElement)) return;
var sig = JSON.stringify(cards.map(function (c) { return [c.key, c.enabled, c.identities, c.power_pct, c.pinned, c.power_applied, c.sweep_state, c.sweep_pct, c.sweep_note, Math.round(c.power_w || 0), Math.round(c.temp_gpu || 0), Math.round(c.temp_mem || 0), c.problem || '', c.removed_at > 0]; }));
if (sig === setCardsSig) return;
setCardsSig = sig;
setText('s-cards-eyebrow', cards.length ? cards.length + ' card' + (cards.length === 1 ? '' : 's') : '');
box.innerHTML = cards.length ? cards.map(setCardHtml).join('') : '<div class="empty">No card yet. Mine shows what was found.</div>';
}
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;
@ -925,25 +1159,33 @@ if (typeof document !== 'undefined') (function () {
else if (s.mining.state === 'idle' && s.node.state === 'synced') { text = 'node synced'; pill.classList.add('on'); }
else text = s.mining.state;
$('pill-text').textContent = text;
// the top line and the rail foot: the machine, the uptime, where rewards go
var nodeWord = s.node.state === 'synced' ? 'synced' : s.node.state;
var top = phase !== 'dashboard' ? '' : s.quitting ? '<span class="pc">stopping: miners first, then the node</span>' : '<span class="pc">' + esc(s.mining.state === 'mining' ? 'mining' : s.mining.state) + '</span><span class="pc">node ' + esc(nodeWord) + '</span><span class="pc">up ' + uptime(s.uptime_s) + '</span>';
if (top !== lastTop) { lastTop = top; $('top-status').innerHTML = top; }
var foot = (s.display_name ? '<b title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</b><br>' : '') + (s.address.display ? 'rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) : 'no address yet');
if (foot !== lastFoot) { lastFoot = foot; $('rail-status').innerHTML = foot; }
var fv = 'v' + esc(s.version) + '<span class="chain"> · ' + esc(s.chain || s.network || '') + '</span>';
if (fv !== lastVersion) { lastVersion = fv; $('foot-version').innerHTML = fv; }
}
// 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) {
@ -1044,7 +1286,7 @@ if (typeof document !== 'undefined') (function () {
$('upd-more').addEventListener('click', function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } });
// ---------- over-the-air updates (src/ota.rs): the settings note; the strip is Notices.updateNotice ----------
// ---------- over-the-air updates (src/ota.rs): the Updates page's line; the strip is Notices.updateNotice ----------
function settingsUpdateNote(u) {
var n = Notices.updateNotice(u, state || {}), parts = [];
if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.');
@ -1052,7 +1294,9 @@ if (typeof document !== 'undefined') (function () {
if (n && n.kind !== 'update-installed' && !(u.rolled_back && u.status === 'error')) parts.push(n.text + (n.detail ? ' ' + Notices.endDot(n.detail) : ''));
else if (u.status === 'checking') parts.push('Checking.');
else if (u.status === 'current') parts.push('This is the latest version' + (u.checked_at ? ' (checked ' + rel(state.now - u.checked_at) + ')' : '') + '.');
else if (/no update manifest configured/.test(u.error || '')) parts.push('This build has no update address; updates come as a new download.');
else if (u.error) parts.push(u.error);
else if (!u.status || u.status === 'unknown') parts.push('Not checked yet. The engine checks by itself every hour.');
if (u.activation_height && !u.urgent) parts.push('Consensus upgrade at height ' + withCommas(u.activation_height) + '.');
return parts.join(' ');
}
@ -1072,34 +1316,15 @@ if (typeof document !== 'undefined') (function () {
if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.6'; u.version = '0.3.7'; }
return u;
}
// ---------- remote jobs (src/jobrun.rs): the settings block; the strip is Notices.jobNotice ----------
// ---------- remote jobs (src/jobrun.rs): the Updates page; the strip is Notices.jobNotice ----------
var jobTitle = Notices.jobTitle;
function sampleJob(kind, now) {
var j = { allowed: true, key_fingerprint: '8f186e37', url_set: true, checked_at: now - 30, error: '', queued: 0, account: '', active: false, id: '', kind: '', title: '', stage: '', message: '', started_at: 0, run_id: '', results: [], last: {}, last_results: [], history: [] };
if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; }
else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; }
else if (kind === 'failed') { j.last = { id: 'job-2026-10-05-01', kind: 'build', title: 'build', status: 'failed', started_at: now - 2000, finished_at: now - 900, exit: 1, run_id: 'r0', uploaded: false, summary: 'build exited with code 1' }; j.last_results = ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo` in this scope (src/engine.rs:2082)']; }
if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; j.history = [{ id: j.id, kind: j.kind, title: j.title, status: 'running', started_at: j.started_at, finished_at: 0, exit: 0, run_id: 'r2', uploaded: false, summary: '' }]; }
else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; j.history = [j.last]; }
else if (kind === 'failed') { j.last = { id: 'job-2026-10-05-01', kind: 'build', title: 'build', status: 'failed', started_at: now - 2000, finished_at: now - 900, exit: 1, run_id: 'r0', uploaded: false, summary: 'build exited with code 1' }; j.last_results = ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo` in this scope (src/engine.rs:2082)']; j.history = [j.last]; }
return j;
}
function fillJobsSettings(j) {
$('s-jobs-allow').checked = !!j.allowed;
$('s-prove').checked = !!(state.settings && state.settings.prove);
$('s-trust').checked = !!(state.settings && state.settings.proof_verify_trust);
$('s-sweep').checked = !!(state.settings && state.settings.sweep);
$('s-jobs-key').textContent = j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : '';
var parts = [];
if (j.account) parts.push(j.account);
if (!j.allowed) parts.push('Off: nothing runs here until it is switched on.');
else if (!j.url_set) parts.push('No jobs address in this build.');
else if (j.error) parts.push(j.error + '.');
else if (j.active) parts.push('Running ' + jobTitle(j) + ' (' + j.id + ').');
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((state.now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.');
$('s-jobs-note').textContent = parts.join(' ');
var h = j.history || [];
$('s-jobs-history').innerHTML = h.length ? '<table><thead><tr><th>job</th><th>kind</th><th>started</th><th>exit</th><th>report</th></tr></thead><tbody>' + h.map(function (r) {
return '<tr class="' + esc(r.status) + '"><td class="mono" title="' + esc(r.summary || '') + '">' + esc(r.id) + '</td><td>' + esc(r.kind) + '</td><td>' + (r.started_at ? clock(r.started_at) : '') + '</td><td>' + esc(r.status) + (r.status !== 'running' ? ' (' + r.exit + ')' : '') + '</td><td>' + (r.uploaded ? 'uploaded' : (r.status === 'running' ? 'pending' : 'not uploaded')) + '</td></tr>';
}).join('') + '</tbody></table>' : '<p class="note">No job has run on this machine yet.</p>';
}
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; } }
@ -1110,7 +1335,7 @@ if (typeof document !== 'undefined') (function () {
renderNotices(s);
renderUpdateCard(s);
if (phase === 'cards') renderCards(s);
if (phase === 'dashboard') renderDashboard(s);
if (phase === 'dashboard') renderPage(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }
}
var failures = 0, first = true, pollTick = 0;
@ -1123,17 +1348,25 @@ if (typeof document !== 'undefined') (function () {
if (first) {
first = false;
var start = forced || (s.setup_done ? 'dashboard' : 'welcome');
if (start === 'settings') { show('dashboard'); setTimeout(function () { fillSettings(); $('settings').hidden = false; }, 300); }
if (start === 'settings') { page = 'settings'; show('dashboard'); }
else if (start === 'key') { show('address'); showKeySheet({ address: s.address.value || '0x0000000000000000000000000000000000000000', display: s.address.display || '0x0000000000000000000000000000000000000000', private_key: '0x' + '0'.repeat(64), wallet_file: s.address.wallet_file || (s.node_dir.replace(/[^\/\\]+$/, '') + 'app/wallet.json') }); }
else show(start);
if (start === 'cards') renderCards(s);
if (params.get('logs') === '1' && start === 'dashboard') setDrawer(true); // screenshots: the log drawer open
}
render(s);
perf.render += performance.now() - a; perf.polls++;
if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); }
}).catch(function () {
failures++;
if (failures >= 4) { $('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; if (phase === 'dashboard') { $('d-node').textContent = 'no answer'; $('d-node-sub').textContent = 'the engine is not answering; the window reconnects by itself'; $('d-node-cell').classList.add('bad'); } }
if (failures >= 4) {
$('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away';
if (phase === 'dashboard') {
setText('d-node', 'no answer'); setText('d-node-sub', 'the engine is not answering; the window reconnects by itself'); $('n-state-cell').classList.add('bad');
setText('btn-toggle-sub', 'the engine is not answering'); $('btn-toggle').disabled = true;
setText('d-hash-sub', 'no answer from the engine');
}
}
});
}
poll(); setInterval(poll, 1000);

View file

@ -8,24 +8,47 @@
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body class="phase-welcome" data-phase="welcome">
<body class="phase-welcome" data-phase="welcome" data-page="mine">
<!-- the rail: one button per section (miner-ui-2). Shown on the dashboard; the setup screens have no rail. -->
<aside class="rail" id="rail" hidden>
<div class="rail-brand">
<img src="mark.svg" width="28" height="28" alt="">
<span class="word">IGNEUM</span>
</div>
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
<button class="nav" data-page="rewards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Rewards</span></button>
<button class="nav" data-page="node"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/></svg><span>Node</span></button>
<button class="nav" data-page="updates"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5"/><path d="M4 19h16"/></svg><span>Updates</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span></button>
</nav>
<div class="rail-foot">
<div class="rail-status mono" id="rail-status"></div>
<button class="nav small" id="btn-logs" aria-expanded="false" aria-controls="drawer"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span id="btn-logs-text">Logs</span></button>
<button class="nav small danger" id="btn-quit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg><span>Quit</span></button>
<div class="rail-version mono" id="foot-version"></div>
</div>
</aside>
<header class="top">
<div class="brand">
<div class="brand" id="top-brand">
<img src="mark.svg" width="30" height="30" alt="">
<span class="word">IGNEUM</span><span class="miner">MINER</span>
</div>
<div class="page-title" id="page-title" hidden>
<h1 id="page-title-text">Mine</h1>
<span class="page-sub" id="page-sub"></span>
</div>
<div class="top-right">
<span class="top-status mono" id="top-status"></span>
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings" hidden>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"></path></svg>
</button>
</div>
</header>
<!-- the notice strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
it from the state and the content below moves once when it appears or goes. The close control hides a notice
until its state moves on. -->
<!-- the status strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
it from the state (Notices) and the content below moves once when it appears or goes. -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
@ -85,8 +108,8 @@
</div>
</div>
<p class="note" id="cards-note" hidden></p>
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable (an RTX 5090 at full power hard-crashed in the field). This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. The slider sets the cap per card. In the first hour, and then weekly, the efficiency sweep steps the cap from 100% down to 50% on the live program and holds the best MH per watt; a cap you set by hand stays pinned.</p>
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. Identities take turns on the card and each one votes and pays separately; 8 suits a big card, 2 a small one, 1 an integrated GPU.</p>
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable. This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. Settings has a slider per card.</p>
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
<div class="cta">
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
@ -128,64 +151,168 @@
</div>
</section>
<!-- 4. dashboard -->
<!-- 4. the dashboard: six pages behind the rail -->
<section class="screen" id="screen-dashboard">
<div class="strip">
<div class="cell big ember">
<div class="k">hash rate</div>
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
<div class="s" id="d-hash-sub">waiting for the worker</div>
</div>
<div class="cell big">
<div class="k">blocks found</div>
<div class="v" id="d-blocks">0</div>
<div class="s" id="d-blocks-sub">accepted by the node</div>
</div>
<div class="cell big" id="d-node-cell">
<div class="k">node</div>
<div class="v state" id="d-node">starting</div>
<div class="s" id="d-node-sub">opening the database</div>
</div>
<div class="cell big">
<div class="k">next program</div>
<div class="v" id="d-eta">--:--</div>
<div class="s" id="d-eta-sub">waiting for the node</div>
</div>
</div>
<div class="grid">
<div class="col-main">
<div class="card">
<div class="card-head">
<div class="eyebrow"><span class="dot small"></span>your blocks</div>
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>dev fee <b id="d-fee">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
</div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
<!-- Mine -->
<section class="page" id="page-mine" data-page="mine">
<div class="hero-row">
<button class="toggle-big" id="btn-toggle" disabled>
<span class="ring"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg></span>
<span class="tl" id="btn-toggle-text">Start mining</span>
<span class="ts" id="btn-toggle-sub">waiting for the engine</span>
</button>
<div class="cell big ember">
<div class="k">hash rate</div>
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
<div class="s" id="d-hash-sub">waiting for the worker</div>
</div>
<div class="card">
<div class="card-head"><h3>Cards</h3><div class="eyebrow" id="d-cards-eyebrow">1 worker</div></div>
<div class="gpu-tiles" id="d-cards"><div class="empty">No card yet.</div></div>
<div class="gpu-off mono" id="d-cards-off" hidden></div>
<div class="cell big">
<div class="k">blocks found</div>
<div class="v" id="d-blocks">0</div>
<div class="s" id="d-blocks-sub">accepted by the node</div>
</div>
<div class="cell big">
<div class="k">next program</div>
<div class="v" id="d-eta">--:--</div>
<div class="s" id="d-eta-sub">waiting for the node</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-head"><h3>Your GPUs</h3><div class="eyebrow" id="d-cards-eyebrow">detecting</div></div>
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
<p class="note" id="d-cards-note" hidden></p>
</div>
<div class="card">
<div class="card-head">
<h3>Your blocks</h3>
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>dev fee <b id="d-fee">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
</div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
</div>
<div class="card">
<div class="card-head"><h3>Activity</h3><div class="eyebrow">newest first</div></div>
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
</div>
</section>
<!-- Prove -->
<section class="page" id="page-prove" data-page="prove" hidden>
<div class="card lead-card">
<div class="lead-row">
<div class="lead-text">
<h3>Prove shards on this machine</h3>
<p class="help">Every block on Igneum is turned into a short mathematical proof, in pieces called shards. The chain assigns shards to your keys; this machine proves them and is paid for each one.</p>
</div>
<label class="switch lg" title="Prove assigned shards"><input type="checkbox" id="s-prove" aria-label="Prove shards on this machine"><span class="track"></span></label>
</div>
<p class="note" id="pv-note">Off. Switch it on and this machine proves the shards the chain assigns to its keys.</p>
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
</div>
<div class="strip four">
<div class="cell"><div class="k">state</div><div class="v state" id="pv-state">off</div><div class="s" id="pv-state-sub">not proving</div></div>
<div class="cell"><div class="k">assigned</div><div class="v" id="pv-assigned">0</div><div class="s">shards given to your keys</div></div>
<div class="cell"><div class="k">proven</div><div class="v" id="pv-submitted">0</div><div class="s">proofs sent to the node</div></div>
<div class="cell"><div class="k">paid</div><div class="v" id="pv-paid">0</div><div class="s" id="pv-paid-sub">shards paid out</div></div>
</div>
<div class="grid2">
<div class="card">
<div class="card-head"><h3>Node</h3><div class="eyebrow" id="d-node-net">devnet v4</div></div>
<div class="card-head"><h3>Verifier</h3><div class="eyebrow">the node's check</div></div>
<div class="big-word" id="pv-verifier">not read yet</div>
<p class="help" id="pv-verifier-help">Before a proof counts, the node checks it. A node without a verifier passes proofs along and never includes them.</p>
<p class="note" id="pv-verifier-note" hidden></p>
</div>
<div class="card">
<div class="card-head"><h3>Program</h3><div class="eyebrow">pinned guest</div></div>
<div class="field">
<div class="k">shard program id</div>
<div class="box mono"><span id="pv-program">not read yet</span><button class="btn tiny" data-copy="pv-program">Copy</button></div>
</div>
<div class="field">
<div class="k">aggregator id</div>
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
</div>
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
</div>
</div>
</section>
<!-- Rewards -->
<section class="page" id="page-rewards" data-page="rewards" hidden>
<div class="card">
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
<p class="help" id="r-address-help">Every block this machine finds pays this address.</p>
</div>
<div class="strip three">
<div class="cell"><div class="k">blocks found</div><div class="v" id="r-blocks">0</div><div class="s">lifetime, accepted by the node</div></div>
<div class="cell"><div class="k">this run</div><div class="v" id="r-session">0</div><div class="s" id="r-session-sub">since the app started</div></div>
<div class="cell"><div class="k">balance</div><div class="v dim" id="r-balance">--</div><div class="s">shown in the wallet, not here yet</div></div>
</div>
<div class="grid2">
<div class="card" id="r-key-card">
<div class="card-head"><h3>Save your key</h3><div class="eyebrow ember">once</div></div>
<p class="help" id="r-key-help">The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe: anyone with the key can spend what the address holds.</p>
<div class="row" id="r-key-row"><button class="btn small" id="s-reveal">Show my key</button><button class="btn small ghost" id="s-hide" hidden>Hide</button></div>
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
<p class="note mono small" id="r-key-file"></p>
</div>
<div class="card">
<div class="card-head"><h3>Use another address</h3></div>
<p class="help">Paste an EVM address you control. The miner restarts and pays the new address from the next block.</p>
<div class="row">
<input type="text" class="addr-input mono" id="s-address-input" placeholder="0x" spellcheck="false" autocomplete="off" aria-label="New rewards address">
<button class="btn small" id="s-address-save">Change</button>
</div>
<div class="err" id="s-address-err" hidden></div>
<p class="note" id="r-devfee-line"></p>
<div class="row"><button class="btn small ghost" id="r-wallet">Open the wallet page</button></div>
</div>
</div>
</section>
<!-- Node -->
<section class="page" id="page-node" data-page="node" hidden>
<div class="strip four">
<div class="cell" id="n-state-cell"><div class="k">node</div><div class="v state" id="d-node">starting</div><div class="s" id="d-node-sub">opening the database</div></div>
<div class="cell"><div class="k">height</div><div class="v" id="n-blocks">0</div><div class="s" id="n-blocks-sub">blocks this node holds</div></div>
<div class="cell"><div class="k">peers</div><div class="v" id="n-peers">0</div><div class="s" id="n-peers-sub">other nodes it talks to</div></div>
<div class="cell"><div class="k">version</div><div class="v small" id="n-version">--</div><div class="s" id="d-node-net">devnet v4</div></div>
</div>
<div class="clock-card" id="n-clock" hidden>
<p class="clock-msg" id="n-clock-msg"></p>
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
<p class="note" id="n-clock-hint"></p>
</div>
<div class="grid2">
<div class="card">
<div class="card-head"><h3>Chain</h3><div class="eyebrow" id="n-reading">reading</div></div>
<div class="kv">
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span></div>
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span></div>
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span></div>
<div><span class="k">daa score</span><span class="v mono" id="n-daa">0</span></div>
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span></div>
</div>
<div class="clock-card" id="n-clock" hidden>
<p class="clock-msg" id="n-clock-msg"></p>
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
<p class="note" id="n-clock-hint"></p>
<p class="help">Headers arrive before blocks. The DAA score counts blocks the whole network made; the difficulty is how hard the next one is to find.</p>
</div>
<div class="card">
<div class="card-head"><h3>Consensus</h3><div class="eyebrow">the rules</div></div>
<div class="field">
<div class="k">digest</div>
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
<p class="help">The fingerprint of the rules this node runs. Every node on the network shows the same one; a peer with another is refused.</p>
</div>
<div class="field">
<div class="k">next switch</div>
<div class="big-word" id="n-switch">none planned</div>
<p class="help" id="n-switch-help">A switch is a planned rule change. The node applies it by itself when the chain reaches that height.</p>
<div class="switch-list mono" id="n-switches"></div>
</div>
<p class="note" id="n-note"></p>
</div>
<div class="card">
<div class="card-head"><h3>Finality</h3><div class="eyebrow">miner-only</div></div>
@ -194,27 +321,93 @@
<div><span class="k">age</span><span class="v mono" id="f-age">n/a</span></div>
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span></div>
</div>
<p class="note" id="f-note">Locks appear once the miner votes on checkpoints.</p>
</div>
<div class="tile" id="tile-proving">
<div class="h">Proving</div>
<div class="kv">
<div><span class="k">state</span><span class="v mono" id="pv-state">off</span></div>
<div><span class="k">assigned</span><span class="v mono" id="pv-assigned">0</span></div>
<div><span class="k">submitted</span><span class="v mono" id="pv-submitted">0</span></div>
<div><span class="k">paid</span><span class="v mono" id="pv-paid">0</span></div>
<div><span class="k">verifier</span><span class="v mono" id="pv-verifier">not read yet</span></div>
</div>
<p class="note" id="pv-note">Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.</p>
<p class="note" id="pv-verifier-note"></p>
<div class="row" id="pv-setup-row" hidden><button class="btn small" id="pv-setup">Set up</button></div>
<p class="help" id="f-note">A lock is a point the miners have agreed can never be undone. This machine votes on one every 30 s.</p>
</div>
<div class="card">
<div class="card-head"><h3>Events</h3><div class="eyebrow">newest first</div></div>
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
<div class="card-head"><h3>Sync</h3><div class="eyebrow" id="n-sync-eyebrow"></div></div>
<div class="big-word" id="n-sync-word">starting</div>
<p class="help" id="n-note"></p>
</div>
</div>
</div>
</section>
<!-- Updates -->
<section class="page" id="page-updates" data-page="updates" hidden>
<div class="card lead-card">
<div class="lead-row">
<div class="lead-text">
<h3 id="s-version">Igneum Miner</h3>
<p class="help" id="s-update-note">Not checked yet.</p>
</div>
<div class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check now</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself</span></label>
<p class="help">Downloads in the background and installs at a quiet moment, never mid-program. Off: it downloads, then waits for Install now.</p>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3>Remote jobs</h3>
<p class="help">Igneum publishes signed jobs (a benchmark, a script, logs to collect) next to the update manifest. This machine runs each one once and reports back. Only jobs signed by Igneum's key run.</p>
</div>
<button class="btn small" id="s-jobs-check">Check now</button>
</div>
<p class="note" id="s-jobs-note"></p>
<div class="job-history" id="s-jobs-history"></div>
<p class="note mono small" id="s-jobs-key"></p>
</div>
</section>
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<div class="card">
<div class="card-head"><h3>Graphics cards</h3><div class="eyebrow" id="s-cards-eyebrow"></div></div>
<div class="set-cards" id="s-cards"><div class="empty">No card yet.</div></div>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Find each NVIDIA card's best efficiency</span></label>
<p class="help">Once after install, then weekly, the power cap steps from 100% down to 50% and holds the step with the most hashes per watt. A cap you set by hand is left alone.</p>
</div>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
<p class="help">The miner opens when you sign in and keeps mining in the background.</p>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum</span></label>
<p class="help">Signed jobs from Igneum run on this machine and report back. Updates shows what ran.</p>
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
<p class="help">Your miner signs a checkpoint every 30 s. Votes are what lock the chain; leave it on.</p>
<div class="field">
<div class="k">name</div>
<div class="row">
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
<button class="btn small" id="s-name-save">Rename</button>
</div>
<p class="help">A label for you only. Keys come from the machine id <span class="mono" id="s-mid"></span>, never from the name.</p>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Dev fee</h3></div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span id="s-devfee-text">Dev fee 1% (1 block in 100)</span></label>
<p class="help" id="s-devfee-note">One block in 100 is mined for the miner software's author, the same way every GPU miner takes a fee. The protocol itself takes nothing. This switch turns it off.</p>
</div>
<div class="card">
<div class="card-head"><h3>Logs</h3></div>
<div class="row wrap">
<button class="btn small" id="s-log-copy">Copy the log</button>
<button class="btn small ghost" id="s-log-open">Show the log</button>
</div>
<p class="help">Copies the last lines the node and the miner wrote, for a support message. Show the log opens the drawer with every line.</p>
<p class="note mono small" id="s-log-dir"></p>
<p class="note mono small" id="s-node-dir"></p>
</div>
<details class="card adv" id="s-advanced">
<summary><h3>Advanced</h3><span class="eyebrow">devnet tools</span></summary>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them</span></label>
<p class="help" id="s-trust-note">Devnet only. When no verifier is found next to the engine, the node includes proof records it never checked. A found verifier always wins. Changing this restarts the node.</p>
<div class="row"><button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button></div>
</details>
</section>
</section>
</main>
@ -252,7 +445,7 @@
<div class="k">private key</div>
<div class="box mono key"><span id="key-private"></span><button class="btn tiny" data-copy="key-private">Copy</button></div>
</div>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Settings can show it again.</p>
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Rewards can show it again.</p>
<p class="note">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.</p>
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
<div class="cta">
@ -261,75 +454,6 @@
</div>
</div>
<!-- settings -->
<div class="panel-wrap" id="settings" hidden>
<aside class="panel">
<div class="panel-head"><h3>Settings</h3><button class="icon-btn" id="btn-settings-close" aria-label="Close">&times;</button></div>
<div class="panel-body">
<div class="field">
<div class="k">rewards address</div>
<div class="box mono"><span id="s-address"></span><button class="btn tiny" data-copy="s-address">Copy</button></div>
<div class="row">
<input type="text" class="addr-input mono" id="s-address-input" placeholder="paste a new address" spellcheck="false" autocomplete="off">
<button class="btn small" id="s-address-save">Change</button>
</div>
<div class="err" id="s-address-err" hidden></div>
<button class="btn small ghost" id="s-reveal" hidden>Show my key</button>
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span id="s-devfee-text">Dev fee 1% (1 block in 100)</span></label>
<p class="note" id="s-devfee-note">The miner software's fee, the same way every GPU miner takes one. The protocol takes none. This switch turns it off.</p>
</div>
<div class="field">
<div class="k">this machine</div>
<div class="row">
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false">
<button class="btn small" id="s-name-save">Rename</button>
</div>
<p class="note">A label for you only. Keys and labels come from the machine id <span class="mono" id="s-mid"></span>, never from the computer name.</p>
</div>
<div class="field">
<div class="k">cards</div>
<div class="cards compact" id="s-cards"></div>
<div class="row between"><p class="note">Switch a card on or off, set its identities. Only that card's worker restarts; the node keeps running.</p><button class="btn small" id="s-cards-save">Apply</button></div>
</div>
<div class="field">
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
<label class="switch"><input type="checkbox" id="s-prove"><span class="track"></span><span>Prove assigned shards (proving v0; on a Mac the CPU prover is slow)</span></label>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them (devnet only)</span></label>
<p class="note" id="s-trust-note">Only when no verifier is found next to the engine: the node then includes proof records it never checked. Never on a testnet. A found verifier always wins. Changing this restarts the node.</p>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
</div>
<div class="field">
<div class="k">efficiency sweep</div>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Find each NVIDIA card's best MH per watt (once after install, then weekly)</span></label>
<p class="note">On the live program, never restarting the worker: the cap steps from 100% of the card's default limit down to 50%, 15 s to settle and 60 s to measure per step, then holds the step with the most MH per watt. One administrator prompt per sweep. It stops at once if the card faults, a remote job takes the GPU, or the hour boundary is near. A cap you set with the slider is pinned: the sweep records, but leaves it. "Sweep now" on a card's tile runs one at any time; the table is in the log (SWEEP lines).</p>
</div>
<div class="field">
<div class="k">version</div>
<div class="row between"><span class="mono" id="s-version"></span><span class="row"><button class="btn small primary" id="s-install" hidden>Install now</button><button class="btn small" id="s-update">Check now</button></span></div>
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself at a safe moment</span></label>
<p class="note" id="s-update-note"></p>
</div>
<div class="field">
<div class="k">remote jobs</div>
<div class="row between"><label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum (signed)</span></label><button class="btn small" id="s-jobs-check">Check now</button></div>
<p class="note">Igneum publishes signed jobs (a benchmark, a script, a file to fetch, logs to collect, a restart) next to the update manifest. This machine runs each one once and reports to the Igneum log intake. Only jobs signed by the key below run; nothing else can send one.</p>
<p class="note mono small" id="s-jobs-key"></p>
<p class="note" id="s-jobs-note"></p>
<div class="job-history" id="s-jobs-history"></div>
</div>
<div class="field">
<div class="k">folders</div>
<p class="note mono small" id="s-node-dir"></p>
<p class="note mono small" id="s-log-dir"></p>
</div>
<div class="field">
<button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button>
</div>
</div>
</aside>
</div>
<!-- the log drawer: chips filter by source, search filters by text, the ruler and the jump controls move in time.
The list is virtualised (fixed row height, only the visible rows are in the DOM) so 20,000 lines scroll smoothly. -->
<div class="drawer" id="drawer" aria-label="Logs">
@ -357,6 +481,7 @@
<button class="chip" id="log-wrap" title="Wrap long lines (up to 5,000 lines)">wrap</button>
<button class="chip" id="log-copy" title="Copy the lines in view">copy</button>
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
<button class="chip" id="log-close" title="Close the log">close</button>
</div>
</div>
<div class="log-ruler" id="log-ruler" title="Click to jump"><span class="t0 mono" id="log-ruler-0"></span><span class="t1 mono" id="log-ruler-1"></span><i class="win" id="log-ruler-win"></i></div>
@ -367,18 +492,6 @@
<button class="btn small log-jump" id="log-jump" hidden><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"></path></svg><span id="log-jump-text">Newest</span></button>
</div>
<footer class="bottom" id="bottom" hidden>
<div class="left">
<button class="btn small" id="btn-pause">Pause</button>
<button class="btn small ghost" id="btn-logs" aria-expanded="false" aria-controls="drawer"><span id="btn-logs-text">Logs</span><svg class="chev" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 15 6-6 6 6"></path></svg></button>
</div>
<div class="mid mono" id="foot-status" aria-live="off"></div>
<div class="right">
<span class="mono dim" id="foot-version"></span>
<button class="btn small ghost danger" id="btn-quit">Quit</button>
</div>
</footer>
<div class="toast" id="toast" hidden></div>
<script src="app.js"></script>
</body>

View file

@ -0,0 +1,182 @@
// node --test app/igneum-app/ui/view.test.mjs (no dependencies; CI runs it in the site job)
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
// the pure blocks execute) and checks the words each page shows for a given state (miner-ui-2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
test('the six sections and their order', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'prove', 'rewards', 'node', 'updates', 'settings']);
assert.equal(V.page('node').title, 'Node');
assert.equal(V.page('nonsense').id, 'mine');
});
test('a GPU row: name, kind, the numbers where known, the switch', () => {
const r = V.cardRow(card());
assert.equal(r.name, 'NVIDIA GeForce RTX 5090');
assert.equal(r.kindWord, 'Discrete');
assert.equal(r.integrated, false);
assert.equal(r.on, true);
assert.equal(r.word, 'mining');
assert.equal(r.tone, 'on');
assert.equal(r.hash, '124');
assert.equal(r.temp, '61 °C');
assert.equal(r.tempTone, '');
assert.equal(r.power, '410 W');
assert.equal(r.cap, 460);
assert.equal(r.meta, '32 GB · 3 blocks · next program ready');
assert.equal(r.canToggle, true);
});
test('a GPU row: temperatures turn amber then red; unknown numbers are empty', () => {
assert.equal(V.cardRow(card({ temp_mem: 92 })).tempTone, 'warm');
assert.equal(V.cardRow(card({ temp_mem: 96 })).tempTone, 'hot');
assert.equal(V.cardRow(card({ temp_gpu: 93 })).tempTone, 'hot');
const apple = V.cardRow(card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072, power_w: 0, power_default_w: 0, temp_gpu: 0, temp_mem: 0, telemetry_at: 0, hash_now: 7.4, accepted: 0, prepared: false }));
assert.equal(apple.kindWord, 'Apple silicon');
assert.equal(apple.hash, '7.4');
assert.equal(apple.temp, '');
assert.equal(apple.power, '');
assert.equal(apple.cap, 0);
assert.equal(apple.meta, '128 GB unified');
});
test('an integrated GPU is shown as integrated and off, with its reason', () => {
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
assert.equal(r.integrated, true);
assert.equal(r.kindWord, 'Integrated');
assert.equal(r.on, false);
assert.equal(r.word, 'off');
assert.equal(r.tone, 'off');
assert.equal(r.hash, '');
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
});
test('the big button: start when paused, stop when mining, disabled with no card on', () => {
const m = (over) => ({ state: 'mining', paused: false, cards: [card()], ...over });
assert.deepEqual(V.toggle(m(), { synced: true }, {}), { label: 'Stop mining', sub: 'mining on 1 of 1 card', cls: 'stop', disabled: false, act: 'pause' });
assert.deepEqual(V.toggle(m({ state: 'paused', paused: true }), { synced: true }, {}), { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' });
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
assert.equal(none.disabled, true);
assert.equal(none.act, '');
assert.equal(none.sub, 'switch a GPU on below first');
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
});
test('the node words: synced, syncing with a percentage, failed, a blocked clock', () => {
const n = (over) => ({ state: 'synced', blocks: 135200, headers: 135200, peers: 3, daa: 140000, last_reading_age_s: 4, message: '', restart_in_s: 0, ...over });
const ok = V.nodeWords(n(), { severity: 'none' }, '');
assert.equal(ok.word, 'synced');
assert.equal(ok.tone, 'ok');
assert.match(ok.line, /every block/);
const sy = V.nodeWords(n({ state: 'syncing', blocks: 50000, headers: 100000 }), { severity: 'none' }, 'about 3 min left at 300 blocks/s');
assert.equal(sy.word, 'syncing');
assert.equal(sy.line, 'about 3 min left at 300 blocks/s · 50,000 of 100,000 (50%)');
assert.equal(V.nodeWords(n({ state: 'failed', message: 'igneumd could not start' }), { severity: 'none' }, '').tone, 'bad');
const blocked = V.nodeWords(n(), { severity: 'block', skew_s: -75 }, '');
assert.equal(blocked.tone, 'bad');
assert.match(blocked.line, /clock is off by 75 s/);
assert.equal(V.peersLine(n({ peers: 0 })), 'none yet: looking for the seed node');
assert.equal(V.peersLine(n({ peers: 1 })), 'one other node this one talks to');
assert.equal(V.heightLine(n({ state: 'syncing', blocks: 10, headers: 500 })), 'of 500 headers seen');
assert.equal(V.heightLine(n()), 'blocks this node holds');
});
test('the next consensus switch is the first height above the DAA score, in plain words', () => {
const sw = [
{ key: 'fees_v1_activation_daa', name: 'Fees v1', daa: 210000 },
{ key: 'difficulty_v2_activation_daa', name: 'Difficulty v2', daa: 33000 },
{ key: 'finality_v3_activation_daa', name: 'Finality v3', daa: 135200 }
];
const next = V.nextSwitch(sw, 140000);
assert.equal(next.name, 'Fees v1');
assert.equal(next.away, 70000);
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
assert.equal(V.nextSwitch(sw, 300000), null);
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
assert.equal(V.nextSwitch([], 5), null);
});
test('the prove words follow the switch, the setup, the node and the status', () => {
const pv = (over) => ({ enabled: true, available: true, setup_hint: '', backend: 'cuda', status: 'idle', message: '', current: '', verifier_mode: 'command', pool_entries: 4, pool_verified: 4, pool_failed: 0, ...over });
assert.equal(V.proveWords(pv(), false, true).word, 'off');
assert.equal(V.proveWords(pv({ status: 'setup', available: false, setup_hint: 'proving needs the WSL2 setup' }), true, true).word, 'needs setup');
assert.equal(V.proveWords(pv(), true, false).word, 'waiting');
assert.equal(V.proveWords(pv({ status: 'proving', current: 'block 59199 shard 0' }), true, true).sub, 'block 59199 shard 0');
assert.equal(V.proveWords(pv({ status: 'submitted' }), true, true).tone, 'on');
assert.equal(V.proveWords(pv(), true, true).word, 'idle');
assert.equal(V.verifierWords(pv()).word, 'verifying · pool 4/4');
assert.equal(V.verifierWords(pv()).tone, 'ok');
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', pool_entries: 0 })).tone, 'warn');
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', verifier_reason: 'the WSL2 prover is not installed' })).needsSetup, true);
assert.equal(V.verifierWords({}).word, 'not read yet');
});
test('the dev-fee lines name the share and where Settings turns it off', () => {
const s = (on, fee) => ({ settings: { dev_fee: on }, mining: { fee_total: fee }, dev_fee: { on, percent: on ? 1 : 0, address: '0x1234567890abcdef1234567890abcdef12345678', line: '' } });
assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). Settings turns it off.');
assert.equal(V.devFeeLine(s(false, 0)), 'The dev fee is off. Every block pays this address.');
assert.equal(V.devFeeText(s(true, 0)), 'Dev fee 1% (1 block in 100) to 0x123456…5678');
assert.match(V.devFeeText(s(false, 0)), /^Dev fee off/);
});
test('the remote-jobs line and the helpers', () => {
const title = (j) => j.title || j.kind;
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until Settings allows remote jobs.');
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
assert.equal(V.shortHex('0xabc'), '0xabc');
assert.equal(V.withCommas(1234567), '1,234,567');
assert.equal(V.compact(2500000), '2.50M');
assert.equal(V.rel(90), '1 min ago');
});
test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as such, with no switch, and leave the counts', () => {
const gone = card({ key: 'amd:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: 1000, added_at: 0, gone: false, hash_now: 0 });
const r = V.cardRow(gone);
assert.equal(r.removed, true);
assert.equal(r.on, false);
assert.equal(r.canToggle, false);
assert.equal(r.word, 'removed');
assert.equal(r.tone, 'off');
assert.equal(r.hash, '');
assert.match(r.sub, /^unplugged; its worker stopped/);
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
const b = V.cardRow(bad);
assert.equal(b.unusable, true);
assert.equal(b.canToggle, false);
assert.equal(b.word, 'not usable (Code 43)');
assert.equal(b.tone, 'bad');
assert.match(b.sub, /^reboot with the card attached/);
assert.equal(b.title, 'gfx1201 · CUDA device 1 · AMD Accelerated Parallel Processing · bus 0000:03:00.0');
assert.equal(V.cardRow(card()).title, 'CUDA device undefined'.replace(' device undefined', '') === '' ? '' : V.cardRow(card()).title);
// a row that is gone (five minutes after removal) is not shown at all; the big button counts only present cards
assert.deepEqual(V.shownCards([card(), card({ key: 'x', gone: true })]).map((c) => c.key), ['nvidia:0:RTX 5090']);
assert.equal(V.present(card()), true);
assert.equal(V.present(gone), false);
assert.equal(V.present(bad), false);
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
assert.equal(t.disabled, true);
assert.equal(t.sub, 'no GPU this app can drive');
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
assert.equal(t2.sub, 'mining on 1 of 1 card');
});

View file

@ -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

View file

@ -285,6 +285,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
case WM_SIZE:
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
return 0;
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
return 0;
case WM_ENGINE_LINE: {
if (wp == 1) {
g_exited = true;

171
docs/plans/miner-ui-2.md Normal file
View file

@ -0,0 +1,171 @@
# Igneum Miner UI 2: sections behind a rail, 5 October 2026
Josh, 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. Josh approved the redesign from the screenshots (20:1x UTC) and moved it into 0.3.10: the 0.3.10 shipper merges
`miner-ui-2` last and rebuilds the app. Times are UTC.
## 1. What changed
| Where | What |
|---|---|
| `app/igneum-app/ui/index.html` | One page became a rail with six sections: Mine, Prove, Rewards, Node, Updates, Settings. The welcome, GPU and address screens and the one-time key sheet stay as they were. The settings panel, the bottom bar and the proving tile are gone; every control they held is placed on a section. |
| `app/igneum-app/ui/app.css` | Rewritten around the rail, the thin top bar, the status strip and the pages. Same tokens as the site (obsidian, graphite, ember, molten, bone, ash; Unbounded, IBM Plex Sans, IBM Plex Mono). One accent. Lays out from 900 x 600: under 1000 px the rail folds to icons, the number strips to two columns, the two-column grid to one. Focus rings on every control (`:focus-visible`, the switch tracks). |
| `app/igneum-app/ui/app.js` | New pure block `View` (the words every section shows for a state), tested by `view.test.mjs`. The Notices and UpdateCard blocks, the log drawer and the blocks canvas are unchanged. `?page=<name>` 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 <png> --url <the
test engine> --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 Josh wants a timer |
## 5. The 0.3.10 merge: gpu-hotplug (4d122e1) resolved on the new UI
The branch was merged with `gpu-hotplug` 4d122e1 (the 0.3.10 tree carries it). The engine, relay and tooling files
merged by themselves; `app.js` and `app.css` were taken from this branch and the hot-plug UI ported by hand:
| Hot-plug state | On the six-section UI |
|---|---|
| The strip notices (card added, mining; new card not usable (Code 43); card removed) | the Notices block is gpu-hotplug's, unchanged; `notices.test.mjs` (12) passes |
| A card the OS reports a problem on (`problem`, "Code 43") | Mine row: name in ember, the word "not usable (Code 43)", the reboot hint under it, the switch disabled; the Mine note repeats the hint; first-run row and Settings card say the same, with no controls |
| A removed card (`removed_at`) | Mine row dimmed, the word "removed", "unplugged; its worker stopped. The row goes in five minutes."; no switch; Settings card says the same; after five minutes (`gone`) the row is not shown |
| The counts and the big button | only present cards count (a removed or faulty card never makes the button say "Stop mining") |
| The name tooltip | the tool's code (gfx1201), the device index, the OpenCL platform, the PCI address (`View.cardTitle`) |
| The card list sent on a switch | removed and faulty cards are left out, as gpu-hotplug's `readCardRows` does |
`view.test.mjs` has an eleventh test for the two states. The whole app crate's unit tests pass on the merged tree
(99, on the Mac under the build lock, `cargo test --release --bin igneum-app`). `relay/test/parse.test.mjs`
passes. The Mac engine builds and runs (the same scratch instance as section 3).
Build tooling for the Windows compile: `packaging/windows/push-build-inputs.sh --no-node` and
`node tools/build-job.mjs run --no-node` pack and build the app engine only (no node source, no node build, no node
tests), so an app-only change compiles on PC 1 in a fraction of the full job.
PC 1 job (20:21Z, from this tree at d62b445, `node tools/build-job.mjs run --target ae432dc7 --no-node --targets
windows --no-tests --no-place`): extract 167 files, `windows app/igneum-app build exit 0 6 s` (the PC's target dir
was warm), `igneum-app.exe` 2,970,112 bytes sha256 62ce96a9..., PE check ok, coin icon and version block ok, done
after 13 s. The exe carries the new UI and engine strings ("Prove shards on this machine" x2, `consensus_switches`
x6, "Consensus params digest:" x1, `card:removed:` x1), so the 6 s was a real compile of the merged sources, with
gpu-hotplug's Windows-only `detect::adapters` path and this branch's prover change in it. Downloads under the session
scratchpad (`pc1-app/`), not placed.
The ship tool runs no node tests of its own (`tools/ship-app.mjs --self-test` is the version bump), so the only
list to carry `view.test.mjs` is `.github/workflows/ci.yml` (done in 83a293f).
What the PC job does not compile: `app/windows/host.cpp` (the window host with the `WM_GETMINMAXINFO` and
`WM_DEVICECHANGE` cases) is built by the GitHub runner (`windows.yml`) at the shipper's push, never on a PC; the
`WM_GETMINMAXINFO` case is three lines of plain Win32 (`MINMAXINFO`, `ptMinTrackSize`) and reads at the runner's log.
## 6. The changelog paragraph for the 0.3.10 manifest notes (what the user sees)
The miner has six sections behind a rail: Mine, Prove, Rewards, Node, Updates, Settings. Mine has one big Start
mining / Stop mining button, the hash rate, the blocks found, and one row per graphics card with its name, an on/off
switch, its hash rate, temperature and power. Prove says in plain words what proving is and shows the shards
assigned, proven and paid, the verifier and the program ids. Rewards shows the address with one Copy and keeps the
key backup in its own card. Node shows the sync, height, peers and version with a plain line under each, the
consensus digest, and the list of planned rule switches with the next one and its height. Updates holds the
version, Check now and the remote jobs. Settings has one switch or slider per setting with one line of help under
each: the power cap per card with the watt number, identities, start at login, remote jobs, voting, the dev fee
stated plainly, the log. The window lays out from 900 x 600. Not in this release: the balance on Rewards (shown in
the wallet), a "pause while I use the machine" switch, log export as a file, temperature and power on Apple
silicon.
## 7. The branch
| Commit | What |
|---|---|
| 83a293f | the UI pass, the three engine additions with tests, the hosts' minimum size, this plan and the 19 screenshots (the blank marker in a GPU row is `n/a`, not a dash: copy law) |
| 364feef | this plan's hash line |
| d62b445 | Merge gpu-hotplug 4d122e1: the hot-plug states on the new UI, `--no-node` for the build tools |

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View file

@ -6,11 +6,13 @@
# signed hash is trusted, never the host. Nothing secret goes in: the jobs file is public.
#
# packaging/windows/push-build-inputs.sh [--node <fork worktree, default vendor/igneum-node-v4>]
# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-deploy] [--out <zip>]
# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-node] [--no-deploy] [--out <zip>]
# [--name <basename.zip>] the zip's name in the downloads folder (default build-inputs.zip; build-job.mjs gives
# every job its own name since 5 October 2026 night: with one shared name a job
# published while another agent's pack landed pinned THAT agent's sources, three
# times in one evening; zips older than two days are pruned here)
# --no-node packs and builds the app engine only (a UI or engine change with no node change: miner-ui-2, 5 October
# 2026); the manifest's node block says "none" and the builds list has no node entry.
#
# What goes in (under igneum-build-inputs/):
# manifest.json created_at, node {branch, commit, dirty, source}, repo {branch, commit, dirty}, app_version,
@ -43,6 +45,7 @@ while [ $# -gt 0 ]; do
--node-tests) NODE_TESTS="$2"; shift 2 ;;
--app-tests) APP_TESTS="$2"; shift 2 ;;
--no-app) WITH_APP=0; shift ;;
--no-node) WITH_NODE=0; shift ;;
--no-deploy) DEPLOY=0; shift ;;
--out) OUT="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
@ -50,7 +53,8 @@ while [ $# -gt 0 ]; do
esac
done
case "$NODE_SRC" in /*) ;; *) NODE_SRC="$ROOT/$NODE_SRC" ;; esac
[ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; }
[ "${WITH_NODE:-1}" = 0 ] || [ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; }
[ "${WITH_NODE:-1}" = 1 ] || [ "$WITH_APP" = 1 ] || { echo "--no-node with --no-app packs nothing" >&2; exit 2; }
[ -f "$ROOT/app/igneum-app/Cargo.toml" ] || { echo "no app crate at $ROOT/app/igneum-app" >&2; exit 1; }
[ -f "$ROOT/brand/icons/igneum.ico" ] || { echo "no $ROOT/brand/icons/igneum.ico (python3 brand/icons/make-icons.py)" >&2; exit 1; }
command -v rsync >/dev/null || { echo "rsync is needed" >&2; exit 1; }
@ -85,8 +89,13 @@ APP_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.t
TMP="$(mktemp -d)"
STAGE="$TMP/igneum-build-inputs"
mkdir -p "$STAGE/node" "$STAGE/app" "$STAGE/brand"
echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))"
rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/"
if [ "${WITH_NODE:-1}" = 1 ]; then
echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))"
rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/"
else
echo "no node (--no-node): the app engine only"
NODE_BRANCH=none; NODE_COMMIT=none; NODE_DIRTY=false
fi
if [ "$WITH_APP" = 1 ]; then
echo "packing app/igneum-app ($APP_VERSION) and brand/icons"
rsync -a --exclude 'target' --exclude '.DS_Store' "$ROOT/app/igneum-app/" "$STAGE/app/igneum-app/"
@ -99,12 +108,12 @@ rsync -a --exclude 'target' --exclude 'target-*' --exclude '.DS_Store' "$ROOT/ig
echo "packing proto-cuda (without nvrtc/redist)"
rsync -a --exclude 'nvrtc/redist' --exclude '.DS_Store' --exclude '*.exe' --exclude '*.dll' "$ROOT/proto-cuda/" "$STAGE/proto-cuda/"
python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" <<'PY'
python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" "${WITH_NODE:-1}" <<'PY'
import json, sys, datetime
out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests = sys.argv[1:13]
builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}]
out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests, with_node = sys.argv[1:14]
builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}] if with_node == "1" else []
tests = []
if node_tests.strip():
if node_tests.strip() and with_node == "1":
tests.append({"dir": "node", "packages": node_tests.split()})
if with_app == "1":
builds.append({"dir": "app/igneum-app", "packages": ["igneum-app"], "bins": ["igneum-app"], "targets": ["linux", "windows"], "optional_on": ["linux"]})

View file

@ -5,7 +5,7 @@
// and puts them where the packaging scripts look.
// node tools/build-job.mjs run [--node vendor/igneum-node-v4] [--target ae432dc7] [--budget-minutes 40]
// [--stage-minutes '{"linux":20}'] [--targets linux,windows] [--no-tests]
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app]
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app] [--no-node]
// [--title "..."] [--no-place] [--out dir] pack + publish + watch + fetch
// node tools/build-job.mjs publish [the same flags] pack + publish, prints the id
// node tools/build-job.mjs watch <job id> STAGE and RESULT lines as they land
@ -32,7 +32,7 @@ const RELAY_BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'help']);
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'no-node', 'help']);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (!BOOL.has(k) && next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
@ -217,6 +217,7 @@ function publish() {
if (flags['node-tests']) pack.push('--node-tests', String(flags['node-tests']));
if (flags['app-tests']) pack.push('--app-tests', String(flags['app-tests']));
if (flags['no-app']) pack.push('--no-app');
if (flags['no-node']) pack.push('--no-node');
if (flags['no-deploy']) pack.push('--no-deploy');
console.log(`$ ${pack.join(' ')}`);
const r = spawnSync('bash', pack, { cwd: ROOT, stdio: 'inherit' });