From 49c266a905a1a7a96b728be8b63bec6a66364689 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 9 Sep 2026 16:53:41 +0300 Subject: [PATCH] ui: real URLs for every view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hash router was the right size for a chain and a window. It stopped being once a block, a miner and — shortly — an account each became a page worth sending someone: a fragment never reaches a server, so those URLs cannot carry a title, be crawled, or be resolved by anything but the page itself. So paths, via react-router, which was already a dependency and unused. Both vhosts already answer any path with `index.html`, so nothing outside the client changed. `lib/routes` holds the grammar in one place and `fromLegacyHash` rewrites the old form on load, so every `#/…` link already published still lands where it meant to. Segments are named — `/quantus/block/13160`, not `/quantus/13160` — rather than told apart by shape. Shape works 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. Six characters buys the whole class of problem away before accounts arrive. Everything navigable is an anchor now, chain chips and the window control included, which is what "real URLs" has to mean in practice: middle-click opens another chain's 24h view in a tab, and the back button walks the panels. The miner panel had no address at all before this — opening one changed the page and not the URL. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- readme.md | 36 ++++++- web/src/App.tsx | 151 +++++++++------------------ web/src/components/BlockPanel.tsx | 27 ++--- web/src/components/BlockTicker.tsx | 27 ++--- web/src/components/ChainSwitcher.tsx | 56 +++++++--- web/src/components/Leaderboard.tsx | 24 ++--- web/src/index.css | 17 ++- web/src/lib/routes.ts | 115 ++++++++++++++++++++ web/src/main.tsx | 12 ++- 9 files changed, 286 insertions(+), 179 deletions(-) create mode 100644 web/src/lib/routes.ts 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( - + + + , )