feat: block explorer routes, addressed by hash
`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.
`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.
The answer is assembled from two sources because neither is sufficient, and
they fail at different times:
- The node holds the block and forgets it. Non-canonical bodies go once finality
passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
that sighting was at the tip — and what it has since forgotten: the difficulty
read while the state behind it still existed.
So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.
Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
+66
-8
@@ -10,6 +10,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { Window as WindowName } from './api/generated/Window'
|
||||
import { BlockPanel } from './components/BlockPanel'
|
||||
import { BlockTicker } from './components/BlockTicker'
|
||||
import { ChainSwitcher } from './components/ChainSwitcher'
|
||||
import { Leaderboard } from './components/Leaderboard'
|
||||
@@ -29,15 +30,33 @@ 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() }
|
||||
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase(), block: null }
|
||||
}
|
||||
const window = WINDOWS.find((w) => w.id === parts[1])?.id ?? 'six_hours'
|
||||
return { chain: parts[0] ?? null, window, miner: 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() {
|
||||
@@ -67,15 +86,38 @@ export default function App() {
|
||||
const merged = { ...parseHash(window.location.hash), ...next }
|
||||
window.location.hash = merged.miner
|
||||
? `/miner/${merged.miner}`
|
||||
: `/${merged.chain ?? ''}/${merged.window}`
|
||||
: 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 }))
|
||||
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.
|
||||
*/
|
||||
const blockResolved = useCallback(
|
||||
(hash: string) => {
|
||||
if (!chain) return
|
||||
window.history.replaceState(null, '', `#/${chain}/${hash}`)
|
||||
},
|
||||
[chain],
|
||||
)
|
||||
|
||||
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]!
|
||||
@@ -100,7 +142,7 @@ export default function App() {
|
||||
key={w.id}
|
||||
aria-pressed={w.id === route.window}
|
||||
title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`}
|
||||
onClick={() => go({ window: w.id, miner: null })}
|
||||
onClick={() => go({ window: w.id, miner: null, block: null })}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
@@ -117,7 +159,7 @@ export default function App() {
|
||||
<ChainSwitcher
|
||||
chains={state.chains}
|
||||
active={chain}
|
||||
onSelect={(id) => go({ chain: id, miner: null })}
|
||||
onSelect={(id) => go({ chain: id, miner: null, block: null })}
|
||||
/>
|
||||
|
||||
{info?.status === 'awaiting' && (
|
||||
@@ -141,6 +183,17 @@ export default function App() {
|
||||
|
||||
<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}
|
||||
onSelectMiner={selectMiner}
|
||||
onClose={() => go({ window: route.window, miner: null, block: null })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{route.miner && chain && (
|
||||
<MinerPanel
|
||||
chain={chain}
|
||||
@@ -172,7 +225,12 @@ export default function App() {
|
||||
: 'measuring'}
|
||||
</span>
|
||||
</div>
|
||||
<BlockTicker blocks={state.blocks} onSelect={selectMiner} />
|
||||
<BlockTicker
|
||||
blocks={state.blocks}
|
||||
chain={chain}
|
||||
onSelect={selectMiner}
|
||||
onSelectBlock={selectBlock}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AttributionSource } from "./AttributionSource";
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { MinerId } from "./MinerId";
|
||||
|
||||
/**
|
||||
* One block, as completely as it can still be described.
|
||||
*
|
||||
* Assembled from two sources that know different things, because neither is
|
||||
* sufficient on its own:
|
||||
*
|
||||
* - The **node** holds the block itself — parent, body, and whether this hash
|
||||
* still holds its height. It forgets: `blocks-pruning` defaults to
|
||||
* `archive-canonical`, so a non-canonical body goes once finality passes it,
|
||||
* and difficulty is a state read behind a 256-block window.
|
||||
* - The **observer** holds what the node never had — when we first saw the
|
||||
* block, whether that sighting was at the tip — and what it has since
|
||||
* forgotten, the difficulty recorded while the state still existed.
|
||||
*
|
||||
* Every field the pair cannot supply is `None` rather than a substituted
|
||||
* plausible value. A block page that quietly filled in the current difficulty
|
||||
* for one it could not look up would be the same lie the ingest path was fixed
|
||||
* to stop telling.
|
||||
*/
|
||||
export type BlockDetail = {
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* Block number.
|
||||
*/
|
||||
height: number,
|
||||
/**
|
||||
* This block's hash — the identity the page is addressed by, so that a
|
||||
* link keeps meaning the same block after the height has moved on.
|
||||
*/
|
||||
hash: string,
|
||||
/**
|
||||
* Parent hash, from the header.
|
||||
*/
|
||||
parent_hash: string | null,
|
||||
/**
|
||||
* Author's reward preimage from the `pow_` PreRuntime digest.
|
||||
*/
|
||||
miner: MinerId | null,
|
||||
/**
|
||||
* What to call the author: a telemetry node name, or the abbreviated
|
||||
* preimage. `None` where the author could not be decoded at all.
|
||||
*/
|
||||
display: string | null,
|
||||
/**
|
||||
* Whether `display` is an inferred name or the preimage.
|
||||
*/
|
||||
attribution: AttributionSource | null,
|
||||
/**
|
||||
* How much of the attribution vote window agrees, 0.0–1.0.
|
||||
*/
|
||||
confidence: number | null,
|
||||
/**
|
||||
* Expected hashes to win this block: the difficulty in force at its
|
||||
* parent, which is the figure the miner actually had to meet.
|
||||
*/
|
||||
difficulty: BigUintDec | null,
|
||||
/**
|
||||
* The author's own timestamp, from the block's `Timestamp::set` inherent.
|
||||
*/
|
||||
authored_at: string | null,
|
||||
/**
|
||||
* When this observer first saw the block. `None` for a block it never saw
|
||||
* at all — an orphan on a fork it missed, or history before it started.
|
||||
*/
|
||||
observed_at: string | null,
|
||||
/**
|
||||
* Whether `observed_at` was a tip sighting. When false, the difference
|
||||
* between the timestamps measures when the observer got round to looking
|
||||
* and nothing about the network, so the UI must not read it as propagation.
|
||||
*/
|
||||
at_tip: boolean | null,
|
||||
/**
|
||||
* Seconds between this block's timestamp and its parent's — how long this
|
||||
* block took to find, against the chain's target.
|
||||
*/
|
||||
seconds_since_parent: number | null,
|
||||
/**
|
||||
* Extrinsics in the body, inherents included. `None` once the body has
|
||||
* been pruned.
|
||||
*/
|
||||
extrinsics: number | null,
|
||||
/**
|
||||
* Whether this hash is the one that currently holds this height.
|
||||
*/
|
||||
canonical: boolean,
|
||||
/**
|
||||
* The hash that currently holds this height. Equal to `hash` when
|
||||
* canonical; on an orphan page it is the link to the block that won.
|
||||
*/
|
||||
canonical_hash: string | null,
|
||||
/**
|
||||
* Other hashes this observer has recorded at this height — the losing
|
||||
* sides of past reorgs. Navigable, which is the whole reason the
|
||||
* displacement is written down.
|
||||
*/
|
||||
also_seen: Array<string>,
|
||||
/**
|
||||
* False when the node could not be reached or no longer holds the block,
|
||||
* so everything here came from what the observer had already recorded.
|
||||
* The UI owes the reader that distinction rather than showing gaps.
|
||||
*/
|
||||
from_node: boolean, };
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { ApiError } from './generated/ApiError'
|
||||
import type { BlockDetail } from './generated/BlockDetail'
|
||||
import type { ChainSeries } from './generated/ChainSeries'
|
||||
import type { MinerDetail } from './generated/MinerDetail'
|
||||
import type { Window as WindowName } from './generated/Window'
|
||||
@@ -59,6 +60,24 @@ export function fetchSeries(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One block, by height or by hash.
|
||||
*
|
||||
* A height names whatever currently holds it and a hash names one block for
|
||||
* good, so the caller redirects to the hash once the height resolves — see
|
||||
* `BlockPanel`. The API accepts either spelling at the same path.
|
||||
*/
|
||||
export function fetchBlock(
|
||||
chain: string,
|
||||
block: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BlockDetail> {
|
||||
return get<BlockDetail>(
|
||||
`/chains/${encodeURIComponent(chain)}/blocks/${encodeURIComponent(block)}`,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/** One miner's standing and history. */
|
||||
export function fetchMiner(
|
||||
chain: string,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* One block.
|
||||
*
|
||||
* Opened from a height — the number in the ticker, or a URL somebody typed —
|
||||
* and then it rewrites the address to the block's hash. That is not tidiness:
|
||||
* `block` is keyed on (chain, height) and a reorg overwrites, so a height names
|
||||
* a *position* and a link to one quietly comes to mean a different block the
|
||||
* moment the chain forks there. A hash names one block for good, including
|
||||
* after it has lost.
|
||||
*
|
||||
* The page says where each fact came from, because the two sources fail in
|
||||
* different ways and at different times. The node holds the block and forgets
|
||||
* it — non-canonical bodies go once finality passes them, and difficulty is a
|
||||
* state read behind a 256-block window. The observer holds what the node never
|
||||
* had, the sighting times, and what it has since forgotten. A blank here means
|
||||
* neither could answer, never that the value is zero.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BlockDetail } from '../api/generated/BlockDetail'
|
||||
import { RequestFailed, fetchBlock } from '../api/rest'
|
||||
import { ago, bigNumber, height as fmtHeight, seconds, shortMiner } from '../lib/format'
|
||||
|
||||
/** A label and its value, with the caveat that makes the value readable. */
|
||||
function Fact({
|
||||
label,
|
||||
children,
|
||||
note,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
// `| undefined` explicitly: the project sets `exactOptionalPropertyTypes`, so
|
||||
// an optional prop does not otherwise accept a conditional that may be
|
||||
// undefined — which is exactly how every caller here computes its caveat.
|
||||
note?: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="fact">
|
||||
<div className="fact-label">{label}</div>
|
||||
<div className="fact-value">{children}</div>
|
||||
{note && <div className="fact-note">{note}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A value the observer does not have, said plainly rather than left blank. */
|
||||
function Unknown({ why }: { why: string }) {
|
||||
return <span className="fact-unknown">not recorded — {why}</span>
|
||||
}
|
||||
|
||||
export function BlockPanel({
|
||||
chain,
|
||||
block,
|
||||
targetSeconds,
|
||||
onResolved,
|
||||
onSelectMiner,
|
||||
onClose,
|
||||
}: {
|
||||
chain: string
|
||||
/** Height or hash, as it appeared in the URL. */
|
||||
block: string
|
||||
/** The chain's target block time, for reading the gap against. */
|
||||
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<BlockDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setDetail(null)
|
||||
setError(null)
|
||||
fetchBlock(chain, block, controller.signal)
|
||||
.then((d) => {
|
||||
setDetail(d)
|
||||
// Swap the address for the immutable one. Only ever a replace: this is
|
||||
// the same block under a better name, not somewhere the reader went.
|
||||
if (d.hash !== block) onResolved(d.hash)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain, block, onResolved])
|
||||
|
||||
// Propagation: the author stamps its timestamp when it *builds* the proposal,
|
||||
// so this is that moment to the moment we saw the block. Only a number at all
|
||||
// when the sighting was at the tip — on a gap fill it measures when the
|
||||
// observer got round to looking, which is a fact about us.
|
||||
const propagationMs =
|
||||
detail?.at_tip && detail.authored_at && detail.observed_at
|
||||
? new Date(detail.observed_at).getTime() - new Date(detail.authored_at).getTime()
|
||||
: null
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<div className="eyebrow">Block</div>
|
||||
<h2 className="panel-title" style={{ marginTop: 4 }}>
|
||||
{detail
|
||||
? `#${fmtHeight(detail.height)}`
|
||||
: block.startsWith('0x')
|
||||
? 'Block'
|
||||
: `#${block}`}
|
||||
{detail && !detail.canonical && <span className="badge badge-warn">replaced</span>}
|
||||
</h2>
|
||||
<div
|
||||
className="numeral"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
marginTop: 4,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{detail?.hash ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="segmented"
|
||||
style={{
|
||||
padding: '7px 13px',
|
||||
font: 'inherit',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !detail && <p className="empty">Reading the block…</p>}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
{!detail.canonical && (
|
||||
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
|
||||
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 && (
|
||||
<a href={`#/${chain}/${detail.canonical_hash}`}>See the block that won →</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!detail.from_node && (
|
||||
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
|
||||
The node could not answer for this block, so everything below is what the observer
|
||||
recorded at the time. The body, the parent and the timing against it are missing
|
||||
because only the chain ever had them.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="facts">
|
||||
<Fact label="Author" note={detail.confidence ? undefined : 'no telemetry name held'}>
|
||||
{detail.miner ? (
|
||||
<a
|
||||
href={`#/miner/${detail.miner}`}
|
||||
title={detail.miner}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectMiner(detail.miner as string)
|
||||
}}
|
||||
>
|
||||
{detail.display ?? shortMiner(detail.miner)}
|
||||
</a>
|
||||
) : (
|
||||
// Genesis carries no `pow_` digest, and neither would a header
|
||||
// shape this decoder does not know.
|
||||
<Unknown why="this header carries no author digest" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact label="Difficulty" note="expected hashes to win it">
|
||||
{detail.difficulty ? (
|
||||
<span title={detail.difficulty}>{bigNumber(detail.difficulty)}</span>
|
||||
) : (
|
||||
<Unknown why="the node's state for this height is pruned" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact
|
||||
label="Time to find"
|
||||
note={
|
||||
detail.seconds_since_parent != null ? `target ${seconds(targetSeconds)}` : undefined
|
||||
}
|
||||
>
|
||||
{detail.seconds_since_parent != null ? (
|
||||
seconds(detail.seconds_since_parent)
|
||||
) : (
|
||||
<Unknown why="the parent's timestamp is unavailable" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact label="Extrinsics" note="inherents included">
|
||||
{detail.extrinsics != null ? (
|
||||
String(detail.extrinsics)
|
||||
) : (
|
||||
<Unknown why="the body is pruned" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact label="Authored" note={detail.authored_at ? "the author's own clock" : undefined}>
|
||||
{detail.authored_at ? (
|
||||
<span title={detail.authored_at}>{ago(detail.authored_at)}</span>
|
||||
) : (
|
||||
<Unknown why="no timestamp inherent was decoded" />
|
||||
)}
|
||||
</Fact>
|
||||
|
||||
<Fact
|
||||
label="Seen here"
|
||||
note={
|
||||
propagationMs != null
|
||||
? `${propagationMs} ms after it was authored`
|
||||
: detail.observed_at
|
||||
? 'caught up from history, not seen live'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{detail.observed_at ? (
|
||||
<span title={detail.observed_at}>{ago(detail.observed_at)}</span>
|
||||
) : (
|
||||
<Unknown why="this observer never saw this block" />
|
||||
)}
|
||||
</Fact>
|
||||
</div>
|
||||
|
||||
<div className="block-links">
|
||||
{detail.parent_hash && (
|
||||
<div>
|
||||
<span className="eyebrow">Parent</span>{' '}
|
||||
<a className="numeral" href={`#/${chain}/${detail.parent_hash}`}>
|
||||
{detail.parent_hash}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{detail.also_seen.length > 0 && (
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
Also seen at this height ({detail.also_seen.length})
|
||||
</span>
|
||||
{detail.also_seen.map((h) => (
|
||||
<div key={h}>
|
||||
<a className="numeral" href={`#/${chain}/${h}`}>
|
||||
{h}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -15,10 +15,15 @@ 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<Set<number>>(new Set())
|
||||
@@ -54,7 +59,20 @@ export function BlockTicker({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="ticker-height">#{height(block.height)}</span>
|
||||
{chain ? (
|
||||
<a
|
||||
className="ticker-height"
|
||||
href={`#/${chain}/${block.height}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectBlock(String(block.height))
|
||||
}}
|
||||
>
|
||||
#{height(block.height)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="ticker-height">#{height(block.height)}</span>
|
||||
)}
|
||||
<a
|
||||
href={`#/miner/${block.miner}`}
|
||||
title={block.miner}
|
||||
|
||||
@@ -469,6 +469,111 @@ a {
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* ---- block facts --------------------------------------------------------- */
|
||||
|
||||
/* The same grid as the stat tiles: this is the same kind of content — a handful
|
||||
of single values with a caveat each — and giving it a second look would say
|
||||
the two are different sorts of fact when they are not. */
|
||||
.facts {
|
||||
display: grid;
|
||||
/* Three, not auto-fit. There are exactly six facts, and auto-fit resolves to
|
||||
five at this width — leaving one on a row of its own beside four empty
|
||||
cells that read as a grid that failed to load. Six divides evenly by three
|
||||
and by two, so every breakpoint below is a full rectangle. A seventh fact
|
||||
would need this revisited, which is the right amount of friction. */
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border-bottom: var(--rule);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.facts {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fact {
|
||||
background: var(--surface-1);
|
||||
padding: 14px 18px 15px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fact-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fact-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 17px;
|
||||
margin-top: 7px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fact-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* A value nobody has. Muted and in the body face so it does not read as data —
|
||||
it is a sentence explaining an absence, and formatting it like a figure would
|
||||
invite it to be compared with one. */
|
||||
.fact-unknown {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.block-links {
|
||||
padding: 15px 18px 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.block-links a {
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.block-links a:hover {
|
||||
color: var(--data-bright);
|
||||
}
|
||||
|
||||
/* The one place the accent hue belongs: not a series, a state. A block that
|
||||
lost its height is the single most important thing its page can say. */
|
||||
.badge {
|
||||
margin-left: 10px;
|
||||
padding: 2px 7px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-bright);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
a.ticker-height:hover {
|
||||
color: var(--data-bright);
|
||||
}
|
||||
|
||||
/* ---- leaderboard --------------------------------------------------------- */
|
||||
|
||||
.board {
|
||||
|
||||
Reference in New Issue
Block a user