From 28e99bee4469887acf0951fc0eb26296d746acb4 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sun, 13 Sep 2026 17:26:28 +0300 Subject: [PATCH] feat: show the node a miner's blocks were reported from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caches what the telemetry feed says each node *is*, and surfaces it on the miner route: client version, target triple, CPU, cores, memory, kernel, distribution, whether it is virtualised, country, and how long the node process has been up. Kept in `node_telemetry`, keyed by peer id — never by the feed's `node_id`, which is per-connection and would accumulate a row per reconnect for the same machine. Written every five minutes rather than every thirty seconds like the attributions beside it: a CPU does not change, and mainnet alone has nearly three hundred nodes. The route reads the live feed first and the cache second, so a node the feed has not re-announced since our last restart still answers. Nulls never overwrite. The telemetry server geolocates asynchronously, so every reconnect re-sends every node with a null location — a naive upsert would blank the country of every node on the site for as long as the lookups took to catch up. The guard is in the feed state and in the `on conflict` clause both, with a test for each. **Location is a country and no finer.** The feed sends coordinates and a city from an IP geolocation database, and at city resolution those are wrong often enough that showing one would assert something nobody knows. `blackbeard_core::geo` derives the country offline from a 508 KiB boundary set — no network call, and nothing told where these nodes are — and the coordinates are then dropped. The schema has no city, latitude or longitude column: a column that does not exist cannot be rendered by mistake. The lookup returns subdivisions before countries and `GB-ENG` is exactly the precision being avoided, so only the two-letter code survives. **The panel wears its uncertainty in the header, not a footnote.** A miner is joined to a node by the same first-reporter voting that gives it a display name, so `Attribution` now carries the peer id it settled on — `None` for a preimage or a carried name, because there is no node behind either. The heading reads "reported by" and the confidence and vote count sit beside it. The note says the CPU is the node's and not necessarily what is hashing: on this network the work is on GPUs that telemetry does not report at all. Checked against the live feed end to end, not a fixture: real frames decoded, cached, served and rendered. One node reports an Ubuntu userspace on a Fedora kernel, which is a container seeing its host — the panel shows that faithfully rather than tidying it away. Closes #15 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- ...be8352f1b854974ef3ccadaa7e78bad324866.json | 112 +++++++++ ...9f469413e60334afc7402f9e5ace6d42e3fab.json | 30 +++ CLAUDE.md | 29 +++ crates/blackbeard-api/src/ingest.rs | 38 ++- crates/blackbeard-api/src/routes.rs | 78 +++++- crates/blackbeard-core/src/attribution.rs | 17 ++ .../migrations/0007_node_telemetry.sql | 49 ++++ crates/blackbeard-data/src/store.rs | 222 ++++++++++++++++++ crates/blackbeard-data/tests/store.rs | 87 +++++++ crates/blackbeard-entities/src/lib.rs | 4 +- crates/blackbeard-entities/src/miner.rs | 66 ++++++ web/src/api/generated/MinerDetail.ts | 8 +- web/src/api/generated/NodeInfo.ts | 95 ++++++++ web/src/components/MinerPanel.tsx | 196 +++++++++------- web/src/components/NodePanel.tsx | 131 +++++++++++ web/src/index.css | 27 +++ web/src/lib/format.ts | 40 ++++ 17 files changed, 1134 insertions(+), 95 deletions(-) create mode 100644 .sqlx/query-928ca44eadea3ee5636a8815533be8352f1b854974ef3ccadaa7e78bad324866.json create mode 100644 .sqlx/query-fffb9cbacfb97a5a9a152cc72b39f469413e60334afc7402f9e5ace6d42e3fab.json create mode 100644 crates/blackbeard-data/migrations/0007_node_telemetry.sql create mode 100644 web/src/api/generated/NodeInfo.ts create mode 100644 web/src/components/NodePanel.tsx diff --git a/.sqlx/query-928ca44eadea3ee5636a8815533be8352f1b854974ef3ccadaa7e78bad324866.json b/.sqlx/query-928ca44eadea3ee5636a8815533be8352f1b854974ef3ccadaa7e78bad324866.json new file mode 100644 index 0000000..98f5ee3 --- /dev/null +++ b/.sqlx/query-928ca44eadea3ee5636a8815533be8352f1b854974ef3ccadaa7e78bad324866.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select name, implementation, version, target_os, target_arch, target_env,\n cpu, core_count, memory, linux_kernel, linux_distro,\n is_virtual_machine, country_code, country_name, startup_time, seen_at\n from node_telemetry\n where peer_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "implementation", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "version", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "target_os", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "target_arch", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "target_env", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "cpu", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "core_count", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "memory", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "linux_kernel", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "linux_distro", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "is_virtual_machine", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "country_code", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "country_name", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "startup_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 15, + "name": "seen_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false + ] + }, + "hash": "928ca44eadea3ee5636a8815533be8352f1b854974ef3ccadaa7e78bad324866" +} diff --git a/.sqlx/query-fffb9cbacfb97a5a9a152cc72b39f469413e60334afc7402f9e5ace6d42e3fab.json b/.sqlx/query-fffb9cbacfb97a5a9a152cc72b39f469413e60334afc7402f9e5ace6d42e3fab.json new file mode 100644 index 0000000..e232b10 --- /dev/null +++ b/.sqlx/query-fffb9cbacfb97a5a9a152cc72b39f469413e60334afc7402f9e5ace6d42e3fab.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into node_telemetry (\n peer_id, chain, name, implementation, version,\n target_os, target_arch, target_env,\n cpu, core_count, memory, linux_kernel, linux_distro,\n is_virtual_machine, country_code, country_name, startup_time, seen_at\n )\n select * from unnest(\n $1::text[], $2::text[], $3::text[], $4::text[], $5::text[],\n $6::text[], $7::text[], $8::text[],\n $9::text[], $10::integer[], $11::bigint[], $12::text[], $13::text[],\n $14::boolean[], $15::text[], $16::text[], $17::timestamptz[]\n ), now()\n on conflict (peer_id) do update set\n chain = excluded.chain,\n name = excluded.name,\n implementation = coalesce(excluded.implementation, node_telemetry.implementation),\n version = coalesce(excluded.version, node_telemetry.version),\n target_os = coalesce(excluded.target_os, node_telemetry.target_os),\n target_arch = coalesce(excluded.target_arch, node_telemetry.target_arch),\n target_env = coalesce(excluded.target_env, node_telemetry.target_env),\n cpu = coalesce(excluded.cpu, node_telemetry.cpu),\n core_count = coalesce(excluded.core_count, node_telemetry.core_count),\n memory = coalesce(excluded.memory, node_telemetry.memory),\n linux_kernel = coalesce(excluded.linux_kernel, node_telemetry.linux_kernel),\n linux_distro = coalesce(excluded.linux_distro, node_telemetry.linux_distro),\n is_virtual_machine = coalesce(excluded.is_virtual_machine, node_telemetry.is_virtual_machine),\n country_code = coalesce(excluded.country_code, node_telemetry.country_code),\n country_name = coalesce(excluded.country_name, node_telemetry.country_name),\n startup_time = coalesce(excluded.startup_time, node_telemetry.startup_time),\n seen_at = now()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "TextArray", + "TextArray", + "TextArray", + "TextArray", + "TextArray", + "TextArray", + "TextArray", + "Int4Array", + "Int8Array", + "TextArray", + "TextArray", + "BoolArray", + "TextArray", + "TextArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "fffb9cbacfb97a5a9a152cc72b39f469413e60334afc7402f9e5ace6d42e3fab" +} diff --git a/CLAUDE.md b/CLAUDE.md index d6f65b5..eedca97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -394,6 +394,35 @@ nothing about mainnet, which targets 12 s. `/tmp/env-config.js` on the telemetry site, **not** the `/feed` path on the main host, which 404s. +**Telemetry hardware is the node's word, reached by inference.** A miner is +joined to a node through the same first-reporter voting that produces its +display name — `Attribution::peer_id` is that join, and it is `None` for +anything but a `Telemetry` attribution because there is no node behind a +preimage or a carried name. So everything on the node panel carries two layers +of doubt: a node could state its own CPU wrongly, and it might not be this +miner's node at all. The panel puts the confidence and vote count in its header +for that reason. The CPU is the sharpest case — beside a hashrate it reads as +the thing hashing, and on this network `miner_cpu_hash_rate` is zero on every +host measured, so the work is on GPUs telemetry never mentions. + +**Location is a country and no finer, and the schema enforces it.** The feed +sends `[latitude, longitude, city]`; `blackbeard_core::geo` derives a country +offline and the coordinates are then dropped. `node_telemetry` has no city, +latitude or longitude column — a column that does not exist cannot be rendered +by mistake. IP geolocation is unreliable at city resolution, so publishing one +would assert something nobody knows. + +**The telemetry wire format is positional and version-specific.** `NodeDetails` +is eleven fields in a bare array, and which field is which has changed between +telemetry releases. The tests are built from frames captured off the live feed +rather than from a spec, because bytes the feed actually sent are the only +honest fixture; if a release renumbers a field, those are what says so. A null +in a re-announcement never overwrites what is already known — the server +geolocates asynchronously, so every reconnect re-sends every node with a null +location, and a naive overwrite blanks every country on the site for as long as +the lookups take to catch up. That guard exists in both the feed state and the +`on conflict` clause. + **A miner's reward address is derivable from its preimage, offline.** `pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address(preimage)`, which is `qp_poseidon_core::rehash_to_bytes` — Poseidon2 over Goldilocks. diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 6149d42..406c0ad 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -142,6 +142,16 @@ const DISCOVERY_WAIT: Duration = Duration::from_secs(5); /// Interval between attribution persists. const PERSIST_INTERVAL: Duration = Duration::from_secs(30); +/// Interval between telemetry metadata caches. +/// +/// Far slower than the attribution persist beside it, because the two write +/// very different things. Attributions change as votes accumulate; a node's CPU, +/// distro and country do not change at all, and mainnet alone has nearly three +/// hundred nodes. Five minutes is frequent enough that a node which appears +/// between restarts is cached long before anyone browses to it, and rare enough +/// that the write is unnoticeable. +const NODE_CACHE_INTERVAL: Duration = Duration::from_secs(300); + /// Start every task for one chain. pub fn spawn(chain: Arc, store: Store, config: &crate::config::Config) { let (tx, rx) = mpsc::channel(HEAD_BUFFER); @@ -831,7 +841,13 @@ async fn housekeeping(chain: Arc, store: Store, refresh: Duration) let mut attribution = tokio::time::interval(Duration::from_secs(1)); let mut leaderboard = tokio::time::interval(refresh); let mut persist = tokio::time::interval(PERSIST_INTERVAL); - for t in [&mut attribution, &mut leaderboard, &mut persist] { + let mut node_cache = tokio::time::interval(NODE_CACHE_INTERVAL); + for t in [ + &mut attribution, + &mut leaderboard, + &mut persist, + &mut node_cache, + ] { t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); } @@ -871,6 +887,26 @@ async fn housekeeping(chain: Arc, store: Store, refresh: Duration) tracing::warn!(chain = %chain.id(), error = %e, "attributions not persisted"); } } + _ = node_cache.tick() => { + // What the feed says every node *is*, kept so a miner page can + // answer for a node the feed has not re-announced since our + // last restart — which, for a node that never goes down, is + // every node. + let nodes: Vec<_> = chain + .telemetry + .all_metadata() + .iter() + .map(|(peer, m)| (peer.clone(), blackbeard_data::store::NodeTelemetry::from(m))) + .collect(); + if nodes.is_empty() { + continue; + } + let count = nodes.len(); + match store.cache_node_telemetry(&chain.id(), &nodes).await { + Ok(_) => tracing::debug!(chain = %chain.id(), nodes = count, "node telemetry cached"), + Err(e) => tracing::warn!(chain = %chain.id(), error = %e, "node telemetry not cached"), + } + } } } } diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 8e22fa2..dcbb8a4 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -19,8 +19,8 @@ use blackbeard_entities::{ AccountDetail, AccountEvent, AccountRoleEntry, AccountRow, ActivitySource, ApiError, BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries, ChainState, ChainSummary, EventSummary, GenesisDetail, LeaderboardRow, MinerDetail, MinerId, - MinerSeriesPoint, NamedAccount, PendingTransfer, RecentBlock, ReversibleState, RewardSummary, - RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, + MinerSeriesPoint, NamedAccount, NodeInfo, PendingTransfer, RecentBlock, ReversibleState, + RewardSummary, RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window, }; use serde::{Deserialize, Serialize}; @@ -748,6 +748,23 @@ async fn miner( }) .collect(); + // The node the attribution settled on, if it settled on one at all. Read + // live from the feed first and from the cache second: the feed is current, + // and the cache answers for a node that has not been re-announced since + // this process started — which, for a node that never goes down, is every + // node. + let node = match runtime.attribute(&miner).peer_id { + Some(peer) => { + let live = runtime.telemetry.metadata_of(&peer); + let stored = match live { + Some(_) => None, + None => state.store.node_telemetry(&peer).await.ok().flatten(), + }; + node_info(peer, live.as_ref(), stored.as_ref()) + } + None => None, + }; + Ok(Json(MinerDetail { chain: runtime.id(), address: blackbeard_core::wormhole::reward_address(&miner), @@ -758,9 +775,66 @@ async fn miner( last_seen, series, cumulative_work: BigUintDec(work), + node, })) } +/// Assemble what is known about a node from the live feed or the cache. +/// +/// Whichever source answered, the shape is the same — the reader is not told +/// which, because it makes no difference to how much any of it can be trusted: +/// both are the node's own report of itself, reached through an inference. +fn node_info( + peer_id: String, + live: Option<&blackbeard_data::telemetry::NodeMetadata>, + stored: Option<&blackbeard_data::store::NodeTelemetry>, +) -> Option { + match (live, stored) { + (Some(m), _) => Some(NodeInfo { + name: m.name.clone(), + peer_id, + implementation: m.implementation.clone(), + version: m.version.clone(), + target_os: m.target_os.clone(), + target_arch: m.target_arch.clone(), + target_env: m.target_env.clone(), + cpu: m.cpu.clone(), + core_count: m.core_count, + memory: m.memory, + linux_kernel: m.linux_kernel.clone(), + linux_distro: m.linux_distro.clone(), + is_virtual_machine: m.is_virtual_machine, + country_code: m.country_code.clone(), + country_name: m.country_name.clone(), + startup_time: m + .startup_time_ms + .and_then(|ms| chrono::DateTime::from_timestamp_millis(ms as i64)), + // Live: the feed is telling us now. + seen_at: Some(chrono::Utc::now()), + }), + (None, Some(s)) => Some(NodeInfo { + name: s.name.clone(), + peer_id, + implementation: s.implementation.clone(), + version: s.version.clone(), + target_os: s.target_os.clone(), + target_arch: s.target_arch.clone(), + target_env: s.target_env.clone(), + cpu: s.cpu.clone(), + core_count: s.core_count, + memory: s.memory, + linux_kernel: s.linux_kernel.clone(), + linux_distro: s.linux_distro.clone(), + is_virtual_machine: s.is_virtual_machine, + country_code: s.country_code.clone(), + country_name: s.country_name.clone(), + startup_time: s.startup_time, + seen_at: s.seen_at, + }), + (None, None) => None, + } +} + /// How many rows an index page carries. const INDEX_PAGE: i64 = 50; diff --git a/crates/blackbeard-core/src/attribution.rs b/crates/blackbeard-core/src/attribution.rs index ac1124a..ab144e2 100644 --- a/crates/blackbeard-core/src/attribution.rs +++ b/crates/blackbeard-core/src/attribution.rs @@ -107,6 +107,15 @@ pub struct Attribution { /// How many votes are behind it. A fraction alone cannot distinguish one /// vote of one from nine of nine, and those are not the same claim. pub votes: u32, + /// The libp2p peer id of the node this settled on, when it settled on one + /// the feed identified by peer rather than by connection. + /// + /// The durable half of the inference. `display` can change when an operator + /// renames a node; this does not, which is what makes it safe to key a + /// cache of that node's hardware by. `None` whenever `source` is not + /// `Telemetry` — there is no node behind a preimage or a carried name, and + /// anything hung off one would be describing a different machine. + pub peer_id: Option, } #[derive(Debug, Default, Clone)] @@ -238,6 +247,7 @@ impl Attributor { source: AttributionSource::Carried, confidence: 0.0, votes: 0, + peer_id: None, }, None => Attribution { display: address @@ -247,6 +257,7 @@ impl Attributor { source: AttributionSource::Preimage, confidence: 0.0, votes: 0, + peer_id: None, }, }; let Some(entry) = self.authors.get_mut(miner) else { @@ -319,6 +330,12 @@ impl Attributor { source: AttributionSource::Telemetry, confidence, votes: backing, + peer_id: match &held { + NodeKey::Peer(peer) => Some(peer.clone()), + // A per-connection id, which is meaningless the moment the + // feed reconnects. Nothing may be keyed by it. + NodeKey::Node(_) => None, + }, }, _ => fallback, } diff --git a/crates/blackbeard-data/migrations/0007_node_telemetry.sql b/crates/blackbeard-data/migrations/0007_node_telemetry.sql new file mode 100644 index 0000000..36af0aa --- /dev/null +++ b/crates/blackbeard-data/migrations/0007_node_telemetry.sql @@ -0,0 +1,49 @@ +-- What the telemetry feed says a node is, kept across restarts. +-- +-- Keyed on the peer id, never on the feed's `node_id`: that one is per +-- connection and changes every time the telemetry server restarts or the node +-- reconnects, so a table keyed on it would accumulate a new row per reconnect +-- for the same machine. +-- +-- One row per node, overwritten whenever the feed says something newer. There +-- is deliberately no history: this is a cache of the current answer, and a node +-- that changes its CPU is telling us the old answer was superseded rather than +-- adding a fact about the past. +create table if not exists node_telemetry ( + peer_id text primary key, + -- Which chain we last saw it on. A node reports to one chain's feed, but + -- an operator may move a machine between chains, and the row follows it. + chain text not null references chain (id) on delete cascade, + name text not null, + implementation text, + version text, + target_os text, + target_arch text, + target_env text, + cpu text, + core_count integer, + -- Bytes. `bigint` because 64 GiB does not fit in an integer, and the + -- machines on this network routinely have more. + memory bigint, + linux_kernel text, + linux_distro text, + is_virtual_machine boolean, + -- ISO 3166-1 alpha-2, derived from coordinates we do not keep. + -- + -- No city column, and no latitude or longitude, by design: the feed's + -- coordinates come from IP geolocation and are wrong at city resolution + -- often enough that storing them would invite someone to render one. A + -- column that does not exist cannot be displayed by mistake. + country_code text, + country_name text, + -- When the node process started, per the feed. Lets uptime be a fact about + -- the node rather than about how long this observer has been watching. + startup_time timestamptz, + -- When we last heard any of this. + seen_at timestamptz not null default now() +); + +-- The join the miner route makes: attribution gives a peer id, and rows are +-- read one at a time by it — which the primary key already serves. This index +-- is for the other direction, listing a chain's nodes. +create index if not exists node_telemetry_chain_idx on node_telemetry (chain, seen_at desc); diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 4efeec6..6259395 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -60,6 +60,83 @@ pub struct StoreConfig { pub max_connections: u32, } +/// One node's cached telemetry, as a row. +/// +/// The same facts as [`crate::telemetry::NodeMetadata`], with the two +/// differences the database insists on: the feed's `startup_time` is +/// milliseconds since the epoch and this is a `timestamptz`, and a row knows +/// when it was written. +/// +/// Deliberately not the same struct. The wire shape is whatever the telemetry +/// server happens to send and has already changed between releases; the row +/// shape is ours. Converting between them in one place is what keeps a feed +/// change from reaching the schema. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NodeTelemetry { + /// The node's self-reported name. Not an identity claim. + pub name: String, + /// `"Quantus Node"`. + pub implementation: Option, + /// The client version with its commit. + pub version: Option, + /// `"linux"`, `"windows"`. + pub target_os: Option, + /// `"x86_64"`. + pub target_arch: Option, + /// `"gnu"`, `"musl"`. + pub target_env: Option, + /// The CPU model string the node reports. + pub cpu: Option, + /// Physical cores. + pub core_count: Option, + /// Bytes of RAM. + pub memory: Option, + /// Kernel release. + pub linux_kernel: Option, + /// Distribution and version. + pub linux_distro: Option, + /// Whether the node believes it is virtualised. + pub is_virtual_machine: Option, + /// ISO 3166-1 alpha-2. Country and no finer — there is no city column, and + /// no coordinates, so nothing can render one by mistake. + pub country_code: Option, + /// The ISO name for `country_code`. + pub country_name: Option, + /// When the node process started, per the feed. + pub startup_time: Option>, + /// When this row was last written. `None` on a value not yet stored. + pub seen_at: Option>, +} + +impl From<&crate::telemetry::NodeMetadata> for NodeTelemetry { + fn from(m: &crate::telemetry::NodeMetadata) -> Self { + Self { + name: m.name.clone(), + implementation: m.implementation.clone(), + version: m.version.clone(), + target_os: m.target_os.clone(), + target_arch: m.target_arch.clone(), + target_env: m.target_env.clone(), + cpu: m.cpu.clone(), + core_count: m.core_count, + memory: m.memory, + linux_kernel: m.linux_kernel.clone(), + linux_distro: m.linux_distro.clone(), + is_virtual_machine: m.is_virtual_machine, + country_code: m.country_code.clone(), + country_name: m.country_name.clone(), + // A startup time the node reports as further in the future than the + // epoch can express, or otherwise unrepresentable, is dropped rather + // than clamped: an uptime computed from a clamped instant is a + // number that looks like a measurement. + startup_time: m + .startup_time_ms + .and_then(|ms| DateTime::from_timestamp_millis(ms as i64)), + seen_at: None, + } + } +} + /// A connected Postgres store. #[derive(Debug, Clone)] pub struct Store { @@ -1706,6 +1783,151 @@ impl Store { Ok(row.map(|r| MinerId(r.miner))) } + /// Cache what the telemetry feed says about a set of nodes. + /// + /// Overwrite on conflict, because this is the current answer rather than a + /// history: a node that reports a new CPU is saying the old row was + /// superseded, not adding a fact about the past. + /// + /// Nulls do not overwrite what is already known. The feed re-announces + /// every node on reconnect and its geolocation lookup lags that, so an + /// announcement arriving without a country must leave the country alone — + /// otherwise every reconnect blanks the map for the minute it takes the + /// locations to follow. + pub async fn cache_node_telemetry( + &self, + chain: &ChainId, + nodes: &[(String, NodeTelemetry)], + ) -> Result { + if nodes.is_empty() { + return Ok(0); + } + let peer_ids: Vec = nodes.iter().map(|(p, _)| p.clone()).collect(); + let names: Vec = nodes.iter().map(|(_, n)| n.name.clone()).collect(); + let implementations: Vec> = nodes + .iter() + .map(|(_, n)| n.implementation.clone()) + .collect(); + let versions: Vec> = nodes.iter().map(|(_, n)| n.version.clone()).collect(); + let target_oses: Vec> = + nodes.iter().map(|(_, n)| n.target_os.clone()).collect(); + let target_arches: Vec> = + nodes.iter().map(|(_, n)| n.target_arch.clone()).collect(); + let target_envs: Vec> = + nodes.iter().map(|(_, n)| n.target_env.clone()).collect(); + let cpus: Vec> = nodes.iter().map(|(_, n)| n.cpu.clone()).collect(); + let core_counts: Vec> = nodes + .iter() + .map(|(_, n)| n.core_count.map(|c| c as i32)) + .collect(); + let memories: Vec> = nodes + .iter() + .map(|(_, n)| n.memory.map(|m| m as i64)) + .collect(); + let kernels: Vec> = + nodes.iter().map(|(_, n)| n.linux_kernel.clone()).collect(); + let distros: Vec> = + nodes.iter().map(|(_, n)| n.linux_distro.clone()).collect(); + let virtuals: Vec> = nodes.iter().map(|(_, n)| n.is_virtual_machine).collect(); + let country_codes: Vec> = + nodes.iter().map(|(_, n)| n.country_code.clone()).collect(); + let country_names: Vec> = + nodes.iter().map(|(_, n)| n.country_name.clone()).collect(); + let startups: Vec>> = + nodes.iter().map(|(_, n)| n.startup_time).collect(); + + let done = sqlx::query!( + r#" + insert into node_telemetry ( + peer_id, chain, name, implementation, version, + target_os, target_arch, target_env, + cpu, core_count, memory, linux_kernel, linux_distro, + is_virtual_machine, country_code, country_name, startup_time, seen_at + ) + select * from unnest( + $1::text[], $2::text[], $3::text[], $4::text[], $5::text[], + $6::text[], $7::text[], $8::text[], + $9::text[], $10::integer[], $11::bigint[], $12::text[], $13::text[], + $14::boolean[], $15::text[], $16::text[], $17::timestamptz[] + ), now() + on conflict (peer_id) do update set + chain = excluded.chain, + name = excluded.name, + implementation = coalesce(excluded.implementation, node_telemetry.implementation), + version = coalesce(excluded.version, node_telemetry.version), + target_os = coalesce(excluded.target_os, node_telemetry.target_os), + target_arch = coalesce(excluded.target_arch, node_telemetry.target_arch), + target_env = coalesce(excluded.target_env, node_telemetry.target_env), + cpu = coalesce(excluded.cpu, node_telemetry.cpu), + core_count = coalesce(excluded.core_count, node_telemetry.core_count), + memory = coalesce(excluded.memory, node_telemetry.memory), + linux_kernel = coalesce(excluded.linux_kernel, node_telemetry.linux_kernel), + linux_distro = coalesce(excluded.linux_distro, node_telemetry.linux_distro), + is_virtual_machine = coalesce(excluded.is_virtual_machine, node_telemetry.is_virtual_machine), + country_code = coalesce(excluded.country_code, node_telemetry.country_code), + country_name = coalesce(excluded.country_name, node_telemetry.country_name), + startup_time = coalesce(excluded.startup_time, node_telemetry.startup_time), + seen_at = now() + "#, + &peer_ids, + &vec![chain.as_str().to_owned(); nodes.len()], + &names, + &implementations as &[Option], + &versions as &[Option], + &target_oses as &[Option], + &target_arches as &[Option], + &target_envs as &[Option], + &cpus as &[Option], + &core_counts as &[Option], + &memories as &[Option], + &kernels as &[Option], + &distros as &[Option], + &virtuals as &[Option], + &country_codes as &[Option], + &country_names as &[Option], + &startups as &[Option>], + ) + .execute(&self.pool) + .await? + .rows_affected(); + Ok(done) + } + + /// What is cached about one node, by peer id. + pub async fn node_telemetry(&self, peer_id: &str) -> Result, DataError> { + let row = sqlx::query!( + r#" + select name, implementation, version, target_os, target_arch, target_env, + cpu, core_count, memory, linux_kernel, linux_distro, + is_virtual_machine, country_code, country_name, startup_time, seen_at + from node_telemetry + where peer_id = $1 + "#, + peer_id, + ) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|r| NodeTelemetry { + name: r.name, + implementation: r.implementation, + version: r.version, + target_os: r.target_os, + target_arch: r.target_arch, + target_env: r.target_env, + cpu: r.cpu, + core_count: r.core_count.map(|c| c as u32), + memory: r.memory.map(|m| m as u64), + linux_kernel: r.linux_kernel, + linux_distro: r.linux_distro, + is_virtual_machine: r.is_virtual_machine, + country_code: r.country_code, + country_name: r.country_name, + startup_time: r.startup_time, + seen_at: Some(r.seen_at), + })) + } + /// Persist the attributions currently held. pub async fn save_attributions( &self, diff --git a/crates/blackbeard-data/tests/store.rs b/crates/blackbeard-data/tests/store.rs index 3b6a5de..a3e1806 100644 --- a/crates/blackbeard-data/tests/store.rs +++ b/crates/blackbeard-data/tests/store.rs @@ -624,3 +624,90 @@ async fn a_chain_that_reorgs_back_and_forth_keeps_both_sides() { f.cleanup().await; } + +/// The cache keeps the current answer, and a silent frame does not erase it. +/// +/// #15: the telemetry server geolocates asynchronously, so a node is announced +/// with a null location and the country follows. Every reconnect re-announces +/// every node the same way — so if a null overwrote, each reconnect would blank +/// the country of every node on the site for as long as the locations took to +/// catch up. +#[tokio::test] +async fn node_telemetry_overwrites_but_a_null_does_not_erase() { + use blackbeard_data::store::NodeTelemetry; + + let f = fixture!("node_telemetry"); + seed_chain(&f.store).await; + let peer = "QmcjjPmnyM6KYXp1U4Rdy6v2stskYQnTpgk4FH1cMfhMpB".to_owned(); + + let full = NodeTelemetry { + name: "Wallace-Vast-ODB-G".into(), + implementation: Some("Quantus Node".into()), + version: Some("1.0.1-f1176cea6a6".into()), + target_os: Some("linux".into()), + target_arch: Some("x86_64".into()), + target_env: Some("gnu".into()), + cpu: Some("AMD Ryzen 5 5600 6-Core Processor".into()), + core_count: Some(6), + memory: Some(65_192_095_744), + linux_kernel: Some("7.0.0-31-generic".into()), + linux_distro: Some("Ubuntu 24.04.4 LTS".into()), + is_virtual_machine: Some(false), + country_code: Some("FR".into()), + country_name: Some("France".into()), + startup_time: chrono::DateTime::from_timestamp_millis(1_789_225_142_469), + seen_at: None, + }; + f.store + .cache_node_telemetry(&chain(), &[(peer.clone(), full.clone())]) + .await + .unwrap(); + + let stored = f + .store + .node_telemetry(&peer) + .await + .unwrap() + .expect("cached"); + assert_eq!(stored.cpu, full.cpu); + assert_eq!(stored.memory, Some(65_192_095_744), "64 GiB needs a bigint"); + assert_eq!(stored.core_count, Some(6)); + assert_eq!(stored.country_code.as_deref(), Some("FR")); + assert_eq!(stored.startup_time, full.startup_time); + assert!(stored.seen_at.is_some()); + + // The node upgrades and is re-announced before its location resolves. + let reannounced = NodeTelemetry { + name: "Wallace-Vast-ODB-G".into(), + version: Some("1.0.2-abcdef0123".into()), + ..Default::default() + }; + f.store + .cache_node_telemetry(&chain(), &[(peer.clone(), reannounced)]) + .await + .unwrap(); + + let stored = f + .store + .node_telemetry(&peer) + .await + .unwrap() + .expect("cached"); + assert_eq!( + stored.version.as_deref(), + Some("1.0.2-abcdef0123"), + "a newer value overwrites" + ); + assert_eq!( + stored.country_code.as_deref(), + Some("FR"), + "a null must not erase a country we already knew" + ); + assert_eq!( + stored.cpu.as_deref(), + Some("AMD Ryzen 5 5600 6-Core Processor"), + "nor the hardware" + ); + + f.cleanup().await; +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index ba66b93..67f57b2 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -33,7 +33,9 @@ pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, Recen pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking}; pub use error::{ApiError, EntityError}; -pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint}; +pub use miner::{ + AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeInfo, +}; pub use reversible::{PendingTransfer, ReversibleState}; pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource}; pub use runtime::{ diff --git a/crates/blackbeard-entities/src/miner.rs b/crates/blackbeard-entities/src/miner.rs index df6695b..af317e9 100644 --- a/crates/blackbeard-entities/src/miner.rs +++ b/crates/blackbeard-entities/src/miner.rs @@ -177,4 +177,70 @@ pub struct MinerDetail { /// cumulative measure of work done, and the one that does not shrink when /// difficulty rises. pub cumulative_work: BigUintDec, + /// What telemetry says about the node this miner's blocks were reported + /// from, when one has been identified. + pub node: Option, +} + +/// What the telemetry feed says about a node. +/// +/// **Every field here is reported by the node about itself, and the join from +/// this miner to that node is an inference.** The chain says who was paid; it +/// says nothing about what hardware won the block. This is telemetry, which a +/// node volunteers and could state wrongly, reached through the same +/// first-reporter voting that produces the miner's display name — so the +/// `confidence` and `attribution` already on the row govern how much weight any +/// of it carries. The UI must show that alongside, not beneath. +/// +/// One preimage may be mined from several nodes and one node may mine for +/// several preimages, so this is the node the attribution settled on rather +/// than "the miner's machine". +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "NodeInfo.ts")] +pub struct NodeInfo { + /// The node's self-reported name. + pub name: String, + /// The libp2p peer id this was matched on. The one durable identifier in + /// here, and what the cache is keyed by. + pub peer_id: String, + /// `"Quantus Node"`. + pub implementation: Option, + /// The client version with its commit. + pub version: Option, + /// `"linux"`, `"windows"`. + pub target_os: Option, + /// `"x86_64"`. + pub target_arch: Option, + /// `"gnu"`, `"musl"`. + pub target_env: Option, + /// The CPU model the node reports. Not a claim about what is hashing: on + /// this network most work is on GPU, which telemetry does not report at all. + pub cpu: Option, + /// Physical cores. + #[ts(type = "number")] + pub core_count: Option, + /// Bytes of RAM. + #[ts(type = "number")] + pub memory: Option, + /// Kernel release. + pub linux_kernel: Option, + /// Distribution and version. + pub linux_distro: Option, + /// Whether the node believes it is virtualised. + pub is_virtual_machine: Option, + /// ISO 3166-1 alpha-2. + /// + /// **Country, and deliberately no finer.** The feed sends coordinates and a + /// city from an IP geolocation database; at city resolution those are wrong + /// often enough that showing one would assert something nobody knows. The + /// coordinates are not stored and the city is never sent here. + pub country_code: Option, + /// The ISO name for `country_code`. Long for some countries. + pub country_name: Option, + /// When the node process started, per the feed. The UI renders uptime from + /// this, which makes it a fact about the node rather than about how long + /// this observer has been watching. + pub startup_time: Option>, + /// When the feed last told us any of this. + pub seen_at: Option>, } diff --git a/web/src/api/generated/MinerDetail.ts b/web/src/api/generated/MinerDetail.ts index b7b4b38..5b5b0e8 100644 --- a/web/src/api/generated/MinerDetail.ts +++ b/web/src/api/generated/MinerDetail.ts @@ -4,6 +4,7 @@ import type { ChainId } from "./ChainId"; import type { LeaderboardRow } from "./LeaderboardRow"; import type { MinerId } from "./MinerId"; import type { MinerSeriesPoint } from "./MinerSeriesPoint"; +import type { NodeInfo } from "./NodeInfo"; /** * Everything the miner detail page shows for one preimage. @@ -50,4 +51,9 @@ series: Array, * cumulative measure of work done, and the one that does not shrink when * difficulty rises. */ -cumulative_work: BigUintDec, }; +cumulative_work: BigUintDec, +/** + * What telemetry says about the node this miner's blocks were reported + * from, when one has been identified. + */ +node: NodeInfo | null, }; diff --git a/web/src/api/generated/NodeInfo.ts b/web/src/api/generated/NodeInfo.ts new file mode 100644 index 0000000..53d430e --- /dev/null +++ b/web/src/api/generated/NodeInfo.ts @@ -0,0 +1,95 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What the telemetry feed says about a node. + * + * **Every field here is reported by the node about itself, and the join from + * this miner to that node is an inference.** The chain says who was paid; it + * says nothing about what hardware won the block. This is telemetry, which a + * node volunteers and could state wrongly, reached through the same + * first-reporter voting that produces the miner's display name — so the + * `confidence` and `attribution` already on the row govern how much weight any + * of it carries. The UI must show that alongside, not beneath. + * + * One preimage may be mined from several nodes and one node may mine for + * several preimages, so this is the node the attribution settled on rather + * than "the miner's machine". + */ +export type NodeInfo = { +/** + * The node's self-reported name. + */ +name: string, +/** + * The libp2p peer id this was matched on. The one durable identifier in + * here, and what the cache is keyed by. + */ +peer_id: string, +/** + * `"Quantus Node"`. + */ +implementation: string | null, +/** + * The client version with its commit. + */ +version: string | null, +/** + * `"linux"`, `"windows"`. + */ +target_os: string | null, +/** + * `"x86_64"`. + */ +target_arch: string | null, +/** + * `"gnu"`, `"musl"`. + */ +target_env: string | null, +/** + * The CPU model the node reports. Not a claim about what is hashing: on + * this network most work is on GPU, which telemetry does not report at all. + */ +cpu: string | null, +/** + * Physical cores. + */ +core_count: number, +/** + * Bytes of RAM. + */ +memory: number, +/** + * Kernel release. + */ +linux_kernel: string | null, +/** + * Distribution and version. + */ +linux_distro: string | null, +/** + * Whether the node believes it is virtualised. + */ +is_virtual_machine: boolean | null, +/** + * ISO 3166-1 alpha-2. + * + * **Country, and deliberately no finer.** The feed sends coordinates and a + * city from an IP geolocation database; at city resolution those are wrong + * often enough that showing one would assert something nobody knows. The + * coordinates are not stored and the city is never sent here. + */ +country_code: string | null, +/** + * The ISO name for `country_code`. Long for some countries. + */ +country_name: string | null, +/** + * When the node process started, per the feed. The UI renders uptime from + * this, which makes it a fact about the node rather than about how long + * this observer has been watching. + */ +startup_time: string | null, +/** + * When the feed last told us any of this. + */ +seen_at: string | null, }; diff --git a/web/src/components/MinerPanel.tsx b/web/src/components/MinerPanel.tsx index fc1fc05..7ee2f50 100644 --- a/web/src/components/MinerPanel.tsx +++ b/web/src/components/MinerPanel.tsx @@ -26,6 +26,7 @@ import { import { href } from '../lib/routes' import { usePinnedMiners } from '../lib/store' import { ShareChart } from './ShareChart' +import { NodePanel } from './NodePanel' export function MinerPanel({ chain, @@ -60,121 +61,136 @@ export function MinerPanel({ const named = row?.attribution === 'telemetry' return ( -
-
-
-
Miner
-

