diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index c76cbd129..7f865ed0b 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -52,6 +52,10 @@ pub enum Cmd { WorkerBuilt(usize, Result), /// local minus the latest block's timestamp, seconds (from the node's EVM RPC) ClockSample(f64), + /// another node holds the app's ports: what it answered (src/extnode.rs) + ExternalNode(crate::extnode::Check), + /// the node's own description over RPC (igneum_getNodeInfo), every 30 s + NodeInfo(crate::extnode::Check), /// local minus an HTTPS Date header, seconds; None when the check failed ClockHttps(Option), ClockCheck, @@ -124,6 +128,7 @@ impl Shared { st.setup_done = settings.setup_done; st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() }; st.node.state = "stopped".into(); + st.node.tip_age_s = -1.0; st.mining.state = "idle".into(); st.mining.paused = settings.paused; st.mining.accepted_total = settings.accepted_total; @@ -542,6 +547,7 @@ pub struct Engine { lines_tx: Sender, node: Option, node_external: bool, + node_info_at: Instant, node_restart_at: Option, /// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log resume_check_at: Option, @@ -665,6 +671,7 @@ impl Engine { lines_tx, node: None, node_external: false, + node_info_at: Instant::now() - Duration::from_secs(25), node_restart_at: None, resume_check_at: None, node_started_at: now, @@ -956,6 +963,24 @@ impl Engine { } } } + Cmd::ExternalNode(check) => self.external_node_decided(check), + Cmd::NodeInfo(info) => { + if info.pow_engine.as_deref() == Some("stub") { + let first = self.st().node.state != "stub"; + let mut st = self.st(); + st.node.state = "stub".into(); + st.node.synced = false; + st.node.message = "this node was built without the mining engine (stub) and refuses every real block".into(); + drop(st); + if first { self.shared.event("error", "the node runs the stub engine, not igneum-pow: it refuses every real block. Reinstall the app (ledger N5)"); } + } + let mut st = self.st(); + if let Some(d) = info.digest.filter(|d| d.len() >= 16) { + if st.node.consensus_digest != d { st.node.consensus_digest = d; } + st.node.digest_source = "rpc".into(); + } + if let Some(v) = info.version { if !v.is_empty() && st.node.version.is_empty() { st.node.version = v; } } + } Cmd::ClockSample(d) => { self.clock_samples.push(d); if self.clock_samples.len() > 9 { @@ -1441,14 +1466,22 @@ impl Engine { let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir); self.apply_power_limits("start"); if port_open(self.shared.runtime.rpc_port) { - self.node_external = true; - self.shared.event("info", &format!("a node already answers on 127.0.0.1:{}; using it (it is not stopped by this app)", self.shared.runtime.rpc_port)); - let mut st = self.st(); - st.node.state = "syncing".into(); - st.node.message = "external node".into(); - st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into(); - st.proving.verifier_note = crate::verifier::note("unknown", "", "", true); + // another node holds the app's ports: check whose rules it runs before reading it (publish 2's read-back, + // 6 October 2026: the Mac read the hand node silently as its own); the answer comes back as Cmd::ExternalNode + let port = self.shared.runtime.rpc_port; + { + let mut st = self.st(); + st.node.state = "starting".into(); + st.node.source = String::new(); + st.node.message = format!("another node holds port {port} on this machine; checking its rules"); + } + self.shared.log(&format!("port {port} is held by another node; asking it for its chain id and rules before using it")); + let shared = self.shared.clone(); + let evm_port = self.shared.runtime.evm_port(); + std::thread::spawn(move || { shared.send(Cmd::ExternalNode(crate::extnode::probe(evm_port))); }); } else { + crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10); + self.st().node.source = "app".into(); self.start_node(); } self.plan_miners(); @@ -1482,6 +1515,41 @@ impl Engine { /// The packager's consensus parameters (igneum-app.json `node_override_params`, for example the difficulty v2 /// activation height) written to /override-params.json for --override-params-file. None when the /// package pins nothing, so the node runs on the network's defaults as before. + /// The check of another node on the app's ports came back (src/extnode.rs): use it and say so, or refuse it + fn external_node_decided(&mut self, check: crate::extnode::Check) { + let port = self.shared.runtime.p2p_port; + let ours = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::(&t).ok()); + let network = self.shared.runtime.network.clone(); + let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check); + self.node_external = d.use_it; + { + let mut st = self.st(); + st.node.rules_check = d.verdict.into(); + st.node.port_note = d.line.clone(); + } + if d.use_it { + self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line); + let mut st = self.st(); + st.node.source = "external".into(); + st.node.state = "syncing".into(); + st.node.message = "another node on this machine".into(); + if let Some(dg) = check.digest.filter(|x| x.len() >= 16) { st.node.consensus_digest = dg; st.node.digest_source = "rpc".into(); } + if let Some(v) = check.version { if !v.is_empty() { st.node.version = v; } } + st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into(); + st.proving.verifier_note = crate::verifier::note("unknown", "", "", true); + } else { + { + let mut st = self.st(); + st.node.source = "none".into(); + st.node.state = "stopped".into(); + st.node.synced = false; + st.node.message = d.line.clone(); + } + self.shared.event("error", &d.line); + self.shared.event("error", "mining waits: stop the other node or start this app with other ports, then open the app again"); + } + } + fn node_override_file(&self) -> Option { if self.node_override_unusable { return None; @@ -1529,6 +1597,9 @@ impl Engine { let mut st = self.st(); st.node.pid = p.pid(); st.node.state = "starting".into(); + st.node.stall_exits = 0; + st.node.tip_age_s = -1.0; + st.node.sync_cause = String::new(); st.node.starts = self.node_starts; st.node.message = "opening the database".into(); st.node.restart_in_s = 0; @@ -3051,6 +3122,12 @@ impl Engine { self.last_awake = now; crate::platform::keep_awake_tick(); } + if self.running && (self.node_external || self.node.is_some()) && now.duration_since(self.node_info_at) >= Duration::from_secs(30) { + self.node_info_at = now; + let shared = self.shared.clone(); + let evm_port = self.shared.runtime.evm_port(); + std::thread::spawn(move || { let c = crate::extnode::probe(evm_port); if c.digest.is_some() || c.version.is_some() { shared.send(Cmd::NodeInfo(c)); } }); + } if self.running { self.tick_node(now); self.tick_watch(now); @@ -3456,6 +3533,30 @@ impl Engine { } else if verdict != crate::watchdog::Action::None { // exit 43: the miner gave up on its worker; once more, then the card is faulted self.watchdog_verdict(i, verdict, &tail); + } else if is_stall_exit(code, &tail) { + // N4: the miner found no new template for its stall span and stopped hashing and voting. Once: + // the miner restarts and the node gets another look. Twice since the node started: the node + // itself restarts and re-dials its peers (the seed list), because the miner alone cannot fix a + // node that fell off the network. + let n = { let mut st = self.st(); st.node.stall_exits += 1; st.node.stall_exits }; + let label = self.miners[i].label.clone(); + for l in &tail { + self.shared.log(&format!(" miner: {l}")); + } + if n >= 2 { + self.shared.event("error", &format!("{label}: no new block to work on, twice; the node restarts and re-dials its peers")); + self.st().node.stall_exits = 0; + self.restart_node("the miner saw no new block twice", Duration::from_secs(2)); + } else { + let delay = jitter_secs(crate::platform::unix_now() + i as u64); + self.miners[i].restart_at = Some(now + Duration::from_secs(delay)); + self.shared.event("error", &format!("{label}: no new block to work on for the stall span; the miner restarts in {delay} s (a second stall restarts the node)")); + if let Some(c) = self.st().mining.cards.get_mut(card_idx) { + c.state = "restarting".into(); + c.hash_now = 0.0; + c.message = "no new block to work on; waiting for the node".into(); + } + } } else { self.miners[i].restarts += 1; let delay = jitter_secs(crate::platform::unix_now() + i as u64); @@ -3741,6 +3842,7 @@ impl Engine { if st.node.consensus_digest != d { st.node.consensus_digest = d; } + if st.node.digest_source.is_empty() { st.node.digest_source = "log".into(); } return; } if !text.contains("too far into the future") { @@ -3846,6 +3948,8 @@ impl Engine { let blue = kv_u64(text, "blue").unwrap_or(0); let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0); let flag = kv(text, "synced").map(|s| s == "true"); + // N4: the sink header's age from the node (ca3-v4-0316 appends tip_age_s after synced=); -1 on an older node + let tip_age_s = kv(text, "tip_age_s").and_then(|s| s.parse::().ok()).unwrap_or(-1.0); let was_synced = self.st().node.synced; // Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a // clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false). @@ -3858,7 +3962,7 @@ impl Engine { let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0); let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false); let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty(); - let synced = sync_decision(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private); + let (synced, cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s); self.sync_prev = Some(blocks); // the first clock source: local time against the latest block the peers produced, once blocks arrive if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) { @@ -3881,11 +3985,17 @@ impl Engine { st.node.blue = blue; st.node.difficulty = difficulty; st.node.synced = synced; - st.node.state = if synced { "synced".into() } else { "syncing".into() }; + st.node.tip_age_s = tip_age_s; + st.node.sync_cause = cause.to_string(); + // the state word: "behind" for a frozen tip, "no peers" for none, "syncing" for the rest (N4: never + // "synced" on a tip that stopped moving) + st.node.state = if synced { "synced".into() } else if cause == "frozen" { "behind".into() } else if cause == "no peers" { "no peers".into() } else { "syncing".into() }; st.node.message = if synced { String::new() + } else if cause == "frozen" { + format!("no new block for {} s with {peers} peer{}; the chain may have moved on without this node", tip_age_s.round() as i64, if peers == 1 { "" } else { "s" }) } else if peers == 0 { - "waiting for a peer".into() + "waiting for a peer on our chain".into() } else if headers > blocks + 2 { format!("{blocks} of {headers} blocks") } else { @@ -4435,6 +4545,38 @@ pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent: caught_up && (moving || stable_s >= 60.0) } +/// The tip age at which a node that still says "synced" is treated as frozen (ledger N4, 6 October 2026: a home +/// miner's one same-digest peer relayed nothing for 53 minutes while the app read "mining, synced"). +pub const TIP_FROZEN_S: f64 = 120.0; + +/// N4: what "synced" means on every surface. The old decision, AND the tip moved in the last 120 s (the watch +/// line's tip_age_s, the sink header's age; -1 = the node does not report it yet, which gates nothing so an older node +/// keeps today's words), AND at least one peer (the handshake refuses a peer with another digest, so `peers` already +/// counts peers on our chain). Returns (synced, cause): the cause is "" when synced, else "frozen" (a peer, caught up, +/// no new block for over 120 s), "no peers", "syncing", or "behind" (the node's own flag false while the count moves). +pub fn sync_decision_v2(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool, tip_age_s: f64) -> (bool, &'static str) { + if private { + return (true, ""); + } + let frozen = tip_age_s > TIP_FROZEN_S && !accepted_recent; + if r.peers == 0 && !accepted_recent { + return (false, "no peers"); + } + if frozen { + return (false, "frozen"); + } + if sync_decision(r, moving, stable_s, accepted_recent, private) { + return (true, ""); + } + (false, if r.headers > r.blocks + 2 { "syncing" } else { "behind" }) +} + +/// The miner's stall exit (the node lane, ca3-v4-0316): code 45, or a tail line starting "STALLED '". +pub const STALL_EXIT_CODE: i32 = 45; +pub fn is_stall_exit(code: i32, tail: &[String]) -> bool { + code == STALL_EXIT_CODE || tail.iter().any(|l| l.contains(" STALLED '") || l.starts_with("STALLED '")) +} + /// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind. fn behind_from_warning(text: &str) -> Option { let nums: Vec = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::().ok()).collect(); @@ -4593,7 +4735,7 @@ mod tests { assert_eq!(super::power_control_after_prompt(&Err("the elevated step exited with code 2".into())), None); } - use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading}; + use super::{is_stall_exit, sync_decision_v2, digest_from_line, parse_race, switches_of, sync_decision, Reading}; #[test] fn digest_comes_from_the_nodes_own_line_only() { @@ -4665,6 +4807,44 @@ mod tests { assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true)); } + /// Ledger N4 (6 October 2026): a home miner's node kept one same-digest peer that relayed nothing for 53 minutes; + /// today's rule said "synced" the whole time. The known-failed case first, then the fix. + #[test] + fn n4_a_frozen_tip_is_never_synced() { + // the frozen tip: caught up, one peer, the count stable for an hour, getInfo's flag true. Today: synced. + let frozen = r(133_000, 133_000, 1, Some(true)); + assert!(sync_decision(&frozen, false, 3_180.0, false, false), "the old rule says synced on the frozen tip (the N4 failure)"); + // the fix: the tip age gates it + let (ok, cause) = sync_decision_v2(&frozen, false, 3_180.0, false, false, 3_180.0); + assert!(!ok); + assert_eq!(cause, "frozen"); + // the same node moving: synced + assert_eq!(sync_decision_v2(&frozen, true, 0.0, false, false, 4.0), (true, "")); + // exactly at the bound is still synced; one second over is not + assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 120.0), (true, "")); + assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 121.0), (false, "frozen")); + // an older node that does not report the age (-1) keeps today's words + assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, -1.0), (true, "")); + // our own block accepted in the last minute proves the tip moves, whatever the reported age + assert_eq!(sync_decision_v2(&frozen, false, 60.0, true, false, 900.0), (true, "")); + // no peer at all: "no peers", not "frozen" and never synced + assert_eq!(sync_decision_v2(&r(133_000, 133_000, 0, Some(true)), false, 600.0, false, false, 500.0), (false, "no peers")); + // still downloading: "syncing" + assert_eq!(sync_decision_v2(&r(100, 700, 1, Some(false)), true, 0.0, false, false, 2.0), (false, "syncing")); + // caught up but the flag false and the count not yet stable: "behind" + assert_eq!(sync_decision_v2(&r(700, 700, 1, Some(false)), false, 10.0, false, false, 5.0), (false, "behind")); + // a private test node is always synced + assert_eq!(sync_decision_v2(&r(0, 0, 0, None), false, 0.0, false, true, 9_999.0), (true, "")); + } + + #[test] + fn n4_the_stall_exit_is_code_45_or_the_stalled_line() { + assert!(is_stall_exit(45, &[])); + assert!(is_stall_exit(1, &["1791327000.123 STALLED 'nvidia-1ccfe586-1': no new template for 300 s (tip d3dc2f78, daa 197752); the node has fallen off the network or stopped moving; hashing and voting paused, exiting with code 45 so the launcher restarts (and after a second stall, restarts the node)".to_string()])); + assert!(!is_stall_exit(1, &["worker fault: rejected nonce".to_string()])); + assert!(!is_stall_exit(42, &[])); + } + #[test] fn node_warning() { let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000"; diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs new file mode 100644 index 000000000..19612b1a6 --- /dev/null +++ b/app/igneum-app/src/extnode.rs @@ -0,0 +1,192 @@ +//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611, +//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the +//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its +//! consensus overrides (`igneum_getNodeInfo`, the 0.3.17 node lane; "method not found" on an older node) against the +//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using +//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node +//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node +//! too, read every 30 s instead of parsed from a stdout the app does not own. +use serde_json::{json, Value}; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +/// What the other node answered: None where it could not answer. +#[derive(Debug, Default, Clone)] +pub struct Check { + pub chain_id: Option, + pub digest: Option, + pub network: Option, + pub version: Option, + /// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name) + pub overrides: Option, + /// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block) + pub pow_engine: Option, +} + +/// The decision for the port-collision path. +#[derive(Debug, PartialEq)] +pub struct Decision { + pub use_it: bool, + /// match | mismatch | unknown + pub verdict: &'static str, + /// the event line, in a user's words + pub line: String, +} + +/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the +/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known. +pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) } + +/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal. +pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option, our_network: Option<&str>, theirs: &Check) -> Decision { + let mut checked = false; + if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) { + checked = true; + if a != b { + return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") }; + } + } + if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) { + checked = true; + if a != b { + return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") }; + } + } + if theirs.pow_engine.as_deref() == Some("stub") { + return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") }; + } + // every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash) + if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) { + checked = true; + for (k, v) in o { + if t.get(k) != Some(v) { + let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into()); + return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") }; + } + } + } + if checked { + Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") } + } else { + Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (it predates 0.3.17), so the app reads it as it is") } + } +} + +/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same). +pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); + let out = crate::detect::run_timeout( + Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &format!("{}", limit.as_secs().max(1)), "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", &body]), + None, + limit + Duration::from_secs(2), + )?; + let v: Value = serde_json::from_str(&out).ok()?; + v.get("result").cloned().filter(|r| !r.is_null()) +} + +/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (0.3.17). +pub fn probe(evm_port: u16) -> Check { + let mut c = Check::default(); + if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) { + c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok(); + } + if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) { + c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string()); + c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string()); + c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()); + c.overrides = info.get("params").cloned().filter(|v| v.is_object()); + c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + c +} + +/// Keep the newest `keep` files named `...` in `dir`; the rest go (the node and miner logs grow one per start). +pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize { + let Ok(rd) = std::fs::read_dir(dir) else { return 0 }; + let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false)) + .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path()))) + .collect(); + files.sort_by(|a, b| b.0.cmp(&a.0)); + let mut removed = 0; + for (_, p) in files.into_iter().skip(keep) { + if std::fs::remove_file(&p).is_ok() { + removed += 1; + } + } + removed +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) } + + /// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own. + #[test] + fn a_node_on_our_rules_is_used_and_said_so() { + let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() }; + let d = decide(26611, Some(&ours()), Some(7_777), &theirs); + assert!(d.use_it); + assert_eq!(d.verdict, "match"); + assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)"); + } + + #[test] + fn a_node_on_another_network_or_other_rules_is_refused() { + let other_chain = Check { chain_id: Some(7_778), ..Default::default() }; + let d = decide(26611, Some(&ours()), Some(7_777), &other_chain); + assert!(!d.use_it); + assert_eq!(d.verdict, "mismatch"); + assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)"); + let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000); + let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() }); + assert!(!d.use_it); + assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)"); + // a stub engine is refused whatever else matches + assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it); + // an override we have that the other node lacks is a mismatch too + let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() }); + assert!(!d.use_it); + assert!(d.line.contains("exec_restart_hash none"), "{}", d.line); + // the network name (node lane, igneum_getNodeInfo.network) must be equal + let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() }); + assert!(!d.use_it); + assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)"); + assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it); + } + + #[test] + fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() { + let d = decide(26611, Some(&ours()), None, &Check::default()); + assert!(d.use_it); + assert_eq!(d.verdict, "unknown"); + assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked")); + // a chain id alone, matching, is a check + assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match"); + } + + #[test] + fn prune_keeps_the_newest_logs() { + let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..6 { + let p = dir.join(format!("node-2026100{i}.log")); + std::fs::write(&p, "x").unwrap(); + let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60); + let f = std::fs::File::options().write(true).open(&p).unwrap(); + f.set_modified(t).unwrap(); + } + std::fs::write(dir.join("miner-1.log"), "y").unwrap(); + std::fs::write(dir.join("node-notes.txt"), "z").unwrap(); + assert_eq!(prune_logs(&dir, "node-", 4), 2); + let mut left: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + left.sort(); + assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index c2ce4f43b..83bb08b55 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -39,6 +39,7 @@ mod ember; mod powertask; mod watchdog; mod live; +mod extnode; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 81afdd798..3786609a2 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -139,6 +139,15 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) { respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false); } +/// Where a screenshot lands: `/shots/igneum--.png`, the name reduced to [a-z0-9-] (at most +/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder. +pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf { + let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect(); + let safe = if safe.is_empty() { "shot".to_string() } else { safe }; + data_root.join("shots").join(format!("igneum-{safe}-{unix}.png")) +} + + fn query_param(q: &str, key: &str) -> Option { q.split('&').find_map(|kv| { let (k, v) = kv.split_once('=')?; @@ -368,6 +377,17 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result", beside URL, STATE and EXIT) and the host writes a PNG of + // what it shows to that path under /shots/, where a collect job's glob "shots/*.png" picks it up. + // Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file. + "/api/shot" => { + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now()); + if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } + println!("SHOT {}", path.display()); + Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" })) + } "/api/quit" => { // the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)")); @@ -376,3 +396,17 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result Err("unknown api".into()), } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn a_shot_lands_under_the_data_root_with_a_safe_name() { + let root = std::path::Path::new("/tmp/igneum-data"); + assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png")); + assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png")); + assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png")); + assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png")); + assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48); + } +} diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 966eddd1b..0d9159c79 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -21,6 +21,23 @@ pub struct NodeState { pub version: String, pub last_reading_age_s: f64, pub message: String, + /// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's + /// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120. + pub tip_age_s: f64, + /// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another + /// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules + /// or another network, so no node is read), "" before the first start + pub source: String, + /// for an external node: match | mismatch | unknown (an older node that cannot answer the check) + pub rules_check: String, + /// the one line that says what happened at the ports, kept for the Node details + pub port_note: String, + /// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), "" + pub digest_source: String, + /// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen" + pub sync_cause: String, + /// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node + pub stall_exits: u32, /// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none pub consensus_switch_daa: u64, pub override_restart_wait: String, diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 1d5976093..9d6a852e5 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -344,8 +344,9 @@ var View = (function () { // MH per watt: the engine's live figure, else rate over draw function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; } function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; } - // the watts drawn now by the present cards - function wattsTotal(cards) { return (cards || []).filter(present).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); } + // the watts drawn now by the cards that are mining or tuning (an idle card's standby draw is not the fleet's cost; + // the 0.3.17 engine's mining.watts_total counts the same way) + function wattsTotal(cards) { return (cards || []).filter(function (c) { return present(c) && (c.state === 'mining' || c.state === 'tuning'); }).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); } // why a row has no power or temperature cell: one sentence, once, instead of three n/a function noReading(cd) { if (cd.power_w > 0 || cd.temp_gpu > 0) return ''; @@ -450,7 +451,7 @@ var View = (function () { if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', 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 ? 'card starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' }; + if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : n && n.state === 'behind' ? 'waiting: the node is behind the chain' : n && n.state === 'no peers' ? 'waiting: no peers on our chain' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' }; return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' }; } // the row's state word while the miner is paused: the card is on, the miner is not @@ -461,7 +462,10 @@ var View = (function () { 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 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.tip_age_s >= 0 ? ' Last block ' + Math.round(n.tip_age_s) + ' s ago.' : n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' }; + // N4 (6 October 2026): a tip that stopped moving is never "synced"; the node says why + case 'behind': return { word: 'behind', line: (n.tip_age_s >= 0 ? 'No new block for ' + tipAge(n.tip_age_s) + '. ' : '') + 'The chain may have moved on without this node. Mining pauses until it catches up; the node re-dials its peers by itself.', tone: 'bad' }; + case 'no peers': return { word: 'no peers', line: 'No other node on our chain is connected. The node keeps dialling the seed list; mining waits.', tone: 'bad' }; 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'); @@ -469,15 +473,19 @@ var View = (function () { 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' }; + case 'stopped': return { word: n.source === 'none' ? 'not started' : 'stopped', line: n.message || 'the node is not running', tone: 'bad' }; + case 'stub': return { word: 'stub engine', line: 'This node was built without the mining engine, so it refuses every real block. Reinstall the app.', tone: 'bad' }; } return { word: n.state || 'starting', line: n.message || '', tone: '' }; } - // the one node line on Mine: "Node synced · 6 peers · 109,859 blocks" + // "12 s" / "4 min" / "53 min" / "2 h" for a tip age + function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; } + // the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago" function nodeLine(n, clock, eta) { var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word]; if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's')); if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks'); + if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago'); return { text: parts.join(' · '), sub: w.line, tone: w.tone }; } function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; } @@ -653,8 +661,11 @@ var View = (function () { if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' }; if (n.state === 'starting') return { text: 'Starting the node', tone: '' }; if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' }; + if (n.state === 'behind') return { text: 'Node behind', tone: 'bad' }; + if (n.state === 'no peers') return { text: 'No peers', tone: 'bad' }; if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' }; - if (n.state === 'stopped') return { text: 'Node stopped', tone: 'bad' }; + if (n.state === 'stopped') return { text: n.source === 'none' ? 'Node not started' : 'Node stopped', tone: 'bad' }; + if (n.state === 'stub') return { text: 'Node fault', tone: 'bad' }; if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' }; if (m.state === 'idle') return { text: 'Not mining', tone: '' }; return { text: cap(m.state || 'starting'), tone: '' }; @@ -695,7 +706,7 @@ var View = (function () { } return out; } - out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = 'starts after 2 min of steady mining'; return out; + out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = st.sweep === false ? 'Tune starts one by hand' : 'starts after 2 min of steady mining'; return out; } // the Tuning strip's fleet saving: watts and £ a day summed over the tuned cards function fleetSaving(cards, s, now, pence) { @@ -705,10 +716,63 @@ var View = (function () { var ppd = poundsPerDay(w, pence); return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's'); } + // ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei + // (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign + // (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ---- + function ign(wei) { var n = Number(wei); return isNaN(n) ? null : n / 1e18; } + // the Earnings balance row: {text, sub, pounds} ; the UI shows "not read yet" until the first read + function balanceWords(a, now) { + a = a || {}; + if (a.balance_wei === undefined) return { text: '', sub: '', pounds: null }; + if (a.balance_wei === null || a.balance_wei === '') return { text: 'not read yet', sub: a.balance_note || 'the node answers the balance once it is synced', pounds: null }; + var v = ign(a.balance_wei); if (v === null) return { text: 'not read yet', sub: a.balance_note || '', pounds: null }; + var age = a.balance_age_s >= 0 ? ' · read ' + ago(a.balance_age_s) : ''; + var price = a.price_gbp_per_ign; + return { text: (v >= 1000 ? withCommas(v) : v.toFixed(4)) + ' IGN', sub: 'in the wallet' + age + (a.balance_note ? ' · ' + a.balance_note : ''), pounds: price > 0 ? v * price : null }; + } + // the fleet watts and £ a day: the engine's figures when it carries them, else the UI's own sums + function fleetWatts(m, cards) { return m && m.watts_total > 0 ? m.watts_total : wattsTotal(cards); } + function fleetPounds(m, cards, pence) { if (m && m.pounds_per_day > 0) return m.pounds_per_day; return poundsPerDay(fleetWatts(m, cards), pence); } + // is a lane key this machine's? The cards' ids are the first 8 hex of each vote key hash; a source may shorten the + // hash to another length (the 0.3.16 engine keeps 12), so either prefix of the other counts + function isMine(miner, cards) { + var m = String(miner || '').replace(/^0x/, '').toLowerCase(); if (!m) return false; + var head = m.split('..')[0]; // the 0.3.16 engine shortens a long hash to "head6..tail6"; the site keeps 8 hex + return (cards || []).some(function (c) { return (c.ids || []).some(function (id) { + id = String(id).replace(/^0x/, '').toLowerCase(); if (!id) return false; + var n = Math.min(head.length, id.length); return n >= 6 && head.slice(0, n) === id.slice(0, n); + }); }); + } + // the chain scene's note from the reply's source + function liveNote(reply) { + if (!reply) return ''; + if (reply.pending) return 'connecting'; + var src = reply.source || ((reply.state || {}).source); + if (src === 'node') return 'From your node. Your blocks are ringed.'; + if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.'; + return ''; + } + // finality (ember-tune 1357d280, folded in for 0.3.17): f = state.finality. While the network's finality is paused + // (under two thirds of the weight signing) the node line's sub-text and the lock row say so, and no surface calls a + // lock "final" (main, Q83/Q84). {paused, age, note} + function finalityWords(f) { + f = f || {}; + if (f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' }; + if (f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' }; + return { paused: false, age: 'n/a', note: f.message || 'Locks appear once the miner votes on checkpoints.' }; + } + // whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | "" + function nodeSource(n) { + n = n || {}; + if (n.source === 'app') return { text: 'this app’s own node', sub: n.port_note || '' }; + if (n.source === 'external') return { text: 'another node on this machine', sub: n.rules_check === 'match' ? 'same network and rules, checked' : n.rules_check === 'unknown' ? 'its rules could not be checked (it predates 0.3.17)' : (n.port_note || '') }; + if (n.source === 'none') return { text: 'no node', sub: n.port_note || 'the ports are taken by a node on other rules' }; + return { text: '', sub: '' }; + } // the confirmations' words (in place, never a dialog) function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -1318,7 +1382,8 @@ if (typeof document !== 'undefined') (function () { function mountDag() { if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return; try { - dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: 'you', tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onState: function (st, why) { + var mine = function (b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); }; + dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: mine, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onData: function (d) { var n = View.liveNote(d); if (n && dagMode !== 'failed') setText('dag-note', n); }, onState: function (st, why) { var live = st === 'live' || st === 'stale'; if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); } $('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live; @@ -1499,9 +1564,9 @@ if (typeof document !== 'undefined') (function () { // the live total: rate, watts, £ a day, blocks setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1)); setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state); - var watts = View.wattsTotal(m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : ''; + var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : ''; setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now'); - var ppd = View.poundsPerDay(watts, price); + var ppd = View.fleetPounds(m, m.cards, price); setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh'); setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '')); // the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider @@ -1548,11 +1613,12 @@ if (typeof document !== 'undefined') (function () { var switchesSig = ''; function renderNode(s) { var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now); - var nl = View.nodeLine(n, s.clock, eta); - setText('node-line-text', nl.text); setText('node-sub', nl.sub); + var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f); + setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub); $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); $('node-card').classList.toggle('bad', nl.tone === 'bad'); setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '')); + var ns = View.nodeSource(n); setText('n-source', ns.text); setText('n-source-m', ns.sub); setHidden('n-source', !ns.text); 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*/, '') : '--'); @@ -1569,8 +1635,9 @@ if (typeof document !== 'undefined') (function () { switchesSig = sig; $('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '' + withCommas(x.daa) + ''; }).join(''); } - if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock) + ' · ' + rel(f.age_s)); setText('f-note', 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining'); } - else { setText('f-lock', 'none yet'); setText('f-note', f.message || 'checkpoints lock once this machine and the others vote'); } + setText('f-lock', (f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet') + ' · ' + fw.age); + setText('f-note', fw.paused ? fw.note : f.last_lock > 0 ? 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' : (f.message || 'checkpoints lock once this machine and the others vote')); + $('f-lock').classList.toggle('warn', !!fw.paused); setText('f-votes', withCommas(f.votes)); } @@ -1580,7 +1647,9 @@ if (typeof document !== 'undefined') (function () { var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000'; setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : '')); setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s)); - var watts = View.wattsTotal(m.cards), ppd = View.poundsPerDay(watts, price); + var watts = View.fleetWatts(m, m.cards), ppd = View.fleetPounds(m, m.cards, price); + var bw = View.balanceWords(a, s.now); + if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); } setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day'); setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W'); setHidden('e-price-btn', !(watts > 0 && ppd === null)); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index de7d6d616..5bd14791a 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -204,6 +204,7 @@