Merge miner-ui-4 4f6cfbfb for 0.3.17: ui/ whole (Ember's finality words folded), extnode.rs on igneum_getNodeInfo, N4 stall exits, the port-collision check, POST /api/shot; src/live.rs is ember-tune's merged module

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 23:28:51 +00:00
commit 1c99053aad
32 changed files with 741 additions and 31 deletions

View file

@ -52,6 +52,10 @@ pub enum Cmd {
WorkerBuilt(usize, Result<PathBuf, String>),
/// 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<f64>),
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<Line>,
node: Option<Proc>,
node_external: bool,
node_info_at: Instant,
node_restart_at: Option<Instant>,
/// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log
resume_check_at: Option<Instant>,
@ -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 <app data>/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::<Value>(&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<std::path::PathBuf> {
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::<f64>().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<f64> {
let nums: Vec<f64> = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::<f64>().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";

View file

@ -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<u64>,
pub digest: Option<String>,
pub network: Option<String>,
pub version: Option<String>,
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
pub overrides: Option<Value>,
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
pub pow_engine: Option<String>,
}
/// 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<u64>, 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<u64>, 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<Value> {
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 `<prefix>...` 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<String> = 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);
}
}

View file

@ -39,6 +39,7 @@ mod ember;
mod powertask;
mod watchdog;
mod live;
mod extnode;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;

View file

@ -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: `<data root>/shots/igneum-<name>-<unix>.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<String> {
q.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
@ -368,6 +377,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ClockCheck);
Ok(json!({ "ok": true }))
}
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
// what it shows to that path under <data root>/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<Shared>, path: &str, body: Value) -> Result<Value, Stri
_ => 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);
}
}

View file

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

View file

@ -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 '<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) + ' · ' + 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));

View file

@ -204,6 +204,7 @@
<div class="details" id="node-details" hidden>
<div class="kv">
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
<div><span class="k">whose node</span><span class="v" id="n-source"></span><span class="m" id="n-source-m"></span></div>
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>

View file

@ -233,6 +233,7 @@ test('money: £ a day from watts at a price, nothing without one, the cells leav
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
assert.equal(V.wattsTotal([card(), card({ key: 'd', power_w: 48, state: 'off', enabled: false })]), 410.2, 'an idle card’s standby draw is not counted');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
});
@ -369,9 +370,89 @@ test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet');
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet'); assert.equal(idle.sub, 'starts after 2 min of steady mining');
const off = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings({ sweep: false }), NOW);
assert.equal(off.label, 'Not tuned: Ember Tune is off'); assert.equal(off.sub, 'Tune starts one by hand');
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day across 1 tuned card');
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
});
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
const cards = [card(), card({ key: 'b', power_w: 100 })];
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
assert.equal(V.fleetPounds({}, cards, 0), null);
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
assert.equal(V.liveNote(null), '');
const mineCards = [card({ ids: ['8fafda27', '1b2c3d4e'] })];
assert.equal(V.isMine('8fafda27', mineCards), true, 'the site: the first 8 hex of the vote key hash');
assert.equal(V.isMine('8fafda..e01f22', mineCards), true, 'the 0.3.16 engine: head6..tail6 of the same hash');
assert.equal(V.isMine('8fafda27a9c1', mineCards), true, 'a longer prefix of the same hash');
assert.equal(V.isMine('0x1B2C3D4E', mineCards), true);
assert.equal(V.isMine('8fafdb..e01f22', mineCards), false, 'one hex off');
assert.equal(V.isMine('deadbeef', mineCards), false);
assert.equal(V.isMine('8faf', mineCards), false, 'under 6 hex never matches');
assert.equal(V.isMine('', mineCards), false);
});
test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age is on the node line', () => {
const n = (over) => ({ state: 'synced', blocks: 133000, headers: 133000, peers: 1, daa: 140000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
const ok = V.nodeLine(n(), { severity: 'none' }, '');
assert.equal(ok.text, 'Node synced · 1 peer · 133,000 blocks · last block 3 s ago'); assert.equal(ok.tone, 'ok');
assert.match(ok.sub, /Last block 3 s ago/);
const behind = V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180, sync_cause: 'frozen' }), { severity: 'none' }, '');
assert.equal(behind.text, 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'); assert.equal(behind.tone, 'bad');
assert.match(behind.sub, /^No new block for 53 min\. The chain may have moved on without this node\./);
const none = V.nodeLine(n({ state: 'no peers', synced: false, peers: 0, tip_age_s: 500 }), { severity: 'none' }, '');
assert.equal(none.text, 'No peers · 133,000 blocks'.replace('No peers', 'Node no peers')); assert.equal(none.tone, 'bad');
assert.match(none.sub, /^No other node on our chain is connected/);
// an older node without the age keeps the old words
assert.equal(V.nodeLine(n({ tip_age_s: -1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer · 133,000 blocks');
assert.equal(V.tipAge(12), '12 s'); assert.equal(V.tipAge(240), '4 min'); assert.equal(V.tipAge(3180), '53 min'); assert.equal(V.tipAge(7300), '2 h');
const base = { setup_done: true, quitting: false, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'behind' } };
assert.deepEqual(V.pill(base), { text: 'Node behind', tone: 'bad' });
assert.deepEqual(V.pill({ ...base, node: { state: 'no peers' } }), { text: 'No peers', tone: 'bad' });
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain');
});
test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => {
const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' });
assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
assert.equal(/\bfinal\b/i.test(p.note), false);
assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing');
const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 });
assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/);
const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' });
assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner');
// N5: a node built without the mining engine is a fault
assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine');
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' });
});
test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => {
assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' });
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' });
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (it predates 0.3.17)' });
assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' });
assert.deepEqual(V.nodeSource({}), { text: '', sub: '' });
const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/);
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' });
});

View file

@ -209,6 +209,9 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
}
} else if line.hasPrefix("STATE ") {
applyState(String(line.dropFirst(6)))
} else if line.hasPrefix("SHOT ") {
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
} else if line.hasPrefix("FATAL ") {
fail(String(line.dropFirst(6)))
} else if line == "EXIT" {
@ -322,6 +325,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
}
// ---- a screenshot on request (the SHOT line), the app keeps running ----
func shotTo(_ path: String) {
let conf = WKSnapshotConfiguration()
conf.rect = web.bounds
web.takeSnapshot(with: conf) { image, error in
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
return
}
do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) }
}
}
// ---- snapshot ----
func snapshot(to path: String) {
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {

View file

@ -16,6 +16,7 @@
#endif
#include <windows.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
@ -39,6 +40,13 @@ static HWND g_hwnd = nullptr;
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> g_webview;
// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler
static void capturePreview(const std::wstring& path) {
if (!g_webview) return;
ComPtr<IStream> stream;
if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return;
g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback<ICoreWebView2CapturePreviewCompletedHandler>([stream](HRESULT) -> HRESULT { return S_OK; }).Get());
}
static std::wstring g_url, g_status = L"starting the engine";
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
static NOTIFYICONDATAW g_nid = {};
@ -308,6 +316,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
runElevated(widen(line->substr(8)));
} else if (line->rfind("SHOT ", 0) == 0) {
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
capturePreview(widen(line->substr(5)));
} else if (line->rfind("FATAL ", 0) == 0) {
g_status = widen(line->substr(6));
repaintStatus();

View file

@ -66,3 +66,35 @@ Tests: `node --test app/igneum-app/ui/*.test.mjs`: 37 pass.
The Rust tests did not run on this Mac (its cargo predates the lock file's version 4); the box runs them at the
merge, as the rule says. UI tests: 37 pass. Screenshots `u01`, `u03`, `u04`, `u06` reshot after the sweep.
## 7. Pinned to the next engine (ember-tune fd03f35, then b55bc1f), 20:5x UK
Numbering (the shipper, 21:3x UK): 0.3.16 is tonight's corrective cut, the same release as 0.3.15 (this branch's ui/
at 872f1c9) under a new number; the engine fields and the pinning below ship as 0.3.17 with miner-ui-4's tip e31acab.
| Field | Where the UI reads it |
|---|---|
| `mining.watts_total`, `mining.pounds_per_day` | the Overview hero and Earnings use the engine's figures when present (`View.fleetWatts`, `View.fleetPounds`), else the UI's own sums as before |
| `address.balance_wei` (string or null), `address.balance_age_s` (-1 until read), `address.balance_note`, `address.price_gbp_per_ign` (null until a signed manifest carries a price) | Earnings' first row: "12.3456 IGN · in the wallet · read 20 s ago" (`View.balanceWords`); a priced balance shows £ with "at the signed price"; an older engine without the field keeps the row as it was |
| `card.tune_floor` | the Ember line's "(the lowest clock Ember will set)" |
| `/api/live` reply `source` ("node" when igneumd answers `igneum_getRecentBlocks`, "site" until then; `pending: true` before the first body) | the chain scene's note (`View.liveNote`): "From your node. Your blocks are ringed." or "From Igneum’s observer. Your blocks are ringed."; "you" is matched in the UI from every card's vote-key ids whatever the source names the lane |
At the 0.3.17 merge `src/live.rs` is ember-tune's (the fuller one, node first, site second); this branch's 0.3.15
file is superseded and its `source` word is "site" too, so nothing in the UI changes between the two.
## 8. N4 (the frozen tip), the capture route, and PC 2's light render (6 October 2026, 23:0x to 23:4x UK)
| What | Where |
|---|---|
| N4, the app half: `sync_decision_v2` (the old rule AND the watch line's `tip_age_s` at or under 120 s AND peers >= 1; the cause word frozen / no peers / syncing / behind on `node.sync_cause`; the state word "behind" or "no peers", never "synced" on a frozen tip); `is_stall_exit` (code 45 or a "STALLED '" tail line; the first restarts the miner, the second since the node started restarts the node and re-dials its peers); `node.tip_age_s`, `node.stall_exits`. The known-failed case is the first test. UI: "Node behind · 1 peer · 133,000 blocks · last block 53 min ago" with the cause line, the pill "Node behind", the big button's "waiting: the node is behind the chain". The node lane's miner half is ca3-v4-0316. | `src/engine.rs`, `src/state.rs`, `ui/app.js`, `view.test.mjs` |
| (b) a screenshot from the machine itself: `POST /api/shot {name}` writes `SHOT <path>` on the host protocol (beside URL, STATE, EXIT); the Mac host answers with `WKWebView.takeSnapshot`, the Windows host with WebView2 `CapturePreview` (PNG), to `<data root>/shots/igneum-<name>-<unix>.png` (`live::shot_path`, the name reduced to [a-z0-9-]); `relay/playbooks/app-shot.ps1` asks and waits, a collect job's glob `shots/*.png` brings the file back. Read-only. The Swift parses (`swiftc -parse`); the C++ builds on the GitHub runner at the cut (`BUILD-APP.bat` already links shlwapi.lib). | `src/server.rs`, `src/live.rs`, `app/mac/IgneumMiner.swift`, `app/windows/host.cpp`, `relay/playbooks/app-shot.ps1` |
| The light-theme showcase capture: Edge headless on PC 2 wrote nothing in three takes (154.0.4258.53, exit 0, no stderr, in the app's elevated session); PC 2's `api/state` and `api/live` were fetched by `relay/playbooks/app-state-grab.ps1` into the data root and collected, and the ui-mock replayed them (`IGNEUM_MOCK_STATE`, `IGNEUM_MOCK_LIVE`) through the real 0.3.16 UI on the Mac: `docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png`, `pc2-cards-light-1440.png`, `pc2-overview-dark-1440.png`, captioned "rendered from PC 2's live state at 23:33 UK" (DESKTOP-KMCV30N, RTX 5090 at 103 MH/s and 317 W, node synced, 5 peers). Handed to the site lane. | `tools/ui-mock/server.mjs`, `relay/playbooks/app-state-grab.ps1` |
| The fleet watts count mining and tuning cards only (PC 2's idle integrated card drew 48 W and was summed); the Ember off-state line no longer promises a start. | `ui/app.js` |
Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, the four live tests among them).
## 9. 0.3.17 fold (7 October 2026, 23:20 UK)
- Rebased onto a9eb58f1 (origin/release-0.3.15). ui/ comes wholly from this branch; Ember's finality-paused hunk (ember-tune 1357d280) is folded in as `View.finalityWords(f)`: while finality is paused the node sub-line and the lock row say "paused" and nothing calls a lock final. Test in view.test.mjs (41 UI tests).
- src/extnode.rs reads the node lane's `igneum_getNodeInfo` shape: `params` (every resolved consensus field, compared value by value against the manifest's keys), `network` (must be equal), `powEngine` ("stub" = refused on the port check, and a fault on the app's own node: node state `stub`, pill "Node fault", one error event). 168 box tests.
- src/live.rs is Ember's merged module at the merge (nothing of this branch's lives there any more).

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View file

@ -0,0 +1,29 @@
# Igneum playbook: two light-mode screenshots of the installed app (Overview, Cards) for the site's showcase card.
# Read-only: GETs the app's own URL and runs Edge headless, which renders prefers-color-scheme light by default.
# Never pauses, resumes or quits anything (the standing rule of 5 October 2026); the miners never notice.
# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-shot-light.ps1 --title "app light-mode shots"
$ErrorActionPreference = 'Continue'
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 }
$url = (Get-Content $urlFile -Raw).Trim()
$edge = @("$env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $edge) { Write-Host "ERROR: Edge not found"; exit 2 }
Write-Host ("edge " + (Get-Item $edge).VersionInfo.ProductVersion + " at " + $edge)
$out = Join-Path $env:TEMP 'igneum-shots'; New-Item -ItemType Directory -Force -Path $out | Out-Null
# a profile of its own, or the launcher hands the request to the user's running Edge and returns at once with no file
# (run-20261006-222112 on PC 2: both shots missing in 0 s); -Wait holds until the headless browser exits
$profile = Join-Path $out 'profile'; New-Item -ItemType Directory -Force -Path $profile | Out-Null
foreach ($page in @('overview', 'cards')) {
$png = Join-Path $out "igneum-$page-light-1440.png"
if (Test-Path $png) { Remove-Item $png -Force }
# take 3: the old headless mode (the new one wrote nothing on PC 2's Edge, exit 0 in 11 s); the screenshot path is
# passed bare, and the working directory is the output folder because old headless falls back to screenshot.png there
$args = @('--headless', '--disable-gpu', '--hide-scrollbars', '--no-first-run', '--no-sandbox', '--window-size=1440,1000', '--virtual-time-budget=8000', "--user-data-dir=$profile", "--screenshot=$png", "$url?page=$page&visible=1")
$errLog = Join-Path $out "edge-$page.log"
$p = Start-Process -FilePath $edge -ArgumentList $args -Wait -PassThru -WindowStyle Hidden -WorkingDirectory $out -RedirectStandardError $errLog
$tries = 0; while (-not (Test-Path $png) -and $tries -lt 20) { Start-Sleep -Milliseconds 500; $tries++ }
if (-not (Test-Path $png) -and (Test-Path (Join-Path $out 'screenshot.png'))) { Move-Item (Join-Path $out 'screenshot.png') $png -Force }
if (Test-Path $errLog) { Get-Content $errLog | Select-Object -First 5 | ForEach-Object { Write-Host "edge: $_" } }
if (Test-Path $png) { Write-Host "RESULT shot=$page bytes=$((Get-Item $png).Length) edge_exit=$($p.ExitCode)"; & $env:RELAY_SEND -TaskId ([long]$env:RELAY_TASK_ID) -Title "app $page light" $png } else { Write-Host "RESULT shot=$page missing edge_exit=$($p.ExitCode)" }
}
exit 0

View file

@ -0,0 +1,17 @@
# Igneum playbook: a screenshot of the installed app's window from the machine itself (Miner UI 4 (b), 0.3.17):
# POST api/shot asks the engine, the window host writes a PNG of what it shows into <data root>\shots\, and a collect
# job's glob "shots/*.png" brings it back. Read-only: never pauses, resumes or quits anything (the rule of 5 Oct 2026).
# Needs an app at 0.3.17 or later (the route and the host's SHOT line); an older app answers 400 and this exits 3.
# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-shot.ps1 --title "app shot"
# then packaging/ota/publish-jobs.sh add --kind collect --target 1ccfe586 --glob "shots/*.png"
$ErrorActionPreference = 'Continue'
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 }
$url = (Get-Content $urlFile -Raw).Trim()
$name = if ($env:SHOT_NAME) { $env:SHOT_NAME } else { 'window' }
try { $r = Invoke-RestMethod -Method Post -Uri ($url + 'api/shot') -ContentType 'application/json' -Body (@{ name = $name } | ConvertTo-Json) -TimeoutSec 15 }
catch { Write-Host "RESULT shot=$name failed: $($_.Exception.Message) (the app needs 0.3.17 or later)"; exit 3 }
$path = $r.path
$tries = 0; while (-not (Test-Path $path) -and $tries -lt 30) { Start-Sleep -Milliseconds 500; $tries++ }
if (Test-Path $path) { Write-Host "RESULT shot=$name path=$path bytes=$((Get-Item $path).Length)"; exit 0 }
Write-Host "RESULT shot=$name missing after 15 s (path $path); the window host did not write it"; exit 4

View file

@ -0,0 +1,18 @@
# Igneum playbook: copy the installed app's live state and chain feed (two GETs) back through the relay, so a capture
# of the dashboard can be rendered elsewhere from this machine's real numbers. Read-only: never pauses, resumes or
# quits anything (the standing rule of 5 October 2026).
# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-state-grab.ps1 --title "app state grab"
# then packaging/ota/publish-jobs.sh add --kind collect --target 1ccfe586 --glob "state-grab/*.json"
$ErrorActionPreference = 'Continue'
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 }
$url = (Get-Content $urlFile -Raw).Trim()
# into the app's data root, where a collect job's glob "state-grab/*.json" picks the files up (the relay's send.ps1 is
# not installed on every PC: PC 2, 6 October 2026)
$out = Join-Path $env:LOCALAPPDATA 'igneum\state-grab'; New-Item -ItemType Directory -Force -Path $out | Out-Null
foreach ($pair in @(@('api/state', 'state.json'), @('api/live?window=120', 'live.json'))) {
$dest = Join-Path $out $pair[1]
try { Invoke-WebRequest -UseBasicParsing -Uri ($url + $pair[0]) -TimeoutSec 15 -OutFile $dest; Write-Host "RESULT file=$($pair[1]) bytes=$((Get-Item $dest).Length)"; if ($env:RELAY_SEND -and (Test-Path $env:RELAY_SEND)) { & $env:RELAY_SEND -TaskId ([long]$env:RELAY_TASK_ID) -Title ("app " + $pair[1]) $dest } }
catch { Write-Host "RESULT file=$($pair[1]) failed: $($_.Exception.Message)" }
}
exit 0

View file

@ -31,7 +31,10 @@ const coin = path.resolve(here, '../../brand/igneum-coin-1024.png');
const port = parseInt(process.argv[2] || '4310', 10);
const PREFIX = '/t/mock';
const recState = JSON.parse(fs.readFileSync(path.join(here, 'recorded/state.json'), 'utf8'));
// IGNEUM_MOCK_STATE=<file> replays a state captured from a real machine (relay/playbooks/app-state-grab.ps1) instead of
// the recorded one; IGNEUM_MOCK_LIVE=<file> serves that machine's /api/live reply as it was (timestamps shifted to now)
const recState = JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_STATE || path.join(here, 'recorded/state.json'), 'utf8'));
const recLive = process.env.IGNEUM_MOCK_LIVE ? JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_LIVE, 'utf8')) : null;
const recLog = JSON.parse(fs.readFileSync(path.join(here, 'recorded/log.json'), 'utf8')).lines;
const t0 = Date.now() / 1000; // server start
const shift = t0 - recState.now; // recorded clock -> live clock
@ -116,6 +119,8 @@ function stateNow() {
s.program.eta_s = Math.max(0, Math.round(3600 - ((t - t0) % 3600)));
s.node.blocks = recState.node.blocks + Math.floor(t - t0); s.node.headers = s.node.blocks; s.node.daa = s.node.blocks;
s.node.last_reading_age_s = (t - t0) % 10;
// a captured state (IGNEUM_MOCK_STATE) is replayed as it was: the machine's own name, cards, rate and numbers
if (process.env.IGNEUM_MOCK_STATE) { s.node.blocks = recState.node.blocks; s.node.headers = recState.node.headers; s.node.daa = recState.node.daa; return s; }
// a live-looking hash rate
const wob = Math.sin((t - t0) / 7) * 1.4;
s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total;
@ -180,7 +185,14 @@ http.createServer((req, res) => {
return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html')));
}
if (rest === '/api/state' && req.method === 'GET') return json(res, stateNow());
if (rest === '/api/live' && req.method === 'GET') return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
if (rest === '/api/live' && req.method === 'GET') {
if (recLive && recLive.blocks) {
// replayed with its newest block 3 s behind now, so the module's window shows it as it was captured
const newest = Math.max(...recLive.blocks.map(b => b.ts || 0)), dt = Date.now() - 3000 - newest;
const d = JSON.parse(JSON.stringify(recLive)); d.blocks.forEach(b => { b.ts = (b.ts || newest) + dt; if (!b.miner) b.miner = 'unknown'; }); d.now = Math.floor(Date.now() / 1000); return json(res, d);
}
return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
}
if (rest === '/api/log' && req.method === 'GET') {
const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10));
let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit);