feat: section indexes, so the routes can be found
All checks were successful
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s

Every route added lately was reachable only by already knowing an address — a
block hash, a preimage, an SS58 string, a spec_version. Fine for a link somebody
sent you, useless for discovering the pages exist. A kind with nothing after it
is now the index of that kind, and a nav under the chain chips names them.

Not the same page three times. Blocks is the record behind the live ticker,
paged by height. Accounts ranks by tokens earned over the whole indexed record,
which is a different question from the standings' blocks-per-window and here a
different answer: difficulty rose 346-fold inside mainnet's first day, so an
early block cost a three-hundredth of a current one. Runtimes is the only list
with no other home — a block does not name its runtime and neither do the
standings.

No `/:chain/miner`: the standings already are the index of miners, and a second
page of the same rows under another address would be two answers to one
question. That path redirects there, along with anything else that resolves to
the front page without being spelled like it, so the address bar and the section
nav agree about where the reader is.

Two things caught by looking at the rendered pages:

The block index's gaps came from `observed_at` and read *three milliseconds*
between blocks on a chain targeting twelve seconds — exactly the trap CLAUDE.md
records, since a gap fill writes a whole batch within one second. They come from
`authored_at` now, the chain's own clock.

The first page of that index came from the live ticker, which is ordered
oldest-first because that is the order it is pushed in, so page one climbed and
every page after it descended. The index sends a cursor for its first page too.

Accounts shows the address beside any node name: one node legitimately reports
for several payout addresses, and the first cut had two rows reading
`pearl-prover` with nothing to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 18:07:11 +03:00
parent 289a80a7eb
commit efbe901646
16 changed files with 1082 additions and 31 deletions

View File

@@ -0,0 +1,48 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, miner, observed_at, authored_at, difficulty\n from block\n where chain = $1 and height < $2\n order by height desc\n limit $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "miner",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "observed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "authored_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "difficulty",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "525f21399582e40b79567a370e5ef052670f46ac12a413c6636bc06c720f9935"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n select a.account as \"account!\",\n count(*) as \"blocks!\",\n sum((e.fields->>'reward')::numeric)::text as \"total!\",\n -- Any of them: the wormhole derivation runs one way from a\n -- preimage to an address, so every reward paid to one\n -- account came from the same preimage. `max` is how to say\n -- \"any non-null\" in a group by, not a choice between\n -- candidates. Null when no reward height has a block row —\n -- events are indexed from genesis, blocks only from when\n -- this observer started watching.\n max(b.miner) as miner\n from chain_event e\n cross join unnest(e.accounts) a(account)\n left join block b on b.chain = e.chain and b.height = e.height\n where e.chain = $1\n and e.pallet = 'MiningRewards' and e.variant = 'MinerRewarded'\n group by a.account\n order by sum((e.fields->>'reward')::numeric) desc\n limit $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "account!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "blocks!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "total!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "miner",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "914725a46ee04bd2655fd22c329a981b848f944edec94a5c50624fddae910e68"
}

View File

