feat: show the node a miner's blocks were reported from
deploy / build (push) Successful in 7m37s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s

Caches what the telemetry feed says each node *is*, and surfaces it on the miner
route: client version, target triple, CPU, cores, memory, kernel, distribution,
whether it is virtualised, country, and how long the node process has been up.

Kept in `node_telemetry`, keyed by peer id — never by the feed's `node_id`,
which is per-connection and would accumulate a row per reconnect for the same
machine. Written every five minutes rather than every thirty seconds like the
attributions beside it: a CPU does not change, and mainnet alone has nearly
three hundred nodes. The route reads the live feed first and the cache second,
so a node the feed has not re-announced since our last restart still answers.

Nulls never overwrite. The telemetry server geolocates asynchronously, so every
reconnect re-sends every node with a null location — a naive upsert would blank
the country of every node on the site for as long as the lookups took to catch
up. The guard is in the feed state and in the `on conflict` clause both, with a
test for each.

**Location is a country and no finer.** The feed sends coordinates and a city
from an IP geolocation database, and at city resolution those are wrong often
enough that showing one would assert something nobody knows.
`blackbeard_core::geo` derives the country offline from a 508 KiB boundary set —
no network call, and nothing told where these nodes are — and the coordinates
are then dropped. The schema has no city, latitude or longitude column: a column
that does not exist cannot be rendered by mistake. The lookup returns
subdivisions before countries and `GB-ENG` is exactly the precision being
avoided, so only the two-letter code survives.

**The panel wears its uncertainty in the header, not a footnote.** A miner is
joined to a node by the same first-reporter voting that gives it a display name,
so `Attribution` now carries the peer id it settled on — `None` for a preimage
or a carried name, because there is no node behind either. The heading reads
"reported by" and the confidence and vote count sit beside it. The note says the
CPU is the node's and not necessarily what is hashing: on this network the work
is on GPUs that telemetry does not report at all.

Checked against the live feed end to end, not a fixture: real frames decoded,
cached, served and rendered. One node reports an Ubuntu userspace on a Fedora
kernel, which is a container seeing its host — the panel shows that faithfully
rather than tidying it away.

