diff --git a/readme.md b/readme.md index 4cf9dc1..d7df6a1 100644 --- a/readme.md +++ b/readme.md @@ -134,11 +134,12 @@ minutes. ## A block has two addresses, and only one of them is stable -`#/quantus/1069799` is a *position*. Because `block` is keyed on (chain, height) -and a reorg overwrites, a link to a height quietly comes to mean a different -block the moment the chain forks there. So opening one resolves it and rewrites -the address to `#/quantus/0x…`, which names one block for good — including after -it has lost, which is the only way an orphan is linkable at all. +`/quantus/block/1069799` is a *position*. Because `block` is keyed on +(chain, height) and a reorg overwrites, a link to a height quietly comes to mean +a different block the moment the chain forks there. So opening one resolves it +and rewrites the address to `/quantus/block/0x…`, which names one block for good +— including after it has lost, which is the only way an orphan is linkable at +all. The page is assembled from both sources because neither is sufficient. The node holds the block and forgets it: `blocks-pruning` defaults to @@ -149,6 +150,31 @@ the tip — and what it has since forgotten, the difficulty recorded while the state still existed. Anything neither can answer says so in words rather than going blank, because a gap on a block page reads as a zero. +## Every view has an address + +Paths, not a hash: `/quantus/day`, `/quantus/block/13160`, +`/quantus/miner/0x…`. A fragment is never sent to a server, so a hash URL cannot +be given a title, cannot be crawled, and cannot be resolved by anything but the +page itself — which was a fine trade while the whole surface was a chain and a +window, and stopped being one once a block and a miner each became a page worth +sending someone. Both vhosts already answer any path with `index.html`, so +nothing outside the client changed. `lib/routes` holds the grammar, and +`fromLegacyHash` rewrites the old form on load, so every `#/…` link already +published still lands where it meant to. + +Route segments are named — `/quantus/block/13160` rather than +`/quantus/13160` — rather than told apart by shape. Shape works right up until +two kinds of thing can look alike, and an SS58 address is arbitrary base58 with +no rule keeping it clear of a window name forever. + +Everything navigable is an ``, including the chain chips and the window +control. That is what "real URLs" has to mean in practice: middle-click opens +the 24-hour view of another chain in a tab, right-click copies a link to a +miner, and the back button walks the panels the way it walks pages. The one +deliberate exception is a height resolving to its hash, which *replaces* rather +than pushes — it is the same block under a better name, not a second place the +reader has been. + ## Data Postgres on `magrathea.kosherinata.internal`, mTLS and passwordless — the host's diff --git a/web/src/App.tsx b/web/src/App.tsx index d6dd8da..aaca5be 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,15 @@ /** * The page. * - * One route, effectively: a chain, a window, and optionally one miner in focus. - * State lives in the URL hash rather than a router, because the whole - * navigable surface is `#/planck/day` and `#/miner/0x…` — pulling in a router - * to parse two segments would be more code than parsing them. + * 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, useState } from 'react' +import { useCallback, useEffect, useMemo } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' -import type { Window as WindowName } from './api/generated/Window' import { BlockPanel } from './components/BlockPanel' import { BlockTicker } from './components/BlockTicker' import { ChainSwitcher } from './components/ChainSwitcher' @@ -17,105 +17,59 @@ 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' -const WINDOWS: { id: WindowName; label: string; blocks: number }[] = [ - { id: 'hour', label: '1h', blocks: 600 }, - { id: 'six_hours', label: '6h', blocks: 3600 }, - { id: 'day', label: '24h', blocks: 14400 }, - { id: 'week', label: '7d', blocks: 100800 }, -] - -interface Route { - chain: string | null - window: WindowName - miner: string | null - block: string | null -} - -/** - * A block number, or a 32-byte hash. - * - * The second path segment is a window (`day`, `six_hours`, …) or a block, and - * the two can never be confused: no window name is all digits or `0x` followed - * by 64 hex characters. That is what lets `#/planck/day` and - * `#/planck/1069799` share a shape without a discriminating prefix. - */ -function isBlockRef(segment: string): boolean { - return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment) -} - -function parseHash(hash: string): Route { - const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean) - if (parts[0] === 'miner' && parts[1]) { - return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase(), block: null } - } - const chain = parts[0] ?? null - const second = parts[1] - if (second && isBlockRef(second)) { - return { chain, window: 'six_hours', miner: null, block: second.toLowerCase() } - } - const window = WINDOWS.find((w) => w.id === second)?.id ?? 'six_hours' - return { chain, window, miner: null, block: null } -} - export default function App() { const state = useObserver() - const [route, setRoute] = useState(() => parseHash(window.location.hash)) + const location = useLocation() + const navigate = useNavigate() const { pinned } = usePinnedMiners() - useEffect(() => { - const onHash = () => setRoute(parseHash(window.location.hash)) - window.addEventListener('hashchange', onHash) - return () => window.removeEventListener('hashchange', onHash) - }, []) + const route = useMemo(() => parse(location.pathname), [location.pathname]) - // 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. // 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 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) - const go = useCallback((next: Partial) => { - const merged = { ...parseHash(window.location.hash), ...next } - window.location.hash = merged.miner - ? `/miner/${merged.miner}` - : merged.block - ? `/${merged.chain ?? ''}/${merged.block}` - : `/${merged.chain ?? ''}/${merged.window}` - }, []) - - const selectMiner = useCallback((miner: string) => { - // Keep the chain and window; the miner panel opens above the board rather - // than replacing the page, so the standings stay visible behind it. - setRoute((r) => ({ ...r, miner, block: null })) - }, []) - - const selectBlock = useCallback((block: string) => { - setRoute((r) => ({ ...r, block, miner: null })) - }, []) - /** * A height has resolved to its hash; put the hash in the address bar. * - * `replaceState`, 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. Route state is left - * alone deliberately — rewriting it would re-render the panel with a new - * `block` prop and fetch the same block a second time. + * `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 - window.history.replaceState(null, '', `#/${chain}/${hash}`) + navigate(href({ chain, block: hash }), { replace: true }) }, - [chain], + [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 = @@ -136,18 +90,21 @@ export default function App() {
-
+ {/* 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. */} +
+
@@ -156,11 +113,7 @@ export default function App() {
- go({ chain: id, miner: null, block: null })} - /> + {info?.status === 'awaiting' && (
@@ -189,8 +142,7 @@ export default function App() { block={route.block} targetSeconds={info?.target_block_time_seconds ?? 6} onResolved={blockResolved} - onSelectMiner={selectMiner} - onClose={() => go({ window: route.window, miner: null, block: null })} + onClose={closePanel} /> )} @@ -199,7 +151,7 @@ export default function App() { chain={chain} miner={route.miner} windowName={route.window} - onClose={() => setRoute((r) => ({ ...r, miner: null }))} + onClose={closePanel} /> )} @@ -213,7 +165,7 @@ export default function App() { {pinned.length > 0 && ` · ${pinned.length} marked yours`}
- +
@@ -225,12 +177,7 @@ export default function App() { : 'measuring'}
- + diff --git a/web/src/components/BlockPanel.tsx b/web/src/components/BlockPanel.tsx index 7ad1ddd..8a8c556 100644 --- a/web/src/components/BlockPanel.tsx +++ b/web/src/components/BlockPanel.tsx @@ -17,10 +17,12 @@ */ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import type { BlockDetail } from '../api/generated/BlockDetail' import { RequestFailed, fetchBlock } from '../api/rest' import { ago, bigNumber, height as fmtHeight, seconds, shortMiner } from '../lib/format' +import { href } from '../lib/routes' /** A label and its value, with the caveat that makes the value readable. */ function Fact({ @@ -54,7 +56,6 @@ export function BlockPanel({ block, targetSeconds, onResolved, - onSelectMiner, onClose, }: { chain: string @@ -64,7 +65,6 @@ export function BlockPanel({ targetSeconds: number /** Called with the canonical hash once a height has resolved to one. */ onResolved: (hash: string) => void - onSelectMiner: (miner: string) => void onClose: () => void }) { const [detail, setDetail] = useState(null) @@ -149,7 +149,9 @@ export function BlockPanel({ This block lost its height to a reorg. It is kept here because the observer saw it — the chain itself no longer serves losing blocks for long.{' '} {detail.canonical_hash && ( -
See the block that won → + + See the block that won → + )} )} @@ -164,16 +166,9 @@ export function BlockPanel({
{detail.miner ? ( - { - e.preventDefault() - onSelectMiner(detail.miner as string) - }} - > + {detail.display ?? shortMiner(detail.miner)} - + ) : ( // Genesis carries no `pow_` digest, and neither would a header // shape this decoder does not know. @@ -240,9 +235,9 @@ export function BlockPanel({ {detail.parent_hash && (
Parent{' '} - + {detail.parent_hash} - +
)} {detail.also_seen.length > 0 && ( @@ -252,9 +247,9 @@ export function BlockPanel({ {detail.also_seen.map((h) => (
- + {h} - +
))}
diff --git a/web/src/components/BlockTicker.tsx b/web/src/components/BlockTicker.tsx index a53d444..88e1eda 100644 --- a/web/src/components/BlockTicker.tsx +++ b/web/src/components/BlockTicker.tsx @@ -8,22 +8,20 @@ */ import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' import type { RecentBlock } from '../api/generated/RecentBlock' import { ago, height, seconds } from '../lib/format' +import { href } from '../lib/routes' import { usePinnedMiners } from '../lib/store' export function BlockTicker({ blocks, chain, - onSelect, - onSelectBlock, }: { blocks: RecentBlock[] /** Needed to address a block; null before the first chain has resolved. */ chain: string | null - onSelect: (miner: string) => void - onSelectBlock: (block: string) => void }) { const { isPinned } = usePinnedMiners() const seen = useRef>(new Set()) @@ -63,26 +61,15 @@ export function BlockTicker({ .join(' ')} > {chain ? ( - { - e.preventDefault() - onSelectBlock(String(block.height)) - }} - > + #{height(block.height)} - + ) : ( #{height(block.height)} )} - { - e.preventDefault() - onSelect(block.miner) - }} style={{ overflow: 'hidden', textOverflow: 'ellipsis', @@ -92,7 +79,7 @@ export function BlockTicker({ }} > {block.display} - + {block.gap_seconds !== null ? `+${seconds(block.gap_seconds)}` diff --git a/web/src/components/ChainSwitcher.tsx b/web/src/components/ChainSwitcher.tsx index 290ada3..a95d825 100644 --- a/web/src/components/ChainSwitcher.tsx +++ b/web/src/components/ChainSwitcher.tsx @@ -12,7 +12,11 @@ * button that says why beats a navigable one that leads to an empty board. */ +import { Link } from 'react-router-dom' + import type { ChainInfo } from '../api/generated/ChainInfo' +import type { Window as WindowName } from '../api/generated/Window' +import { href } from '../lib/routes' function reason(chain: ChainInfo): string { if (chain.tracking === 'no_endpoint') { @@ -33,11 +37,12 @@ function reason(chain: ChainInfo): string { export function ChainSwitcher({ chains, active, - onSelect, + window: windowName, }: { chains: ChainInfo[] active: string | null - onSelect: (id: string) => void + /** Carried across the switch: changing chain is not a reason to change span. */ + window: WindowName }) { if (chains.length === 0) return null @@ -48,18 +53,15 @@ export function ChainSwitcher({ {chains.map((chain) => { const tracked = chain.tracking === 'full' const isActive = chain.id === active - return ( - ) })} diff --git a/web/src/components/Leaderboard.tsx b/web/src/components/Leaderboard.tsx index 861e087..572bcd8 100644 --- a/web/src/components/Leaderboard.tsx +++ b/web/src/components/Leaderboard.tsx @@ -11,22 +11,25 @@ * explicit "YOU" chip — so identity never rests on colour alone. */ +import { Link } from 'react-router-dom' + import type { LeaderboardRow } from '../api/generated/LeaderboardRow' import { ago, hashrate, share } from '../lib/format' +import { href } from '../lib/routes' import { usePinnedMiners } from '../lib/store' function Row({ row, + chain, max, mine, onToggle, - onSelect, }: { row: LeaderboardRow + chain: string | null max: number mine: boolean onToggle: (miner: string) => void - onSelect: (miner: string) => void }) { const named = row.attribution === 'telemetry' // A name this operator earned on another chain, under the same reward @@ -50,23 +53,19 @@ function Row({ > {mine ? '★' : '☆'} - { - e.preventDefault() - onSelect(row.miner) - }} > {/* `display` already carries the abbreviated reward address when no name is known — the backend derives it, because the preimage is exact and unrecognisable. */} {row.display} - + {mine && You} {carried && ( void }) { const { isPinned, toggle } = usePinnedMiners() // Bars are scaled to the leader, not to 100%: with twenty miners the top @@ -182,8 +182,8 @@ export function Leaderboard({ row={row} max={max} mine={isPinned(row.miner)} + chain={chain} onToggle={toggle} - onSelect={onSelect} /> ))} diff --git a/web/src/index.css b/web/src/index.css index f1aa942..71f8e0c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -241,8 +241,10 @@ a { background: var(--surface-1); } -.segmented button { +.segmented a { appearance: none; + text-decoration: none; + display: inline-block; border: 0; background: transparent; color: var(--text-secondary); @@ -255,25 +257,20 @@ a { white-space: nowrap; } -.segmented button + button { +.segmented a + a { border-left: var(--rule); } -.segmented button:hover:not(:disabled) { +.segmented a:hover { color: var(--text-primary); background: var(--surface-2); } -.segmented button[aria-pressed='true'] { +.segmented a[aria-current='page'] { background: var(--data-wash); color: var(--data-bright); } -.segmented button:disabled { - color: var(--text-muted); - cursor: not-allowed; -} - /* ---- chain nav ----------------------------------------------------------- */ .chains { @@ -296,6 +293,8 @@ a { .chain-chip { display: inline-flex; + /* The tracked chips are links now, so they arrive underlined. */ + text-decoration: none; align-items: center; gap: 8px; appearance: none; diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts new file mode 100644 index 0000000..7631f97 --- /dev/null +++ b/web/src/lib/routes.ts @@ -0,0 +1,115 @@ +/** + * The site's URL grammar, in one place. + * + * Real paths, not a hash. The hash form was fine while the whole navigable + * surface was a chain and a window, but a URL that a server never sees cannot + * be given a title, cannot be crawled, and cannot be linked to from anywhere + * that resolves links — and now that a block, a miner and an account each have + * a page worth sending someone, that matters. Both vhosts already answer any + * path with `index.html` (`try_files $uri $uri/ /index.html`), so the change is + * entirely in the client. + * + * Segments are explicit — `/quantus/block/13160`, not `/quantus/13160` — rather + * than discriminated by shape. Shape works until two kinds of thing can look + * alike, and an SS58 address is arbitrary base58: there is no rule that keeps + * it clear of a window name forever. Naming the kind costs six characters and + * removes the whole class of problem. + */ + +import type { Window as WindowName } from '../api/generated/Window' + +export const WINDOWS: { id: WindowName; label: string; blocks: number }[] = [ + { id: 'hour', label: '1h', blocks: 600 }, + { id: 'six_hours', label: '6h', blocks: 3600 }, + { id: 'day', label: '24h', blocks: 14400 }, + { id: 'week', label: '7d', blocks: 100800 }, +] + +export const DEFAULT_WINDOW: WindowName = 'six_hours' + +/** What the address bar currently says the page should show. */ +export interface Route { + /** `null` means "whichever chain the backend puts first". */ + chain: string | null + window: WindowName + /** A height, or a `0x` block hash. */ + block: string | null + /** A reward preimage. */ + miner: string | null + /** An SS58 address. */ + account: string | null +} + +export const EMPTY: Route = { + chain: null, + window: DEFAULT_WINDOW, + block: null, + miner: null, + account: null, +} + +function asWindow(segment: string | undefined): WindowName | null { + return WINDOWS.find((w) => w.id === segment)?.id ?? null +} + +/** A block number, or a 32-byte hash. */ +export function isBlockRef(segment: string): boolean { + return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment) +} + +/** + * Parse a pathname into a route. + * + * Deliberately total: an unrecognised path degrades to the front page rather + * than a 404, because every path here is a guess about live chain data — a + * height that has not happened yet, a chain that was renamed — and a blank + * error page is a worse answer than the standings. + */ +export function parse(pathname: string): Route { + const parts = pathname.split('/').filter(Boolean) + + // The legacy chainless miner link, kept working: the preimage identifies the + // same account on every Quantus chain, so the only thing it lacks is which + // chain's figures to show, and the default is the right guess. + if (parts[0] === 'miner' && parts[1]) { + return { ...EMPTY, miner: parts[1].toLowerCase() } + } + + const chain = parts[0] ?? null + if (!chain) return EMPTY + + const kind = parts[1] + const value = parts[2] + if (kind && value) { + if (kind === 'block') return { ...EMPTY, chain, block: value.toLowerCase() } + if (kind === 'miner') return { ...EMPTY, chain, miner: value.toLowerCase() } + if (kind === 'account') return { ...EMPTY, chain, account: value } + } + // A bare height or hash as the second segment: the shape the site used + // before this module existed. Recognised so links already sent stay good. + if (kind && isBlockRef(kind)) return { ...EMPTY, chain, block: kind.toLowerCase() } + + return { ...EMPTY, chain, window: asWindow(kind) ?? DEFAULT_WINDOW } +} + +/** The canonical path for a route. The inverse of {@link parse}. */ +export function href(route: Partial): string { + const chain = route.chain ?? '' + if (route.block) return `/${chain}/block/${route.block}` + if (route.miner) return chain ? `/${chain}/miner/${route.miner}` : `/miner/${route.miner}` + if (route.account) return `/${chain}/account/${route.account}` + return `/${chain}/${route.window ?? DEFAULT_WINDOW}` +} + +/** + * The path a legacy hash URL meant, or `null` if there is nothing to migrate. + * + * Every link to this site published before today is `#/quantus/day` or + * `#/quantus/1069799`, and a fragment is never sent to the server, so nothing + * but the page itself can honour them. Rewriting on load with `replaceState` + * keeps them working without leaving the old form in the back stack. + */ +export function fromLegacyHash(hash: string): string | null { + if (!hash.startsWith('#/')) return null + return href(parse(hash.slice(1))) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 45d8078..8bd085e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,19 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import App from './App' import { Observer } from './api/socket' import { ObserverProvider } from './lib/ObserverProvider' +import { fromLegacyHash } from './lib/routes' import './index.css' +// Before the router sees the URL, not after: `BrowserRouter` reads +// `location.pathname` on mount and would otherwise render the front page while +// the fragment sat in the address bar, unread and unexplained. +const migrated = fromLegacyHash(window.location.hash) +if (migrated) window.history.replaceState(null, '', migrated) + // One socket per tab, created here rather than inside a component so React 19's // double-invoked StrictMode effects cannot open two. const observer = new Observer() @@ -16,7 +24,9 @@ if (!root) throw new Error('#root is missing from index.html') createRoot(root).render( - + + + , )