@@ -16,8 +16,8 @@ use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use blackbeard_entities::{
AccountDetail, AccountEvent, ApiError, BigUintDec, BlockDetail, ChainInfo, ChainSeries,
ChainSummary, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock,
AccountDetail, AccountEvent, AccountRow, ApiError, BigUintDec, BlockDetail, ChainInfo,
ChainSeries, ChainSummary, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock,
RewardSummary, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet,
RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, Window,
};
@@ -52,6 +52,7 @@ 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}/accounts", get(accounts))
.route("/v1/chains/{chain}/accounts/{address}", get(account))
.route("/v1/chains/{chain}/runtimes", get(runtimes))
.route("/v1/chains/{chain}/runtimes/{spec}", get(runtime))
@@ -189,9 +190,50 @@ async fn leaderboard(
async fn blocks(
State(state): State<AppState>,
Path(chain): Path<String>,
Query(query): Query<BeforeQuery>,
) -> Result<Json<Vec<RecentBlock>>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
Ok(Json(runtime.ticker()))
// No cursor means "the newest", which the in-memory ticker already is and
// answers without touching the database — it is what every socket
// bootstrap asks for. A cursor means "before that", which only the record
// has. Same question, two sources, and the boundary between them is
// whether the caller is at the front of the list.
let Some(before) = query.before else {
return Ok(Json(runtime.ticker()));
};
let observed = state
.store
.blocks_before(&runtime.id(), Some(before), INDEX_PAGE)
.await
.map_err(database_unavailable)?;
let mut out = Vec::with_capacity(observed.len());
for (i, o) in observed.iter().enumerate() {
let attributed = runtime.attribute(&o.miner);
out.push(RecentBlock {
height: o.height,
miner: o.miner.clone(),
display: attributed.display,
attribution: attributed.source,
observed_at: o.observed_at,
// The chain's clock, not ours. `observed_at` is a measurement only
// at the tip (CLAUDE.md, and the `at_tip` column exists for it): a
// gap fill writes a whole batch within the same second, so
// differencing it here reported three *milliseconds* between blocks
// on a chain targeting twelve seconds. Authored times are what the
// authors stamped, which is the question a history is asking.
//
// Against the next row down, which is the previous block — the rows
// are newest first. `None` at the end of the page, where the
// predecessor is on the page after it rather than absent, and
// `None` for a block recorded before `authored_at` was captured.
gap_seconds: observed
.get(i + 1)
.and_then(|p| Some((o.authored_at? - p.authored_at?).as_seconds_f64())),
});
}
Ok(Json(out))
}
/// `GET /v1/chains/{chain}/blocks/{block}`
@@ -559,6 +601,65 @@ async fn miner(
}))
}
/// How many rows an index page carries.
const INDEX_PAGE: i64 = 50;
/// Paging cursor for the block index.
#[derive(Debug, Deserialize)]
struct BeforeQuery {
before: Option<u64>,
}
/// Accounts ranked by what they have earned, most first.
///
/// The standings answer "who is winning blocks now". This answers "who has been
/// paid the most", which on a chain whose difficulty rose 346-fold inside its
/// first day is emphatically not the same list.
async fn accounts(
State(state): State<AppState>,
Path(chain): Path<String>,
) -> Result<Json<Vec<AccountRow>>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
let ranked = state
.store
.top_accounts(&runtime.id(), INDEX_PAGE)
.await
.map_err(database_unavailable)?;
Ok(Json(
ranked
.into_iter()
.enumerate()
.map(|(i, r)| {
// Only a real name. `attribute` falls back to the abbreviated
// preimage, which beside the address in the same row would be
// a second spelling of the same thing dressed as an identity.
let held = r.miner.as_ref().map(|m| runtime.attribute(m));
let display = held.as_ref().and_then(|h| match h.source {
blackbeard_entities::AttributionSource::Preimage => None,
_ => Some(h.display.clone()),
});
AccountRow {
rank: i as u32 + 1,
attribution: held
.as_ref()
.map(|h| h.source)
.unwrap_or(blackbeard_entities::AttributionSource::Preimage),
confidence: held.as_ref().map(|h| h.confidence).unwrap_or(0.0),
attribution_votes: held.as_ref().map(|h| h.votes).unwrap_or(0),
address: blackbeard_core::wormhole::ss58_of(&r.account)
.unwrap_or_else(|| r.account.clone()),
account: r.account,
miner: r.miner,
display,
blocks: r.blocks,
total: BigUintDec(r.total),
}
})
.collect(),
))
}
/// Every runtime this chain has been seen running, oldest first.
async fn runtimes(
State(state): State<AppState>,

View File

@@ -180,6 +180,19 @@ pub struct StoredEvent {
pub fields: serde_json::Value,
}
/// One account's mining income, for the ranked list.
#[derive(Debug, Clone)]
pub struct AccountRanking {
/// The raw account id, `0x` hex.
pub account: String,
/// How many blocks it was paid for.
pub blocks: u64,
/// Their sum, in the chain's smallest unit, as a decimal string.
pub total: String,
/// The reward preimage behind it, when a block row names one.
pub miner: Option<MinerId>,
}
/// One runtime version this chain has run, as cached.
#[derive(Debug, Clone)]
pub struct CachedRuntime {
@@ -461,6 +474,103 @@ impl Store {
.collect())
}
/// Blocks below `before`, newest first — the paged history behind the
/// block index.
///
/// Deliberately not `recent_blocks` with an offset: blocks arrive at the
/// front of this ordering while somebody is paging through it, and an
/// offset would show them a row twice. The height is the cursor because it
/// is already the key.
pub async fn blocks_before(
&self,
chain: &ChainId,
before: Option<u64>,
limit: i64,
) -> Result<Vec<Observed>, DataError> {
// `i64::MAX` rather than a second query for the first page: every real
// height is under it, so "before everything" is the same statement.
let before = before.map(|h| h as i64).unwrap_or(i64::MAX);
let rows = sqlx::query!(
r#"
select height, miner, observed_at, authored_at, difficulty
from block
where chain = $1 and height < $2
order by height desc
limit $3
"#,
chain.as_str(),
before,
limit,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| Observed {
height: r.height as u64,
miner: MinerId(r.miner),
observed_at: r.observed_at,
authored_at: r.authored_at,
difficulty: r
.difficulty
.as_ref()
.and_then(|d| d.to_string().parse().ok()),
})
.collect())
}
/// Accounts by what they have been paid, most first.
///
/// The standings ranked by tokens rather than by blocks, which is a
/// different question and a different answer: difficulty rose 346-fold
/// inside mainnet's first day, so a miner that won early holds blocks that
/// cost a three-hundredth of today's and was paid accordingly.
///
/// Over the whole indexed record rather than a window — an account page is
/// about a balance, and a balance has no window.
pub async fn top_accounts(
&self,
chain: &ChainId,
limit: i64,
) -> Result<Vec<AccountRanking>, DataError> {
let rows = sqlx::query!(
r#"
select a.account as "account!",
count(*) as "blocks!",
sum((e.fields->>'reward')::numeric)::text as "total!",
-- Any of them: the wormhole derivation runs one way from a
-- preimage to an address, so every reward paid to one
-- account came from the same preimage. `max` is how to say
-- "any non-null" in a group by, not a choice between
-- candidates. Null when no reward height has a block row —
-- events are indexed from genesis, blocks only from when
-- this observer started watching.
max(b.miner) as miner
from chain_event e
cross join unnest(e.accounts) a(account)
left join block b on b.chain = e.chain and b.height = e.height
where e.chain = $1
and e.pallet = 'MiningRewards' and e.variant = 'MinerRewarded'
group by a.account
order by sum((e.fields->>'reward')::numeric) desc
limit $2
"#,
chain.as_str(),
limit,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| AccountRanking {
account: r.account,
blocks: r.blocks.max(0) as u64,
total: r.total,
miner: r.miner.map(MinerId),
})
.collect())
}
/// The last `limit` blocks with their tip flag, newest **first**.
///
/// Feeds the two things a restart otherwise loses: the block ticker, which

