feat(chains): discover every Quantus chain from telemetry; track the ones we can reach
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

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:
2026-09-04 13:04:11 +03:00
co-authored by Claude Opus 5
parent 110fbc3631
commit 5f3aac2a13
22 changed files with 1317 additions and 183 deletions
+83
View 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>
)
}