feat: show the node a miner's blocks were reported from
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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user