View File

@@ -13,7 +13,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{BigUintDec, ChainId, MinerId};
use crate::{AttributionSource, BigUintDec, ChainId, MinerId};
/// Everything the observer holds about one account.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -52,6 +52,42 @@ pub struct AccountDetail {
pub indexed_to: Option<u64>,
}
/// One row of the accounts index: an account and what it has earned.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "AccountRow.ts")]
pub struct AccountRow {
/// Position in the ranking, 1-based.
#[ts(type = "number")]
pub rank: u32,
/// The SS58 address, which is what a reader recognises and what the page
/// for it is addressed by.
pub address: String,
/// The raw account id, `0x` hex.
pub account: String,
/// The reward preimage behind it, when the observer recorded a block it was
/// paid for. Absent for an account whose blocks all predate this observer.
pub miner: Option<MinerId>,
/// A telemetry name for that miner, if one is held.
pub display: Option<String>,
/// Where `display` came from. Carried for the same reason the leaderboard
/// carries it: a name here is an inference from which node reported a block
/// first, and a row that renders a guess identically to a settled name is
/// making a claim the observer cannot support.
pub attribution: AttributionSource,
/// Agreement behind the held name, 0.01.0. Meaningless when `attribution`
/// is `Preimage`.
pub confidence: f32,
/// How many votes are behind it. A fraction alone cannot tell one vote of
/// one from nine of nine, and those are not the same claim.
#[ts(type = "number")]
pub attribution_votes: u32,
/// How many blocks it was paid for.
#[ts(type = "number")]
pub blocks: u64,
/// Their sum, in the chain's smallest unit.
pub total: BigUintDec,
}
/// What an account has been paid for mining.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "RewardSummary.ts")]

View File

@@ -24,7 +24,7 @@ mod runtime;
mod series;
mod ws;
pub use account::{AccountDetail, AccountEvent, RewardSummary};
pub use account::{AccountDetail, AccountEvent, AccountRow, RewardSummary};
pub use block::{BlockDetail, BlockObservation, RecentBlock};
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking};
pub use error::{ApiError, EntityError};

View File