- {/* The best identifier available, in the order a reader can use + <> +
+
+
+
Miner
+

+ {/* The best identifier available, in the order a reader can use them: an attributed node name, else the reward address the miner configured, else the preimage the header carries. The full set is below regardless. */} - {named - ? row.display - : detail?.address - ? `${detail.address.slice(0, 8)}…${detail.address.slice(-4)}` - : shortMiner(miner)} -

- {/* All three identifiers, because they answer different questions. + {named + ? row.display + : detail?.address + ? `${detail.address.slice(0, 8)}…${detail.address.slice(-4)}` + : shortMiner(miner)} +

+ {/* All three identifiers, because they answer different questions. The node name is an inference and may be absent; the address is what the miner configured and recognises; the preimage is what the block header actually carries and the only one the chain asserts. A miner checking "is this me?" needs the address; anyone checking our working needs the preimage. */} -
- {detail?.address && ( - <> -
Rewards
-
- {/* The way through to what was actually paid: this page +
+ {detail?.address && ( + <> +
Rewards
+
+ {/* The way through to what was actually paid: this page counts blocks, the account page counts tokens, and the address is the only thing that joins them. */} - {detail.address} -
- - )} -
Preimage
-
{miner}
-
-
- {/* No close button: this is a URL somebody navigated to, not a dialog + {detail.address} + + + )} +
Preimage
+
{miner}
+ +
+ {/* No close button: this is a URL somebody navigated to, not a dialog that opened over something they were reading. The back button and the chain chip both already do what it would. */} - - + + - {error &&

{error}

} + {error &&

{error}

} - {!error && !detail &&

Reading the record…

} + {!error && !detail &&

Reading the record…

} - {detail && ( - <> -
-
-
Rank
-
{row ? `#${row.rank}` : '—'}
-
- {row ? `${share(row.share)} of the window` : 'no blocks in this window'} + {detail && ( + <> +
+
+
Rank
+
{row ? `#${row.rank}` : '—'}
+
+ {row ? `${share(row.share)} of the window` : 'no blocks in this window'} +
-
-
-
Est. hashrate
-
- {hashrate(row?.hashrate_estimate)} -
- {/* The note carries the error bar, because on this page it is +
+
Est. hashrate
+
+ {hashrate(row?.hashrate_estimate)} +
+ {/* The note carries the error bar, because on this page it is the headline number and a bare figure off two blocks is a coin toss with three significant figures. */} -
- {uncertainty(row?.hashrate_relative_error) - ? `${uncertainty(row?.hashrate_relative_error)} over ${row?.blocks ?? 0} block${row?.blocks === 1 ? '' : 's'}` - : 'from work its blocks cost'} +
+ {uncertainty(row?.hashrate_relative_error) + ? `${uncertainty(row?.hashrate_relative_error)} over ${row?.blocks ?? 0} block${row?.blocks === 1 ? '' : 's'}` + : 'from work its blocks cost'} +
-
-
-
Blocks observed
-
{height(detail.blocks_observed)}
-
- {detail.first_seen ? `first seen ${ago(detail.first_seen)}` : 'never seen'} +
+
Blocks observed
+
{height(detail.blocks_observed)}
+
+ {detail.first_seen ? `first seen ${ago(detail.first_seen)}` : 'never seen'} +
-
-
-
Cumulative work
-
- {bigNumber(detail.cumulative_work)} -
- {/* Difficulty summed over every block won. It does not shrink when +
+
Cumulative work
+
+ {bigNumber(detail.cumulative_work)} +
+ {/* Difficulty summed over every block won. It does not shrink when difficulty rises, which a block count effectively does. */} -
expected hashes, all blocks
-
-
-
Best streak
-
- {row && row.best_streak > 1 ? `×${row.best_streak}` : '—'} +
expected hashes, all blocks
+
+
+
Best streak
+
+ {row && row.best_streak > 1 ? `×${row.best_streak}` : '—'} +
+
consecutive blocks
-
consecutive blocks
-
-
-
- Share of network blocks +
+
+ Share of network blocks +
+
- -
- + + )} +
+ {/* A sibling of the miner panel, not a child: the node is a different + subject with a different provenance, and nesting it put a panel border + inside a panel border. */} + {detail?.node && ( + )} - + ) } diff --git a/web/src/components/NodePanel.tsx b/web/src/components/NodePanel.tsx new file mode 100644 index 0000000..8f11f7f --- /dev/null +++ b/web/src/components/NodePanel.tsx @@ -0,0 +1,131 @@ +/** + * The node a miner's blocks were reported from. + * + * **Two layers of uncertainty, and the panel has to wear both.** The chain says + * who was paid and nothing about what won the block; everything here is a node's + * report about itself, reached through the same first-reporter voting that gave + * the miner its display name. So the confidence and the vote count sit at the + * top of the panel rather than in a footnote, and the heading says "reported + * by", not "mined on". + * + * The hardware is the sharpest case. A CPU model beside a hashrate reads as the + * thing doing the hashing, and on this network it is usually not: the miners + * measured all report `miner_cpu_hash_rate` of zero, so the work is on GPUs that + * telemetry does not mention at all. The note says so. + * + * Location is a country and never finer — see `blackbeard_core::geo`. + */ + +import type { NodeInfo } from '../api/generated/NodeInfo' +import type { AttributionSource } from '../api/generated/AttributionSource' +import { ago, bytes, since } from '../lib/format' + +/** A two-letter country code as its flag, by offsetting into the regional + * indicator block. Returns null for anything that is not two letters, so a + * malformed code renders as nothing rather than as tofu. */ +function flag(code: string | null): string | null { + if (!code || !/^[A-Za-z]{2}$/.test(code)) return null + const base = 0x1f1e6 - 'A'.charCodeAt(0) + return String.fromCodePoint(...[...code.toUpperCase()].map((c) => base + c.charCodeAt(0))) +} + +function Field({ + label, + value, + note, +}: { + label: string + value: string + note?: string | undefined +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +export function NodePanel({ + node, + confidence, + votes, + attribution, +}: { + node: NodeInfo + confidence: number | null + votes: number | null + attribution: AttributionSource | null +}) { + const os = [node.target_os, node.target_arch, node.target_env].filter(Boolean).join(' · ') + const country = node.country_code + ? `${flag(node.country_code) ?? ''} ${node.country_code}`.trim() + : null + + // A percentage on its own cannot tell one vote of one from nine of nine, and + // those are not the same claim — so both travel together, as they do on the + // standings. + const strength = + confidence !== null && votes !== null + ? `${Math.round(confidence * 100)}% of ${votes} vote${votes === 1 ? '' : 's'}` + : null + + return ( +
+
+
+
Reported by
+

+ {node.name} +

+
+ {strength && ( + + attributed on {strength} + + )} +
+ +
+ {node.version && } + {node.implementation && } + {os && } + {country && } + {node.cpu && } + {node.core_count !== null && } + {node.memory !== null && } + {node.linux_distro && } + {node.linux_kernel && } + {node.is_virtual_machine !== null && ( + + )} + {node.startup_time && ( + + )} +
+ +

+ Everything here is what the node says about itself on the telemetry feed, and the link + between it and this miner is inferred from which node reported its blocks first — + never a claim the operator made, and never an identity check.{' '} + {attribution === 'telemetry' + ? 'One operator may run several nodes under one reward preimage, and one node may mine for several, so this is the node the attribution settled on rather than the miner’s only machine.' + : ''}{' '} + The CPU is the node’s, not necessarily what is hashing: on this network most work is on + GPUs, which telemetry does not report. Location is resolved to a country and no finer, + because the feed’s coordinates come from IP geolocation and are unreliable at city + resolution. + {node.seen_at && <> Last heard {ago(node.seen_at)}.} +

+
+ ) +} diff --git a/web/src/index.css b/web/src/index.css index 44b9da1..02ebf19 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -273,6 +273,33 @@ a { flex-wrap: wrap; } +/* ---- node panel ---------------------------------------------------------- */ + +.node-grid { + display: grid; + /* Two columns on a phone, as many as fit on a desktop. A CPU model is the + longest value here by far and gets the room it needs rather than wrapping + mid-word. */ + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 1px; + background: var(--border); + border-top: var(--rule); +} + +.node-field { + background: var(--surface-1); + padding: 11px 16px 13px; +} + +.node-value { + font-family: var(--font-mono); + font-size: 13px; + margin-top: 5px; + /* A CPU model can be sixty characters. Wrapping is better than a scrollbar + in a cell this small. */ + overflow-wrap: anywhere; +} + /* ---- theme toggle -------------------------------------------------------- */ .theme-toggle { diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 756baf9..c80a7ed 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -68,7 +68,47 @@ export function ago(iso: string, now: number = Date.now()): string { return `${Math.floor(delta / 86400)}d ago` } +/** + * How long something has been running, as a duration rather than a point. + * + * `ago` renders "4d ago", which is right for an event and wrong for an uptime: + * a node is not up four days ago, it has been up for four days. Same arithmetic, + * different sentence. + */ +export function since(iso: string, now: number = Date.now()): string { + const delta = Math.max(0, (now - new Date(iso).getTime()) / 1000) + if (delta < 60) return `${Math.floor(delta)}s` + if (delta < 3600) return `${Math.floor(delta / 60)}m` + if (delta < 86400) return `${Math.floor(delta / 3600)}h` + const days = Math.floor(delta / 86400) + const hours = Math.floor((delta % 86400) / 3600) + // A node up eight days and a node up eight days and twenty hours are + // different claims about stability, and "8d" flattens them. + return hours > 0 ? `${days}d ${hours}h` : `${days}d` +} + /** Block heights with thousands separators. */ +/** + * Bytes, as a machine's spec sheet would print them. + * + * Binary units, because that is what a node reports and what its owner bought: + * 65,192,095,744 bytes is a 64 GiB machine, and rendering it as "65.2 GB" would + * have the reader checking whether they have the right box. + */ +export function bytes(value: number | null | undefined): string { + if (value === null || value === undefined) return '—' + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + let n = value + let unit = 0 + while (n >= 1024 && unit < units.length - 1) { + n /= 1024 + unit += 1 + } + // Whole numbers above a gibibyte: nobody cares that a machine has 63.7 GiB + // rather than 64, and the fraction invites a precision the report lacks. + return `${n >= 100 || unit >= 3 ? Math.round(n) : n.toFixed(1)} ${units[unit]}` +} + export function height(value: number | null | undefined): string { return value === null || value === undefined ? '—' : value.toLocaleString('en-US') }