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
+7 -1
View File
@@ -4,6 +4,7 @@ import type { ChainId } from "./ChainId";
import type { LeaderboardRow } from "./LeaderboardRow";
import type { MinerId } from "./MinerId";
import type { MinerSeriesPoint } from "./MinerSeriesPoint";
import type { NodeInfo } from "./NodeInfo";
/**
* Everything the miner detail page shows for one preimage.
@@ -50,4 +51,9 @@ series: Array<MinerSeriesPoint>,
* cumulative measure of work done, and the one that does not shrink when
* difficulty rises.
*/
cumulative_work: BigUintDec, };
cumulative_work: BigUintDec,
/**
* What telemetry says about the node this miner's blocks were reported
* from, when one has been identified.
*/
node: NodeInfo | null, };
+95
View File
@@ -0,0 +1,95 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* What the telemetry feed says about a node.
*
* **Every field here is reported by the node about itself, and the join from
* this miner to that node is an inference.** The chain says who was paid; it
* says nothing about what hardware won the block. This is telemetry, which a
* node volunteers and could state wrongly, reached through the same
* first-reporter voting that produces the miner's display name — so the
* `confidence` and `attribution` already on the row govern how much weight any
* of it carries. The UI must show that alongside, not beneath.
*
* One preimage may be mined from several nodes and one node may mine for
* several preimages, so this is the node the attribution settled on rather
* than "the miner's machine".
*/
export type NodeInfo = {
/**
* The node's self-reported name.
*/
name: string,
/**
* The libp2p peer id this was matched on. The one durable identifier in
* here, and what the cache is keyed by.
*/
peer_id: string,
/**
* `"Quantus Node"`.
*/
implementation: string | null,
/**
* The client version with its commit.
*/
version: string | null,
/**
* `"linux"`, `"windows"`.
*/
target_os: string | null,
/**
* `"x86_64"`.
*/
target_arch: string | null,
/**
* `"gnu"`, `"musl"`.
*/
target_env: string | null,
/**
* The CPU model the node reports. Not a claim about what is hashing: on
* this network most work is on GPU, which telemetry does not report at all.
*/
cpu: string | null,
/**
* Physical cores.
*/
core_count: number,
/**
* Bytes of RAM.
*/
memory: number,
/**
* Kernel release.
*/
linux_kernel: string | null,
/**
* Distribution and version.
*/
linux_distro: string | null,
/**
* Whether the node believes it is virtualised.
*/
is_virtual_machine: boolean | null,
/**
* ISO 3166-1 alpha-2.
*
* **Country, and deliberately no finer.** The feed sends coordinates and a
* city from an IP geolocation database; at city resolution those are wrong
* often enough that showing one would assert something nobody knows. The
* coordinates are not stored and the city is never sent here.
*/
country_code: string | null,
/**
* The ISO name for `country_code`. Long for some countries.
*/
country_name: string | null,
/**
* When the node process started, per the feed. The UI renders uptime from
* this, which makes it a fact about the node rather than about how long
* this observer has been watching.
*/
startup_time: string | null,
/**
* When the feed last told us any of this.
*/
seen_at: string | null, };
+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>
)
}
+27
View File
@@ -273,6 +273,33 @@ a {
flex-wrap: wrap;
}
/* ---- node panel ---------------------------------------------------------- */
.node-grid {
display: grid;
/* Two columns on a phone, as many as fit on a desktop. A CPU model is the
longest value here by far and gets the room it needs rather than wrapping
mid-word. */
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1px;
background: var(--border);
border-top: var(--rule);
}
.node-field {
background: var(--surface-1);
padding: 11px 16px 13px;
}
.node-value {
font-family: var(--font-mono);
font-size: 13px;
margin-top: 5px;
/* A CPU model can be sixty characters. Wrapping is better than a scrollbar
in a cell this small. */
overflow-wrap: anywhere;
}
/* ---- theme toggle -------------------------------------------------------- */
.theme-toggle {
+40
View File
@@ -68,7 +68,47 @@ export function ago(iso: string, now: number = Date.now()): string {
return `${Math.floor(delta / 86400)}d ago`
}
/**
* How long something has been running, as a duration rather than a point.
*
* `ago` renders "4d ago", which is right for an event and wrong for an uptime:
* a node is not up four days ago, it has been up for four days. Same arithmetic,
* different sentence.
*/
export function since(iso: string, now: number = Date.now()): string {
const delta = Math.max(0, (now - new Date(iso).getTime()) / 1000)
if (delta < 60) return `${Math.floor(delta)}s`
if (delta < 3600) return `${Math.floor(delta / 60)}m`
if (delta < 86400) return `${Math.floor(delta / 3600)}h`
const days = Math.floor(delta / 86400)
const hours = Math.floor((delta % 86400) / 3600)
// A node up eight days and a node up eight days and twenty hours are
// different claims about stability, and "8d" flattens them.
return hours > 0 ? `${days}d ${hours}h` : `${days}d`
}
/** Block heights with thousands separators. */
/**
* Bytes, as a machine's spec sheet would print them.
*
* Binary units, because that is what a node reports and what its owner bought:
* 65,192,095,744 bytes is a 64 GiB machine, and rendering it as "65.2 GB" would
* have the reader checking whether they have the right box.
*/
export function bytes(value: number | null | undefined): string {
if (value === null || value === undefined) return '—'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let n = value
let unit = 0
while (n >= 1024 && unit < units.length - 1) {
n /= 1024
unit += 1
}
// Whole numbers above a gibibyte: nobody cares that a machine has 63.7 GiB
// rather than 64, and the fraction invites a precision the report lacks.
return `${n >= 100 || unit >= 3 ? Math.round(n) : n.toFixed(1)} ${units[unit]}`
}
export function height(value: number | null | undefined): string {
return value === null || value === undefined ? '—' : value.toLocaleString('en-US')
}