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
258 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|