Closes #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-13 17:26:28 +03:00
co-authored by Claude Opus 5
parent 0b3c1b2e1b
commit 28e99bee44
17 changed files with 1134 additions and 95 deletions
+106 -90
View File
@@ -26,6 +26,7 @@ import {
import { href } from '../lib/routes'
import { usePinnedMiners } from '../lib/store'
import { ShareChart } from './ShareChart'
import { NodePanel } from './NodePanel'
export function MinerPanel({
chain,
@@ -60,121 +61,136 @@ export function MinerPanel({
const named = row?.attribution === 'telemetry'
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Miner</div>
<h2 className="panel-title" style={{ marginTop: 4 }}>
{/* The best identifier available, in the order a reader can use
<>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Miner</div>
<h2 className="panel-title" style={{ marginTop: 4 }}>
{/* The best identifier available, in the order a reader can use
them: an attributed node name, else the reward address the miner
configured, else the preimage the header carries. The full set
is below regardless. */}
{named
? row.display
: detail?.address
? `${detail.address.slice(0, 8)}…${detail.address.slice(-4)}`
: shortMiner(miner)}
</h2>
{/* All three identifiers, because they answer different questions.
{named
? row.display
: detail?.address
? `${detail.address.slice(0, 8)}…${detail.address.slice(-4)}`
: shortMiner(miner)}
</h2>
{/* All three identifiers, because they answer different questions.
The node name is an inference and may be absent; the address is
what the miner configured and recognises; the preimage is what the
block header actually carries and the only one the chain asserts.
A miner checking "is this me?" needs the address; anyone checking
our working needs the preimage. */}
<dl className="identifiers">
{detail?.address && (
<>
<dt>Rewards</dt>
<dd className="numeral">
{/* The way through to what was actually paid: this page
<dl className="identifiers">
{detail?.address && (
<>
<dt>Rewards</dt>
<dd className="numeral">
{/* The way through to what was actually paid: this page
counts blocks, the account page counts tokens, and the
address is the only thing that joins them. */}
<Link to={href({ chain, account: detail.address })}>{detail.address}</Link>
</dd>
</>
)}
<dt>Preimage</dt>
<dd className="numeral">{miner}</dd>
</dl>
</div>
{/* No close button: this is a URL somebody navigated to, not a dialog
<Link to={href({ chain, account: detail.address })}>{detail.address}</Link>
</dd>
</>
)}
<dt>Preimage</dt>
<dd className="numeral">{miner}</dd>
</dl>
</div>
{/* No close button: this is a URL somebody navigated to, not a dialog
that opened over something they were reading. The back button and
the chain chip both already do what it would. */}
<button
className="segmented panel-button"
style={{ color: mine ? 'var(--accent-bright)' : 'var(--text-secondary)' }}
aria-pressed={mine}
onClick={() => toggle(miner)}
>
{mine ? '★ Yours' : '☆ Mark as yours'}
</button>
</div>
<button
className="segmented panel-button"
style={{ color: mine ? 'var(--accent-bright)' : 'var(--text-secondary)' }}
aria-pressed={mine}
onClick={() => toggle(miner)}
>
{mine ? '★ Yours' : '☆ Mark as yours'}
</button>
</div>
{error && <p className="empty">{error}</p>}
{error && <p className="empty">{error}</p>}
{!error && !detail && <p className="empty">Reading the record…</p>}
{!error && !detail && <p className="empty">Reading the record…</p>}
{detail && (
<>
<div className="stats" style={{ margin: 0, border: 0, borderBottom: 'var(--rule)' }}>
<div className="stat">
<div className="stat-label">Rank</div>
<div className="stat-value">{row ? `#${row.rank}` : '—'}</div>
<div className="stat-note">
{row ? `${share(row.share)} of the window` : 'no blocks in this window'}
{detail && (
<>
<div className="stats" style={{ margin: 0, border: 0, borderBottom: 'var(--rule)' }}>
<div className="stat">
<div className="stat-label">Rank</div>
<div className="stat-value">{row ? `#${row.rank}` : '—'}</div>
<div className="stat-note">
{row ? `${share(row.share)} of the window` : 'no blocks in this window'}
</div>
</div>
</div>
<div className="stat">
<div className="stat-label">Est. hashrate</div>
<div
className={
tooFewBlocks(row?.hashrate_relative_error) ? 'stat-value rate-weak' : 'stat-value'
}
>
{hashrate(row?.hashrate_estimate)}
</div>
{/* The note carries the error bar, because on this page it is
<div className="stat">
<div className="stat-label">Est. hashrate</div>
<div
className={
tooFewBlocks(row?.hashrate_relative_error)
? 'stat-value rate-weak'
: 'stat-value'
}
>
{hashrate(row?.hashrate_estimate)}
</div>
{/* The note carries the error bar, because on this page it is
the headline number and a bare figure off two blocks is a
coin toss with three significant figures. */}
<div className="stat-note">
{uncertainty(row?.hashrate_relative_error)
? `${uncertainty(row?.hashrate_relative_error)} over ${row?.blocks ?? 0} block${row?.blocks === 1 ? '' : 's'}`
: 'from work its blocks cost'}
<div className="stat-note">
{uncertainty(row?.hashrate_relative_error)
? `${uncertainty(row?.hashrate_relative_error)} over ${row?.blocks ?? 0} block${row?.blocks === 1 ? '' : 's'}`
: 'from work its blocks cost'}
</div>
</div>
</div>
<div className="stat">
<div className="stat-label">Blocks observed</div>
<div className="stat-value">{height(detail.blocks_observed)}</div>
<div className="stat-note">
{detail.first_seen ? `first seen ${ago(detail.first_seen)}` : 'never seen'}
<div className="stat">
<div className="stat-label">Blocks observed</div>
<div className="stat-value">{height(detail.blocks_observed)}</div>
<div className="stat-note">
{detail.first_seen ? `first seen ${ago(detail.first_seen)}` : 'never seen'}
</div>
</div>
</div>
<div className="stat">
<div className="stat-label">Cumulative work</div>
<div className="stat-value" title={detail.cumulative_work}>
{bigNumber(detail.cumulative_work)}
</div>
{/* Difficulty summed over every block won. It does not shrink when
<div className="stat">
<div className="stat-label">Cumulative work</div>
<div className="stat-value" title={detail.cumulative_work}>
{bigNumber(detail.cumulative_work)}
</div>
{/* Difficulty summed over every block won. It does not shrink when
difficulty rises, which a block count effectively does. */}
<div className="stat-note">expected hashes, all blocks</div>
</div>
<div className="stat">
<div className="stat-label">Best streak</div>
<div className="stat-value">
{row && row.best_streak > 1 ? `×${row.best_streak}` : '—'}
<div className="stat-note">expected hashes, all blocks</div>
</div>
<div className="stat">
<div className="stat-label">Best streak</div>
<div className="stat-value">
{row && row.best_streak > 1 ? `×${row.best_streak}` : '—'}
</div>
<div className="stat-note">consecutive blocks</div>
</div>
<div className="stat-note">consecutive blocks</div>
</div>
</div>
<div style={{ padding: '16px 18px 18px' }}>
<div className="eyebrow" style={{ marginBottom: 10 }}>
Share of network blocks
<div style={{ padding: '16px 18px 18px' }}>
<div className="eyebrow" style={{ marginBottom: 10 }}>
Share of network blocks
</div>
<ShareChart points={detail.series} />
</div>
<ShareChart points={detail.series} />
</div>
</>
</>
)}
</section>
{/* A sibling of the miner panel, not a child: the node is a different
subject with a different provenance, and nesting it put a panel border
inside a panel border. */}
{detail?.node && (
<NodePanel
node={detail.node}
confidence={row?.confidence ?? null}
votes={row?.attribution_votes ?? null}
attribution={row?.attribution ?? null}
/>
)}
</section>
</>
)
}
+131
View File
@@ -0,0 +1,131 @@
/**
* The node a miner's blocks were reported from.
*
* **Two layers of uncertainty, and the panel has to wear both.** The chain says
* who was paid and nothing about what won the block; everything here is a node's
* report about itself, reached through the same first-reporter voting that gave
* the miner its display name. So the confidence and the vote count sit at the
* top of the panel rather than in a footnote, and the heading says "reported
* by", not "mined on".
*
* The hardware is the sharpest case. A CPU model beside a hashrate reads as the
* thing doing the hashing, and on this network it is usually not: the miners
* measured all report `miner_cpu_hash_rate` of zero, so the work is on GPUs that
* telemetry does not mention at all. The note says so.
*
* Location is a country and never finer — see `blackbeard_core::geo`.
*/
import type { NodeInfo } from '../api/generated/NodeInfo'
import type { AttributionSource } from '../api/generated/AttributionSource'
import { ago, bytes, since } from '../lib/format'
/** A two-letter country code as its flag, by offsetting into the regional
* indicator block. Returns null for anything that is not two letters, so a
* malformed code renders as nothing rather than as tofu. */
function flag(code: string | null): string | null {
if (!code || !/^[A-Za-z]{2}$/.test(code)) return null
const base = 0x1f1e6 - 'A'.charCodeAt(0)
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => base + c.charCodeAt(0)))
}
function Field({
label,
value,
note,
}: {
label: string
value: string
note?: string | undefined
}) {
return (
<div className="node-field">
<div className="eyebrow">{label}</div>
<div className="node-value" title={note}>
{value}
</div>
</div>
)
}
export function NodePanel({
node,
confidence,
votes,
attribution,
}: {
node: NodeInfo
confidence: number | null
votes: number | null
attribution: AttributionSource | null
}) {
const os = [node.target_os, node.target_arch, node.target_env].filter(Boolean).join(' · ')
const country = node.country_code
? `${flag(node.country_code) ?? ''} ${node.country_code}`.trim()
: null
// A percentage on its own cannot tell one vote of one from nine of nine, and
// those are not the same claim — so both travel together, as they do on the
// standings.
const strength =
confidence !== null && votes !== null
? `${Math.round(confidence * 100)}% of ${votes} vote${votes === 1 ? '' : 's'}`
: null
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Reported by</div>
<h2 className="panel-title" style={{ marginTop: 4, textTransform: 'none' }}>
{node.name}
</h2>
</div>
{strength && (
<span
className="eyebrow"
title="How much of the telemetry voting agrees that this miner's blocks come from this node."
>
attributed on {strength}
</span>
)}
</div>
<div className="node-grid">
{node.version && <Field label="Version" value={node.version} />}
{node.implementation && <Field label="Client" value={node.implementation} />}
{os && <Field label="Target" value={os} />}
{country && <Field label="Country" value={country} note={node.country_name ?? undefined} />}
{node.cpu && <Field label="CPU" value={node.cpu} note={node.cpu} />}
{node.core_count !== null && <Field label="Cores" value={String(node.core_count)} />}
{node.memory !== null && <Field label="Memory" value={bytes(node.memory)} />}
{node.linux_distro && <Field label="Distribution" value={node.linux_distro} />}
{node.linux_kernel && <Field label="Kernel" value={node.linux_kernel} />}
{node.is_virtual_machine !== null && (
<Field label="Machine" value={node.is_virtual_machine ? 'virtual' : 'bare metal'} />
)}
{node.startup_time && (
<Field
label="Node uptime"
value={since(node.startup_time)}
note={`Started ${new Date(node.startup_time).toISOString()}`}
/>
)}
</div>
<p className="panel-note">
Everything here is what the node says about itself on the telemetry feed, and the link
between it and this miner is <em>inferred</em> from which node reported its blocks first —
never a claim the operator made, and never an identity check.{' '}
{attribution === 'telemetry'
? 'One operator may run several nodes under one reward preimage, and one node may mine for several, so this is the node the attribution settled on rather than the miner’s only machine.'
: ''}{' '}
The CPU is the node’s, not necessarily what is hashing: on this network most work is on
GPUs, which telemetry does not report. Location is resolved to a country and no finer,
because the feed’s coordinates come from IP geolocation and are unreliable at city
resolution.
{node.seen_at && <> Last heard {ago(node.seen_at)}.</>}
</p>
</section>
)
}