feat(chains): discover every Quantus chain from telemetry; track the ones we can reach
The chain list is no longer config. substrate-telemetry announces every chain it knows — with live node counts — before and independent of any subscription, so one feed connection that never subscribes is a complete, self-maintaining index. A chain that launches tomorrow appears in the nav on its own. Config now contributes only RPC endpoints. Authorship lives in the `pow_` digest of a block *header*, and telemetry publishes hashes and heights but never headers — so a chain with no endpoint is listed with its node count and is not navigable, and the nav says why rather than offering an empty board. The two sets join on genesis hash, never on name. Two chains are tracked in full: Planck (our own node on bob) and Heisenberg (a1-heisenberg.quantus.cat, found via Quantus-Network/quantus-apps). Both decode identically — same digest shape, same QPoW runtime API — which is the proof that adding a chain is one [[chains]] entry and no new code. Quantus Staging Mainnet and Quantus Dirac Testnet are listed and disabled: no endpoint exists under the a1-/a2- pattern and none resolves under any plausible name. Nav is ordered by node count descending, ties broken on id so it cannot reshuffle between refreshes. Telemetry now also yields chain-wide best height, best finalized height, average block time and the client-version histogram (feed codes 1, 2, 12 and 22). Infra moves to a single site: oolon fronts blackbeard.observer, reverse-proxying cross-site over the mesh to the API beside the node on bob. That hop is the one the loopback health probe cannot see, so the deploy now checks it explicitly — a firewalld service scoped to bob's own /16 would leave a live site with a dead /v1 and nothing would fail. infra-setup gains `dns` and `cert` roles that run on oolon with the credential already there: the Cloudflare token is never copied off the proxy, and it refuses to repoint an apex record that already exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
This commit is contained in:
@@ -26,7 +26,9 @@ env:
|
||||
API_HOST: bob.hanzalova.internal
|
||||
API_PORT: "25864"
|
||||
# The site's edge proxy. It serves the built SPA and reverse-proxies /v1.
|
||||
EDGE_HOST: hanzalova.internal
|
||||
# Cross-site on purpose: the API has to sit beside the node it reads, and the
|
||||
# public name is fronted from the DC.
|
||||
EDGE_HOST: oolon.kosherinata.internal
|
||||
WEBROOT: /var/www/blackbeard.observer
|
||||
PUBLIC_NAME: blackbeard.observer
|
||||
|
||||
@@ -261,6 +263,16 @@ jobs:
|
||||
echo "blackbeard-api did not become healthy" >&2
|
||||
exit 1
|
||||
|
||||
- name: reachable from the edge proxy
|
||||
# The health probe above runs on the API host over loopback and so
|
||||
# cannot see the hop that actually matters: the edge proxy is at a
|
||||
# different site, and if the firewalld service were scoped to this
|
||||
# host's own /16 the site would serve a static bundle with a dead /v1
|
||||
# and nothing would fail.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh "$EDGE_HOST" "curl -sf --max-time 10 http://$API_HOST:$API_PORT/v1/healthz -o /dev/null -w '%{http_code}\n'"
|
||||
|
||||
- name: journal
|
||||
if: always()
|
||||
run: ssh "$API_HOST" journalctl -u blackbeard-api.service -n 80 --no-pager || true
|
||||
@@ -308,8 +320,20 @@ jobs:
|
||||
|
||||
- name: fetch the site
|
||||
# `nginx -t` parses without binding, so a passing test is not evidence
|
||||
# the reload landed. Fetching the page is.
|
||||
# the reload landed. Fetching the page is — and fetching the chain list
|
||||
# through the vhost proves the whole path: nginx, the cross-site hop,
|
||||
# the API, and the telemetry directory behind it. A 200 with an empty
|
||||
# chain list is a broken deploy that a status code alone would pass.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh "$EDGE_HOST" "curl -sfI https://$PUBLIC_NAME/ --resolve $PUBLIC_NAME:443:127.0.0.1 -o /dev/null -w '%{http_code}\n'" \
|
||||
|| ssh "$EDGE_HOST" "curl -sfI https://blackbeard.internal/ -o /dev/null -w 'internal %{http_code}\n'"
|
||||
ssh "$EDGE_HOST" "curl -sf --max-time 15 https://$PUBLIC_NAME/v1/chains" > chains.json
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
chains = json.load(open("chains.json"))
|
||||
full = [c for c in chains if c["tracking"] == "full"]
|
||||
for c in chains:
|
||||
print(f"{c['node_count'] or 0:>4} nodes {c['tracking']:<12} {c['display_name']}")
|
||||
if not full:
|
||||
print("no chain is tracked in full — the site would show an empty board", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
# There are no secrets here and there is nowhere to put one: the Postgres
|
||||
# connection is mTLS with this host's own certificate as the credential
|
||||
# (architecture/generic.md §5), and every chain endpoint is either loopback or a
|
||||
# public feed. The only substituted value is the host's FQDN, because the
|
||||
# certificate paths carry it and the workflow is the thing that knows which host
|
||||
# this is going to.
|
||||
# public one. The only substituted values are the host's FQDN and its mesh
|
||||
# address, because the workflow is the thing that knows which host this is
|
||||
# going to.
|
||||
|
||||
[server]
|
||||
# The host's mesh address, not 0.0.0.0. The fleet uses a single firewalld
|
||||
# default zone (generic.md §9), so a wildcard bind plus the named service would
|
||||
# publish this on every address the host carries.
|
||||
#
|
||||
# The edge proxy is CROSS-SITE — oolon at kosherinata, this API at hanzalova —
|
||||
# so the firewalld service must be open to the whole default zone rather than
|
||||
# scoped to this host's own /16.
|
||||
listen = "{{BIND_ADDRESS}}:25864"
|
||||
|
||||
# Not a security boundary — every byte this serves is derived from public block
|
||||
@@ -28,6 +32,18 @@ allowed_origins = [
|
||||
ticker_blocks = 40
|
||||
leaderboard_refresh_seconds = 5
|
||||
|
||||
[telemetry]
|
||||
# The chain list comes from here, not from [[chains]] below. One connection that
|
||||
# never subscribes gives every Quantus chain telemetry knows, with live node
|
||||
# counts — so a chain that launches tomorrow appears in the site's navigation
|
||||
# on its own, with no redeploy.
|
||||
#
|
||||
# This URL is found in /tmp/env-config.js on the telemetry site — NOT the /feed
|
||||
# path on the main host, which 404s (lair/quantus readme).
|
||||
url = "wss://feed-telemetry.quantus.cat/feed"
|
||||
max_tracked_chains = 16
|
||||
default_block_time_seconds = 6.0
|
||||
|
||||
[database]
|
||||
host = "magrathea.kosherinata.internal"
|
||||
port = 5432
|
||||
@@ -42,35 +58,51 @@ max_connections = 8
|
||||
|
||||
# --- chains ------------------------------------------------------------------
|
||||
#
|
||||
# The first chain listed is the one the site opens on.
|
||||
# NOT the list of chains the site shows — telemetry supplies that. Listing a
|
||||
# chain here gives it an RPC endpoint, which upgrades it from "listed, with a
|
||||
# node count" to a full miner leaderboard. A chain with no entry here is shown
|
||||
# in the nav and not navigable, because authorship lives in block *headers* and
|
||||
# only a node's JSON-RPC serves those.
|
||||
#
|
||||
# A chain may be configured before it launches: the observer reports it as
|
||||
# `awaiting`, the UI says so, and it comes alive on its own the moment the node
|
||||
# starts answering — no redeploy, no restart.
|
||||
# Adding a chain is one entry. No code path differs between the first and the
|
||||
# tenth — which is the claim this file exists to make good on.
|
||||
#
|
||||
# Setting `genesis` is optional but worth it: it lets a chain join its telemetry
|
||||
# population before its node has answered even once, which matters on a cold
|
||||
# start.
|
||||
|
||||
[[chains]]
|
||||
id = "planck"
|
||||
display_name = "Planck Testnet"
|
||||
mainnet = false
|
||||
# Loopback: the node runs on this host. 9944 serves HTTP and WebSocket both.
|
||||
display_name = "Planck"
|
||||
genesis = "0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72"
|
||||
# Loopback: we run this node. 9944 serves HTTP and WebSocket both.
|
||||
rpc_url = "http://127.0.0.1:9944"
|
||||
ws_url = "ws://127.0.0.1:9944"
|
||||
# The feed URL is found in /tmp/env-config.js on the telemetry site — NOT the
|
||||
# /feed path on the main host, which 404s (lair/quantus readme).
|
||||
telemetry_url = "wss://feed-telemetry.quantus.cat/feed"
|
||||
target_block_time_seconds = 6.0
|
||||
warm_start_blocks = 100800
|
||||
max_gap_fill_blocks = 5000
|
||||
|
||||
# Mainnet. Uncomment and fill in the endpoints when the chain spec is published;
|
||||
# until then the observer would report it `awaiting` forever, which is honest but
|
||||
# puts a permanently dead tab in the switcher.
|
||||
[[chains]]
|
||||
id = "heisenberg"
|
||||
display_name = "Heisenberg"
|
||||
genesis = "0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f"
|
||||
# A public endpoint operated by the Quantus team, not by us. Treated exactly
|
||||
# like the local node: same decoding, same leaderboard, same everything — which
|
||||
# is the point. It is third-party infrastructure, so it may disappear without
|
||||
# notice; the chain then reports `unreachable` and keeps its last standings.
|
||||
rpc_url = "https://a1-heisenberg.quantus.cat"
|
||||
ws_url = "wss://a1-heisenberg.quantus.cat"
|
||||
target_block_time_seconds = 6.0
|
||||
warm_start_blocks = 100800
|
||||
max_gap_fill_blocks = 5000
|
||||
|
||||
# Quantus Staging Mainnet and Quantus Dirac Testnet are listed in the nav from
|
||||
# telemetry and are NOT navigable: neither publishes an RPC endpoint we can
|
||||
# find. The pattern for the ones that do is `a1-<chain>.quantus.cat` /
|
||||
# `a2-<chain>.quantus.cat`; if endpoints appear for those chains, each becomes a
|
||||
# [[chains]] block like the two above and nothing else changes.
|
||||
#
|
||||
# [[chains]]
|
||||
# id = "quantus"
|
||||
# display_name = "Quantus"
|
||||
# mainnet = true
|
||||
# rpc_url = "http://127.0.0.1:9945"
|
||||
# ws_url = "ws://127.0.0.1:9945"
|
||||
# telemetry_url = "wss://feed-telemetry.quantus.cat/feed"
|
||||
# target_block_time_seconds = 6.0
|
||||
# Real mainnet has not launched. It is expected to follow a commit to
|
||||
# github.com/Quantus-Network/chain publishing a new mainnet spec, around the
|
||||
# announced 2026-09-09 TGE — "Quantus Staging Mainnet" is a staging chain and
|
||||
# deliberately not labelled `mainnet = true` here.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Mesh vhost for blackbeard.observer, on the hanzalova edge proxy.
|
||||
# Mesh vhost for blackbeard.observer, on the oolon edge proxy (kosherinata).
|
||||
#
|
||||
# This exists because a public name does not hairpin: from inside the mesh,
|
||||
# `blackbeard.observer` resolves to the site's WAN address, the packet hits the
|
||||
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# The split-horizon DNS record is a separate manual step on BOTH site routers:
|
||||
# opn-cli --config ~/.opn-cli/{hanzalova,kosherinata}.yml unbound host create \
|
||||
# --hostname blackbeard --domain internal --rr A --server <hanzalova mesh ip>
|
||||
# --hostname blackbeard --domain internal --rr A --server <oolon mesh ip>
|
||||
# A record added to only one router NXDOMAINs everywhere else.
|
||||
|
||||
server {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Public vhost for blackbeard.observer, on the hanzalova edge proxy.
|
||||
# Public vhost for blackbeard.observer, on the oolon edge proxy (kosherinata).
|
||||
#
|
||||
# Cert: Let's Encrypt via certbot + Cloudflare DNS-01 (architecture/external-tls.md).
|
||||
# Installed by script/infra-setup.sh --edge, never by CI — the runner has no
|
||||
@@ -13,6 +13,13 @@
|
||||
# failure mode is nginx silently serving stale certificates. Copy this listen
|
||||
# line from a currently ENABLED vhost; several files in sites-available have
|
||||
# drifted from their enabled counterparts.
|
||||
#
|
||||
# NOTE: the upstream is CROSS-SITE. oolon is at kosherinata; the API runs on bob
|
||||
# at hanzalova, because that is where the Planck node it reads lives. The hop
|
||||
# traverses the WireGuard mesh, so the API's firewalld service must be open to
|
||||
# the whole default zone rather than scoped to bob's own /16 — a rich rule
|
||||
# scoped the way quantus-node's RPC is would reject oolon and the site would
|
||||
# serve a static bundle with a dead /v1.
|
||||
|
||||
# The `blackbeard_api` upstream is declared once in
|
||||
# conf.d/blackbeard-upstream.conf so both vhosts can use it independently, and
|
||||
|
||||
@@ -23,11 +23,43 @@ pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
/// Postgres connection.
|
||||
pub database: DatabaseConfig,
|
||||
/// Every chain to watch. A chain may be listed before it launches.
|
||||
/// The substrate-telemetry feed, which supplies the chain list.
|
||||
pub telemetry: TelemetryConfig,
|
||||
/// Chains we hold a node for. **Not** the list of chains the site shows —
|
||||
/// that comes from telemetry. Listing a chain here upgrades it from
|
||||
/// telemetry-only observation to a full miner leaderboard.
|
||||
#[serde(default)]
|
||||
pub chains: Vec<ChainConfig>,
|
||||
}
|
||||
|
||||
/// The telemetry feed and how much of it to track.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TelemetryConfig {
|
||||
/// The feed's WebSocket URL.
|
||||
///
|
||||
/// Found in `/tmp/env-config.js` on the telemetry site — **not** the
|
||||
/// `/feed` path on the main host, which 404s.
|
||||
pub url: String,
|
||||
|
||||
/// Most chains to observe at once.
|
||||
///
|
||||
/// Each tracked chain holds its own feed connection, so this bounds both
|
||||
/// sockets and memory against a telemetry instance that starts listing
|
||||
/// hundreds of chains. Chains beyond the cap are simply not tracked; the
|
||||
/// cut is by node count, so what is dropped is the least populated.
|
||||
#[serde(default = "default_max_tracked_chains")]
|
||||
pub max_tracked_chains: usize,
|
||||
|
||||
/// Target block time assumed for a discovered chain, in seconds.
|
||||
///
|
||||
/// Only a fallback for display: a chain with no RPC has no difficulty, so
|
||||
/// nothing derives a hashrate from this. Telemetry's own average block time
|
||||
/// is preferred wherever it is available.
|
||||
#[serde(default = "default_target_block_time")]
|
||||
pub default_block_time_seconds: f64,
|
||||
}
|
||||
|
||||
/// The HTTP listener and what it will accept.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -97,11 +129,20 @@ pub struct DatabaseConfig {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ChainConfig {
|
||||
/// Operator name, used in routes and the WebSocket protocol.
|
||||
/// URL-safe id used in routes and the WebSocket protocol.
|
||||
pub id: ChainId,
|
||||
/// Human-facing name.
|
||||
/// Human-facing name. Should match the chain's telemetry name so the two
|
||||
/// views of it read as one chain.
|
||||
pub display_name: String,
|
||||
/// True for the production network.
|
||||
/// Genesis hash, if known ahead of time.
|
||||
///
|
||||
/// Optional — the node is asked for it at startup. Setting it lets the
|
||||
/// chain join its telemetry population before its node has answered even
|
||||
/// once, which matters on a cold start.
|
||||
#[serde(default)]
|
||||
pub genesis: Option<String>,
|
||||
/// **Operator-asserted, never inferred.** A chain published as "Quantus
|
||||
/// Staging Mainnet" is exactly why this is not derived from the name.
|
||||
#[serde(default)]
|
||||
pub mainnet: bool,
|
||||
/// HTTP JSON-RPC endpoint, e.g. `http://127.0.0.1:9944`.
|
||||
@@ -109,10 +150,6 @@ pub struct ChainConfig {
|
||||
/// WebSocket JSON-RPC endpoint for the head subscription. Usually the same
|
||||
/// host and port as `rpc_url` — `9944` serves both.
|
||||
pub ws_url: String,
|
||||
/// substrate-telemetry feed. Empty disables telemetry for this chain, which
|
||||
/// costs node counts and miner names and nothing else.
|
||||
#[serde(default)]
|
||||
pub telemetry_url: String,
|
||||
/// The chain's target seconds per block. Used as the hashrate denominator
|
||||
/// whenever no measured interval is trustworthy.
|
||||
#[serde(default = "default_target_block_time")]
|
||||
@@ -157,6 +194,9 @@ fn default_warm_start_blocks() -> usize {
|
||||
fn default_max_gap_fill() -> u64 {
|
||||
5_000
|
||||
}
|
||||
fn default_max_tracked_chains() -> usize {
|
||||
16
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load from `path`, then overlay `BLACKBEARD_*` environment variables.
|
||||
@@ -189,8 +229,11 @@ impl Config {
|
||||
/// reports healthy, and serves an empty site — which is what a duplicate
|
||||
/// chain id or a missing certificate would otherwise produce.
|
||||
fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.chains.is_empty() {
|
||||
anyhow::bail!("no chains configured — the observer would have nothing to observe");
|
||||
if self.chains.is_empty() && self.telemetry.url.is_empty() {
|
||||
anyhow::bail!(
|
||||
"no telemetry url and no configured chains — the observer would have nothing to \
|
||||
observe. Telemetry supplies the chain list; [[chains]] only adds RPC endpoints."
|
||||
);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for chain in &self.chains {
|
||||
@@ -246,6 +289,9 @@ mod tests {
|
||||
[server]
|
||||
listen = "127.0.0.1:25864"
|
||||
|
||||
[telemetry]
|
||||
url = "wss://feed-telemetry.quantus.cat/feed"
|
||||
|
||||
[database]
|
||||
host = "magrathea.kosherinata.internal"
|
||||
database = "blackbeard"
|
||||
@@ -291,18 +337,6 @@ ws_url = "ws://127.0.0.1:9944"
|
||||
assert!(err.contains("more than once"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_with_no_chains_is_refused() {
|
||||
let dir = tempdir();
|
||||
let p = write(&dir, "config.toml", &config_toml(&dir, ""));
|
||||
assert!(
|
||||
Config::load(&p)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no chains")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_certificate_is_refused_rather_than_discovered_later() {
|
||||
let dir = tempdir();
|
||||
|
||||
@@ -51,22 +51,28 @@ const POLL_INTERVAL: Duration = Duration::from_secs(4);
|
||||
const PERSIST_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Start every task for one chain.
|
||||
pub fn spawn(chain: Arc<ChainRuntime>, store: Store, ticker_blocks: usize, refresh: Duration) {
|
||||
pub fn spawn(chain: Arc<ChainRuntime>, store: Store, config: &crate::config::Config) {
|
||||
let (tx, rx) = mpsc::channel(HEAD_BUFFER);
|
||||
let ticker_blocks = config.server.ticker_blocks;
|
||||
let refresh = Duration::from_secs(config.server.leaderboard_refresh_seconds);
|
||||
|
||||
tokio::spawn(rpc::subscribe_new_heads(chain.config.ws_url.clone(), tx));
|
||||
tokio::spawn(rpc::subscribe_new_heads(chain.spec.ws_url.clone(), tx));
|
||||
tokio::spawn(ingest(Arc::clone(&chain), store.clone(), rx, ticker_blocks));
|
||||
tokio::spawn(poll(Arc::clone(&chain), store.clone()));
|
||||
tokio::spawn(poll(
|
||||
Arc::clone(&chain),
|
||||
store.clone(),
|
||||
config.telemetry.url.clone(),
|
||||
));
|
||||
tokio::spawn(housekeeping(chain, store, refresh));
|
||||
}
|
||||
|
||||
/// Load what the database already knows, so a restart does not serve an empty
|
||||
/// site while it re-watches a week of blocks.
|
||||
pub async fn warm_start(chain: &Arc<ChainRuntime>, store: &Store) {
|
||||
let id = chain.id().clone();
|
||||
let id = chain.id();
|
||||
|
||||
match store
|
||||
.recent_blocks(&id, chain.config.warm_start_blocks as i64)
|
||||
.recent_blocks(&id, chain.spec.warm_start_blocks as i64)
|
||||
.await
|
||||
{
|
||||
Ok(blocks) => {
|
||||
@@ -127,7 +133,7 @@ async fn ingest(
|
||||
if let Some(last) = last
|
||||
&& height > last + 1
|
||||
{
|
||||
let gap_start = (last + 1).max(height.saturating_sub(chain.config.max_gap_fill_blocks));
|
||||
let gap_start = (last + 1).max(height.saturating_sub(chain.spec.max_gap_fill_blocks));
|
||||
fill_gap(&chain, &store, gap_start, height).await;
|
||||
}
|
||||
|
||||
@@ -163,7 +169,7 @@ async fn fill_gap(chain: &Arc<ChainRuntime>, store: &Store, from: u64, to: u64)
|
||||
.and_then(from_millis);
|
||||
let now = Utc::now();
|
||||
batch.push(BlockRecord {
|
||||
chain: chain.id().clone(),
|
||||
chain: chain.id(),
|
||||
height,
|
||||
hash,
|
||||
miner: miner.clone(),
|
||||
@@ -231,7 +237,7 @@ async fn record(
|
||||
|
||||
if let Err(e) = store
|
||||
.record_blocks(&[BlockRecord {
|
||||
chain: chain.id().clone(),
|
||||
chain: chain.id(),
|
||||
height,
|
||||
hash: hash.clone(),
|
||||
miner: miner.clone(),
|
||||
@@ -282,7 +288,7 @@ async fn record(
|
||||
|
||||
chain.broadcast(
|
||||
&ServerMessage::Block {
|
||||
chain: chain.id().clone(),
|
||||
chain: chain.id(),
|
||||
block,
|
||||
},
|
||||
None,
|
||||
@@ -304,7 +310,7 @@ async fn record(
|
||||
}
|
||||
|
||||
/// Poll the things that are not pushed: difficulty, sync state, properties.
|
||||
async fn poll(chain: Arc<ChainRuntime>, store: Store) {
|
||||
async fn poll(chain: Arc<ChainRuntime>, store: Store, telemetry_url: String) {
|
||||
let mut ticker = tokio::time::interval(POLL_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut telemetry_started = false;
|
||||
@@ -348,15 +354,21 @@ async fn poll(chain: Arc<ChainRuntime>, store: Store) {
|
||||
if chain.read().genesis.is_none()
|
||||
&& let Ok(Some(genesis)) = chain.rpc.genesis().await
|
||||
{
|
||||
chain.write().genesis = Some(genesis.clone());
|
||||
if !chain.config.telemetry_url.is_empty() && !telemetry_started {
|
||||
telemetry_started = true;
|
||||
tokio::spawn(TelemetryFeed::run(
|
||||
chain.telemetry.clone(),
|
||||
chain.config.telemetry_url.clone(),
|
||||
genesis,
|
||||
));
|
||||
}
|
||||
chain.write().genesis = Some(genesis);
|
||||
}
|
||||
// The per-chain telemetry feed needs the genesis hash to subscribe. A
|
||||
// chain whose genesis is set in config starts its feed on the first
|
||||
// poll; one that has to discover it starts a moment later.
|
||||
if !telemetry_started
|
||||
&& !telemetry_url.is_empty()
|
||||
&& let Some(genesis) = chain.read().genesis.clone()
|
||||
{
|
||||
telemetry_started = true;
|
||||
tokio::spawn(TelemetryFeed::run(
|
||||
chain.telemetry.clone(),
|
||||
telemetry_url.clone(),
|
||||
genesis,
|
||||
));
|
||||
}
|
||||
if chain.read().token_symbol.is_none()
|
||||
&& let Ok(props) = chain.rpc.properties().await
|
||||
@@ -383,13 +395,13 @@ async fn poll(chain: Arc<ChainRuntime>, store: Store) {
|
||||
let record = {
|
||||
let inner = chain.read();
|
||||
ChainRecord {
|
||||
chain: chain.id().clone(),
|
||||
display_name: chain.config.display_name.clone(),
|
||||
mainnet: chain.config.mainnet,
|
||||
chain: chain.id(),
|
||||
display_name: chain.spec.display_name.clone(),
|
||||
mainnet: chain.spec.mainnet,
|
||||
genesis: inner.genesis.clone(),
|
||||
token_symbol: inner.token_symbol.clone(),
|
||||
token_decimals: inner.token_decimals.map(i16::from),
|
||||
target_block_time: chain.config.target_block_time_seconds,
|
||||
target_block_time: chain.spec.target_block_time_seconds,
|
||||
}
|
||||
};
|
||||
if let Err(e) = store.upsert_chain(&record).await {
|
||||
@@ -398,7 +410,7 @@ async fn poll(chain: Arc<ChainRuntime>, store: Store) {
|
||||
|
||||
chain.broadcast(
|
||||
&ServerMessage::Summary {
|
||||
chain: chain.id().clone(),
|
||||
chain: chain.id(),
|
||||
summary: chain.summary(),
|
||||
},
|
||||
None,
|
||||
@@ -409,7 +421,7 @@ async fn poll(chain: Arc<ChainRuntime>, store: Store) {
|
||||
fn announce_status(chain: &Arc<ChainRuntime>) {
|
||||
chain.broadcast(
|
||||
&ServerMessage::ChainStatus {
|
||||
chain: chain.id().clone(),
|
||||
chain: chain.id(),
|
||||
info: chain.info(),
|
||||
},
|
||||
None,
|
||||
@@ -434,7 +446,7 @@ async fn housekeeping(chain: Arc<ChainRuntime>, store: Store, refresh: Duration)
|
||||
if changed {
|
||||
chain.broadcast(
|
||||
&ServerMessage::Leaderboard {
|
||||
chain: chain.id().clone(),
|
||||
chain: chain.id(),
|
||||
window,
|
||||
rows,
|
||||
},
|
||||
@@ -456,7 +468,7 @@ async fn housekeeping(chain: Arc<ChainRuntime>, store: Store, refresh: Duration)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if let Err(e) = store.save_attributions(chain.id(), &held).await {
|
||||
if let Err(e) = store.save_attributions(&chain.id(), &held).await {
|
||||
tracing::warn!(chain = %chain.id(), error = %e, "attributions not persisted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
mod config;
|
||||
mod ingest;
|
||||
mod registry;
|
||||
mod routes;
|
||||
mod state;
|
||||
mod ws;
|
||||
@@ -19,11 +20,10 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use blackbeard_data::rpc::RpcClient;
|
||||
use blackbeard_data::store::{Store, StoreConfig};
|
||||
use clap::Parser;
|
||||
|
||||
use crate::state::{AppState, ChainRuntime};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// How long an RPC call may take before it is abandoned.
|
||||
///
|
||||
@@ -78,27 +78,30 @@ async fn main() -> anyhow::Result<()> {
|
||||
let store = connect_store(&config).await?;
|
||||
tracing::info!(host = %config.database.host, "postgres connected, migrations applied");
|
||||
|
||||
let mut chains = Vec::new();
|
||||
let mut by_id = std::collections::HashMap::new();
|
||||
for chain_config in &config.chains {
|
||||
let rpc = RpcClient::new(chain_config.rpc_url.clone(), RPC_TIMEOUT)?;
|
||||
let runtime = Arc::new(ChainRuntime::new(chain_config.clone(), rpc));
|
||||
// Before any task starts, so the first browser to connect sees a
|
||||
// populated site rather than one filling in over the next week.
|
||||
ingest::warm_start(&runtime, &store).await;
|
||||
ingest::spawn(
|
||||
Arc::clone(&runtime),
|
||||
store.clone(),
|
||||
config.server.ticker_blocks,
|
||||
Duration::from_secs(config.server.leaderboard_refresh_seconds),
|
||||
// The chain directory: one feed connection that never subscribes, giving
|
||||
// the full list of Quantus chains and their live node counts. It is what
|
||||
// makes the site's navigation reflect the network rather than the config.
|
||||
let directory = blackbeard_data::telemetry::TelemetryDirectory::new();
|
||||
if config.telemetry.url.is_empty() {
|
||||
tracing::warn!(
|
||||
"no telemetry url configured — the chain list will show only tracked chains"
|
||||
);
|
||||
by_id.insert(runtime.id().clone(), Arc::clone(&runtime));
|
||||
chains.push(runtime);
|
||||
} else {
|
||||
tokio::spawn(directory.clone().run(config.telemetry.url.clone()));
|
||||
}
|
||||
|
||||
let config = Arc::new(config);
|
||||
let registry = Arc::new(
|
||||
registry::Registry::new(Arc::clone(&config), store.clone(), directory.clone()).await,
|
||||
);
|
||||
tracing::info!(
|
||||
tracked = registry.tracked().len(),
|
||||
"chains with an rpc endpoint are tracked in full; the rest are listed from telemetry"
|
||||
);
|
||||
tokio::spawn(Arc::clone(®istry).supervise());
|
||||
|
||||
let state = AppState {
|
||||
chains: Arc::new(chains),
|
||||
by_id: Arc::new(by_id),
|
||||
registry: Arc::clone(®istry),
|
||||
store,
|
||||
started: chrono::Utc::now(),
|
||||
leaderboard_max_age: Duration::from_secs(config.server.leaderboard_refresh_seconds),
|
||||
|
||||
281
crates/blackbeard-api/src/registry.rs
Normal file
281
crates/blackbeard-api/src/registry.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! The set of chains, and which of them this observer can actually track.
|
||||
//!
|
||||
//! Two sources, joined on **genesis hash** and never on name:
|
||||
//!
|
||||
//! - **The telemetry directory** says what *exists*. substrate-telemetry
|
||||
//! announces every Quantus chain it knows, with live node counts, before and
|
||||
//! independent of any subscription — so one connection that never subscribes
|
||||
//! is a complete, self-maintaining index. A chain that launches tomorrow
|
||||
//! appears in the site's navigation on its own, with no redeploy.
|
||||
//! - **Config** says what we can *observe*. Authorship comes from the `pow_`
|
||||
//! digest in a block **header**, and only a node's JSON-RPC serves headers —
|
||||
//! telemetry publishes hashes and heights but never headers. So a chain with
|
||||
//! no endpoint is listed and named, with its node count, and is not
|
||||
//! navigable; the UI says why rather than offering an empty page.
|
||||
//!
|
||||
//! Adding a chain is therefore one `[[chains]]` entry naming an RPC endpoint.
|
||||
//! No code path differs between the first chain and the tenth — which is the
|
||||
//! whole claim this module exists to make good on.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use blackbeard_data::rpc::RpcClient;
|
||||
use blackbeard_data::store::Store;
|
||||
use blackbeard_data::telemetry::TelemetryDirectory;
|
||||
use blackbeard_entities::{ChainId, ChainInfo, ChainStatus, Tracking};
|
||||
|
||||
use crate::config::{ChainConfig, Config};
|
||||
use crate::state::ChainRuntime;
|
||||
|
||||
/// Everything needed to observe one chain.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChainSpec {
|
||||
/// URL-safe id used in routes and the WebSocket protocol.
|
||||
pub slug: String,
|
||||
/// Human-facing name.
|
||||
pub display_name: String,
|
||||
/// Genesis hash, when known ahead of the node answering.
|
||||
pub genesis: Option<String>,
|
||||
/// **Operator-asserted, never inferred.** A chain published as "Quantus
|
||||
/// Staging Mainnet" is exactly why this is not derived from the name.
|
||||
pub mainnet: bool,
|
||||
/// HTTP JSON-RPC endpoint.
|
||||
pub rpc_url: String,
|
||||
/// WebSocket JSON-RPC endpoint for the head subscription.
|
||||
pub ws_url: String,
|
||||
/// Target seconds per block, the hashrate denominator of last resort.
|
||||
pub target_block_time_seconds: f64,
|
||||
/// Blocks replayed from the database at startup.
|
||||
pub warm_start_blocks: usize,
|
||||
/// Cap on a single gap fill.
|
||||
pub max_gap_fill_blocks: u64,
|
||||
}
|
||||
|
||||
impl ChainSpec {
|
||||
/// A tracked chain always has an endpoint; that is what makes it tracked.
|
||||
pub fn tracking(&self) -> Tracking {
|
||||
Tracking::Full
|
||||
}
|
||||
|
||||
fn from_config(chain: &ChainConfig) -> Self {
|
||||
Self {
|
||||
slug: chain.id.0.clone(),
|
||||
display_name: chain.display_name.clone(),
|
||||
genesis: chain.genesis.clone(),
|
||||
mainnet: chain.mainnet,
|
||||
rpc_url: chain.rpc_url.clone(),
|
||||
ws_url: chain.ws_url.clone(),
|
||||
target_block_time_seconds: chain.target_block_time_seconds,
|
||||
warm_start_blocks: chain.warm_start_blocks,
|
||||
max_gap_fill_blocks: chain.max_gap_fill_blocks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a chain's published name into a URL-safe slug.
|
||||
///
|
||||
/// `"Quantus Staging Mainnet"` becomes `quantus-staging-mainnet`. Used only to
|
||||
/// give an untracked chain a stable key in the nav; a tracked chain's slug
|
||||
/// comes from config.
|
||||
pub fn slugify(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut pending_dash = false;
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
if pending_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
}
|
||||
pending_dash = false;
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
pending_dash = true;
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
// A name with nothing URL-safe in it still needs a key, or React has
|
||||
// no way to tell two such rows apart.
|
||||
return "chain".to_owned();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The chains this observer knows about.
|
||||
pub struct Registry {
|
||||
/// Chains we hold an endpoint for, by slug. Fixed at startup: a runtime is
|
||||
/// created from config, never from discovery.
|
||||
tracked: Vec<Arc<ChainRuntime>>,
|
||||
by_slug: HashMap<String, Arc<ChainRuntime>>,
|
||||
/// The most recent view of chains we have **no** endpoint for.
|
||||
untracked: RwLock<Vec<ChainInfo>>,
|
||||
directory: TelemetryDirectory,
|
||||
}
|
||||
|
||||
/// How often node counts and the untracked list are refreshed.
|
||||
const RECONCILE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
impl Registry {
|
||||
/// Build the registry and start every configured chain.
|
||||
pub async fn new(config: Arc<Config>, store: Store, directory: TelemetryDirectory) -> Self {
|
||||
let mut tracked = Vec::new();
|
||||
let mut by_slug = HashMap::new();
|
||||
|
||||
for chain in &config.chains {
|
||||
let spec = ChainSpec::from_config(chain);
|
||||
let rpc = match RpcClient::new(spec.rpc_url.clone(), crate::RPC_TIMEOUT) {
|
||||
Ok(rpc) => rpc,
|
||||
Err(e) => {
|
||||
// A malformed endpoint is a config error, but it must not
|
||||
// stop the other chains from being served.
|
||||
tracing::error!(chain = %spec.slug, url = %spec.rpc_url, error = %e,
|
||||
"unusable rpc endpoint; this chain will not be tracked");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let runtime = Arc::new(ChainRuntime::new(spec, rpc));
|
||||
crate::ingest::warm_start(&runtime, &store).await;
|
||||
crate::ingest::spawn(Arc::clone(&runtime), store.clone(), &config);
|
||||
by_slug.insert(runtime.spec.slug.clone(), Arc::clone(&runtime));
|
||||
tracked.push(runtime);
|
||||
}
|
||||
|
||||
Self {
|
||||
tracked,
|
||||
by_slug,
|
||||
untracked: RwLock::new(Vec::new()),
|
||||
directory,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a tracked chain by slug or genesis hash.
|
||||
///
|
||||
/// Accepting the genesis too keeps a link working across a rename and gives
|
||||
/// an operator an unambiguous way to name a chain.
|
||||
pub fn get(&self, id: &str) -> Option<Arc<ChainRuntime>> {
|
||||
if let Some(found) = self.by_slug.get(id) {
|
||||
return Some(Arc::clone(found));
|
||||
}
|
||||
self.tracked
|
||||
.iter()
|
||||
.find(|c| c.read().genesis.as_deref() == Some(id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Every chain, tracked and not, ordered by node count descending.
|
||||
///
|
||||
/// One list rather than two: a reader wants to see where the chain they
|
||||
/// care about sits in the network as a whole, and hiding the ones we cannot
|
||||
/// track would misrepresent that. The `tracking` field is what the nav uses
|
||||
/// to decide which entries are navigable.
|
||||
pub fn infos(&self) -> Vec<ChainInfo> {
|
||||
let mut all: Vec<ChainInfo> = self.tracked.iter().map(|c| c.info()).collect();
|
||||
all.extend(
|
||||
self.untracked
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
all.sort_by(|a, b| {
|
||||
b.node_count
|
||||
.unwrap_or(0)
|
||||
.cmp(&a.node_count.unwrap_or(0))
|
||||
// Ties break on id so the nav cannot reshuffle between refreshes.
|
||||
.then_with(|| a.id.0.cmp(&b.id.0))
|
||||
});
|
||||
all
|
||||
}
|
||||
|
||||
/// Refresh node counts on tracked chains and rebuild the untracked list.
|
||||
pub fn reconcile(&self) {
|
||||
let discovered = self.directory.chains();
|
||||
|
||||
// Genesis is the join key. A tracked chain learns its own from its node,
|
||||
// so this can only match once that node has answered at least once —
|
||||
// which is why `genesis` is worth setting in config for a cold start.
|
||||
let mut matched: Vec<&str> = Vec::new();
|
||||
for runtime in &self.tracked {
|
||||
let genesis = runtime.read().genesis.clone();
|
||||
let Some(genesis) = genesis else { continue };
|
||||
if let Some(found) = discovered.iter().find(|c| c.genesis == genesis) {
|
||||
runtime.set_node_count(Some(found.node_count));
|
||||
matched.push(&found.genesis);
|
||||
}
|
||||
}
|
||||
|
||||
let untracked: Vec<ChainInfo> = discovered
|
||||
.iter()
|
||||
.filter(|c| !matched.contains(&c.genesis.as_str()))
|
||||
.map(|c| ChainInfo {
|
||||
id: ChainId(slugify(&c.name)),
|
||||
display_name: c.name.clone(),
|
||||
node_count: Some(c.node_count),
|
||||
// Listed so the network's shape is visible, but not navigable:
|
||||
// without an RPC endpoint there are no headers, and without
|
||||
// headers there is no authorship to show.
|
||||
tracking: Tracking::NoEndpoint,
|
||||
mainnet: false,
|
||||
genesis: Some(c.genesis.clone()),
|
||||
token_symbol: None,
|
||||
token_decimals: None,
|
||||
target_block_time_seconds: 0.0,
|
||||
status: ChainStatus::Unreachable,
|
||||
})
|
||||
.collect();
|
||||
|
||||
*self.untracked.write().unwrap_or_else(|e| e.into_inner()) = untracked;
|
||||
}
|
||||
|
||||
/// Run the reconcile loop forever.
|
||||
pub async fn supervise(self: Arc<Self>) {
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
self.reconcile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracked chains, for the ingest supervisor and the health endpoint.
|
||||
pub fn tracked(&self) -> &[Arc<ChainRuntime>] {
|
||||
&self.tracked
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Registry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Registry")
|
||||
.field("tracked", &self.tracked.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugs_are_url_safe_and_readable() {
|
||||
assert_eq!(slugify("Planck"), "planck");
|
||||
assert_eq!(
|
||||
slugify("Quantus Staging Mainnet"),
|
||||
"quantus-staging-mainnet"
|
||||
);
|
||||
assert_eq!(slugify("Quantus Dirac Testnet"), "quantus-dirac-testnet");
|
||||
assert_eq!(slugify("Heisenberg"), "heisenberg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_collapses_rather_than_doubling_up() {
|
||||
assert_eq!(slugify("Quantus -- Test / Net"), "quantus-test-net");
|
||||
assert_eq!(slugify(" leading and trailing "), "leading-and-trailing");
|
||||
assert_eq!(slugify("v1.2 (beta)"), "v1-2-beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_nothing_usable_still_yields_a_key() {
|
||||
assert_eq!(slugify("!!!"), "chain");
|
||||
assert_eq!(slugify(""), "chain");
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ async fn miner(
|
||||
|
||||
let (blocks_observed, first_seen, last_seen, work) = state
|
||||
.store
|
||||
.miner_totals(runtime.id(), &miner)
|
||||
.miner_totals(&runtime.id(), &miner)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
|
||||
@@ -225,7 +225,7 @@ async fn miner(
|
||||
let network_hashrate = runtime.summary().network_hashrate;
|
||||
let series = state
|
||||
.store
|
||||
.miner_series(runtime.id(), &miner, since, bucket)
|
||||
.miner_series(&runtime.id(), &miner, since, bucket)
|
||||
.await
|
||||
.map_err(database_unavailable)?
|
||||
.into_iter()
|
||||
@@ -243,7 +243,7 @@ async fn miner(
|
||||
.collect();
|
||||
|
||||
Ok(Json(MinerDetail {
|
||||
chain: runtime.id().clone(),
|
||||
chain: runtime.id(),
|
||||
miner,
|
||||
current,
|
||||
blocks_observed,
|
||||
|
||||
@@ -26,14 +26,14 @@ use blackbeard_data::rpc::RpcClient;
|
||||
use blackbeard_data::store::Store;
|
||||
use blackbeard_data::telemetry::TelemetryFeed;
|
||||
use blackbeard_entities::{
|
||||
ChainId, ChainInfo, ChainStatus, ChainSummary, LeaderboardRow, RecentBlock, ServerMessage,
|
||||
Window,
|
||||
ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, LeaderboardRow, RecentBlock,
|
||||
ServerMessage, Tracking, Window,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use primitive_types::U512;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::config::ChainConfig;
|
||||
use crate::registry::ChainSpec;
|
||||
|
||||
/// One serialised message on its way to every subscriber of a chain.
|
||||
#[derive(Debug)]
|
||||
@@ -90,6 +90,15 @@ pub struct ChainInner {
|
||||
pub height: Option<u64>,
|
||||
/// When the last block was observed, for the ticker's gap figure.
|
||||
pub last_block_at: Option<DateTime<Utc>>,
|
||||
/// Nodes on this chain, from the telemetry directory.
|
||||
pub node_count: Option<u32>,
|
||||
/// The chain's name as the site shows it.
|
||||
///
|
||||
/// For a tracked chain this is the operator's name from config, not
|
||||
/// telemetry's: config is intent, and a chain we run a node for should not
|
||||
/// rename itself under the operator because someone edited a node's
|
||||
/// `--name` upstream.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// A block waiting for the telemetry feed to settle before its author can be
|
||||
@@ -112,9 +121,10 @@ pub struct PendingAttribution {
|
||||
/// One chain, live.
|
||||
#[derive(Debug)]
|
||||
pub struct ChainRuntime {
|
||||
/// Static configuration.
|
||||
pub config: ChainConfig,
|
||||
/// HTTP JSON-RPC client for this chain's node.
|
||||
/// How this chain is identified and reached.
|
||||
pub spec: ChainSpec,
|
||||
/// JSON-RPC client for this chain's node. Every runtime has one — a chain
|
||||
/// with no endpoint gets no runtime, only a listing.
|
||||
pub rpc: RpcClient,
|
||||
/// Telemetry feed. Unconnected and inert when no URL is configured.
|
||||
pub telemetry: TelemetryFeed,
|
||||
@@ -132,8 +142,8 @@ pub struct ChainRuntime {
|
||||
}
|
||||
|
||||
impl ChainRuntime {
|
||||
/// Build a runtime for a configured chain. Does no I/O.
|
||||
pub fn new(config: ChainConfig, rpc: RpcClient) -> Self {
|
||||
/// Build a runtime for a chain. Does no I/O.
|
||||
pub fn new(spec: ChainSpec, rpc: RpcClient) -> Self {
|
||||
let (events, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
Self {
|
||||
inner: RwLock::new(ChainInner {
|
||||
@@ -146,7 +156,6 @@ impl ChainRuntime {
|
||||
// Not `Unreachable`: nothing has failed yet, and a chain that
|
||||
// has not launched must not read as broken.
|
||||
status: ChainStatus::Awaiting,
|
||||
genesis: None,
|
||||
token_symbol: None,
|
||||
token_decimals: None,
|
||||
difficulty: None,
|
||||
@@ -154,8 +163,11 @@ impl ChainRuntime {
|
||||
syncing: false,
|
||||
height: None,
|
||||
last_block_at: None,
|
||||
node_count: None,
|
||||
display_name: spec.display_name.clone(),
|
||||
genesis: spec.genesis.clone(),
|
||||
}),
|
||||
config,
|
||||
spec,
|
||||
rpc,
|
||||
telemetry: TelemetryFeed::new(),
|
||||
events,
|
||||
@@ -164,9 +176,19 @@ impl ChainRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chain's id.
|
||||
pub fn id(&self) -> &ChainId {
|
||||
&self.config.id
|
||||
/// The chain's id on the wire: its slug.
|
||||
pub fn id(&self) -> ChainId {
|
||||
ChainId(self.spec.slug.clone())
|
||||
}
|
||||
|
||||
/// Record the chain's node population, from the telemetry directory.
|
||||
pub fn set_node_count(&self, count: Option<u32>) {
|
||||
self.write().node_count = count;
|
||||
}
|
||||
|
||||
/// What this observer can show for the chain.
|
||||
pub fn tracking(&self) -> Tracking {
|
||||
self.spec.tracking()
|
||||
}
|
||||
|
||||
fn slot(window: Window) -> usize {
|
||||
@@ -218,13 +240,15 @@ impl ChainRuntime {
|
||||
pub fn info(&self) -> ChainInfo {
|
||||
let inner = self.read();
|
||||
ChainInfo {
|
||||
id: self.config.id.clone(),
|
||||
display_name: self.config.display_name.clone(),
|
||||
mainnet: self.config.mainnet,
|
||||
id: self.id(),
|
||||
display_name: inner.display_name.clone(),
|
||||
node_count: inner.node_count,
|
||||
tracking: self.tracking(),
|
||||
mainnet: self.spec.mainnet,
|
||||
genesis: inner.genesis.clone(),
|
||||
token_symbol: inner.token_symbol.clone(),
|
||||
token_decimals: inner.token_decimals,
|
||||
target_block_time_seconds: self.config.target_block_time_seconds,
|
||||
target_block_time_seconds: self.spec.target_block_time_seconds,
|
||||
status: inner.status,
|
||||
}
|
||||
}
|
||||
@@ -237,8 +261,17 @@ impl ChainRuntime {
|
||||
/// plausible on a chart.
|
||||
fn interval(&self, inner: &ChainInner) -> Interval {
|
||||
match inner.window.measured_interval() {
|
||||
// Our own measurement, from blocks this observer watched arrive,
|
||||
// over a window it controls. Preferred whenever it exists.
|
||||
Some(measured) if !inner.syncing => Interval::Measured(measured),
|
||||
_ => Interval::Target(self.config.target_block_time_seconds),
|
||||
// Telemetry's own average, until we have enough tip samples of our
|
||||
// own. Still a measurement, just not ours — so it is not flagged
|
||||
// nominal, but our own is preferred the moment it exists.
|
||||
_ if !inner.syncing => match self.telemetry.average_block_time() {
|
||||
Some(avg) => Interval::Measured(avg),
|
||||
None => Interval::Target(self.spec.target_block_time_seconds),
|
||||
},
|
||||
_ => Interval::Target(self.spec.target_block_time_seconds),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,20 +281,33 @@ impl ChainRuntime {
|
||||
let interval = self.interval(&inner);
|
||||
let (tallies, total) = inner.window.tally(Window::SixHours.blocks() as usize);
|
||||
ChainSummary {
|
||||
chain: self.config.id.clone(),
|
||||
height: inner.height,
|
||||
chain: self.id(),
|
||||
// A chain with no node of ours still has a height — telemetry's,
|
||||
// which is the whole network's best block rather than one node's.
|
||||
height: inner.height.or_else(|| self.telemetry.best_height()),
|
||||
difficulty: inner.difficulty.map(hashrate::u512_to_dec),
|
||||
max_difficulty: inner.max_difficulty.map(hashrate::u512_to_dec),
|
||||
network_hashrate: inner
|
||||
.difficulty
|
||||
.map(|d| hashrate::network_hashrate(d, interval)),
|
||||
hashrate_from_target: interval.is_nominal(),
|
||||
block_interval_seconds: inner.window.measured_interval(),
|
||||
target_block_time_seconds: self.config.target_block_time_seconds,
|
||||
block_interval_seconds: inner
|
||||
.window
|
||||
.measured_interval()
|
||||
.or_else(|| self.telemetry.average_block_time()),
|
||||
target_block_time_seconds: self.spec.target_block_time_seconds,
|
||||
window_blocks: total,
|
||||
distinct_miners: tallies.len() as u32,
|
||||
telemetry_nodes: self.telemetry.node_count(),
|
||||
telemetry_nodes: inner.node_count.or_else(|| self.telemetry.node_count()),
|
||||
telemetry_connected: self.telemetry.connected(),
|
||||
tracking: self.tracking(),
|
||||
finalized_height: self.telemetry.finalized_height(),
|
||||
client_versions: self
|
||||
.telemetry
|
||||
.versions()
|
||||
.into_iter()
|
||||
.map(|(version, nodes)| ClientVersion { version, nodes })
|
||||
.collect(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -338,10 +384,8 @@ impl Drop for WindowGuard {
|
||||
/// Everything the HTTP handlers need.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// Chains in configured order, which is the order the UI shows them in.
|
||||
pub chains: Arc<Vec<Arc<ChainRuntime>>>,
|
||||
/// Lookup by id.
|
||||
pub by_id: Arc<HashMap<ChainId, Arc<ChainRuntime>>>,
|
||||
/// The chain set: what exists, and which of it we can track.
|
||||
pub registry: Arc<crate::registry::Registry>,
|
||||
/// Postgres.
|
||||
pub store: Store,
|
||||
/// When the process started, for the health endpoint.
|
||||
@@ -351,14 +395,15 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Find a chain by id.
|
||||
pub fn chain(&self, id: &str) -> Option<&Arc<ChainRuntime>> {
|
||||
self.by_id.get(&ChainId(id.to_owned()))
|
||||
/// Find a tracked chain by slug or genesis. `None` for a chain that is
|
||||
/// listed but has no endpoint — those are not navigable.
|
||||
pub fn chain(&self, id: &str) -> Option<Arc<ChainRuntime>> {
|
||||
self.registry.get(id)
|
||||
}
|
||||
|
||||
/// Every chain's identity, in configured order.
|
||||
/// Every chain, tracked and not, ordered by node count descending.
|
||||
pub fn chain_infos(&self) -> Vec<ChainInfo> {
|
||||
self.chains.iter().map(|c| c.info()).collect()
|
||||
self.registry.infos()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,13 +414,13 @@ mod tests {
|
||||
|
||||
fn runtime() -> Arc<ChainRuntime> {
|
||||
Arc::new(ChainRuntime::new(
|
||||
ChainConfig {
|
||||
id: ChainId("planck".into()),
|
||||
display_name: "Planck Testnet".into(),
|
||||
ChainSpec {
|
||||
slug: "planck".into(),
|
||||
display_name: "Planck".into(),
|
||||
genesis: None,
|
||||
mainnet: false,
|
||||
rpc_url: "http://127.0.0.1:9944".into(),
|
||||
ws_url: "ws://127.0.0.1:9944".into(),
|
||||
telemetry_url: String::new(),
|
||||
target_block_time_seconds: 6.0,
|
||||
warm_start_blocks: 100,
|
||||
max_gap_fill_blocks: 100,
|
||||
|
||||
@@ -114,7 +114,6 @@ async fn serve(socket: WebSocket, state: AppState) {
|
||||
if let Some(previous) = subscriptions.remove(&chain) {
|
||||
previous.abort();
|
||||
}
|
||||
let runtime = Arc::clone(runtime);
|
||||
let tx = tx.clone();
|
||||
subscriptions.insert(chain, tokio::spawn(subscription(runtime, window, tx)));
|
||||
}
|
||||
|
||||
@@ -45,8 +45,18 @@ const ACTION_ADDED_NODE: u64 = 3;
|
||||
const ACTION_REMOVED_NODE: u64 = 4;
|
||||
/// `ImportedBlock`: `[node_id, [height, hash, block_time, timestamp, propagation_ms]]`
|
||||
const ACTION_IMPORTED_BLOCK: u64 = 6;
|
||||
/// `BestBlock`: `[height, timestamp_ms, average_block_time_ms]` — chain-wide,
|
||||
/// and the only source of height and block time for a chain we have no RPC for.
|
||||
const ACTION_BEST_BLOCK: u64 = 1;
|
||||
/// `BestFinalized`: `[height, hash]`
|
||||
const ACTION_BEST_FINALIZED: u64 = 2;
|
||||
/// `AddedChain`: `[name, genesis, node_count]`
|
||||
const ACTION_ADDED_CHAIN: u64 = 11;
|
||||
/// `RemovedChain`: `genesis`
|
||||
const ACTION_REMOVED_CHAIN: u64 = 12;
|
||||
/// `ChainStatsUpdate`: `{version: {list: [[version, count], ...]}, ...}` — a
|
||||
/// ready-made client histogram, so we do not have to tally `AddedNode` ourselves.
|
||||
const ACTION_CHAIN_STATS: u64 = 22;
|
||||
|
||||
/// Block hashes retained for attribution.
|
||||
///
|
||||
@@ -82,6 +92,19 @@ struct State {
|
||||
peer_names: HashMap<String, String>,
|
||||
imports: HashMap<String, ImportRecord>,
|
||||
import_order: VecDeque<String>,
|
||||
/// Chain-wide best height, from `BestBlock`.
|
||||
best_height: Option<u64>,
|
||||
/// Chain-wide best finalized height, from `BestFinalized`.
|
||||
finalized_height: Option<u64>,
|
||||
/// Telemetry's own average block time, milliseconds.
|
||||
///
|
||||
/// For a chain we hold no RPC for this is the *only* block time available,
|
||||
/// and it is what the site shows. For an RPC-backed chain our own measured
|
||||
/// interval is preferred — it is computed from blocks this observer watched
|
||||
/// arrive, over a window we control.
|
||||
average_block_time_ms: Option<u64>,
|
||||
/// Client versions and their node counts, most common first.
|
||||
versions: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
/// A live view of one chain's telemetry feed.
|
||||
@@ -110,6 +133,31 @@ impl TelemetryFeed {
|
||||
self.read().chain_node_count
|
||||
}
|
||||
|
||||
/// Chain-wide best block height as telemetry reports it.
|
||||
pub fn best_height(&self) -> Option<u64> {
|
||||
self.read().best_height
|
||||
}
|
||||
|
||||
/// Best finalized height as telemetry reports it.
|
||||
pub fn finalized_height(&self) -> Option<u64> {
|
||||
self.read().finalized_height
|
||||
}
|
||||
|
||||
/// Telemetry's own average seconds per block.
|
||||
pub fn average_block_time(&self) -> Option<f64> {
|
||||
self.read()
|
||||
.average_block_time_ms
|
||||
// Zero is what the feed sends before it has enough samples; passing
|
||||
// it on would make every hashrate derived from it infinite.
|
||||
.filter(|ms| *ms > 0)
|
||||
.map(|ms| ms as f64 / 1000.0)
|
||||
}
|
||||
|
||||
/// Client versions with their node counts, most common first.
|
||||
pub fn versions(&self) -> Vec<(String, u32)> {
|
||||
self.read().versions.clone()
|
||||
}
|
||||
|
||||
/// Malformed payloads seen since start. A feed that is connected but whose
|
||||
/// error count climbs means substrate-telemetry changed a payload shape.
|
||||
pub fn decode_errors(&self) -> u64 {
|
||||
@@ -232,11 +280,35 @@ impl TelemetryFeed {
|
||||
}
|
||||
}
|
||||
ACTION_IMPORTED_BLOCK => state.imported_block(payload),
|
||||
ACTION_BEST_BLOCK => {
|
||||
if let Some(height) = payload.get(0).and_then(Value::as_u64) {
|
||||
state.best_height = Some(height);
|
||||
}
|
||||
// Index 2 is null until the feed has timed enough blocks.
|
||||
state.average_block_time_ms = payload.get(2).and_then(Value::as_u64);
|
||||
}
|
||||
ACTION_BEST_FINALIZED => {
|
||||
state.finalized_height = payload.get(0).and_then(Value::as_u64);
|
||||
}
|
||||
ACTION_ADDED_CHAIN => {
|
||||
if let Some(count) = payload.get(2).and_then(Value::as_u64) {
|
||||
state.chain_node_count = Some(count as u32);
|
||||
}
|
||||
}
|
||||
ACTION_CHAIN_STATS => {
|
||||
if let Some(list) = payload.pointer("/version/list").and_then(Value::as_array) {
|
||||
state.versions = list
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let pair = entry.as_array()?;
|
||||
Some((
|
||||
pair.first()?.as_str()?.to_owned(),
|
||||
pair.get(1)?.as_u64()? as u32,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -463,3 +535,246 @@ mod tests {
|
||||
assert_eq!(feed.name_of(&NodeKey::Node(1)), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// One chain substrate-telemetry knows about.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiscoveredChain {
|
||||
/// Genesis hash — the chain's real identity, and the key everything joins on.
|
||||
pub genesis: String,
|
||||
/// The name telemetry publishes, e.g. "Quantus Staging Mainnet".
|
||||
pub name: String,
|
||||
/// Nodes currently on the chain.
|
||||
pub node_count: u32,
|
||||
}
|
||||
|
||||
/// The chain directory: every chain the feed knows, without subscribing to any.
|
||||
///
|
||||
/// substrate-telemetry announces its full chain list with `AddedChain` on
|
||||
/// connect and keeps the node counts current, all *before* and independent of
|
||||
/// any `subscribe:` — so one connection that never subscribes is a complete,
|
||||
/// self-maintaining index of what exists to observe.
|
||||
///
|
||||
/// This is what makes the site chain-agnostic rather than chain-configured: a
|
||||
/// Quantus chain that launches tomorrow appears here on its own, and the
|
||||
/// observer starts watching it without a redeploy or a config change.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TelemetryDirectory {
|
||||
chains: Arc<RwLock<HashMap<String, DiscoveredChain>>>,
|
||||
connected: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl TelemetryDirectory {
|
||||
/// An empty directory.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Whether the directory feed is connected.
|
||||
pub fn connected(&self) -> bool {
|
||||
self.connected.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Every known chain, most nodes first.
|
||||
///
|
||||
/// Ties break on genesis rather than hash-map order, so a refresh cannot
|
||||
/// reorder two equally-populated chains and make the nav jump.
|
||||
pub fn chains(&self) -> Vec<DiscoveredChain> {
|
||||
let mut all: Vec<DiscoveredChain> = self
|
||||
.chains
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
all.sort_by(|a, b| {
|
||||
b.node_count
|
||||
.cmp(&a.node_count)
|
||||
.then_with(|| a.genesis.cmp(&b.genesis))
|
||||
});
|
||||
all
|
||||
}
|
||||
|
||||
/// Look one up by genesis.
|
||||
pub fn get(&self, genesis: &str) -> Option<DiscoveredChain> {
|
||||
self.chains
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(genesis)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Follow the directory feed forever, reconnecting as needed.
|
||||
pub async fn run(self, url: String) {
|
||||
let mut backoff_logged = false;
|
||||
loop {
|
||||
match self.follow_once(&url).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(%url, "chain directory closed, reconnecting");
|
||||
backoff_logged = false;
|
||||
}
|
||||
Err(e) => {
|
||||
if backoff_logged {
|
||||
tracing::debug!(%url, error = %e, "chain directory still down");
|
||||
} else {
|
||||
tracing::warn!(%url, error = %e, "chain directory lost");
|
||||
backoff_logged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.connected
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
// The chain list is deliberately NOT cleared on disconnect. Chains
|
||||
// do not stop existing because our feed hiccuped, and dropping them
|
||||
// would empty the site's navigation until the feed came back.
|
||||
tokio::time::sleep(RECONNECT_DELAY).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn follow_once(&self, url: &str) -> Result<(), DataError> {
|
||||
let (mut socket, _) = tokio_tungstenite::connect_async(url).await?;
|
||||
self.connected
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
tracing::info!(%url, "chain directory subscribed");
|
||||
|
||||
while let Some(msg) = socket.next().await {
|
||||
// Binary frames, as everywhere on this feed — see `follow_once` above.
|
||||
let payload = match msg? {
|
||||
tokio_tungstenite::tungstenite::Message::Text(t) => t.as_bytes().to_vec(),
|
||||
tokio_tungstenite::tungstenite::Message::Binary(b) => b.to_vec(),
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(Value::Array(items)) = serde_json::from_slice::<Value>(&payload) else {
|
||||
continue;
|
||||
};
|
||||
let mut chains = self.chains.write().unwrap_or_else(|e| e.into_inner());
|
||||
for pair in items.chunks_exact(2) {
|
||||
let (Some(code), payload) = (pair[0].as_u64(), &pair[1]) else {
|
||||
continue;
|
||||
};
|
||||
match code {
|
||||
ACTION_ADDED_CHAIN => {
|
||||
let (Some(name), Some(genesis), Some(count)) = (
|
||||
payload.get(0).and_then(Value::as_str),
|
||||
payload.get(1).and_then(Value::as_str),
|
||||
payload.get(2).and_then(Value::as_u64),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
chains.insert(
|
||||
genesis.to_owned(),
|
||||
DiscoveredChain {
|
||||
genesis: genesis.to_owned(),
|
||||
name: name.to_owned(),
|
||||
node_count: count as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
ACTION_REMOVED_CHAIN => {
|
||||
if let Some(genesis) = payload.as_str() {
|
||||
chains.remove(genesis);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply one frame. Test seam for [`follow_once`].
|
||||
#[cfg(test)]
|
||||
fn ingest_directory(&self, items: &[Value]) {
|
||||
let mut chains = self.chains.write().unwrap_or_else(|e| e.into_inner());
|
||||
for pair in items.chunks_exact(2) {
|
||||
let (Some(code), payload) = (pair[0].as_u64(), &pair[1]) else {
|
||||
continue;
|
||||
};
|
||||
match code {
|
||||
ACTION_ADDED_CHAIN => {
|
||||
if let (Some(name), Some(genesis), Some(count)) = (
|
||||
payload.get(0).and_then(Value::as_str),
|
||||
payload.get(1).and_then(Value::as_str),
|
||||
payload.get(2).and_then(Value::as_u64),
|
||||
) {
|
||||
chains.insert(
|
||||
genesis.to_owned(),
|
||||
DiscoveredChain {
|
||||
genesis: genesis.to_owned(),
|
||||
name: name.to_owned(),
|
||||
node_count: count as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
ACTION_REMOVED_CHAIN => {
|
||||
if let Some(genesis) = payload.as_str() {
|
||||
chains.remove(genesis);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod directory_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn added(name: &str, genesis: &str, count: u64) -> Vec<Value> {
|
||||
vec![json!(ACTION_ADDED_CHAIN), json!([name, genesis, count])]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chains_are_listed_by_node_count_descending() {
|
||||
let d = TelemetryDirectory::new();
|
||||
d.ingest_directory(&added("Planck", "0xaa", 44));
|
||||
d.ingest_directory(&added("Quantus Staging Mainnet", "0xbb", 10));
|
||||
d.ingest_directory(&added("Quantus Dirac Testnet", "0xcc", 7));
|
||||
let listed = d.chains();
|
||||
let names: Vec<&str> = listed.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Planck", "Quantus Staging Mainnet", "Quantus Dirac Testnet"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_announcement_updates_the_count_rather_than_duplicating() {
|
||||
let d = TelemetryDirectory::new();
|
||||
d.ingest_directory(&added("Planck", "0xaa", 44));
|
||||
d.ingest_directory(&added("Planck", "0xaa", 41));
|
||||
assert_eq!(d.chains().len(), 1);
|
||||
assert_eq!(d.chains()[0].node_count, 41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_break_deterministically() {
|
||||
// Without a tie-break the nav would reorder itself on every refresh.
|
||||
let d = TelemetryDirectory::new();
|
||||
d.ingest_directory(&added("B", "0xbb", 5));
|
||||
d.ingest_directory(&added("A", "0xaa", 5));
|
||||
let first: Vec<String> = d.chains().into_iter().map(|c| c.genesis).collect();
|
||||
let second: Vec<String> = d.chains().into_iter().map(|c| c.genesis).collect();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first[0], "0xaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_chain_leaves_the_directory() {
|
||||
let d = TelemetryDirectory::new();
|
||||
d.ingest_directory(&added("Planck", "0xaa", 44));
|
||||
d.ingest_directory(&[json!(ACTION_REMOVED_CHAIN), json!("0xaa")]);
|
||||
assert!(d.chains().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconnected_directory_lists_nothing_rather_than_failing() {
|
||||
let d = TelemetryDirectory::new();
|
||||
assert!(!d.connected());
|
||||
assert!(d.chains().is_empty());
|
||||
assert_eq!(d.get("0xaa"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,56 @@ pub enum ChainStatus {
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// How much of a chain this observer can actually see.
|
||||
///
|
||||
/// The distinction is not cosmetic and the UI must not hide it. Authorship
|
||||
/// comes from the `pow_` digest in a block **header**, and headers come from a
|
||||
/// node's JSON-RPC — telemetry publishes block hashes and heights but never
|
||||
/// headers. So a chain the observer has no node for is genuinely observable
|
||||
/// (population, height, block time, client mix) and genuinely *not*
|
||||
/// leaderboard-able, and presenting the two identically would be a lie about
|
||||
/// what the numbers mean.
|
||||
///
|
||||
/// Promoting a chain from one to the other is a single `[[chains]]` entry
|
||||
/// naming an RPC endpoint — no code path differs between the first chain and
|
||||
/// the tenth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "Tracking.ts")]
|
||||
pub enum Tracking {
|
||||
/// An RPC endpoint is configured: difficulty, network hashrate and the full
|
||||
/// per-miner leaderboard. Navigable.
|
||||
Full,
|
||||
/// The chain exists and telemetry names it and counts its nodes, but this
|
||||
/// observer has no endpoint to read headers from. Listed, not navigable —
|
||||
/// an empty page would be worse than an honest "no endpoint".
|
||||
NoEndpoint,
|
||||
}
|
||||
|
||||
/// One client version and how many nodes run it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "ClientVersion.ts")]
|
||||
pub struct ClientVersion {
|
||||
/// Version string as the node reports it.
|
||||
pub version: String,
|
||||
/// Nodes running it.
|
||||
pub nodes: u32,
|
||||
}
|
||||
|
||||
/// Static-ish facts about a chain: what to call it and how to read its numbers.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "ChainInfo.ts")]
|
||||
pub struct ChainInfo {
|
||||
/// Operator name, used in routes and the WS protocol.
|
||||
/// URL-safe slug, used in routes and the WS protocol. Derived from the
|
||||
/// chain's telemetry name, or set in config for a chain we hold RPC for.
|
||||
pub id: ChainId,
|
||||
/// What a human should see: "Quantus", "Planck Testnet".
|
||||
/// What a human should see: "Planck", "Quantus Staging Mainnet".
|
||||
pub display_name: String,
|
||||
/// Nodes on the chain per telemetry. The site's chain nav is ordered by
|
||||
/// this, descending — the busiest chain is the one most people came for.
|
||||
pub node_count: Option<u32>,
|
||||
/// What this observer can show for the chain.
|
||||
pub tracking: Tracking,
|
||||
/// True for the production network, false for a testnet. Drives whether the
|
||||
/// UI treats rewards as real.
|
||||
pub mainnet: bool,
|
||||
@@ -121,6 +163,16 @@ pub struct ChainSummary {
|
||||
pub telemetry_nodes: Option<u32>,
|
||||
/// Whether the telemetry feed is currently connected.
|
||||
pub telemetry_connected: bool,
|
||||
/// What this observer can show for the chain. `TelemetryOnly` means
|
||||
/// `difficulty`, `max_difficulty` and `network_hashrate` are all `None` by
|
||||
/// construction rather than by outage.
|
||||
pub tracking: Tracking,
|
||||
/// Best finalized height, when telemetry reports one.
|
||||
#[ts(type = "number | null")]
|
||||
pub finalized_height: Option<u64>,
|
||||
/// Client versions across the chain's nodes, most common first. The one
|
||||
/// genuinely interesting statistic a chain with no RPC still yields.
|
||||
pub client_versions: Vec<ClientVersion>,
|
||||
/// When these numbers were computed.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ mod miner;
|
||||
mod ws;
|
||||
|
||||
pub use block::{BlockObservation, RecentBlock};
|
||||
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary};
|
||||
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking};
|
||||
pub use error::{ApiError, EntityError};
|
||||
pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint};
|
||||
pub use ws::{ClientMessage, ServerMessage, Window};
|
||||
|
||||
@@ -17,9 +17,15 @@
|
||||
# Roles (all run by default; pass --role to narrow):
|
||||
#
|
||||
# api the host running blackbeard-api beside quantus-node
|
||||
# dns the public apex CNAME on Cloudflare
|
||||
# cert the Let's Encrypt certificate for the public name
|
||||
# edge the site's nginx proxy: vhosts, webroot, internal cert
|
||||
# database Postgres roles, database, and the pg_ident CN mapping
|
||||
#
|
||||
# `dns` and `cert` run BEFORE `edge`, and in that order: certbot uses a DNS-01
|
||||
# challenge, and nginx -t fails on a missing ssl_certificate — which blocks
|
||||
# every reload on the proxy, not just this vhost.
|
||||
#
|
||||
# Conventions: architecture/generic.md §8-§11, deployment-gitea-actions.md.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -27,7 +33,7 @@ set -euo pipefail
|
||||
# --- infra truth, matching .gitea/workflows/deploy.yaml -----------------------
|
||||
API_HOST="${API_HOST:-bob.hanzalova.internal}"
|
||||
API_PORT="${API_PORT:-25864}"
|
||||
EDGE_HOST="${EDGE_HOST:-hanzalova.internal}"
|
||||
EDGE_HOST="${EDGE_HOST:-oolon.kosherinata.internal}"
|
||||
PG_PRIMARY="${PG_PRIMARY:-magrathea.kosherinata.internal}"
|
||||
# The standby needs the same ident mapping: pg_ident.conf contents are NOT
|
||||
# replicated, and a failover to a server missing it locks the app out.
|
||||
@@ -38,9 +44,15 @@ PUBLIC_NAME="${PUBLIC_NAME:-blackbeard.observer}"
|
||||
INTERNAL_NAME="${INTERNAL_NAME:-blackbeard.internal}"
|
||||
DB_NAME="${DB_NAME:-blackbeard}"
|
||||
DB_ROLE="${DB_ROLE:-blackbeard_rw}"
|
||||
# The per-site indirection name the public record CNAMEs to. `bl` is the DC
|
||||
# (oolon), `nh` the office (hanzalova) — architecture/public-dns.md §2. A vhost
|
||||
# record must never carry a site address directly: the address changes and every
|
||||
# hardcoded A record then has to be hunted down across zones.
|
||||
SITE_INDIRECTION="${SITE_INDIRECTION:-bl.thgttg.com}"
|
||||
CERT_EMAIL="${CERT_EMAIL:-ops@blackbeard.observer}"
|
||||
|
||||
PUBKEY=""
|
||||
ROLES="api edge database"
|
||||
ROLES="api dns cert edge database"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
info() { printf '\033[36m==\033[0m %s\n' "$*"; }
|
||||
@@ -217,16 +229,17 @@ REMOTE
|
||||
install -d -m 0755 /etc/nginx/tls/cert
|
||||
install -d -m 0700 /etc/nginx/tls/key
|
||||
if [ -f "/etc/nginx/tls/cert/$INTERNAL_NAME.pem" ]; then
|
||||
echo "$INTERNAL_NAME certificate already present"
|
||||
echo "$INTERNAL_NAME certificate present"
|
||||
systemctl enable --now "step@blackbeard.timer" || \
|
||||
echo "step@ renewal timer not armed — renew $INTERNAL_NAME manually until it is"
|
||||
else
|
||||
echo "MISSING: /etc/nginx/tls/cert/$INTERNAL_NAME.pem" >&2
|
||||
echo "Mint it with the lair provisioner per architecture/internal-tls.md §4," >&2
|
||||
echo "then re-run this script. The vhost is NOT installed until it exists:" >&2
|
||||
echo "nginx -t fails on a missing ssl_certificate and blocks every reload." >&2
|
||||
exit 1
|
||||
# Not fatal. The mesh vhost is a convenience; the public site is the
|
||||
# deliverable, and nginx -t fails on a missing ssl_certificate —
|
||||
# which would block every reload on this proxy, not just ours.
|
||||
echo "NOTE: no internal certificate for $INTERNAL_NAME; the mesh vhost will be skipped."
|
||||
echo " Mint it with the lair provisioner (architecture/internal-tls.md §4)"
|
||||
echo " and re-run --role edge to enable it."
|
||||
fi
|
||||
systemctl enable --now "step@$(basename "$INTERNAL_NAME" .internal).timer" || \
|
||||
echo "step@ renewal timer not armed — renew $INTERNAL_NAME manually until it is"
|
||||
REMOTE
|
||||
|
||||
info "$EDGE_HOST: nginx configuration"
|
||||
@@ -239,8 +252,12 @@ REMOTE
|
||||
"$EDGE_HOST:/etc/nginx/sites-available/"
|
||||
ssh "$EDGE_HOST" sudo bash -euo pipefail <<REMOTE
|
||||
# sites-enabled holds only symlinks, and relative ones.
|
||||
ln -sfn "../sites-available/$PUBLIC_NAME.conf" "/etc/nginx/sites-enabled/$PUBLIC_NAME.conf"
|
||||
ln -sfn "../sites-available/$INTERNAL_NAME.conf" "/etc/nginx/sites-enabled/$INTERNAL_NAME.conf"
|
||||
ln -sfn "../sites-available/$PUBLIC_NAME.conf" "/etc/nginx/sites-enabled/$PUBLIC_NAME.conf"
|
||||
if [ -f "/etc/nginx/tls/cert/$INTERNAL_NAME.pem" ]; then
|
||||
ln -sfn "../sites-available/$INTERNAL_NAME.conf" "/etc/nginx/sites-enabled/$INTERNAL_NAME.conf"
|
||||
else
|
||||
rm -f "/etc/nginx/sites-enabled/$INTERNAL_NAME.conf"
|
||||
fi
|
||||
|
||||
# The vhosts use \$connection_upgrade for the WebSocket upgrade; without
|
||||
# the map the socket silently degrades to a hanging request and the page
|
||||
@@ -268,13 +285,10 @@ REMOTE
|
||||
|
||||
cat <<EOF
|
||||
|
||||
$EDGE_HOST is configured, but two steps are NOT automated here:
|
||||
$EDGE_HOST is configured. One step is NOT automated here:
|
||||
|
||||
1. The public certificate for $PUBLIC_NAME (Let's Encrypt, certbot,
|
||||
Cloudflare DNS-01) — see architecture/external-tls.md.
|
||||
|
||||
2. Split-horizon DNS for $INTERNAL_NAME, on BOTH site routers. A record on
|
||||
only one router NXDOMAINs everywhere else:
|
||||
Split-horizon DNS for $INTERNAL_NAME, on BOTH site routers. A record on
|
||||
only one router NXDOMAINs everywhere else:
|
||||
|
||||
for site in hanzalova kosherinata; do
|
||||
opn-cli --config ~/.opn-cli/\$site.yml unbound host create \\
|
||||
@@ -284,13 +298,111 @@ $EDGE_HOST is configured, but two steps are NOT automated here:
|
||||
`create` only saves; POST /api/unbound/service/reconfigure on each router
|
||||
to apply.
|
||||
|
||||
A public DNS record for $PUBLIC_NAME goes in Cloudflare, unproxied, CNAMEd to
|
||||
the site indirection name rather than carrying a site address directly —
|
||||
architecture/public-dns.md.
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# The Cloudflare API token lives on the edge proxy and is never copied off it:
|
||||
# it can rewrite DNS for every zone on the account, including MX records for
|
||||
# domains whose mail we host (architecture/public-dns.md §1). Every call that
|
||||
# needs it therefore runs ON the proxy.
|
||||
role_dns() {
|
||||
reachable "$EDGE_HOST" || return 0
|
||||
info "$EDGE_HOST: public DNS for $PUBLIC_NAME"
|
||||
|
||||
ssh "$EDGE_HOST" "PUBLIC_NAME='$PUBLIC_NAME' SITE='$SITE_INDIRECTION' bash -euo pipefail" <<'REMOTE'
|
||||
TOKEN=$(sudo grep -oP '(?<=dns_cloudflare_api_token\s=\s).*' /root/.certbot-internal | tr -d "\"' ")
|
||||
[ -n "$TOKEN" ] || { echo "no cloudflare token in /root/.certbot-internal" >&2; exit 1; }
|
||||
api() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
zid=$(api "https://api.cloudflare.com/client/v4/zones?name=${PUBLIC_NAME}" | python3 -c 'import sys,json; r=json.load(sys.stdin)["result"]; print(r[0]["id"] if r else "")')
|
||||
[ -n "$zid" ] || { echo "zone $PUBLIC_NAME is not on this Cloudflare account" >&2; exit 1; }
|
||||
|
||||
# List the name's FULL record set first, all types. Filtering by the one
|
||||
# type you expect and finding nothing does not mean the name is free —
|
||||
# it may be a CNAME, and adding an A beside it is a conflict at best
|
||||
# (architecture/public-dns.md §4).
|
||||
existing=$(api "https://api.cloudflare.com/client/v4/zones/${zid}/dns_records?name=${PUBLIC_NAME}")
|
||||
echo "$existing" | python3 -c '
|
||||
import sys, json
|
||||
rs = json.load(sys.stdin)["result"]
|
||||
print(f"existing records for the apex: {len(rs)}")
|
||||
for r in rs:
|
||||
print(" %s %s -> %s proxied=%s" % (r["type"], r["name"], r["content"], r["proxied"]))
|
||||
'
|
||||
current=$(echo "$existing" | python3 -c '
|
||||
import sys, json
|
||||
rs = json.load(sys.stdin)["result"]
|
||||
cn = [r for r in rs if r["type"] == "CNAME"]
|
||||
print(cn[0]["content"] if cn else "")
|
||||
')
|
||||
if [ "$current" = "$SITE" ]; then
|
||||
echo "apex already CNAMEs to $SITE — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$current" ]; then
|
||||
echo "apex already CNAMEs to $current, not $SITE." >&2
|
||||
echo "Refusing to repoint an existing record; change it deliberately." >&2
|
||||
exit 1
|
||||
fi
|
||||
if echo "$existing" | grep -q '"type"'; then
|
||||
# Records exist but none is a CNAME. A CNAME cannot coexist with
|
||||
# other types at a name, so this needs a human decision.
|
||||
echo "apex carries non-CNAME records; refusing to add a CNAME beside them." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cloudflare flattens an apex CNAME, answering with an A — which is what
|
||||
# makes this convention work at a zone apex at all. It is
|
||||
# Cloudflare-specific, not portable DNS.
|
||||
api -X POST "https://api.cloudflare.com/client/v4/zones/${zid}/dns_records" -H 'Content-Type: application/json' --data "$(python3 -c '
|
||||
import json, os
|
||||
print(json.dumps({"type":"CNAME","name":os.environ["PUBLIC_NAME"],"content":os.environ["SITE"],
|
||||
"ttl":1,"proxied":False,"comment":"Quantus-Network/blackbeard.observer"}))')" | python3 -c '
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print("created %s -> %s" % (d["result"]["name"], d["result"]["content"]) if d["success"]
|
||||
else "FAILED %s" % d["errors"])
|
||||
import sys as s
|
||||
s.exit(0 if d["success"] else 1)
|
||||
'
|
||||
REMOTE
|
||||
}
|
||||
|
||||
role_cert() {
|
||||
reachable "$EDGE_HOST" || return 0
|
||||
info "$EDGE_HOST: Let's Encrypt certificate for $PUBLIC_NAME"
|
||||
|
||||
# DNS-01 via the same Cloudflare credential, ECDSA, per
|
||||
# architecture/external-tls.md §1. --keep-until-expiring makes this a no-op
|
||||
# when the cert is still valid, so the role is safe to run every time.
|
||||
#
|
||||
# `sudo test`, not bare `test`: /etc/letsencrypt/live is root-only 0700 and
|
||||
# an unprivileged check silently returns false, concluding the cert is
|
||||
# missing and re-issuing on every run.
|
||||
ssh "$EDGE_HOST" "PUBLIC_NAME='$PUBLIC_NAME' CERT_EMAIL='$CERT_EMAIL' bash -euo pipefail" <<'REMOTE'
|
||||
if sudo test -d "/etc/letsencrypt/live/${PUBLIC_NAME}"; then
|
||||
echo "certificate lineage ${PUBLIC_NAME} already exists"
|
||||
fi
|
||||
sudo certbot certonly -m "$CERT_EMAIL" --agree-tos --no-eff-email --noninteractive --cert-name "$PUBLIC_NAME" --key-type ecdsa --dns-cloudflare --dns-cloudflare-credentials /root/.certbot-internal --dns-cloudflare-propagation-seconds 60 --keep-until-expiring -d "$PUBLIC_NAME"
|
||||
sudo test -f "/etc/letsencrypt/live/${PUBLIC_NAME}/fullchain.pem" || { echo "certbot reported success but no fullchain.pem exists" >&2; exit 1; }
|
||||
|
||||
# One deploy hook per host, not per cert. Never silence it: a reload is
|
||||
# only a request, and nginx exits 0 having kept its old certificates
|
||||
# whenever it cannot re-acquire a socket. On a 90-day public cert a
|
||||
# stuck reload takes months to become visible.
|
||||
hook=/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
if ! sudo test -x "$hook"; then
|
||||
sudo install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
|
||||
printf '%s\n' '#!/bin/sh' 'systemctl reload nginx || logger -t reload-nginx -p daemon.err \' ' "nginx reload failed after certbot renewal; cert on disk is newer than the one served"' | sudo tee "$hook" > /dev/null
|
||||
sudo chmod +x "$hook"
|
||||
echo "installed the certbot deploy hook"
|
||||
else
|
||||
echo "certbot deploy hook already present"
|
||||
fi
|
||||
systemctl is-enabled certbot-renew.timer >/dev/null 2>&1 && echo "certbot-renew.timer is enabled" || echo "WARNING: certbot-renew.timer is not enabled on this host"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
role_database() {
|
||||
reachable "$PG_PRIMARY" || return 0
|
||||
|
||||
@@ -338,6 +450,10 @@ REMOTE
|
||||
|
||||
info "roles: $ROLES"
|
||||
has_role api && role_api
|
||||
# dns before cert (DNS-01 needs the zone), cert before edge (nginx -t fails on
|
||||
# a missing ssl_certificate and blocks every reload on the proxy).
|
||||
has_role dns && role_dns
|
||||
has_role cert && role_cert
|
||||
has_role edge && role_edge
|
||||
has_role database && role_database
|
||||
info "done"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { Window as WindowName } from './api/generated/Window'
|
||||
import { BlockTicker } from './components/BlockTicker'
|
||||
import { ChainSwitcher } from './components/ChainSwitcher'
|
||||
import { Leaderboard } from './components/Leaderboard'
|
||||
import { MinerPanel } from './components/MinerPanel'
|
||||
import { StatBar } from './components/StatBar'
|
||||
@@ -52,7 +53,9 @@ export default function App() {
|
||||
|
||||
// Default to the first chain the backend reports — which is the first one in
|
||||
// its config, so the operator decides what the front page shows.
|
||||
const chain = route.chain ?? state.chains[0]?.id ?? null
|
||||
// Default to the busiest chain we can actually track — the list is ordered
|
||||
// by node count, and an untracked chain has nothing behind it.
|
||||
const chain = route.chain ?? state.chains.find((c) => c.tracking === 'full')?.id ?? null
|
||||
const info = useMemo(() => state.chains.find((c) => c.id === chain) ?? null, [state.chains, chain])
|
||||
|
||||
useWatch(chain, route.window)
|
||||
@@ -90,22 +93,6 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="masthead-right">
|
||||
{state.chains.length > 1 && (
|
||||
<div className="segmented" role="group" aria-label="Chain">
|
||||
{state.chains.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
aria-pressed={c.id === chain}
|
||||
disabled={c.status === 'awaiting'}
|
||||
title={c.status === 'awaiting' ? `${c.display_name} has not launched yet` : c.display_name}
|
||||
onClick={() => go({ chain: c.id, miner: null })}
|
||||
>
|
||||
{c.display_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="segmented" role="group" aria-label="Leaderboard window">
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
@@ -126,6 +113,12 @@ export default function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ChainSwitcher
|
||||
chains={state.chains}
|
||||
active={chain}
|
||||
onSelect={(id) => go({ chain: id, miner: null })}
|
||||
/>
|
||||
|
||||
{info?.status === 'awaiting' && (
|
||||
<div className="banner banner-warn">
|
||||
<strong>{info.display_name}</strong> has not started producing blocks yet. This page will
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { ChainStatus } from "./ChainStatus";
|
||||
import type { Tracking } from "./Tracking";
|
||||
|
||||
/**
|
||||
* Static-ish facts about a chain: what to call it and how to read its numbers.
|
||||
*/
|
||||
export type ChainInfo = {
|
||||
/**
|
||||
* Operator name, used in routes and the WS protocol.
|
||||
* URL-safe slug, used in routes and the WS protocol. Derived from the
|
||||
* chain's telemetry name, or set in config for a chain we hold RPC for.
|
||||
*/
|
||||
id: ChainId,
|
||||
/**
|
||||
* What a human should see: "Quantus", "Planck Testnet".
|
||||
* What a human should see: "Planck", "Quantus Staging Mainnet".
|
||||
*/
|
||||
display_name: string,
|
||||
/**
|
||||
* Nodes on the chain per telemetry. The site's chain nav is ordered by
|
||||
* this, descending — the busiest chain is the one most people came for.
|
||||
*/
|
||||
node_count: number | null,
|
||||
/**
|
||||
* What this observer can show for the chain.
|
||||
*/
|
||||
tracking: Tracking,
|
||||
/**
|
||||
* True for the production network, false for a testnet. Drives whether the
|
||||
* UI treats rewards as real.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { ClientVersion } from "./ClientVersion";
|
||||
import type { Tracking } from "./Tracking";
|
||||
|
||||
/**
|
||||
* The live headline numbers for one chain: one row of the scoreboard's top.
|
||||
@@ -63,6 +65,21 @@ telemetry_nodes: number | null,
|
||||
* Whether the telemetry feed is currently connected.
|
||||
*/
|
||||
telemetry_connected: boolean,
|
||||
/**
|
||||
* What this observer can show for the chain. `TelemetryOnly` means
|
||||
* `difficulty`, `max_difficulty` and `network_hashrate` are all `None` by
|
||||
* construction rather than by outage.
|
||||
*/
|
||||
tracking: Tracking,
|
||||
/**
|
||||
* Best finalized height, when telemetry reports one.
|
||||
*/
|
||||
finalized_height: number | null,
|
||||
/**
|
||||
* Client versions across the chain's nodes, most common first. The one
|
||||
* genuinely interesting statistic a chain with no RPC still yields.
|
||||
*/
|
||||
client_versions: Array<ClientVersion>,
|
||||
/**
|
||||
* When these numbers were computed.
|
||||
*/
|
||||
|
||||
14
web/src/api/generated/ClientVersion.ts
Normal file
14
web/src/api/generated/ClientVersion.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* One client version and how many nodes run it.
|
||||
*/
|
||||
export type ClientVersion = {
|
||||
/**
|
||||
* Version string as the node reports it.
|
||||
*/
|
||||
version: string,
|
||||
/**
|
||||
* Nodes running it.
|
||||
*/
|
||||
nodes: number, };
|
||||
18
web/src/api/generated/Tracking.ts
Normal file
18
web/src/api/generated/Tracking.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* How much of a chain this observer can actually see.
|
||||
*
|
||||
* The distinction is not cosmetic and the UI must not hide it. Authorship
|
||||
* comes from the `pow_` digest in a block **header**, and headers come from a
|
||||
* node's JSON-RPC — telemetry publishes block hashes and heights but never
|
||||
* headers. So a chain the observer has no node for is genuinely observable
|
||||
* (population, height, block time, client mix) and genuinely *not*
|
||||
* leaderboard-able, and presenting the two identically would be a lie about
|
||||
* what the numbers mean.
|
||||
*
|
||||
* Promoting a chain from one to the other is a single `[[chains]]` entry
|
||||
* naming an RPC endpoint — no code path differs between the first chain and
|
||||
* the tenth.
|
||||
*/
|
||||
export type Tracking = "full" | "no_endpoint";
|
||||
83
web/src/components/ChainSwitcher.tsx
Normal file
83
web/src/components/ChainSwitcher.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The chain nav.
|
||||
*
|
||||
* Every Quantus chain substrate-telemetry knows about, ordered by node count
|
||||
* descending — the busiest chain is the one most people came for, and the order
|
||||
* is a real fact about the network rather than a config ordering.
|
||||
*
|
||||
* Chains this observer holds no RPC endpoint for are **listed but not
|
||||
* navigable**. That is deliberate: they are part of the network and hiding them
|
||||
* would misrepresent its shape, but authorship comes from block headers and
|
||||
* only a node serves those — so there is nothing behind the link. A disabled
|
||||
* button that says why beats a navigable one that leads to an empty board.
|
||||
*/
|
||||
|
||||
import type { ChainInfo } from '../api/generated/ChainInfo'
|
||||
|
||||
function reason(chain: ChainInfo): string {
|
||||
if (chain.tracking === 'no_endpoint') {
|
||||
return `${chain.display_name} has no public RPC endpoint this observer can reach, so its blocks' authors cannot be read. It is listed because it is part of the network.`
|
||||
}
|
||||
switch (chain.status) {
|
||||
case 'awaiting':
|
||||
return `${chain.display_name} has not produced a block yet.`
|
||||
case 'unreachable':
|
||||
return `${chain.display_name}'s node is not answering; the last known standings are shown.`
|
||||
case 'syncing':
|
||||
return `${chain.display_name}'s node is still importing history.`
|
||||
default:
|
||||
return chain.display_name
|
||||
}
|
||||
}
|
||||
|
||||
export function ChainSwitcher({
|
||||
chains,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
chains: ChainInfo[]
|
||||
active: string | null
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
if (chains.length === 0) return null
|
||||
|
||||
return (
|
||||
<nav className="chains" aria-label="Chain">
|
||||
<span className="eyebrow chains-label">Chains</span>
|
||||
<div className="chains-list">
|
||||
{chains.map((chain) => {
|
||||
const tracked = chain.tracking === 'full'
|
||||
const isActive = chain.id === active
|
||||
return (
|
||||
<button
|
||||
key={chain.genesis ?? chain.id}
|
||||
type="button"
|
||||
className={[
|
||||
'chain-chip',
|
||||
isActive ? 'is-active' : '',
|
||||
tracked ? '' : 'is-untracked',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
disabled={!tracked}
|
||||
title={reason(chain)}
|
||||
onClick={() => tracked && onSelect(chain.id)}
|
||||
>
|
||||
<span className="chain-name">{chain.display_name}</span>
|
||||
{chain.node_count !== null && (
|
||||
<span className="chain-nodes" title={`${chain.node_count} nodes on telemetry`}>
|
||||
{chain.node_count}
|
||||
</span>
|
||||
)}
|
||||
{/* Not colour-alone: the disabled chains carry a word, because
|
||||
"greyed out" reads as "loading" at least as often as it reads
|
||||
as "unavailable". */}
|
||||
{!tracked && <span className="chain-note">no endpoint</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -274,6 +274,84 @@ a {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- chain nav ----------------------------------------------------------- */
|
||||
|
||||
.chains {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 18px;
|
||||
}
|
||||
|
||||
.chains-label {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.chains-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chain-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
appearance: none;
|
||||
background: var(--surface-1);
|
||||
border: var(--rule);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 6px 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chain-chip:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.chain-chip.is-active {
|
||||
color: var(--data-bright);
|
||||
border-color: var(--data);
|
||||
background: var(--data-wash);
|
||||
}
|
||||
|
||||
.chain-chip:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--text-muted);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.chain-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chain-nodes {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-3);
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.chain-chip.is-active .chain-nodes {
|
||||
color: var(--data-bright);
|
||||
background: rgba(189, 136, 41, 0.18);
|
||||
}
|
||||
|
||||
.chain-note {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- stat tiles ---------------------------------------------------------- */
|
||||
|
||||
.stats {
|
||||
|
||||
Reference in New Issue
Block a user