feat: a nodes index, so a node need not have mined to be visible
Reported: `baba-gorchitsa` shows its hardware on telemetry.quantus.com and
nothing here. Not a decoding failure — we held the data. Measured, the same node
on both chains at the same moment:
quantus attribution=carried, 0 votes, 33 blocks on the feed: yes
planck attribution=telemetry, 1 vote, 2969 blocks on the feed: no
Exactly inverted. On mainnet we had the metadata and no join to it; on Planck
the join and no node. The mainnet name is *carried* from Planck, and
`Attribution::peer_id` is `None` for anything but a telemetry attribution
because there is no node behind a carried name — that reasoning stands. The
mistake was upstream: node metadata was reachable *only* through the mining
inference, and the feed hands us every node unconditionally. 288 on mainnet, of
which a handful have ever won a block.
So `/:chain/node` lists all of them and `/:chain/node/:peer` is one, neither
gated on mining. The index merges the live feed over the cache, so a node not
re-announced since our last restart still appears, faint, with its peer count
absent rather than stale — a peer count from an hour ago is not a peer count.
Live rows sort first, then by name, and the filter matches name, peer id, CPU,
version or country, because "find my node" is the question the page exists for.
The miner panel now links to the node's own page instead of being the only way
to see it.
`reported` beside the count is the feed's own `AddedChain` figure and will not
match in either direction — 284 against 288 listed, measured, because it is a
snapshot the feed refreshes on its own cadence. Shown next to our count rather
than instead of it, since the difference is a real fact about how each was
arrived at. My first comment claimed it was "usually larger"; it was smaller the
first time I looked at it.
One trap caught in the browser rather than by the type checker: "is this the
standings?" existed three times, and adding a section updated two. The redirect
in `App` judged `/quantus/node/<peer>` to be the standings and replaced it with
`/quantus/3600-blocks` — route parsed, page existed, address bar threw it away.
It is `isStandings` now, in one place.
Closes #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
119
.sqlx/query-fcca57987f095c0b2f7ba9e8cc3c6d32e670a8125300453323d7d54502898cb8.json
generated
Normal file
119
.sqlx/query-fcca57987f095c0b2f7ba9e8cc3c6d32e670a8125300453323d7d54502898cb8.json
generated
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select peer_id, 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 chain = $1\n order by seen_at desc\n limit $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "peer_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "implementation",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "version",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "target_os",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "target_arch",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "target_env",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cpu",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "core_count",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "memory",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "linux_kernel",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "linux_distro",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "is_virtual_machine",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "country_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "country_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "startup_time",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "seen_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fcca57987f095c0b2f7ba9e8cc3c6d32e670a8125300453323d7d54502898cb8"
|
||||
}
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -394,6 +394,22 @@ 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.
|
||||
|
||||
**Node metadata must not be reachable only through the mining inference.** The
|
||||
feed hands over every node on subscribe — 288 on mainnet — while the miner route
|
||||
reaches one only when the first-reporter voting has settled, which needs blocks
|
||||
*and* a resolvable lead. Most nodes never mine, so that door admits almost
|
||||
nobody: a node visible on the official telemetry site was invisible here. The
|
||||
`/:chain/node` index and `/:chain/node/:peer` route have no entry condition,
|
||||
and the miner panel now links to them rather than being the only way in.
|
||||
|
||||
**"Is this the standings?" is one function, not three.** `isStandings` in
|
||||
`lib/routes.ts`. Adding the node section updated the copies in `SectionNav` and
|
||||
`windowApplies` and missed the canonicalising redirect in `App`, which judged
|
||||
`/quantus/node/<peer>` to be the standings and replaced it with
|
||||
`/quantus/3600-blocks` — the route parsed, the page existed, and the address bar
|
||||
threw it away before it rendered. A new section still has to be added to the
|
||||
predicate; it now has one place to be added to.
|
||||
|
||||
**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
|
||||
|
||||
@@ -19,10 +19,10 @@ 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, NodeActivity, NodeInfo, PendingTransfer, RecentBlock,
|
||||
ReversibleState, RewardSummary, RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField,
|
||||
RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant,
|
||||
StateEntry, Window,
|
||||
MinerSeriesPoint, NamedAccount, NodeActivity, NodeIndex, NodeInfo, NodeRow, PendingTransfer,
|
||||
RecentBlock, ReversibleState, RewardSummary, RoleSource, RuntimeConstant, RuntimeDetail,
|
||||
RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary,
|
||||
RuntimeVariant, StateEntry, Window,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::compression::CompressionLayer;
|
||||
@@ -55,6 +55,8 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
|
||||
.route("/v1/chains/{chain}/series", get(series))
|
||||
.route("/v1/chains/{chain}/blocks/{block}", get(block))
|
||||
.route("/v1/chains/{chain}/miners/{miner}", get(miner))
|
||||
.route("/v1/chains/{chain}/nodes", get(nodes))
|
||||
.route("/v1/chains/{chain}/nodes/{peer}", get(node))
|
||||
.route("/v1/chains/{chain}/accounts", get(accounts))
|
||||
.route("/v1/chains/{chain}/accounts/{address}", get(account))
|
||||
.route("/v1/chains/{chain}/calls", get(calls))
|
||||
@@ -781,6 +783,120 @@ async fn miner(
|
||||
}))
|
||||
}
|
||||
|
||||
/// How many cached nodes a chain's index will reach back for.
|
||||
///
|
||||
/// Mainnet telemetry reports under three hundred nodes and the testnets fewer,
|
||||
/// so this is a ceiling rather than a page size — a chain would have to grow
|
||||
/// fourfold before anyone hit it, and at that point the index wants a real
|
||||
/// pager rather than a bigger number.
|
||||
const NODE_INDEX_LIMIT: i64 = 1_000;
|
||||
|
||||
/// `GET /v1/chains/{chain}/nodes`
|
||||
///
|
||||
/// Every node the telemetry feed reports, whether or not it has ever won a
|
||||
/// block. The miner route reaches a node through the first-reporter voting, and
|
||||
/// most nodes never mine — so that door admits almost nobody and this one is
|
||||
/// open to all of them.
|
||||
async fn nodes(
|
||||
State(state): State<AppState>,
|
||||
Path(chain): Path<String>,
|
||||
) -> Result<Json<NodeIndex>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
|
||||
// The cache first, then the live feed over the top of it: a node the feed
|
||||
// is reporting now supersedes its stored row, and one it has not
|
||||
// re-announced since our last restart still appears.
|
||||
let mut by_peer: std::collections::BTreeMap<String, NodeRow> =
|
||||
std::collections::BTreeMap::new();
|
||||
if let Ok(cached) = state
|
||||
.store
|
||||
.cached_nodes(&runtime.id(), NODE_INDEX_LIMIT)
|
||||
.await
|
||||
{
|
||||
for (peer, node) in cached {
|
||||
by_peer.insert(
|
||||
peer.clone(),
|
||||
NodeRow {
|
||||
peer_id: peer,
|
||||
name: node.name,
|
||||
version: node.version,
|
||||
country_code: node.country_code,
|
||||
cpu: node.cpu,
|
||||
core_count: node.core_count,
|
||||
memory: node.memory,
|
||||
peer_count: None,
|
||||
live: false,
|
||||
seen_at: node.seen_at,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
for (peer, m) in runtime.telemetry.all_metadata() {
|
||||
let activity = runtime.telemetry.activity_of(&peer);
|
||||
by_peer.insert(
|
||||
peer.clone(),
|
||||
NodeRow {
|
||||
peer_id: peer,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
country_code: m.country_code,
|
||||
cpu: m.cpu,
|
||||
core_count: m.core_count,
|
||||
memory: m.memory,
|
||||
peer_count: activity.and_then(|a| a.peer_count),
|
||||
live: true,
|
||||
seen_at: Some(now),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Live first, then by name. A reader looking for their own node is looking
|
||||
// for a name; a reader looking at the chain wants the nodes that are
|
||||
// actually up at the top.
|
||||
let mut nodes: Vec<NodeRow> = by_peer.into_values().collect();
|
||||
nodes.sort_by(|a, b| {
|
||||
b.live
|
||||
.cmp(&a.live)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
.then_with(|| a.peer_id.cmp(&b.peer_id))
|
||||
});
|
||||
|
||||
Ok(Json(NodeIndex {
|
||||
chain: runtime.id(),
|
||||
reported: runtime.telemetry.node_count(),
|
||||
nodes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /v1/chains/{chain}/nodes/{peer}`
|
||||
///
|
||||
/// One node, by peer id. The same shape the miner route hangs off, so a node
|
||||
/// reached directly and a node reached through a miner read identically.
|
||||
async fn node(
|
||||
State(state): State<AppState>,
|
||||
Path((chain, peer)): Path<(String, String)>,
|
||||
) -> Result<Json<NodeInfo>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
let live = runtime.telemetry.metadata_of(&peer);
|
||||
let stored = match live {
|
||||
Some(_) => None,
|
||||
None => state.store.node_telemetry(&peer).await.ok().flatten(),
|
||||
};
|
||||
let activity = runtime.telemetry.activity_of(&peer);
|
||||
node_info(peer.clone(), live.as_ref(), stored.as_ref(), activity)
|
||||
.map(Json)
|
||||
.ok_or_else(|| {
|
||||
Failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
ApiError::new(
|
||||
"unknown_node",
|
||||
format!("no node `{peer}` has reported to this chain's telemetry"),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1928,6 +1928,60 @@ impl Store {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every node cached for a chain, most recently heard first.
|
||||
///
|
||||
/// The half of a node index the live feed cannot supply: a node announced
|
||||
/// before this process started and not re-announced since is absent from
|
||||
/// the feed's own state but is still a node this chain has.
|
||||
pub async fn cached_nodes(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(String, NodeTelemetry)>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select peer_id, 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 chain = $1
|
||||
order by seen_at desc
|
||||
limit $2
|
||||
"#,
|
||||
chain.as_str(),
|
||||
limit,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.peer_id,
|
||||
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),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Persist the attributions currently held.
|
||||
pub async fn save_attributions(
|
||||
&self,
|
||||
|
||||
@@ -35,7 +35,7 @@ pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tr
|
||||
pub use error::{ApiError, EntityError};
|
||||
pub use miner::{
|
||||
AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity,
|
||||
NodeInfo,
|
||||
NodeIndex, NodeInfo, NodeRow,
|
||||
};
|
||||
pub use reversible::{PendingTransfer, ReversibleState};
|
||||
pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource};
|
||||
|
||||
@@ -253,6 +253,64 @@ pub struct NodeInfo {
|
||||
pub activity: Option<NodeActivity>,
|
||||
}
|
||||
|
||||
/// One node in a chain's node index.
|
||||
///
|
||||
/// Every node the telemetry feed reports, whether or not it has ever won a
|
||||
/// block — which is the point. The miner route reaches a node through the
|
||||
/// first-reporter voting, and most nodes never mine, so that door admits almost
|
||||
/// nobody. This one is open to all of them.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NodeRow.ts")]
|
||||
pub struct NodeRow {
|
||||
/// The libp2p peer id, and the node's address on this site.
|
||||
pub peer_id: String,
|
||||
/// Self-reported name. Not unique: two operators may pick the same one, and
|
||||
/// one operator may run several nodes under it.
|
||||
pub name: String,
|
||||
/// The client version with its commit.
|
||||
pub version: Option<String>,
|
||||
/// ISO 3166-1 alpha-2. Country and no finer.
|
||||
pub country_code: Option<String>,
|
||||
/// The CPU the node reports.
|
||||
pub cpu: Option<String>,
|
||||
/// Physical cores.
|
||||
#[ts(type = "number")]
|
||||
pub core_count: Option<u32>,
|
||||
/// Bytes of RAM.
|
||||
#[ts(type = "number")]
|
||||
pub memory: Option<u64>,
|
||||
/// Peers, when the feed is currently reporting this node.
|
||||
#[ts(type = "number")]
|
||||
pub peer_count: Option<u32>,
|
||||
/// Whether the feed is reporting it *now*, as opposed to this row coming
|
||||
/// out of the cache. The difference is the whole meaning of the live
|
||||
/// figures beside it.
|
||||
pub live: bool,
|
||||
/// When the feed last said anything about it.
|
||||
pub seen_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A chain's nodes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NodeIndex.ts")]
|
||||
pub struct NodeIndex {
|
||||
/// Which chain.
|
||||
pub chain: ChainId,
|
||||
/// How many the telemetry feed says the chain has.
|
||||
///
|
||||
/// Its own count, from `AddedChain`, and it will not match `nodes.len()` in
|
||||
/// either direction. It is a snapshot the feed refreshes on its own
|
||||
/// cadence, so nodes that joined since leave it *low* — 284 against 288
|
||||
/// listed, measured. Conversely a feed we joined late has told us about
|
||||
/// fewer nodes than it knows, which leaves it high. Shown beside the count
|
||||
/// rather than instead of it, because the difference is a real fact about
|
||||
/// how each number was arrived at.
|
||||
#[ts(type = "number")]
|
||||
pub reported: Option<u32>,
|
||||
/// The nodes, live ones first and then by name.
|
||||
pub nodes: Vec<NodeRow>,
|
||||
}
|
||||
|
||||
/// What a node is doing, at the moment of asking.
|
||||
///
|
||||
/// Every field is a live reading from the telemetry feed. Nothing here is
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ChainSwitcher } from './components/ChainSwitcher'
|
||||
import { Leaderboard } from './components/Leaderboard'
|
||||
import { MinerPanel } from './components/MinerPanel'
|
||||
import { RuntimePanel } from './components/RuntimePanel'
|
||||
import { NodeRoute } from './components/NodeRoute'
|
||||
import { NodesIndex } from './components/NodesIndex'
|
||||
import { RuntimesIndex } from './components/RuntimesIndex'
|
||||
import { ReversibleIndex } from './components/ReversibleIndex'
|
||||
import { SectionNav } from './components/SectionNav'
|
||||
@@ -30,7 +32,7 @@ import { ThemeToggle } from './components/ThemeToggle'
|
||||
import { StateIndex } from './components/StateIndex'
|
||||
import { StatBar } from './components/StatBar'
|
||||
import { measuredSpan, seconds, windowSpan } from './lib/format'
|
||||
import { href, parse, WINDOWS } from './lib/routes'
|
||||
import { href, isStandings, parse, WINDOWS } from './lib/routes'
|
||||
import { useObserver, usePinnedMiners, useWatch } from './lib/store'
|
||||
|
||||
export default function App() {
|
||||
@@ -62,13 +64,9 @@ export default function App() {
|
||||
|
||||
// The standings and a miner's page, which is one row of them. Everything
|
||||
// else is about a thing that happened once.
|
||||
const windowApplies =
|
||||
route.index === null &&
|
||||
route.block === null &&
|
||||
route.account === null &&
|
||||
route.runtime === null &&
|
||||
route.call === null &&
|
||||
route.event === null
|
||||
// The window means something on the standings and on a miner's page — which
|
||||
// is one row of those same standings — and nowhere else.
|
||||
const windowApplies = isStandings(route) || route.miner !== null
|
||||
|
||||
// Normalise a path that resolves to the standings but is not spelled like
|
||||
// them: `/quantus`, `/quantus/miner` (the standings *are* the miner index),
|
||||
@@ -77,14 +75,7 @@ export default function App() {
|
||||
// not, and the section nav cannot mark where the reader is.
|
||||
useEffect(() => {
|
||||
if (!chain) return
|
||||
const standings =
|
||||
route.index === null &&
|
||||
route.block === null &&
|
||||
route.miner === null &&
|
||||
route.account === null &&
|
||||
route.runtime === null &&
|
||||
route.call === null &&
|
||||
route.event === null
|
||||
const standings = isStandings(route)
|
||||
const canonical = href({ chain, window: route.window })
|
||||
if (standings && location.pathname !== canonical) {
|
||||
navigate(canonical, { replace: true })
|
||||
@@ -229,6 +220,7 @@ export default function App() {
|
||||
notice which page it is on. The board is what `/:chain/:window` is
|
||||
for. */}
|
||||
{route.runtime !== null && chain && <RuntimePanel chain={chain} spec={route.runtime} />}
|
||||
{route.node && chain && <NodeRoute chain={chain} peer={route.node} />}
|
||||
|
||||
{route.index === 'block' && chain && <BlocksIndex chain={chain} />}
|
||||
{route.index === 'call' && chain && <CallsIndex chain={chain} />}
|
||||
@@ -275,6 +267,7 @@ export default function App() {
|
||||
{route.index === 'runtime' && chain && (
|
||||
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
|
||||
)}
|
||||
{route.index === 'node' && chain && <NodesIndex chain={chain} />}
|
||||
|
||||
{!route.account &&
|
||||
!route.miner &&
|
||||
|
||||
28
web/src/api/generated/NodeIndex.ts
Normal file
28
web/src/api/generated/NodeIndex.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { NodeRow } from "./NodeRow";
|
||||
|
||||
/**
|
||||
* A chain's nodes.
|
||||
*/
|
||||
export type NodeIndex = {
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* How many the telemetry feed says the chain has.
|
||||
*
|
||||
* Its own count, from `AddedChain`, and it will not match `nodes.len()` in
|
||||
* either direction. It is a snapshot the feed refreshes on its own
|
||||
* cadence, so nodes that joined since leave it *low* — 284 against 288
|
||||
* listed, measured. Conversely a feed we joined late has told us about
|
||||
* fewer nodes than it knows, which leaves it high. Shown beside the count
|
||||
* rather than instead of it, because the difference is a real fact about
|
||||
* how each number was arrived at.
|
||||
*/
|
||||
reported: number,
|
||||
/**
|
||||
* The nodes, live ones first and then by name.
|
||||
*/
|
||||
nodes: Array<NodeRow>, };
|
||||
54
web/src/api/generated/NodeRow.ts
Normal file
54
web/src/api/generated/NodeRow.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* One node in a chain's node index.
|
||||
*
|
||||
* Every node the telemetry feed reports, whether or not it has ever won a
|
||||
* block — which is the point. The miner route reaches a node through the
|
||||
* first-reporter voting, and most nodes never mine, so that door admits almost
|
||||
* nobody. This one is open to all of them.
|
||||
*/
|
||||
export type NodeRow = {
|
||||
/**
|
||||
* The libp2p peer id, and the node's address on this site.
|
||||
*/
|
||||
peer_id: string,
|
||||
/**
|
||||
* Self-reported name. Not unique: two operators may pick the same one, and
|
||||
* one operator may run several nodes under it.
|
||||
*/
|
||||
name: string,
|
||||
/**
|
||||
* The client version with its commit.
|
||||
*/
|
||||
version: string | null,
|
||||
/**
|
||||
* ISO 3166-1 alpha-2. Country and no finer.
|
||||
*/
|
||||
country_code: string | null,
|
||||
/**
|
||||
* The CPU the node reports.
|
||||
*/
|
||||
cpu: string | null,
|
||||
/**
|
||||
* Physical cores.
|
||||
*/
|
||||
core_count: number,
|
||||
/**
|
||||
* Bytes of RAM.
|
||||
*/
|
||||
memory: number,
|
||||
/**
|
||||
* Peers, when the feed is currently reporting this node.
|
||||
*/
|
||||
peer_count: number,
|
||||
/**
|
||||
* Whether the feed is reporting it *now*, as opposed to this row coming
|
||||
* out of the cache. The difference is the whole meaning of the live
|
||||
* figures beside it.
|
||||
*/
|
||||
live: boolean,
|
||||
/**
|
||||
* When the feed last said anything about it.
|
||||
*/
|
||||
seen_at: string | null, };
|
||||
@@ -20,6 +20,8 @@ import type { GenesisDetail } from './generated/GenesisDetail'
|
||||
import type { ReversibleState } from './generated/ReversibleState'
|
||||
import type { RecentBlock } from './generated/RecentBlock'
|
||||
import type { MinerDetail } from './generated/MinerDetail'
|
||||
import type { NodeIndex } from './generated/NodeIndex'
|
||||
import type { NodeInfo } from './generated/NodeInfo'
|
||||
import type { RuntimeDetail } from './generated/RuntimeDetail'
|
||||
import type { RuntimeSummary } from './generated/RuntimeSummary'
|
||||
import type { Window as WindowName } from './generated/Window'
|
||||
@@ -234,3 +236,17 @@ export function fetchMiner(
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every node the chain's telemetry feed reports. */
|
||||
export async function fetchNodes(chain: string, signal?: AbortSignal): Promise<NodeIndex> {
|
||||
return get<NodeIndex>(`/chains/${chain}/nodes`, signal)
|
||||
}
|
||||
|
||||
/** One node, by peer id. */
|
||||
export async function fetchNode(
|
||||
chain: string,
|
||||
peer: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NodeInfo> {
|
||||
return get<NodeInfo>(`/chains/${chain}/nodes/${encodeURIComponent(peer)}`, signal)
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ export function MinerPanel({
|
||||
confidence={row?.confidence ?? null}
|
||||
votes={row?.attribution_votes ?? null}
|
||||
attribution={row?.attribution ?? null}
|
||||
chain={chain}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
|
||||
import type { NodeInfo } from '../api/generated/NodeInfo'
|
||||
import type { AttributionSource } from '../api/generated/AttributionSource'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { ago, bandwidth, bytes, since } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
|
||||
/** 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
|
||||
@@ -53,11 +56,15 @@ export function NodePanel({
|
||||
confidence,
|
||||
votes,
|
||||
attribution,
|
||||
chain,
|
||||
}: {
|
||||
node: NodeInfo
|
||||
confidence: number | null
|
||||
votes: number | null
|
||||
attribution: AttributionSource | null
|
||||
/** Set where the node's own page is somewhere else to go. Omitted on that
|
||||
* page, where the heading would link to itself. */
|
||||
chain?: string | undefined
|
||||
}) {
|
||||
const os = [node.target_os, node.target_arch, node.target_env].filter(Boolean).join(' · ')
|
||||
const country = node.country_code
|
||||
@@ -78,7 +85,13 @@ export function NodePanel({
|
||||
<div>
|
||||
<div className="eyebrow">Reported by</div>
|
||||
<h2 className="panel-title" style={{ marginTop: 4, textTransform: 'none' }}>
|
||||
{node.name}
|
||||
{chain ? (
|
||||
// Only when this panel sits under something else. On the node's
|
||||
// own page the heading would link to where the reader already is.
|
||||
<Link to={href({ chain, node: node.peer_id })}>{node.name}</Link>
|
||||
) : (
|
||||
node.name
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{strength && (
|
||||
|
||||
53
web/src/components/NodeRoute.tsx
Normal file
53
web/src/components/NodeRoute.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* One node, reached directly rather than through a miner.
|
||||
*
|
||||
* Renders the same panel the miner route does, so a node reached by its peer id
|
||||
* and a node reached through the standings read identically — but with no
|
||||
* attribution beside it, because there is none: nobody inferred anything to get
|
||||
* here, the address *is* the node.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { NodeInfo } from '../api/generated/NodeInfo'
|
||||
import { RequestFailed, fetchNode } from '../api/rest'
|
||||
import { NodePanel } from './NodePanel'
|
||||
|
||||
export function NodeRoute({ chain, peer }: { chain: string; peer: string }) {
|
||||
const [node, setNode] = useState<NodeInfo | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setNode(null)
|
||||
setError(null)
|
||||
fetchNode(chain, peer, controller.signal)
|
||||
.then(setNode)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain, peer])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Node</div>
|
||||
<h2 className="panel-title" style={{ marginTop: 4, textTransform: 'none' }}>
|
||||
<code>{peer}</code>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="empty">{error}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
if (!node) return <p className="empty">Asking telemetry…</p>
|
||||
|
||||
// No confidence, no votes, no attribution: this page was reached by naming
|
||||
// the node, so there is nothing inferred to qualify.
|
||||
return <NodePanel node={node} confidence={null} votes={null} attribution={null} />
|
||||
}
|
||||
161
web/src/components/NodesIndex.tsx
Normal file
161
web/src/components/NodesIndex.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Every node the chain's telemetry feed reports.
|
||||
*
|
||||
* The point of this page is that it has no entry condition. A node reaches the
|
||||
* miner route only through the first-reporter voting — it has to have won
|
||||
* blocks *and* had the feed resolve a clean lead on enough of them — and most
|
||||
* nodes never mine at all. So the one door we had admitted almost nobody, while
|
||||
* the feed was handing us every node on the chain unasked.
|
||||
*
|
||||
* Live rows first. A node the feed is reporting right now is a different claim
|
||||
* from one we last heard about at some point in the past, and the `live` column
|
||||
* says which without the reader having to work it out from a timestamp.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { NodeIndex } from '../api/generated/NodeIndex'
|
||||
import { RequestFailed, fetchNodes } from '../api/rest'
|
||||
import { ago, bytes, height as fmtHeight } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
|
||||
/** A two-letter country code as its flag. Null for anything else, 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)))
|
||||
}
|
||||
|
||||
export function NodesIndex({ chain }: { chain: string }) {
|
||||
const [index, setIndex] = useState<NodeIndex | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setIndex(null)
|
||||
setError(null)
|
||||
fetchNodes(chain, controller.signal)
|
||||
.then(setIndex)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain])
|
||||
|
||||
// Name, peer id and CPU all together: "find my node" is the question this
|
||||
// page exists to answer, and a reader may remember any of the three.
|
||||
const rows = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase()
|
||||
const all = index?.nodes ?? []
|
||||
if (!needle) return all
|
||||
return all.filter((n) =>
|
||||
[n.name, n.peer_id, n.cpu, n.version, n.country_code]
|
||||
.filter(Boolean)
|
||||
.some((field) => field!.toLowerCase().includes(needle)),
|
||||
)
|
||||
}, [index, filter])
|
||||
|
||||
const liveCount = index?.nodes.filter((n) => n.live).length ?? 0
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Nodes</h2>
|
||||
{index && (
|
||||
<span className="eyebrow">
|
||||
{liveCount} reporting now
|
||||
{index.reported !== null && ` · ${fmtHeight(index.reported)} on telemetry`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && index === null && <p className="empty">Asking telemetry…</p>}
|
||||
|
||||
{index !== null && (
|
||||
<>
|
||||
<div className="pager" style={{ borderTop: 0, borderBottom: 'var(--rule)' }}>
|
||||
<input
|
||||
className="node-filter"
|
||||
type="search"
|
||||
value={filter}
|
||||
placeholder="Filter by name, peer id, CPU, version or country"
|
||||
aria-label="Filter nodes"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
{filter && (
|
||||
<span className="eyebrow">
|
||||
{rows.length} of {index.nodes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
Every node this chain's telemetry feed reports
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Node
|
||||
</th>
|
||||
<th scope="col">Country</th>
|
||||
<th scope="col" className="left">
|
||||
Version
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
CPU
|
||||
</th>
|
||||
<th scope="col">Cores</th>
|
||||
<th scope="col">Memory</th>
|
||||
<th scope="col">Peers</th>
|
||||
<th scope="col" className="event-when">
|
||||
Heard
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((n) => (
|
||||
<tr key={n.peer_id} className={n.live ? undefined : 'call-unused'}>
|
||||
<td className="left">
|
||||
<div className="call-cell">
|
||||
<Link to={href({ chain, node: n.peer_id })}>{n.name}</Link>
|
||||
<span className="runtime-type">{n.peer_id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td title={n.country_code ?? 'not located'}>
|
||||
{n.country_code ? `${flag(n.country_code) ?? ''} ${n.country_code}` : '—'}
|
||||
</td>
|
||||
<td className="left numeral">{n.version ?? '—'}</td>
|
||||
<td className="left">{n.cpu ?? '—'}</td>
|
||||
<td className="numeral">{n.core_count ?? '—'}</td>
|
||||
<td className="numeral">{n.memory !== null ? bytes(n.memory) : '—'}</td>
|
||||
<td className="numeral">{n.peer_count ?? '—'}</td>
|
||||
<td className="numeral event-when" style={{ color: 'var(--text-muted)' }}>
|
||||
{n.live ? 'now' : n.seen_at ? ago(n.seen_at) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="panel-note">
|
||||
Everything here is what each node reports about itself on the telemetry feed — never
|
||||
verified, and never a claim the chain makes. A node is listed whether or not it has ever
|
||||
won a block, which is the difference between this page and the standings. Rows shown
|
||||
faintly are from this observer's cache: the feed is not reporting them right now,
|
||||
so their peer counts are absent rather than stale. Location is resolved to a country and
|
||||
no finer, because the feed's coordinates come from IP geolocation and are
|
||||
unreliable at city resolution.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { Window as WindowName } from '../api/generated/Window'
|
||||
import { href, type Route, SECTIONS } from '../lib/routes'
|
||||
import { href, isStandings, type Route, SECTIONS } from '../lib/routes'
|
||||
|
||||
export function SectionNav({
|
||||
chain,
|
||||
@@ -30,14 +30,7 @@ export function SectionNav({
|
||||
}) {
|
||||
if (!chain) return null
|
||||
// The standings are current when nothing else is: no panel open, no index.
|
||||
const onStandings =
|
||||
route.index === null &&
|
||||
route.block === null &&
|
||||
route.miner === null &&
|
||||
route.account === null &&
|
||||
route.runtime === null &&
|
||||
route.call === null &&
|
||||
route.event === null
|
||||
const onStandings = isStandings(route)
|
||||
|
||||
return (
|
||||
<nav className="sections" aria-label="Section">
|
||||
@@ -69,7 +62,8 @@ export function SectionNav({
|
||||
aria-current={
|
||||
route.index === s.id ||
|
||||
(s.id === 'call' && route.call !== null) ||
|
||||
(s.id === 'event' && route.event !== null)
|
||||
(s.id === 'event' && route.event !== null) ||
|
||||
(s.id === 'node' && route.node !== null)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -300,6 +300,23 @@ a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.node-filter {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border: var(--rule);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-filter:focus-visible {
|
||||
outline: 2px solid var(--data);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---- theme toggle -------------------------------------------------------- */
|
||||
|
||||
.theme-toggle {
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface Route {
|
||||
call: [string, string] | null
|
||||
/** One kind of event, as `[pallet, variant]`. */
|
||||
event: [string, string] | null
|
||||
/** A libp2p peer id, identifying one telemetry node. */
|
||||
node: string | null
|
||||
/**
|
||||
* A kind with no value: the index of everything of that kind.
|
||||
*
|
||||
@@ -104,7 +106,7 @@ export interface Route {
|
||||
|
||||
/** The kinds that have an index. */
|
||||
export type SectionName =
|
||||
'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime'
|
||||
'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime' | 'node'
|
||||
|
||||
/**
|
||||
* The sections, in the order the nav shows them.
|
||||
@@ -121,6 +123,10 @@ export const SECTIONS: { id: SectionName; label: string }[] = [
|
||||
{ id: 'reversible', label: 'In flight' },
|
||||
{ id: 'state', label: 'State' },
|
||||
{ id: 'runtime', label: 'Runtimes' },
|
||||
// Last, because it is the only section that is not about the chain's own
|
||||
// ledger — a node is a machine watching the chain rather than anything the
|
||||
// chain records.
|
||||
{ id: 'node', label: 'Nodes' },
|
||||
]
|
||||
|
||||
function section(name: string): SectionName | null {
|
||||
@@ -136,6 +142,7 @@ export const EMPTY: Route = {
|
||||
runtime: null,
|
||||
call: null,
|
||||
event: null,
|
||||
node: null,
|
||||
index: null,
|
||||
}
|
||||
|
||||
@@ -220,6 +227,9 @@ export function parse(pathname: string): Route {
|
||||
}
|
||||
if (kind === 'account') return { ...EMPTY, chain, account: value }
|
||||
if (kind === 'runtime') return { ...EMPTY, chain, runtime: spec(value) }
|
||||
// A peer id is base58 and case-sensitive, so unlike a hash or an address it
|
||||
// is passed through exactly as written.
|
||||
if (kind === 'node') return { ...EMPTY, chain, node: value }
|
||||
// `/quantus/call/Balances/transfer_allow_death` — a pallet and a call, both
|
||||
// as the runtime names them, because those names are the identity and any
|
||||
// slug of ours would be a second vocabulary to keep in step.
|
||||
@@ -241,9 +251,36 @@ export function parse(pathname: string): Route {
|
||||
return { ...EMPTY, chain, window: asWindow(kind) ?? DEFAULT_WINDOW }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route names no subject, and is therefore the standings.
|
||||
*
|
||||
* One definition, because there were three and adding a section only updated
|
||||
* two of them: `/quantus/node/<peer>` was judged "the standings", and the
|
||||
* canonicalising redirect in `App` bounced it to `/quantus/3600-blocks` before
|
||||
* it could render. The page existed, the route parsed, and the address bar
|
||||
* threw it away.
|
||||
*
|
||||
* Written as a check over every subject field rather than a list of the ones
|
||||
* that happened to exist when it was written — a new one still has to be added
|
||||
* here, but here is one place instead of three.
|
||||
*/
|
||||
export function isStandings(route: Route): boolean {
|
||||
return (
|
||||
route.index === null &&
|
||||
route.block === null &&
|
||||
route.miner === null &&
|
||||
route.account === null &&
|
||||
route.runtime === null &&
|
||||
route.call === null &&
|
||||
route.event === null &&
|
||||
route.node === null
|
||||
)
|
||||
}
|
||||
|
||||
/** The canonical path for a route. The inverse of {@link parse}. */
|
||||
export function href(route: Partial<Route>): string {
|
||||
const chain = route.chain ?? ''
|
||||
if (route.node) return `/${chain}/node/${route.node}`
|
||||
if (route.block) return `/${chain}/block/${route.block}`
|
||||
if (route.miner) {
|
||||
const window = windowPath(route.window ?? DEFAULT_WINDOW)
|
||||
|
||||
Reference in New Issue
Block a user