feat: block explorer routes, addressed by hash
`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.
`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.
The answer is assembled from two sources because neither is sufficient, and
they fail at different times:
- The node holds the block and forgets it. Non-canonical bodies go once finality
passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
that sighting was at the tip — and what it has since forgotten: the difficulty
read while the state behind it still existed.
So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.
Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
59
.sqlx/query-48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04.json
generated
Normal file
59
.sqlx/query-48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04.json
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select height, hash, miner, authored_at, observed_at,\n difficulty::text as difficulty, at_tip\n from block\n where chain = $1 and height = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "miner",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "authored_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "observed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "difficulty",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "at_tip",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04"
|
||||
}
|
||||
59
.sqlx/query-6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b.json
generated
Normal file
59
.sqlx/query-6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b.json
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select height, hash, miner, authored_at, observed_at,\n difficulty::text as difficulty, at_tip\n from block_displacement\n where chain = $1 and hash = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "miner",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "authored_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "observed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "difficulty",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "at_tip",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b"
|
||||
}
|
||||
23
.sqlx/query-e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963.json
generated
Normal file
23
.sqlx/query-e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select hash from block_displacement\n where chain = $1 and height = $2\n order by displaced_at desc\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963"
|
||||
}
|
||||
@@ -215,5 +215,14 @@ cargo run -p blackbeard-cli -- standings --chain planck
|
||||
curl -s localhost:25864/v1/chains/planck/summary | python3 -m json.tool
|
||||
```
|
||||
|
||||
The block route degrades rather than failing, so it is worth checking both ends
|
||||
of that: a recent height should carry `difficulty`, `extrinsics` and
|
||||
`seconds_since_parent`, while one a few days old keeps the body and loses
|
||||
difficulty to state pruning unless this observer recorded it at the time.
|
||||
|
||||
```sh
|
||||
curl -s localhost:25864/v1/chains/planck/blocks/1070000 | python3 -m json.tool
|
||||
```
|
||||
|
||||
A working deployment shows a non-null `telemetry_nodes`, `distinct_miners` above
|
||||
one, and named rows in the standings.
|
||||
|
||||
@@ -16,8 +16,8 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use blackbeard_entities::{
|
||||
ApiError, BigUintDec, ChainInfo, ChainSeries, ChainSummary, LeaderboardRow, MinerDetail,
|
||||
MinerId, MinerSeriesPoint, RecentBlock, Window,
|
||||
ApiError, BigUintDec, BlockDetail, ChainInfo, ChainSeries, ChainSummary, LeaderboardRow,
|
||||
MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, Window,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::compression::CompressionLayer;
|
||||
@@ -48,6 +48,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
|
||||
.route("/v1/chains/{chain}/leaderboard", get(leaderboard))
|
||||
.route("/v1/chains/{chain}/blocks", get(blocks))
|
||||
.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/ws", get(crate::ws::handler))
|
||||
// Leaderboards are repetitive JSON and compress to a fraction of their
|
||||
@@ -188,6 +189,250 @@ async fn blocks(
|
||||
Ok(Json(runtime.ticker()))
|
||||
}
|
||||
|
||||
/// `GET /v1/chains/{chain}/blocks/{block}`
|
||||
///
|
||||
/// One block, addressed by height or by hash.
|
||||
///
|
||||
/// ## Why both, and why the hash is the real address
|
||||
///
|
||||
/// `block` is keyed on `(chain, height)` and a reorg overwrites, so a height
|
||||
/// names *whatever currently holds it* — a link to one is a link to a position,
|
||||
/// not to a block, and it silently comes to mean something else the moment the
|
||||
/// chain forks there. A hash names one block forever. The frontend resolves a
|
||||
/// height and then redirects to the hash for exactly that reason.
|
||||
///
|
||||
/// ## Why the answer is assembled from two places
|
||||
///
|
||||
/// The node holds the block; the observer holds what the node forgets. Both are
|
||||
/// needed and neither is sufficient:
|
||||
///
|
||||
/// - Difficulty is a state read behind a 256-block default window, so beyond a
|
||||
/// few minutes of history only the recorded row can answer.
|
||||
/// - `observed_at` and `at_tip` are observations. The chain never had them.
|
||||
/// - A displaced block's body is discarded once finality passes it
|
||||
/// (`blocks-pruning` defaults to `archive-canonical`), so an orphan is
|
||||
/// describable only from `block_displacement`, written when it was displaced.
|
||||
///
|
||||
/// Which is why this degrades in a specific order rather than failing: ask the
|
||||
/// node, fall back to what was recorded, and say in `from_node` which of the two
|
||||
/// the reader is looking at.
|
||||
async fn block(
|
||||
State(state): State<AppState>,
|
||||
Path((chain, reference)): Path<(String, String)>,
|
||||
) -> Result<Json<BlockDetail>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
let id = runtime.id();
|
||||
|
||||
// A height and a hash are not ambiguous — one is decimal, the other 32 bytes
|
||||
// of hex — so one route serves both rather than making a caller who has a
|
||||
// number guess which spelling this API wanted.
|
||||
let detail = match parse_block_ref(&reference)? {
|
||||
BlockRef::Height(height) => from_height(&state, &runtime, height).await,
|
||||
BlockRef::Hash(hash) => from_hash(&state, &runtime, &hash).await,
|
||||
};
|
||||
|
||||
detail.map(Json).ok_or_else(|| {
|
||||
Failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
ApiError::new(
|
||||
"unknown_block",
|
||||
format!("neither the node nor this observer has `{reference}` on {id}"),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// How a block was asked for.
|
||||
enum BlockRef {
|
||||
/// A block number. Names a position, and therefore whatever holds it now.
|
||||
Height(u64),
|
||||
/// A block hash. Names one block, for good.
|
||||
Hash(String),
|
||||
}
|
||||
|
||||
fn parse_block_ref(raw: &str) -> Result<BlockRef, Failure> {
|
||||
if let Ok(height) = raw.parse::<u64>() {
|
||||
return Ok(BlockRef::Height(height));
|
||||
}
|
||||
let hex = raw.strip_prefix("0x").unwrap_or(raw);
|
||||
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(BlockRef::Hash(format!("0x{}", hex.to_ascii_lowercase())));
|
||||
}
|
||||
Err(Failure(
|
||||
StatusCode::BAD_REQUEST,
|
||||
ApiError::new(
|
||||
"malformed_block",
|
||||
format!("`{raw}` is neither a block number nor a 32-byte hash"),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve a height to the block that currently holds it.
|
||||
async fn from_height(
|
||||
state: &AppState,
|
||||
runtime: &crate::state::ChainRuntime,
|
||||
height: u64,
|
||||
) -> Option<BlockDetail> {
|
||||
match runtime.rpc.block_hash(height).await {
|
||||
Ok(Some(hash)) => from_hash(state, runtime, &hash).await,
|
||||
// The node is unreachable or has no such height. The observer may still
|
||||
// have watched it — the whole point of keeping a database — so a node
|
||||
// outage costs the reader the body and nothing else.
|
||||
_ => {
|
||||
let recorded = state
|
||||
.store
|
||||
.recorded_block(&runtime.id(), height)
|
||||
.await
|
||||
.ok()??;
|
||||
Some(from_record(state, runtime, recorded, true, None).await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Describe one block by hash, preferring the node and falling back to what was
|
||||
/// recorded when it cannot answer.
|
||||
async fn from_hash(
|
||||
state: &AppState,
|
||||
runtime: &crate::state::ChainRuntime,
|
||||
hash: &str,
|
||||
) -> Option<BlockDetail> {
|
||||
let id = runtime.id();
|
||||
|
||||
let Ok(Some(header)) = runtime.rpc.header(Some(hash)).await else {
|
||||
// No header: either the node is away, or this is a fork it has pruned.
|
||||
// A block we recorded being displaced is still fully describable from
|
||||
// the row written at that moment, which is the reason it is written.
|
||||
let displaced = state.store.displaced_block(&id, hash).await.ok()??;
|
||||
let canonical_hash = runtime
|
||||
.rpc
|
||||
.block_hash(displaced.height)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
return Some(from_record(state, runtime, displaced, false, canonical_hash).await);
|
||||
};
|
||||
let height = header.height()?;
|
||||
|
||||
// Canonical means "this hash is the one the chain currently has at this
|
||||
// height". Asking is one call and it is the field that makes an orphan page
|
||||
// legible rather than a confusing duplicate of a block that looks current.
|
||||
let canonical_hash = runtime.rpc.block_hash(height).await.ok().flatten();
|
||||
let canonical = canonical_hash.as_deref() == Some(hash);
|
||||
|
||||
let body = runtime.rpc.body(hash).await.ok().flatten();
|
||||
let miner = blackbeard_core::digest::author_preimage(&header.digest.logs);
|
||||
let authored_at = body.and_then(|b| b.timestamp_ms).and_then(millis);
|
||||
|
||||
// The parent's timestamp, for how long this block took to find. Asked of the
|
||||
// node by parent hash rather than read at height-1, so it is this block's
|
||||
// own parent even on a fork.
|
||||
let seconds_since_parent = match (authored_at, runtime.rpc.body(&header.parent_hash).await) {
|
||||
(Some(at), Ok(Some(parent))) => parent
|
||||
.timestamp_ms
|
||||
.and_then(millis)
|
||||
.map(|p| (at - p).as_seconds_f64())
|
||||
// A parent stamped after its child is the author's clock, not a
|
||||
// negative block time. Reporting it would put a minus sign on the
|
||||
// page and invite someone to explain it.
|
||||
.filter(|s| *s >= 0.0),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Resolved once: `attribute` takes the write lock and walks the vote window,
|
||||
// and the three fields below are three views of one answer.
|
||||
let named = miner.as_ref().map(|m| runtime.attribute(m));
|
||||
|
||||
let recorded = state.store.recorded_block(&id, height).await.ok().flatten();
|
||||
// Only if it is the *same* block. A recorded row for a height this hash has
|
||||
// lost describes the winner, and lifting its observation times onto this
|
||||
// page would be an outright fabrication.
|
||||
let ours = recorded.filter(|r| r.hash == hash);
|
||||
|
||||
Some(BlockDetail {
|
||||
chain: id.clone(),
|
||||
height,
|
||||
hash: hash.to_owned(),
|
||||
parent_hash: Some(header.parent_hash.clone()),
|
||||
display: named.as_ref().map(|a| a.display.clone()),
|
||||
attribution: named.as_ref().map(|a| a.source),
|
||||
confidence: named.as_ref().map(|a| a.confidence),
|
||||
// The recorded difficulty first: it was read while the state behind it
|
||||
// still existed, and the node cannot go back for it.
|
||||
difficulty: ours
|
||||
.as_ref()
|
||||
.and_then(|r| r.difficulty.clone())
|
||||
.map(BigUintDec),
|
||||
authored_at: authored_at.or_else(|| ours.as_ref().and_then(|r| r.authored_at)),
|
||||
observed_at: ours.as_ref().map(|r| r.observed_at),
|
||||
at_tip: ours.as_ref().map(|r| r.at_tip),
|
||||
miner,
|
||||
seconds_since_parent,
|
||||
extrinsics: body.map(|b| b.extrinsics),
|
||||
canonical,
|
||||
canonical_hash,
|
||||
also_seen: other_hashes(state, &id, height, hash).await,
|
||||
from_node: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Describe a block from what the observer recorded, the node having failed to
|
||||
/// answer for it.
|
||||
async fn from_record(
|
||||
state: &AppState,
|
||||
runtime: &crate::state::ChainRuntime,
|
||||
recorded: blackbeard_data::store::RecordedBlock,
|
||||
canonical: bool,
|
||||
canonical_hash: Option<String>,
|
||||
) -> BlockDetail {
|
||||
let id = runtime.id();
|
||||
let attribution = runtime.attribute(&recorded.miner);
|
||||
BlockDetail {
|
||||
chain: id.clone(),
|
||||
height: recorded.height,
|
||||
hash: recorded.hash.clone(),
|
||||
// All of these live in the header or the body, and the node is the only
|
||||
// one who has either. None of them is guessable from a recorded row.
|
||||
parent_hash: None,
|
||||
seconds_since_parent: None,
|
||||
extrinsics: None,
|
||||
display: Some(attribution.display),
|
||||
attribution: Some(attribution.source),
|
||||
confidence: Some(attribution.confidence),
|
||||
difficulty: recorded.difficulty.clone().map(BigUintDec),
|
||||
authored_at: recorded.authored_at,
|
||||
observed_at: Some(recorded.observed_at),
|
||||
at_tip: Some(recorded.at_tip),
|
||||
canonical: canonical_hash
|
||||
.as_deref()
|
||||
.map_or(canonical, |h| h == recorded.hash),
|
||||
canonical_hash,
|
||||
also_seen: other_hashes(state, &id, recorded.height, &recorded.hash).await,
|
||||
miner: Some(recorded.miner),
|
||||
from_node: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every other hash this observer has seen hold `height`.
|
||||
async fn other_hashes(
|
||||
state: &AppState,
|
||||
chain: &blackbeard_entities::ChainId,
|
||||
height: u64,
|
||||
self_hash: &str,
|
||||
) -> Vec<String> {
|
||||
state
|
||||
.store
|
||||
.hashes_seen_at(chain, height)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|h| h != self_hash)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn millis(ms: u64) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
chrono::DateTime::from_timestamp_millis(i64::try_from(ms).ok()?)
|
||||
}
|
||||
|
||||
/// Points in a chain's history series.
|
||||
///
|
||||
/// Every selectable window divides by this exactly (600, 3,600, 14,400 and
|
||||
@@ -334,6 +579,40 @@ fn database_unavailable(e: blackbeard_data::DataError) -> Failure {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_block_is_addressable_by_number_or_by_hash() {
|
||||
let bare = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59";
|
||||
assert!(matches!(
|
||||
parse_block_ref("1069799"),
|
||||
Ok(BlockRef::Height(1_069_799))
|
||||
));
|
||||
assert!(matches!(parse_block_ref("0"), Ok(BlockRef::Height(0))));
|
||||
// Case-insensitive digits, canonical lowercase out, with or without the
|
||||
// prefix — the same contract `parse_miner` offers, so a hash copied
|
||||
// from a tool that upper-cases hex resolves to one page rather than
|
||||
// several. The `0x` marker itself is lowercase in both, deliberately:
|
||||
// two spellings of the prefix would be two URLs for one block.
|
||||
for spelling in [
|
||||
bare.to_owned(),
|
||||
format!("0x{bare}"),
|
||||
format!("0x{}", bare.to_uppercase()),
|
||||
] {
|
||||
match parse_block_ref(&spelling) {
|
||||
Ok(BlockRef::Hash(h)) => assert_eq!(h, format!("0x{bare}")),
|
||||
_ => panic!("{spelling} should parse as a hash"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_that_is_neither_a_number_nor_a_hash_is_a_bad_request() {
|
||||
// A 404 here would read as "no such block", which is a different claim
|
||||
// and sends the reader looking for a chain problem.
|
||||
for bad in ["", "0x", "0xdeadbeef", "tip", "-1", "1.5", &"f".repeat(63)] {
|
||||
assert!(parse_block_ref(bad).is_err(), "{bad} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preimage_is_accepted_with_or_without_the_prefix() {
|
||||
let bare = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59";
|
||||
|
||||
@@ -26,8 +26,8 @@ use blackbeard_data::rpc::RpcClient;
|
||||
use blackbeard_data::store::Store;
|
||||
use blackbeard_data::telemetry::TelemetryFeed;
|
||||
use blackbeard_entities::{
|
||||
ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, LeaderboardRow, RecentBlock,
|
||||
ServerMessage, Tracking, Window,
|
||||
ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, LeaderboardRow, MinerId,
|
||||
RecentBlock, ServerMessage, Tracking, Window,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use primitive_types::U512;
|
||||
@@ -353,6 +353,20 @@ impl ChainRuntime {
|
||||
self.recompute_leaderboard(window).0
|
||||
}
|
||||
|
||||
/// What to call one miner, resolved the same way the leaderboard does.
|
||||
///
|
||||
/// Takes the write lock because the attributor caches its vote tallies, and
|
||||
/// the caller gets one name rather than a whole board it would throw away.
|
||||
/// CPU-only work on in-memory collections, like everything else under this
|
||||
/// lock, and never held across an await.
|
||||
pub fn attribute(&self, miner: &MinerId) -> blackbeard_core::attribution::Attribution {
|
||||
let mut inner = self.write();
|
||||
let telemetry = &self.telemetry;
|
||||
inner
|
||||
.attributor
|
||||
.attribute(miner, |key| telemetry.name_of(key))
|
||||
}
|
||||
|
||||
/// The live ticker, oldest first.
|
||||
pub fn ticker(&self) -> Vec<RecentBlock> {
|
||||
self.read().ticker.iter().cloned().collect()
|
||||
|
||||
@@ -57,6 +57,15 @@ impl Header {
|
||||
}
|
||||
}
|
||||
|
||||
/// A block body, reduced to what the observer reads from it.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BlockBody {
|
||||
/// Extrinsics in the block, inherents included.
|
||||
pub extrinsics: u32,
|
||||
/// The author's timestamp from the `Timestamp::set` inherent.
|
||||
pub timestamp_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// What `system_health` reports.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -170,14 +179,34 @@ impl RpcClient {
|
||||
/// a shape we do not recognise, which costs this block its timing data and
|
||||
/// nothing else.
|
||||
pub async fn block_timestamp_ms(&self, hash: &str) -> Result<Option<u64>, DataError> {
|
||||
Ok(self.body(hash).await?.and_then(|b| b.timestamp_ms))
|
||||
}
|
||||
|
||||
/// A block's body, reduced to what is read from it.
|
||||
///
|
||||
/// One call for both facts. The ingest path wants only the timestamp and
|
||||
/// the block page wants the extrinsic count too; fetching the same body
|
||||
/// twice to answer them separately would double the load on a node that is
|
||||
/// already mining.
|
||||
///
|
||||
/// `Ok(None)` for a block the node no longer holds — `blocks-pruning`
|
||||
/// defaults to `archive-canonical`, so a losing fork's body goes once
|
||||
/// finality passes it. That is an answer, not a failure.
|
||||
pub async fn body(&self, hash: &str) -> Result<Option<BlockBody>, DataError> {
|
||||
let v = self.call("chain_getBlock", json!([hash])).await?;
|
||||
let first = v
|
||||
.pointer("/block/extrinsics/0")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
Ok(first
|
||||
.as_deref()
|
||||
.and_then(blackbeard_core::scale::timestamp_inherent_ms))
|
||||
let Some(extrinsics) = v.pointer("/block/extrinsics").and_then(Value::as_array) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(BlockBody {
|
||||
extrinsics: extrinsics.len() as u32,
|
||||
// The timestamp inherent is always first. A body whose first
|
||||
// extrinsic does not decode as one costs this block its timing and
|
||||
// nothing else.
|
||||
timestamp_ms: extrinsics
|
||||
.first()
|
||||
.and_then(Value::as_str)
|
||||
.and_then(blackbeard_core::scale::timestamp_inherent_ms),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Current mining difficulty: expected hashes to win a block.
|
||||
|
||||
@@ -108,6 +108,29 @@ pub struct ChainRecord {
|
||||
pub target_block_time: f64,
|
||||
}
|
||||
|
||||
/// What the observer recorded about one block.
|
||||
///
|
||||
/// The fields the chain cannot supply after the fact: when we saw it, whether
|
||||
/// that sighting was at the tip, and the difficulty read while the state
|
||||
/// behind it still existed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecordedBlock {
|
||||
/// Block number.
|
||||
pub height: u64,
|
||||
/// Block hash as recorded.
|
||||
pub hash: String,
|
||||
/// Author's reward preimage.
|
||||
pub miner: MinerId,
|
||||
/// The author's own timestamp.
|
||||
pub authored_at: Option<DateTime<Utc>>,
|
||||
/// When this observer first saw it.
|
||||
pub observed_at: DateTime<Utc>,
|
||||
/// Difficulty as a decimal string.
|
||||
pub difficulty: Option<String>,
|
||||
/// Whether `observed_at` was a tip sighting.
|
||||
pub at_tip: bool,
|
||||
}
|
||||
|
||||
/// A held attribution loaded back from the database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredAttribution {
|
||||
@@ -484,6 +507,92 @@ impl Store {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// What the observer recorded at one height, canonical as far as it knows.
|
||||
pub async fn recorded_block(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
height: u64,
|
||||
) -> Result<Option<RecordedBlock>, DataError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
select height, hash, miner, authored_at, observed_at,
|
||||
difficulty::text as difficulty, at_tip
|
||||
from block
|
||||
where chain = $1 and height = $2
|
||||
"#,
|
||||
chain.as_str(),
|
||||
height as i64,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| RecordedBlock {
|
||||
height: r.height.max(0) as u64,
|
||||
hash: r.hash,
|
||||
miner: MinerId(r.miner),
|
||||
authored_at: r.authored_at,
|
||||
observed_at: r.observed_at,
|
||||
difficulty: r.difficulty,
|
||||
at_tip: r.at_tip,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A block this observer recorded and a reorg later displaced.
|
||||
///
|
||||
/// The only way a losing block is describable at all once finality has
|
||||
/// passed it: the node keeps no non-canonical body that long, so this row —
|
||||
/// written at the moment of the swap — is the whole of what survives.
|
||||
pub async fn displaced_block(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
hash: &str,
|
||||
) -> Result<Option<RecordedBlock>, DataError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
select height, hash, miner, authored_at, observed_at,
|
||||
difficulty::text as difficulty, at_tip
|
||||
from block_displacement
|
||||
where chain = $1 and hash = $2
|
||||
"#,
|
||||
chain.as_str(),
|
||||
hash,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| RecordedBlock {
|
||||
height: r.height.max(0) as u64,
|
||||
hash: r.hash,
|
||||
miner: MinerId(r.miner),
|
||||
authored_at: r.authored_at,
|
||||
observed_at: r.observed_at,
|
||||
difficulty: r.difficulty,
|
||||
at_tip: r.at_tip,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every hash this observer has seen hold a height, newest displacement
|
||||
/// first. Empty for the overwhelming majority of heights, which were only
|
||||
/// ever won once.
|
||||
pub async fn hashes_seen_at(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
height: u64,
|
||||
) -> Result<Vec<String>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select hash from block_displacement
|
||||
where chain = $1 and height = $2
|
||||
order by displaced_at desc
|
||||
"#,
|
||||
chain.as_str(),
|
||||
height as i64,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.hash).collect())
|
||||
}
|
||||
|
||||
/// Persist the attributions currently held.
|
||||
pub async fn save_attributions(
|
||||
&self,
|
||||
|
||||
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{BigUintDec, ChainId, MinerId};
|
||||
use crate::{AttributionSource, BigUintDec, ChainId, MinerId};
|
||||
|
||||
/// A block as the observer recorded it.
|
||||
///
|
||||
@@ -58,3 +58,75 @@ pub struct RecentBlock {
|
||||
/// subtract.
|
||||
pub gap_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
/// One block, as completely as it can still be described.
|
||||
///
|
||||
/// Assembled from two sources that know different things, because neither is
|
||||
/// sufficient on its own:
|
||||
///
|
||||
/// - The **node** holds the block itself — parent, body, and whether this hash
|
||||
/// still holds its height. It forgets: `blocks-pruning` defaults to
|
||||
/// `archive-canonical`, so a non-canonical body goes once finality passes it,
|
||||
/// and difficulty is a state read behind a 256-block window.
|
||||
/// - The **observer** holds what the node never had — when we first saw the
|
||||
/// block, whether that sighting was at the tip — and what it has since
|
||||
/// forgotten, the difficulty recorded while the state still existed.
|
||||
///
|
||||
/// Every field the pair cannot supply is `None` rather than a substituted
|
||||
/// plausible value. A block page that quietly filled in the current difficulty
|
||||
/// for one it could not look up would be the same lie the ingest path was fixed
|
||||
/// to stop telling.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BlockDetail.ts")]
|
||||
pub struct BlockDetail {
|
||||
/// Which chain.
|
||||
pub chain: ChainId,
|
||||
/// Block number.
|
||||
#[ts(type = "number")]
|
||||
pub height: u64,
|
||||
/// This block's hash — the identity the page is addressed by, so that a
|
||||
/// link keeps meaning the same block after the height has moved on.
|
||||
pub hash: String,
|
||||
/// Parent hash, from the header.
|
||||
pub parent_hash: Option<String>,
|
||||
/// Author's reward preimage from the `pow_` PreRuntime digest.
|
||||
pub miner: Option<MinerId>,
|
||||
/// What to call the author: a telemetry node name, or the abbreviated
|
||||
/// preimage. `None` where the author could not be decoded at all.
|
||||
pub display: Option<String>,
|
||||
/// Whether `display` is an inferred name or the preimage.
|
||||
pub attribution: Option<AttributionSource>,
|
||||
/// How much of the attribution vote window agrees, 0.0–1.0.
|
||||
pub confidence: Option<f32>,
|
||||
/// Expected hashes to win this block: the difficulty in force at its
|
||||
/// parent, which is the figure the miner actually had to meet.
|
||||
pub difficulty: Option<BigUintDec>,
|
||||
/// The author's own timestamp, from the block's `Timestamp::set` inherent.
|
||||
pub authored_at: Option<DateTime<Utc>>,
|
||||
/// When this observer first saw the block. `None` for a block it never saw
|
||||
/// at all — an orphan on a fork it missed, or history before it started.
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
/// Whether `observed_at` was a tip sighting. When false, the difference
|
||||
/// between the timestamps measures when the observer got round to looking
|
||||
/// and nothing about the network, so the UI must not read it as propagation.
|
||||
pub at_tip: Option<bool>,
|
||||
/// Seconds between this block's timestamp and its parent's — how long this
|
||||
/// block took to find, against the chain's target.
|
||||
pub seconds_since_parent: Option<f64>,
|
||||
/// Extrinsics in the body, inherents included. `None` once the body has
|
||||
/// been pruned.
|
||||
pub extrinsics: Option<u32>,
|
||||
/// Whether this hash is the one that currently holds this height.
|
||||
pub canonical: bool,
|
||||
/// The hash that currently holds this height. Equal to `hash` when
|
||||
/// canonical; on an orphan page it is the link to the block that won.
|
||||
pub canonical_hash: Option<String>,
|
||||
/// Other hashes this observer has recorded at this height — the losing
|
||||
/// sides of past reorgs. Navigable, which is the whole reason the
|
||||
/// displacement is written down.
|
||||
pub also_seen: Vec<String>,
|
||||
/// False when the node could not be reached or no longer holds the block,
|
||||
/// so everything here came from what the observer had already recorded.
|
||||
/// The UI owes the reader that distinction rather than showing gaps.
|
||||
pub from_node: bool,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ mod miner;
|
||||
mod series;
|
||||
mod ws;
|
||||
|
||||
pub use block::{BlockObservation, RecentBlock};
|
||||
pub use block::{BlockDetail, BlockObservation, RecentBlock};
|
||||
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking};
|
||||
pub use error::{ApiError, EntityError};
|
||||
pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint};
|
||||
|
||||
17
readme.md
17
readme.md
@@ -132,6 +132,23 @@ over three buckets, because the mean of one bucket's gaps still carries enough
|
||||
Poisson noise to draw a chain that looks like it is changing size every few
|
||||
minutes.
|
||||
|
||||
## A block has two addresses, and only one of them is stable
|
||||
|
||||
`#/planck/1069799` is a *position*. Because `block` is keyed on (chain, height)
|
||||
and a reorg overwrites, a link to a height quietly comes to mean a different
|
||||
block the moment the chain forks there. So opening one resolves it and rewrites
|
||||
the address to `#/planck/0x…`, which names one block for good — including after
|
||||
it has lost, which is the only way an orphan is linkable at all.
|
||||
|
||||
The page is assembled from both sources because neither is sufficient. The node
|
||||
holds the block and forgets it: `blocks-pruning` defaults to
|
||||
`archive-canonical`, so a losing fork's body goes once finality passes it, and
|
||||
difficulty is a state read behind a 256-block window. The observer holds what
|
||||
the node never had — when the block was seen, and whether that sighting was at
|
||||
the tip — and what it has since forgotten, the difficulty recorded while the
|
||||
state still existed. Anything neither can answer says so in words rather than
|
||||
going blank, because a gap on a block page reads as a zero.
|
||||
|
||||
## Data
|
||||
|
||||
Postgres on `magrathea.kosherinata.internal`, mTLS and passwordless — the host's
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { Window as WindowName } from './api/generated/Window'
|
||||
import { BlockPanel } from './components/BlockPanel'
|
||||
import { BlockTicker } from './components/BlockTicker'
|
||||
import { ChainSwitcher } from './components/ChainSwitcher'
|
||||
import { Leaderboard } from './components/Leaderboard'
|
||||
@@ -29,15 +30,33 @@ interface Route {
|
||||
chain: string | null
|
||||
window: WindowName
|
||||
miner: string | null
|
||||
block: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A block number, or a 32-byte hash.
|
||||
*
|
||||
* The second path segment is a window (`day`, `six_hours`, …) or a block, and
|
||||
* the two can never be confused: no window name is all digits or `0x` followed
|
||||
* by 64 hex characters. That is what lets `#/planck/day` and
|
||||
* `#/planck/1069799` share a shape without a discriminating prefix.
|
||||
*/
|
||||
function isBlockRef(segment: string): boolean {
|
||||
return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment)
|
||||
}
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean)
|
||||
if (parts[0] === 'miner' && parts[1]) {
|
||||
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase() }
|
||||
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase(), block: null }
|
||||
}
|
||||
const window = WINDOWS.find((w) => w.id === parts[1])?.id ?? 'six_hours'
|
||||
return { chain: parts[0] ?? null, window, miner: null }
|
||||
const chain = parts[0] ?? null
|
||||
const second = parts[1]
|
||||
if (second && isBlockRef(second)) {
|
||||
return { chain, window: 'six_hours', miner: null, block: second.toLowerCase() }
|
||||
}
|
||||
const window = WINDOWS.find((w) => w.id === second)?.id ?? 'six_hours'
|
||||
return { chain, window, miner: null, block: null }
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -67,15 +86,38 @@ export default function App() {
|
||||
const merged = { ...parseHash(window.location.hash), ...next }
|
||||
window.location.hash = merged.miner
|
||||
? `/miner/${merged.miner}`
|
||||
: `/${merged.chain ?? ''}/${merged.window}`
|
||||
: merged.block
|
||||
? `/${merged.chain ?? ''}/${merged.block}`
|
||||
: `/${merged.chain ?? ''}/${merged.window}`
|
||||
}, [])
|
||||
|
||||
const selectMiner = useCallback((miner: string) => {
|
||||
// Keep the chain and window; the miner panel opens above the board rather
|
||||
// than replacing the page, so the standings stay visible behind it.
|
||||
setRoute((r) => ({ ...r, miner }))
|
||||
setRoute((r) => ({ ...r, miner, block: null }))
|
||||
}, [])
|
||||
|
||||
const selectBlock = useCallback((block: string) => {
|
||||
setRoute((r) => ({ ...r, block, miner: null }))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* A height has resolved to its hash; put the hash in the address bar.
|
||||
*
|
||||
* `replaceState`, not a navigation: it is the same block under the name that
|
||||
* will still mean this block after a reorg, so it belongs in place of the
|
||||
* height rather than on top of it in the back stack. Route state is left
|
||||
* alone deliberately — rewriting it would re-render the panel with a new
|
||||
* `block` prop and fetch the same block a second time.
|
||||
*/
|
||||
const blockResolved = useCallback(
|
||||
(hash: string) => {
|
||||
if (!chain) return
|
||||
window.history.replaceState(null, '', `#/${chain}/${hash}`)
|
||||
},
|
||||
[chain],
|
||||
)
|
||||
|
||||
const interval =
|
||||
state.summary?.block_interval_seconds ?? state.summary?.target_block_time_seconds ?? 6
|
||||
const activeWindow = WINDOWS.find((w) => w.id === route.window) ?? WINDOWS[1]!
|
||||
@@ -100,7 +142,7 @@ export default function App() {
|
||||
key={w.id}
|
||||
aria-pressed={w.id === route.window}
|
||||
title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`}
|
||||
onClick={() => go({ window: w.id, miner: null })}
|
||||
onClick={() => go({ window: w.id, miner: null, block: null })}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
@@ -117,7 +159,7 @@ export default function App() {
|
||||
<ChainSwitcher
|
||||
chains={state.chains}
|
||||
active={chain}
|
||||
onSelect={(id) => go({ chain: id, miner: null })}
|
||||
onSelect={(id) => go({ chain: id, miner: null, block: null })}
|
||||
/>
|
||||
|
||||
{info?.status === 'awaiting' && (
|
||||
@@ -141,6 +183,17 @@ export default function App() {
|
||||
|
||||
<StatBar summary={state.summary} windowName={route.window} />
|
||||
|
||||
{route.block && chain && (
|
||||
<BlockPanel
|
||||
chain={chain}
|
||||
block={route.block}
|
||||
targetSeconds={info?.target_block_time_seconds ?? 6}
|
||||
onResolved={blockResolved}
|
||||
onSelectMiner={selectMiner}
|
||||
onClose={() => go({ window: route.window, miner: null, block: null })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{route.miner && chain && (
|
||||
<MinerPanel
|
||||
chain={chain}
|
||||
@@ -172,7 +225,12 @@ export default function App() {
|
||||
: 'measuring'}
|
||||
</span>
|
||||
</div>
|
||||
<BlockTicker blocks={state.blocks} onSelect={selectMiner} />
|
||||
<BlockTicker
|
||||
blocks={state.blocks}
|
||||
chain={chain}
|
||||
onSelect={selectMiner}
|
||||
onSelectBlock={selectBlock}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
111
web/src/api/generated/BlockDetail.ts
Normal file
111
web/src/api/generated/BlockDetail.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AttributionSource } from "./AttributionSource";
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { MinerId } from "./MinerId";
|
||||
|
||||
/**
|
||||
* One block, as completely as it can still be described.
|
||||
*
|
||||
* Assembled from two sources that know different things, because neither is
|
||||
* sufficient on its own:
|
||||
*
|
||||
* - The **node** holds the block itself — parent, body, and whether this hash
|
||||
* still holds its height. It forgets: `blocks-pruning` defaults to
|
||||
* `archive-canonical`, so a non-canonical body goes once finality passes it,
|
||||
* and difficulty is a state read behind a 256-block window.
|
||||
* - The **observer** holds what the node never had — when we first saw the
|
||||
* block, whether that sighting was at the tip — and what it has since
|
||||
* forgotten, the difficulty recorded while the state still existed.
|
||||
*
|
||||
* Every field the pair cannot supply is `None` rather than a substituted
|
||||
* plausible value. A block page that quietly filled in the current difficulty
|
||||
* for one it could not look up would be the same lie the ingest path was fixed
|
||||
* to stop telling.
|
||||
*/
|
||||
export type BlockDetail = {
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* Block number.
|
||||
*/
|
||||
height: number,
|
||||
/**
|
||||
* This block's hash — the identity the page is addressed by, so that a
|
||||
* link keeps meaning the same block after the height has moved on.
|
||||
*/
|
||||
hash: string,
|
||||
/**
|
||||
* Parent hash, from the header.
|
||||
*/
|
||||
parent_hash: string | null,
|
||||
/**
|
||||
* Author's reward preimage from the `pow_` PreRuntime digest.
|
||||
*/
|
||||
miner: MinerId | null,
|
||||
/**
|
||||
* What to call the author: a telemetry node name, or the abbreviated
|
||||
* preimage. `None` where the author could not be decoded at all.
|
||||
*/
|
||||
display: string | null,
|
||||
/**
|
||||
* Whether `display` is an inferred name or the preimage.
|
||||
*/
|
||||
attribution: AttributionSource | null,
|
||||
/**
|
||||
* How much of the attribution vote window agrees, 0.0–1.0.
|
||||
*/
|
||||
confidence: number | null,
|
||||
/**
|
||||
* Expected hashes to win this block: the difficulty in force at its
|
||||
* parent, which is the figure the miner actually had to meet.
|
||||
*/
|
||||
difficulty: BigUintDec | null,
|
||||
/**
|
||||
* The author's own timestamp, from the block's `Timestamp::set` inherent.
|
||||
*/
|
||||
authored_at: string | null,
|
||||
/**
|
||||
* When this observer first saw the block. `None` for a block it never saw
|
||||
* at all — an orphan on a fork it missed, or history before it started.
|
||||
*/
|
||||
observed_at: string | null,
|
||||
/**
|
||||
* Whether `observed_at` was a tip sighting. When false, the difference
|
||||
* between the timestamps measures when the observer got round to looking
|
||||
* and nothing about the network, so the UI must not read it as propagation.
|
||||
*/
|
||||
at_tip: boolean | null,
|
||||
/**
|
||||
* Seconds between this block's timestamp and its parent's — how long this
|
||||
* block took to find, against the chain's target.
|
||||
*/
|
||||
seconds_since_parent: number | null,
|
||||
/**
|
||||
* Extrinsics in the body, inherents included. `None` once the body has
|
||||
* been pruned.
|
||||
*/
|
||||
extrinsics: number | null,
|
||||
/**
|
||||
* Whether this hash is the one that currently holds this height.
|
||||
*/
|
||||
canonical: boolean,
|
||||
/**
|
||||
* The hash that currently holds this height. Equal to `hash` when
|
||||
* canonical; on an orphan page it is the link to the block that won.
|
||||
*/
|
||||
canonical_hash: string | null,
|
||||
/**
|
||||
* Other hashes this observer has recorded at this height — the losing
|
||||
* sides of past reorgs. Navigable, which is the whole reason the
|
||||
* displacement is written down.
|
||||
*/
|
||||
also_seen: Array<string>,
|
||||
/**
|
||||
* False when the node could not be reached or no longer holds the block,
|
||||
* so everything here came from what the observer had already recorded.
|
||||
* The UI owes the reader that distinction rather than showing gaps.
|
||||
*/
|
||||
from_node: boolean, };
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { ApiError } from './generated/ApiError'
|
||||
import type { BlockDetail } from './generated/BlockDetail'
|
||||
import type { ChainSeries } from './generated/ChainSeries'
|
||||
import type { MinerDetail } from './generated/MinerDetail'
|
||||
import type { Window as WindowName } from './generated/Window'
|
||||
@@ -59,6 +60,24 @@ export function fetchSeries(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One block, by height or by hash.
|
||||
*
|
||||
* A height names whatever currently holds it and a hash names one block for
|
||||
* good, so the caller redirects to the hash once the height resolves — see
|
||||
* `BlockPanel`. The API accepts either spelling at the same path.
|
||||
*/
|
||||
export function fetchBlock(
|
||||
chain: string,
|
||||
block: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BlockDetail> {
|
||||
return get<BlockDetail>(
|
||||
`/chains/${encodeURIComponent(chain)}/blocks/${encodeURIComponent(block)}`,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/** One miner's standing and history. */
|
||||
export function fetchMiner(
|
||||
chain: string,
|
||||
|
||||
267
web/src/components/BlockPanel.tsx
Normal file
267
web/src/components/BlockPanel.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* One block.
|
||||
*
|
||||
* Opened from a height — the number in the ticker, or a URL somebody typed —
|
||||
* and then it rewrites the address to the block's hash. That is not tidiness:
|
||||
* `block` is keyed on (chain, height) and a reorg overwrites, so a height names
|
||||
* a *position* and a link to one quietly comes to mean a different block the
|
||||
* moment the chain forks there. A hash names one block for good, including
|
||||
* after it has lost.
|
||||
*
|
||||
* The page says where each fact came from, because the two sources fail in
|
||||
* different ways and at different times. The node holds the block and forgets
|
||||
* it — non-canonical bodies go once finality passes them, and difficulty is a
|
||||
* state read behind a 256-block window. The observer holds what the node never
|
||||
* had, the sighting times, and what it has since forgotten. A blank here means
|
||||
* neither could answer, never that the value is zero.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BlockDetail } from '../api/generated/BlockDetail'
|
||||
import { RequestFailed, fetchBlock } from '../api/rest'
|
||||
import { ago, bigNumber, height as fmtHeight, seconds, shortMiner } from '../lib/format'
|
||||
|
||||
/** A label and its value, with the caveat that makes the value readable. */
|
||||
function Fact({
|
||||
label,
|
||||
children,
|
||||
note,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
// `| undefined` explicitly: the project sets `exactOptionalPropertyTypes`, so
|
||||
// an optional prop does not otherwise accept a conditional that may be
|
||||
// undefined — which is exactly how every caller here computes its caveat.
|
||||
note?: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="fact">
|
||||
<div className="fact-label">{label}</div>
|
||||
<div className="fact-value">{children}</div>
|
||||
{note && <div className="fact-note">{note}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A value the observer does not have, said plainly rather than left blank. */
|
||||
function Unknown({ why }: { why: string }) {
|
||||
return <span className="fact-unknown">not recorded — {why}</span>
|
||||
}
|
||||
|
||||
export function BlockPanel({
|
||||
chain,
|
||||
block,
|
||||
targetSeconds,
|
||||
onResolved,
|
||||
onSelectMiner,
|
||||
onClose,
|
||||
}: {
|
||||
chain: string
|
||||
/** Height or hash, as it appeared in the URL. */
|
||||
block: string
|
||||
/** The chain's target block time, for reading the gap against. */
|
||||
targetSeconds: number
|
||||
/** Called with the canonical hash once a height has resolved to one. */
|
||||
onResolved: (hash: string) => void
|
||||
onSelectMiner: (miner: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<BlockDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setDetail(null)
|
||||
setError(null)
|
||||
fetchBlock(chain, block, controller.signal)
|
||||
.then((d) => {
|
||||
setDetail(d)
|
||||
// Swap the address for the immutable one. Only ever a replace: this is
|
||||
// the same block under a better name, not somewhere the reader went.
|
||||
if (d.hash !== block) onResolved(d.hash)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain, block, onResolved])
|
||||
|
||||
// Propagation: the author stamps its timestamp when it *builds* the proposal,
|
||||
// so this is that moment to the moment we saw the block. Only a number at all
|
||||
// when the sighting was at the tip — on a gap fill it measures when the
|
||||
// observer got round to looking, which is a fact about us.
|
||||
const propagationMs =
|
||||
detail?.at_tip && detail.authored_at && detail.observed_at
|
||||
? new Date(detail.observed_at).getTime() - new Date(detail.authored_at).getTime()
|
||||
: null
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Block</div>
|
||||
<h2 className="panel-title" style={{ marginTop: 4 }}>
|
||||
{detail
|
||||
? `#${fmtHeight(detail.height)}`
|
||||
: block.startsWith('0x')
|
||||
? 'Block'
|
||||
: `#${block}`}
|
||||
{detail && !detail.canonical && <span className="badge badge-warn">replaced</span>}
|
||||
</h2>
|
||||
<div
|
||||
className="numeral"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
marginTop: 4,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{detail?.hash ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="segmented"
|
||||
style={{
|
||||
padding: '7px 13px',
|
||||
font: 'inherit',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !detail && <p className="empty">Reading the block…</p>}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
{!detail.canonical && (
|
||||
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
|
||||
This block lost its height to a reorg. It is kept here because the observer saw it —
|
||||
the chain itself no longer serves losing blocks for long.{' '}
|
||||
{detail.canonical_hash && (
|
||||
<a href={`#/${chain}/${detail.canonical_hash}`}>See the block that won →</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!detail.from_node && (
|
||||
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
|
||||
The node could not answer for this block, so everything below is what the observer
|
||||
recorded at the time. The body, the parent and the timing against it are missing
|
||||
because only the chain ever had them.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="facts">
|
||||
<Fact label="Author" note={detail.confidence ? undefined : 'no telemetry name held'}>
|
||||
{detail.miner ? (
|
||||
<a
|
||||
href={`#/miner/${detail.miner}`}
|
||||
title={detail.miner}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectMiner(detail.miner as string)
|
||||
}}
|
||||
>
|
||||
{detail.display ?? shortMiner(detail.miner)}
|
||||
</a>
|
||||
) : (
|
||||
// Genesis carries no `pow_` digest, and neither would a header
|
||||
// shape this decoder does not know.
|
||||
<Unknown why="this header carries no author digest" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact label="Difficulty" note="expected hashes to win it">
|
||||
{detail.difficulty ? (
|
||||
<span title={detail.difficulty}>{bigNumber(detail.difficulty)}</span>
|
||||
) : (
|
||||
<Unknown why="the node's state for this height is pruned" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact
|
||||
label="Time to find"
|
||||
note={
|
||||
detail.seconds_since_parent != null ? `target ${seconds(targetSeconds)}` : undefined
|
||||
}
|
||||
>
|
||||
{detail.seconds_since_parent != null ? (
|
||||
seconds(detail.seconds_since_parent)
|
||||
) : (
|
||||
<Unknown why="the parent's timestamp is unavailable" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact label="Extrinsics" note="inherents included">
|
||||
{detail.extrinsics != null ? (
|
||||
String(detail.extrinsics)
|
||||
) : (
|
||||
<Unknown why="the body is pruned" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact label="Authored" note={detail.authored_at ? "the author's own clock" : undefined}>
|
||||
{detail.authored_at ? (
|
||||
<span title={detail.authored_at}>{ago(detail.authored_at)}</span>
|
||||
) : (
|
||||
<Unknown why="no timestamp inherent was decoded" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact
|
||||
label="Seen here"
|
||||
note={
|
||||
propagationMs != null
|
||||
? `${propagationMs} ms after it was authored`
|
||||
: detail.observed_at
|
||||
? 'caught up from history, not seen live'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{detail.observed_at ? (
|
||||
<span title={detail.observed_at}>{ago(detail.observed_at)}</span>
|
||||
) : (
|
||||
<Unknown why="this observer never saw this block" />
|
||||
)}
|
||||
</Fact>
|
||||
</div>
|
||||
|
||||
<div className="block-links">
|
||||
{detail.parent_hash && (
|
||||
<div>
|
||||
<span className="eyebrow">Parent</span>{' '}
|
||||
<a className="numeral" href={`#/${chain}/${detail.parent_hash}`}>
|
||||
{detail.parent_hash}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{detail.also_seen.length > 0 && (
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
Also seen at this height ({detail.also_seen.length})
|
||||
</span>
|
||||
{detail.also_seen.map((h) => (
|
||||
<div key={h}>
|
||||
<a className="numeral" href={`#/${chain}/${h}`}>
|
||||
{h}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -15,10 +15,15 @@ import { usePinnedMiners } from '../lib/store'
|
||||
|
||||
export function BlockTicker({
|
||||
blocks,
|
||||
chain,
|
||||
onSelect,
|
||||
onSelectBlock,
|
||||
}: {
|
||||
blocks: RecentBlock[]
|
||||
/** Needed to address a block; null before the first chain has resolved. */
|
||||
chain: string | null
|
||||
onSelect: (miner: string) => void
|
||||
onSelectBlock: (block: string) => void
|
||||
}) {
|
||||
const { isPinned } = usePinnedMiners()
|
||||
const seen = useRef<Set<number>>(new Set())
|
||||
@@ -54,7 +59,20 @@ export function BlockTicker({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="ticker-height">#{height(block.height)}</span>
|
||||
{chain ? (
|
||||
<a
|
||||
className="ticker-height"
|
||||
href={`#/${chain}/${block.height}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectBlock(String(block.height))
|
||||
}}
|
||||
>
|
||||
#{height(block.height)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="ticker-height">#{height(block.height)}</span>
|
||||
)}
|
||||
<a
|
||||
href={`#/miner/${block.miner}`}
|
||||
title={block.miner}
|
||||
|
||||
@@ -469,6 +469,111 @@ a {
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* ---- block facts --------------------------------------------------------- */
|
||||
|
||||
/* The same grid as the stat tiles: this is the same kind of content — a handful
|
||||
of single values with a caveat each — and giving it a second look would say
|
||||
the two are different sorts of fact when they are not. */
|
||||
.facts {
|
||||
display: grid;
|
||||
/* Three, not auto-fit. There are exactly six facts, and auto-fit resolves to
|
||||
five at this width — leaving one on a row of its own beside four empty
|
||||
cells that read as a grid that failed to load. Six divides evenly by three
|
||||
and by two, so every breakpoint below is a full rectangle. A seventh fact
|
||||
would need this revisited, which is the right amount of friction. */
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border-bottom: var(--rule);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.facts {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fact {
|
||||
background: var(--surface-1);
|
||||
padding: 14px 18px 15px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fact-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fact-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 17px;
|
||||
margin-top: 7px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fact-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* A value nobody has. Muted and in the body face so it does not read as data —
|
||||
it is a sentence explaining an absence, and formatting it like a figure would
|
||||
invite it to be compared with one. */
|
||||
.fact-unknown {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.block-links {
|
||||
padding: 15px 18px 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.block-links a {
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.block-links a:hover {
|
||||
color: var(--data-bright);
|
||||
}
|
||||
|
||||
/* The one place the accent hue belongs: not a series, a state. A block that
|
||||
lost its height is the single most important thing its page can say. */
|
||||
.badge {
|
||||
margin-left: 10px;
|
||||
padding: 2px 7px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-bright);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
a.ticker-height:hover {
|
||||
color: var(--data-bright);
|
||||
}
|
||||
|
||||
/* ---- leaderboard --------------------------------------------------------- */
|
||||
|
||||
.board {
|
||||
|
||||
Reference in New Issue
Block a user