ui: only advertise chains we can actually read
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled

The nav listed every chain telemetry names, with the ones we hold no RPC
endpoint for as disabled chips saying "no endpoint". The reasoning was that
they are part of the network and hiding them misrepresents its shape. In
practice it put five dead chips above the standings on every page — four of
which have never had anything behind them — and made the one live chain the
smallest thing in the row.

Nothing is blocked. `/:chain/...` still resolves for any chain the backend
knows, `/v1/chains` still reports every one of them with its `tracking`, and
promoting one is still a single `[[chains]]` entry. This is only about what the
page offers unprompted.

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 17:15:29 +03:00
parent d5a286f220
commit d6f4c0c21e
4 changed files with 48 additions and 81 deletions

View File

@@ -60,13 +60,17 @@ pub enum ChainStatus {
/// 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.
/// The distinction is real, not cosmetic. 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 a page
/// presenting the two identically would be a lie about what the numbers mean.
///
/// This field is what keeps the two apart. Every chain is reported here
/// regardless, because the API's job is to say what it can see; whether an
/// untracked chain is *offered* to a reader is the frontend's call, and the
/// nav no longer advertises them.
///
/// 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
@@ -79,8 +83,8 @@ pub enum Tracking {
/// 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".
/// observer has no endpoint to read headers from, so there is no
/// leaderboard to be had. Reported, but not offered as a link.
NoEndpoint,
}

View File

@@ -3,13 +3,17 @@
/**
* 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.
* The distinction is real, not cosmetic. 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 a page
* presenting the two identically would be a lie about what the numbers mean.
*
* This field is what keeps the two apart. Every chain is reported here
* regardless, because the API's job is to say what it can see; whether an
* untracked chain is *offered* to a reader is the frontend's call, and the
* nav no longer advertises them.
*
* 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

View File

@@ -1,15 +1,18 @@
/**
* 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
* Only the chains this observer holds an RPC endpoint for, ordered by node
* count descending — the busiest 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.
* The rest are **not advertised**. They were listed as disabled chips for a
* while, on the reasoning that they are part of the network and hiding them
* misrepresents its shape. In practice that put five dead chips above the
* standings on every page, four of which had never had anything behind them,
* and the one live chain was the smallest thing in the row. Nothing is
* blocked: `/:chain/...` still resolves for any chain the backend knows, and
* the API still reports every one of them with its `tracking`. This is only
* about what the page offers unprompted.
*/
import { Link } from 'react-router-dom'
@@ -19,9 +22,6 @@ import type { Window as WindowName } from '../api/generated/Window'
import { href } from '../lib/routes'
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.`
@@ -44,61 +44,33 @@ export function ChainSwitcher({
/** Carried across the switch: changing chain is not a reason to change span. */
window: WindowName
}) {
if (chains.length === 0) return null
const navigable = chains.filter((chain) => chain.tracking === 'full')
if (navigable.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'
{navigable.map((chain) => {
const isActive = chain.id === active
const className = [
'chain-chip',
isActive ? 'is-active' : '',
tracked ? '' : 'is-untracked',
]
.filter(Boolean)
.join(' ')
const label = (
<>
// A link, not a button: a chain is a page with an address, and the
// chip is how most people reach it — so it has to survive a middle
// click and a right-click-copy like any other link on the site.
return (
<Link
key={chain.genesis ?? chain.id}
to={href({ chain: chain.id, window: windowName })}
className={isActive ? 'chain-chip is-active' : 'chain-chip'}
aria-current={isActive ? 'page' : undefined}
title={reason(chain)}
>
<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>}
</>
)
// A link, not a button, for the chains that go somewhere: a chain is
// a page with an address, and the chip is how most people reach it —
// so it has to survive a middle click and a right-click-copy like
// any other link on the site. The untracked ones stay buttons
// because a disabled link is not a thing HTML has.
return tracked ? (
<Link
key={chain.genesis ?? chain.id}
to={href({ chain: chain.id, window: windowName })}
className={className}
aria-current={isActive ? 'page' : undefined}
title={reason(chain)}
>
{label}
</Link>
) : (
<button
key={chain.genesis ?? chain.id}
type="button"
className={className}
disabled
title={reason(chain)}
>
{label}
</button>
)
})}
</div>

View File

@@ -320,12 +320,6 @@ a {
background: var(--data-wash);
}
.chain-chip:disabled {
cursor: not-allowed;
color: var(--text-muted);
border-style: dashed;
}
.chain-name {
font-weight: 600;
}
@@ -344,13 +338,6 @@ a {
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 {