@@ -214,6 +214,36 @@ tokens, and the derivation joins them. Going the other way — address to preima
— is a lookup among preimages already seen, not a calculation, because the
Poseidon2 derivation runs one way only.
## Sections, so the addresses are findable
Every route here was reachable only by already knowing an address: a block hash,
a preimage, an SS58 string, a `spec_version`. That is fine for a link somebody
sent you and useless for finding out the pages exist. A kind with nothing after
it is now the index of that kind — `/quantus/block`, `/quantus/account`,
`/quantus/runtime` — and a section nav under the chain chips names all of them.
There is deliberately no `/quantus/miner`. The standings **are** the index of
miners, and a second page listing the same rows under a different address would
be two answers to one question; that path redirects there, as does anything else
that resolves to the front page but is not spelled like it.
The three indexes are not the same page three times:
- **Blocks** is the record behind the live ticker — the same rows from Postgres
rather than memory, paged by height. Its gaps come from `authored_at`, the
chain's own clock, *not* from `observed_at`: a gap fill writes a whole batch
within the same second, and the first cut of this page reported three
milliseconds between blocks on a chain targeting twelve seconds.
- **Accounts** ranks by tokens earned over the whole indexed record, which is a
different question from the standings' blocks-per-window and on this chain a
different answer — difficulty rose 346-fold inside mainnet's first day, so an
early block cost a three-hundredth of a current one and paid accordingly. It
shows the address beside any node name, because one node legitimately reports
for several payout addresses and the first version had two rows reading
`pearl-prover` with nothing to tell them apart.
- **Runtimes** is the only list with no other home at all: a block does not name
its runtime and the standings do not either.
## Every view has an address
Paths, not a hash: `/quantus/day`, `/quantus/block/13160`,

View File

