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>
|
|
@ -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";
|
||||
|
|
|
|||
192
app/igneum-app/src/extnode.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ mod ember;
|
|||
mod powertask;
|
||||
mod watchdog;
|
||||
mod live;
|
||||
mod extnode;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-dark-1440.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-dark-390.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-light-1440.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-open-dark.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-earnings-dark-1440.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-earnings-light-1440.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-dark-1440.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-dark-390.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-light-1440.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-strip-dark.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-prove-dark-1440.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-prove-light-1440.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-settings-dark-1440.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-settings-light-1440.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-cards-light-1440.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-dark-1440.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
29
relay/playbooks/app-shot-light.ps1
Normal 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
|
||||
17
relay/playbooks/app-shot.ps1
Normal 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
|
||||
18
relay/playbooks/app-state-grab.ps1
Normal 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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||