Files
observer/web/src/App.tsx
rob thijssen e330d2b78a
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
ui: a miner and an account are pages, not dialogs
Both were panels that opened above the standings, which is right for a block —
"who won this one, and where do they sit" is read against the board, and the
ticker row below is what you clicked to get here — and wrong for the other two.
A miner's page and an account's page are somebody's own record and the whole
subject of the URL that reached them; eleven other miners' rows underneath is
the site failing to notice which page it is on.

The close buttons go with it. Neither panel opened over something the reader
was looking at, so there is nothing to close back to, and the back button and
the chain chip already do what it would. "Mark as yours" stays: that is a real
control and not navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:21:06 +03:00

258 lines
10 KiB
TypeScript

/**
* The page.
*
* A chain and a window, with at most one of a block, a miner or an account in
* focus above the standings. All of it lives in the path — see `lib/routes` for
* the grammar and why it is a path rather than a hash — so every view here can
* be linked to, opened in a new tab, and returned to with the back button.
*/
import { useCallback, useEffect, useMemo } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { AccountPanel } from './components/AccountPanel'
import { BlockPanel } from './components/BlockPanel'
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'
import { seconds, windowSpan } from './lib/format'
import { href, parse, WINDOWS } from './lib/routes'
import { useObserver, usePinnedMiners, useWatch } from './lib/store'
export default function App() {
const state = useObserver()
const location = useLocation()
const navigate = useNavigate()
const { pinned } = usePinnedMiners()
const route = useMemo(() => parse(location.pathname), [location.pathname])
// 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 fallbackChain = state.chains.find((c) => c.tracking === 'full')?.id ?? null
const chain = route.chain ?? fallbackChain
const info = useMemo(
() => state.chains.find((c) => c.id === chain) ?? null,
[state.chains, chain],
)
// `/` is not a page, it is "whichever chain the operator put first" — a
// question only the backend can answer, and only once it has answered the
// chain list. Replacing rather than pushing keeps the bare domain out of the
// back stack, where it would bounce the user straight forward again.
useEffect(() => {
if (!route.chain && fallbackChain) {
navigate(href({ chain: fallbackChain, window: route.window }), { replace: true })
}
}, [route.chain, route.window, fallbackChain, navigate])
useWatch(chain, route.window)
/**
* A height has resolved to its hash; put the hash in the address bar.
*
* `replace`, not a navigation: it is the same block under the name that will
* still mean this block after a reorg, so it belongs in place of the height
* rather than on top of it in the back stack. The panel keeps rendering from
* the height it was given — `BlockPanel` keys on its own prop — so this does
* not refetch.
*/
const blockResolved = useCallback(
(hash: string) => {
if (!chain) return
navigate(href({ chain, block: hash }), { replace: true })
},
[chain, navigate],
)
/** Back to the standings, keeping the window the panel was opened from. */
const closePanel = useCallback(
() => navigate(href({ chain, window: route.window })),
[chain, route.window, navigate],
)
const interval =
state.summary?.block_interval_seconds ?? state.summary?.target_block_time_seconds ?? 6
const activeWindow = WINDOWS.find((w) => w.id === route.window) ?? WINDOWS[1]!
return (
<div className="shell">
<header className="masthead">
<div className="mark">
<Sigil />
<div>
<div className="mark-name">
blackbeard<span>.observer</span>
</div>
<div className="mark-tagline">quantus mining · this is sparta</div>
</div>
</div>
<div className="masthead-right">
{/* Links, not buttons: a window is part of the address, so "the
last 24 hours on Quantus" is a thing to send someone rather than
a control state that a shared link would drop. */}
<nav className="segmented" aria-label="Leaderboard window">
{WINDOWS.map((w) => (
<Link
key={w.id}
to={href({ chain, window: w.id })}
aria-current={w.id === route.window ? 'page' : undefined}
title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`}
>
{w.label}
</Link>
))}
</nav>
<div className={`status status-${state.connection}`}>
<span className="status-dot" />
{state.connection}
</div>
</div>
</header>
<ChainSwitcher chains={state.chains} active={chain} window={route.window} />
{info?.status === 'awaiting' && (
<div className="banner banner-warn">
<strong>{info.display_name}</strong> has not started producing blocks yet. This page will
come alive on its own the moment it does no refresh needed.
</div>
)}
{info?.status === 'unreachable' && (
<div className="banner banner-warn">
The observer has lost contact with the {info.display_name} node. The standings below are
the last it saw.
</div>
)}
{info?.status === 'syncing' && (
<div className="banner banner-warn">
The node is still importing history. Block interval and therefore every hashrate on this
page is nominal until it reaches the tip.
</div>
)}
<StatBar summary={state.summary} windowName={route.window} />
{route.block && chain && (
<BlockPanel
chain={chain}
block={route.block}
targetSeconds={info?.target_block_time_seconds ?? 6}
onResolved={blockResolved}
onClose={closePanel}
/>
)}
{route.account && chain && (
<AccountPanel
chain={chain}
address={route.account}
// Read from the chain rather than assumed: 12 is what Quantus
// reports, but a chain that said otherwise would render every
// balance on this page wrong and look entirely plausible doing it.
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{route.miner && chain && (
<MinerPanel chain={chain} miner={route.miner} windowName={route.window} />
)}
{/* A miner and an account are pages, not things opened over the board.
Each is somebody's own record and the whole subject of the URL that
reached it; leaving eleven other miners' rows underneath is the site
failing to notice which page it is on. A block keeps the board,
because a block is read *against* it — who won this one, and where do
they sit — and the ticker below is the row you clicked to get here. */}
{!route.account && !route.miner && (
<div className="two-col">
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">The Standings</h2>
<span className="eyebrow">
last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '}
{windowSpan(activeWindow.blocks, interval)}
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
</span>
</div>
<Leaderboard rows={state.leaderboard} chain={chain} ready={state.ready} />
</section>
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Live Blocks</h2>
<span className="eyebrow">
{state.summary?.block_interval_seconds
? `${seconds(state.summary.block_interval_seconds)} apart`
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} chain={chain} />
</section>
</div>
)}
<footer className="footer">
<span>
Every miner on this board is derived from the <code>pow_</code> digest in each block
header no registration, no opt-in, no way to be left out.
</span>
<span>
Hashrates are estimates over a finite window, not measurements of anyone's hardware. A
miner's figure is the difficulty of the blocks it won expected hashes, by definition
divided by the time they spanned, so a miner that won while the network was smaller is
credited with the work it actually did rather than a share of what the network is now.
</span>
<span>
Node names are a best-effort mapping, inferred from which node reported a block to
telemetry first never a claim the miner made. A <code>node?</code> tag marks one resting
on a single block or a narrow lead; later blocks correct it. The reward preimage beside it
is the only identity the chain itself asserts. An <code>elsewhere</code> tag means the
name was earned by the same reward preimage on another chain, not observed on this one.
</span>
<span className="colophon">
{/* Its own line rather than a fourth item in the `space-between`
flow: the three above are caveats about the numbers, this is
where to go and check them. Opens in a tab of its own so the
socket behind this page keeps its connection. */}
Every number here is derived from public block headers, and the code that derives them is
public too {' '}
<a
href="https://git.lair.cafe/quantus/blackbeard.observer"
target="_blank"
rel="noreferrer noopener"
>
git.lair.cafe/quantus/blackbeard.observer
</a>
.
</span>
</footer>
</div>
)
}
/** The mark: a spear-point shield, drawn rather than shipped as an asset. */
function Sigil() {
return (
<svg width="34" height="40" viewBox="0 0 34 40" aria-hidden="true">
<path
d="M17 1 L32 7 V20 C32 29 25 35 17 39 C9 35 2 29 2 20 V7 Z"
fill="none"
stroke="var(--data)"
strokeWidth="1.6"
/>
<path
d="M17 8 L24 12 V21 C24 26 20.5 29.5 17 31.5 C13.5 29.5 10 26 10 21 V12 Z"
fill="var(--data-wash)"
/>
<path d="M17 11 V28" stroke="var(--accent)" strokeWidth="1.6" />
<path d="M13 15 L17 11 L21 15" fill="none" stroke="var(--accent)" strokeWidth="1.6" />
</svg>
)
}