@@ -11,12 +11,16 @@ import { useCallback, useEffect, useMemo } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { AccountPanel } from './components/AccountPanel'
import { AccountsIndex } from './components/AccountsIndex'
import { BlockPanel } from './components/BlockPanel'
import { BlocksIndex } from './components/BlocksIndex'
import { BlockTicker } from './components/BlockTicker'
import { ChainSwitcher } from './components/ChainSwitcher'
import { Leaderboard } from './components/Leaderboard'
import { MinerPanel } from './components/MinerPanel'
import { RuntimePanel } from './components/RuntimePanel'
import { RuntimesIndex } from './components/RuntimesIndex'
import { SectionNav } from './components/SectionNav'
import { StatBar } from './components/StatBar'
import { seconds, windowSpan } from './lib/format'
import { href, parse, WINDOWS } from './lib/routes'
@@ -49,6 +53,25 @@ export default function App() {
}
}, [route.chain, route.window, fallbackChain, navigate])
// Normalise a path that resolves to the standings but is not spelled like
// them: `/quantus`, `/quantus/miner` (the standings *are* the miner index),
// and anything unrecognised, which `parse` degrades to the front page rather
// than a 404. Without this the address bar keeps saying something the page is
// 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
const canonical = href({ chain, window: route.window })
if (standings && location.pathname !== canonical) {
navigate(canonical, { replace: true })
}
}, [chain, route, location.pathname, navigate])
useWatch(chain, route.window)
/**
@@ -111,6 +134,8 @@ export default function App() {
<ChainSwitcher chains={state.chains} active={chain} window={route.window} />
<SectionNav chain={chain} route={route} window={route.window} />
{info?.status === 'awaiting' && (
<div className="banner banner-warn">
<strong>{info.display_name}</strong> has not started producing blocks yet. This page will
@@ -164,33 +189,49 @@ export default function App() {
for. */}
{route.runtime !== null && chain && <RuntimePanel chain={chain} spec={route.runtime} />}
{!route.account && !route.miner && !route.block && route.runtime === null && (
<div className="two-col">
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">The Standings</h2>
<span className="eyebrow">
last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '}
{windowSpan(activeWindow.blocks, interval)}
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
</span>
</div>
<Leaderboard rows={state.leaderboard} chain={chain} ready={state.ready} />
</section>
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Live Blocks</h2>
<span className="eyebrow">
{state.summary?.block_interval_seconds
? `${seconds(state.summary.block_interval_seconds)} apart`
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} chain={chain} />
</section>
</div>
{route.index === 'block' && chain && <BlocksIndex chain={chain} />}
{route.index === 'account' && chain && (
<AccountsIndex
chain={chain}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{route.index === 'runtime' && chain && (
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
)}
{!route.account &&
!route.miner &&
!route.block &&
route.runtime === null &&
route.index === null && (
<div className="two-col">
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">The Standings</h2>
<span className="eyebrow">
last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '}
{windowSpan(activeWindow.blocks, interval)}
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
</span>
</div>
<Leaderboard rows={state.leaderboard} chain={chain} ready={state.ready} />
</section>
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Live Blocks</h2>
<span className="eyebrow">
{state.summary?.block_interval_seconds
? `${seconds(state.summary.block_interval_seconds)} apart`
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} chain={chain} />
</section>
</div>
)}
<footer className="footer">
<span>

View File

@@ -0,0 +1,56 @@
// 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 { MinerId } from "./MinerId";
/**
* One row of the accounts index: an account and what it has earned.
*/
export type AccountRow = {
/**
* Position in the ranking, 1-based.
*/
rank: number,
/**
* The SS58 address, which is what a reader recognises and what the page
* for it is addressed by.
*/
address: string,
/**
* The raw account id, `0x` hex.
*/
account: string,
/**
* The reward preimage behind it, when the observer recorded a block it was
* paid for. Absent for an account whose blocks all predate this observer.
*/
miner: MinerId | null,
/**
* A telemetry name for that miner, if one is held.
*/
display: string | null,
/**
* Where `display` came from. Carried for the same reason the leaderboard
* carries it: a name here is an inference from which node reported a block
* first, and a row that renders a guess identically to a settled name is
* making a claim the observer cannot support.
*/
attribution: AttributionSource,
/**
* Agreement behind the held name, 0.01.0. Meaningless when `attribution`
* is `Preimage`.
*/
confidence: number,
/**
* How many votes are behind it. A fraction alone cannot tell one vote of
* one from nine of nine, and those are not the same claim.
*/
attribution_votes: number,
/**
* How many blocks it was paid for.
*/
blocks: number,
/**
* Their sum, in the chain's smallest unit.
*/
total: BigUintDec, };

View File

@@ -7,9 +7,11 @@
*/
import type { AccountDetail } from './generated/AccountDetail'
import type { AccountRow } from './generated/AccountRow'
import type { ApiError } from './generated/ApiError'
import type { BlockDetail } from './generated/BlockDetail'
import type { ChainSeries } from './generated/ChainSeries'
import type { RecentBlock } from './generated/RecentBlock'
import type { MinerDetail } from './generated/MinerDetail'
import type { RuntimeDetail } from './generated/RuntimeDetail'
import type { RuntimeSummary } from './generated/RuntimeSummary'
@@ -102,6 +104,27 @@ export function fetchAccount(
)
}
/** Accounts ranked by what they have been paid, most first. */
export function fetchAccounts(chain: string, signal?: AbortSignal): Promise<AccountRow[]> {
return get<AccountRow[]>(`/chains/${encodeURIComponent(chain)}/accounts`, signal)
}
/**
* A page of block history, newest first.
*
* Without `before` this is the live ticker out of the API's memory, which is
* what the front of the list is; with it, the record. Same question, and the
* boundary is whether the caller is at the front.
*/
export function fetchBlockPage(
chain: string,
before?: number,
signal?: AbortSignal,
): Promise<RecentBlock[]> {
const cursor = before === undefined ? '' : `?before=${before}`
return get<RecentBlock[]>(`/chains/${encodeURIComponent(chain)}/blocks${cursor}`, signal)
}
/** Every runtime this chain has been seen running, oldest first. */
export function fetchRuntimes(chain: string, signal?: AbortSignal): Promise<RuntimeSummary[]> {
return get<RuntimeSummary[]>(`/chains/${encodeURIComponent(chain)}/runtimes`, signal)

View File

@@ -0,0 +1,154 @@
/**
* Accounts, by what they have been paid.
*
* Not the standings under another name. The standings rank by blocks won in a
* window and answer "who is winning right now"; this ranks by tokens over the
* whole indexed record and answers "who has been paid the most". On a chain
* whose difficulty rose 346-fold inside its first day those are emphatically
* not the same list — an early block cost a three-hundredth of a current one
* and was paid accordingly.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { AccountRow } from '../api/generated/AccountRow'
import { RequestFailed, fetchAccounts } from '../api/rest'
import { exactTokens, height as fmtHeight, shortAddress, tokens } from '../lib/format'
import { href } from '../lib/routes'
export function AccountsIndex({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [rows, setRows] = useState<AccountRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setRows(null)
setError(null)
fetchAccounts(chain, controller.signal)
.then(setRows)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
const max = rows && rows.length > 0 ? Number(BigInt(rows[0]!.total.slice(0, 15))) : 0
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Accounts</h2>
<span className="eyebrow">by mining rewards, all recorded history</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
No rewards indexed for this chain yet. The event index fills outward from the tip.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Accounts ranked by mining rewards received
</caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" className="left">
Account
</th>
<th scope="col">Blocks</th>
<th scope="col">Earned</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
// The same thin-evidence test the leaderboard uses, so a guess
// is marked the same way in both places rather than reading as
// settled here and tentative there.
const named = row.display !== null && row.attribution !== 'preimage'
const tentative = named && (row.attribution_votes < 3 || row.confidence < 0.6)
// Leading digits only: these are u128 and the bar is a shape.
const share = max > 0 ? Number(BigInt(row.total.slice(0, 15))) / max : 0
return (
<tr key={row.account}>
<td className={`rank rank-${row.rank}`}>{row.rank}</td>
<td className="left">
<div className="miner-cell">
<Link
to={href({ chain, account: row.address })}
className={named ? 'miner-name' : 'miner-name anonymous'}
title={row.address}
>
{named ? row.display : shortAddress(row.address)}
</Link>
{/* The address as well as the name, whenever there is a
name. A node name is not unique to an account — one
node legitimately reports for several payout
addresses, and this page had two rows reading
"pearl-prover" with nothing to tell them apart. The
address is what the row actually is. */}
{named && (
<span className="account-address">{shortAddress(row.address)}</span>
)}
{tentative && (
<span
className="chip chip-named chip-guess"
title={`Resting on ${row.attribution_votes} attributed block${
row.attribution_votes === 1 ? '' : 's'
}; a later block may correct it.`}
>
node?
</span>
)}
{row.attribution === 'carried' && (
<span
className="chip chip-named chip-guess"
title="Name carried from another chain, matched on the same reward preimage."
>
elsewhere
</span>
)}
</div>
</td>
<td className="numeral">{fmtHeight(row.blocks)}</td>
<td className="numeral" title={`${exactTokens(row.total, decimals)} ${symbol}`}>
{tokens(row.total, decimals, 2)} {symbol}
</td>
<td>
<div className="share-bar" aria-hidden="true">
<i style={{ width: `${Math.max(share * 100, 1)}%` }} />
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<p className="panel-note">
Summed from <code>MiningRewards::MinerRewarded</code> events decoded against the runtime
that produced each block. This is what the chain paid, not what an account holds it says
nothing about transfers out, and an account that has never mined does not appear however
much it holds.
</p>
</section>
)
}

View File

@@ -0,0 +1,148 @@
/**
* Blocks, newest first, paged back through the record.
*
* The ticker beside the standings is the live feed and holds forty rows. This
* is the history behind it: the same rows, from the database rather than from
* memory, and pageable to wherever this observer's record begins.
*
* The first page sends a cursor too, rather than letting it default. Without
* one the API answers with the live ticker, which is ordered oldest-first
* because that is the order it is pushed in — so the first page climbed and
* every page after it descended. `MAX_SAFE_INTEGER` is "before everything",
* which is what the front of a descending list means.
*
* The gap here is from the chain's own clock, not from when the observer saw
* each block. `observed_at` is a measurement only at the tip — a gap fill
* writes a whole batch within the same second — so differencing it down a page
* of history reports milliseconds between blocks on a chain targeting seconds.
* The API does that conversion; this only renders it.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { RecentBlock } from '../api/generated/RecentBlock'
import { RequestFailed, fetchBlockPage } from '../api/rest'
import { height as fmtHeight, seconds, when } from '../lib/format'
import { href } from '../lib/routes'
export function BlocksIndex({ chain }: { chain: string }) {
const [rows, setRows] = useState<RecentBlock[] | null>(null)
const [error, setError] = useState<string | null>(null)
// The cursor, not the rows: paging replaces the page rather than appending,
// because a block history is read by position and an endless scroll would
// make "where am I" unanswerable.
const [before, setBefore] = useState<number | null>(null)
// Above any height this or any chain will reach, and the same sentinel the
// store documents for an absent cursor.
const FRONT = Number.MAX_SAFE_INTEGER
useEffect(() => {
const controller = new AbortController()
setRows(null)
setError(null)
fetchBlockPage(chain, before ?? FRONT, controller.signal)
.then(setRows)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain, before, FRONT])
const oldest = rows && rows.length > 0 ? rows[rows.length - 1]!.height : null
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Blocks</h2>
<span className="eyebrow">
{rows && rows.length > 0
? `#${fmtHeight(rows[0]!.height)} #${fmtHeight(oldest!)}`
: 'newest first'}
</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
Nothing recorded below this height. The observer's record begins where it started
watching, not at genesis.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Blocks, newest first</caption>
<thead>
<tr>
<th scope="col">Block</th>
<th scope="col" className="left">
Miner
</th>
<th scope="col">Gap</th>
<th scope="col" className="event-when">
Seen
</th>
</tr>
</thead>
<tbody>
{rows.map((b) => (
<tr key={b.height}>
<td className="numeral">
<Link to={href({ chain, block: String(b.height) })}>
#{fmtHeight(b.height)}
</Link>
</td>
<td className="left">
<Link
to={href({ chain, miner: b.miner })}
className={
b.attribution === 'preimage' ? 'miner-name anonymous' : 'miner-name'
}
title={b.miner}
>
{b.display}
</Link>
</td>
<td className="numeral">
{b.gap_seconds === null ? '' : `+${seconds(b.gap_seconds)}`}
</td>
<td className="numeral event-when" style={{ color: 'var(--text-muted)' }}>
{when(b.observed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager">
<button
className="segmented panel-button"
disabled={before === null}
onClick={() => setBefore(null)}
>
← Newest
</button>
<button
className="segmented panel-button"
disabled={oldest === null || oldest <= 1}
onClick={() => setBefore(oldest)}
>
Older →
</button>
</div>
<p className="panel-note">
Gaps are the difference between the authors' own timestamps the chain's clock, not this
observer's. A block's <em>seen</em> time is when this observer first had it, which for
anything it caught up on is later than when the chain made it and is not a measurement of
anything.
</p>
</section>
)
}

View File

@@ -0,0 +1,106 @@
/**
* Every runtime this chain has run.
*
* The one list on the site with no other home: a block does not name its
* runtime, the standings do not, and until this page existed a runtime was
* reachable only by guessing a `spec_version`. The heights come from bisecting
* the upgrade boundaries, so each row is the block that runtime took over at
* rather than the block this observer happened to notice it.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { RuntimeSummary } from '../api/generated/RuntimeSummary'
import { RequestFailed, fetchRuntimes } from '../api/rest'
import { height as fmtHeight, when } from '../lib/format'
import { href } from '../lib/routes'
export function RuntimesIndex({ chain, current }: { chain: string; current: number | null }) {
const [rows, setRows] = useState<RuntimeSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setRows(null)
setError(null)
fetchRuntimes(chain, controller.signal)
.then(setRows)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Runtimes</h2>
<span className="eyebrow">newest first</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
No metadata cached for this chain yet. It is fetched the first time a block is decoded.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Runtime versions this chain has run, newest first
</caption>
<thead>
<tr>
<th scope="col">Version</th>
<th scope="col" className="left">
Name
</th>
<th scope="col">In force from</th>
<th scope="col">Metadata</th>
<th scope="col" className="last-seen">
Cached
</th>
</tr>
</thead>
<tbody>
{[...rows].reverse().map((r) => (
<tr key={r.spec_version}>
<td className="numeral">
<Link to={href({ chain, runtime: r.spec_version })}>v{r.spec_version}</Link>
</td>
<td className="left">
<span className="miner-cell">
<code>{r.spec_name}</code>
{r.spec_version === current && <span className="chip chip-you">Current</span>}
</span>
</td>
<td className="numeral">
<Link to={href({ chain, block: String(r.first_seen_height) })}>
#{fmtHeight(r.first_seen_height)}
</Link>
</td>
<td className="numeral">{Math.round(r.bytes / 1024)} KiB</td>
<td className="numeral last-seen" style={{ color: 'var(--text-muted)' }}>
{when(r.fetched_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="panel-note">
Found by bisecting the upgrade boundaries <code>spec_version</code> only increases, so the
blocks where it changes are a sorted sequence and each is found in a few dozen probes rather
than by walking the chain. Metadata is cached per version, so a runtime stays readable after
the node has pruned the state it would need to describe itself again.
</p>
</section>
)
}

View File

@@ -0,0 +1,61 @@
/**
* The site's sections.
*
* Every route this site has was reachable only by already knowing an address —
* a block hash, a preimage, an SS58 string, a spec_version. That is fine for a
* link somebody sent you and useless for finding out the pages exist at all.
* This is the answer to "what else is here".
*
* The standings are the first entry because they are the front page, and there
* is no "Miners" beside them for the same reason: the standings already are the
* index of miners, and a second page listing the same rows under a different
* address would be two answers to one question.
*/
import { Link } from 'react-router-dom'
import type { Window as WindowName } from '../api/generated/Window'
import { href, type Route, SECTIONS } from '../lib/routes'
export function SectionNav({
chain,
route,
window: windowName,
}: {
chain: string | null
route: Route
/** Carried into the standings link so leaving a section and coming back does
* not silently reset the span someone chose. */
window: WindowName
}) {
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
return (
<nav className="sections" aria-label="Section">
<Link
to={href({ chain, window: windowName })}
className="section-link"
aria-current={onStandings ? 'page' : undefined}
>
Standings
</Link>
{SECTIONS.map((s) => (
<Link
key={s.id}
to={href({ chain, index: s.id })}
className="section-link"
aria-current={route.index === s.id ? 'page' : undefined}
>
{s.label}
</Link>
))}
</nav>
)
}

View File

@@ -1191,3 +1191,60 @@ tr.mine .share-bar > i {
.runtime-extensions {
counter-reset: none;
}
/* ---- section nav --------------------------------------------------------- */
/* A quieter register than the chain chips above it: choosing a chain changes
what every number on the page is about, and choosing a section only changes
which of them you are looking at. Words rather than chips says that. */
.sections {
display: flex;
flex-wrap: wrap;
gap: 18px;
padding: 0 0 18px;
margin-top: -4px;
}
.section-link {
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
text-decoration: none;
padding-bottom: 3px;
border-bottom: 1px solid transparent;
}
.section-link:hover {
color: var(--text-primary);
}
/* Underline rather than a fill: the current section is a position, and a filled
chip here would compete with the active chain chip directly above it. */
.section-link[aria-current='page'] {
color: var(--data-bright);
border-bottom-color: var(--data);
}
/* ---- pager --------------------------------------------------------------- */
.pager {
display: flex;
gap: 10px;
padding: 14px 16px;
border-top: var(--rule);
}
.pager button:disabled {
color: var(--text-muted);
cursor: not-allowed;
}
/* The address beside a name on the accounts index. Muted and monospace: it is
the row's identity but not what a reader scans for, and one node name can
belong to several accounts. */
.account-address {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}

View File

@@ -40,6 +40,35 @@ export interface Route {
account: string | null
/** A `spec_version`. */
runtime: number | null
/**
* A kind with no value: the index of everything of that kind.
*
* `/quantus/runtime/` lists the runtimes, `/quantus/account/` the accounts,
* `/quantus/block/` the blocks. It is how the routes are discoverable at all
* — a page addressed by a hash nobody has cannot be found by wandering
* towards it.
*/
index: SectionName | null
}
/** The kinds that have an index. */
export type SectionName = 'block' | 'account' | 'runtime'
/**
* The sections, in the order the nav shows them.
*
* `miner` is deliberately absent: the standings *are* the index of miners, and
* a second page listing the same rows under a different address would be two
* answers to one question. `/:chain/miner/` redirects there.
*/
export const SECTIONS: { id: SectionName; label: string }[] = [
{ id: 'block', label: 'Blocks' },
{ id: 'account', label: 'Accounts' },
{ id: 'runtime', label: 'Runtimes' },
]
function section(name: string): SectionName | null {
return SECTIONS.some((s) => s.id === name) ? (name as SectionName) : null
}
export const EMPTY: Route = {
@@ -49,6 +78,7 @@ export const EMPTY: Route = {
miner: null,
account: null,
runtime: null,
index: null,
}
function asWindow(segment: string | undefined): WindowName | null {
@@ -106,6 +136,14 @@ export function parse(pathname: string): Route {
if (kind === 'account') return { ...EMPTY, chain, account: value }
if (kind === 'runtime') return { ...EMPTY, chain, runtime: spec(value) }
}
// A kind with nothing after it — `/quantus/account` or `/quantus/account/`,
// which `filter(Boolean)` above makes the same path. The index of that kind.
if (kind && !value) {
const named = section(kind)
if (named) return { ...EMPTY, chain, index: named }
// The standings are the miner index; there is no second page of them.
if (kind === 'miner') return { ...EMPTY, chain }
}
// A bare height or hash as the second segment: the shape the site used
// before this module existed. Recognised so links already sent stay good.
if (kind && isBlockRef(kind)) return { ...EMPTY, chain, block: kind.toLowerCase() }
@@ -118,6 +156,7 @@ export function href(route: Partial<Route>): string {
const chain = route.chain ?? ''
if (route.block) return `/${chain}/block/${route.block}`
if (route.miner) return chain ? `/${chain}/miner/${route.miner}` : `/miner/${route.miner}`
if (route.index) return `/${chain}/${route.index}`
if (route.account) return `/${chain}/account/${route.account}`
if (route.runtime !== null && route.runtime !== undefined) {
return chain ? `/${chain}/runtime/${route.runtime}` : `/runtime/${route.runtime}`