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
229 lines
9.5 KiB
TypeScript
229 lines
9.5 KiB
TypeScript
/**
|
||
* Formatting.
|
||
*
|
||
* All of it in one place because most of these numbers are wrong-looking if
|
||
* formatted naively — a hashrate in bare hashes per second is fourteen digits,
|
||
* a difficulty is thirty, and a share of 0.0004 rounds to "0%".
|
||
*/
|
||
|
||
/** SI-ish hashrate. Miners talk in MH/s and GH/s; nobody says "6.9e9 H/s". */
|
||
export function hashrate(hs: number | null | undefined): string {
|
||
if (hs === null || hs === undefined || !Number.isFinite(hs)) return '—'
|
||
const units = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s']
|
||
let value = hs
|
||
let unit = 0
|
||
while (value >= 1000 && unit < units.length - 1) {
|
||
value /= 1000
|
||
unit += 1
|
||
}
|
||
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2
|
||
return `${value.toFixed(digits)} ${units[unit]}`
|
||
}
|
||
|
||
/**
|
||
* A very large integer, given as a decimal string, in compact form.
|
||
*
|
||
* Difficulty arrives as a string precisely so it is not rounded in transit
|
||
* (see `BigUintDec` in the Rust entities crate); it is rounded here, once, for
|
||
* display, and the full value goes in the element's `title`.
|
||
*/
|
||
export function bigNumber(decimal: string | null | undefined): string {
|
||
if (!decimal) return '—'
|
||
const digits = decimal.replace(/^0+(?=\d)/, '')
|
||
const suffixes = ['', 'K', 'M', 'B', 'T', 'Q']
|
||
const magnitude = Math.floor((digits.length - 1) / 3)
|
||
if (magnitude === 0) return digits
|
||
if (magnitude >= suffixes.length) {
|
||
// Past quadrillions, an exponent is more honest than inventing a suffix.
|
||
return `${digits[0]}.${digits.slice(1, 3)}e${digits.length - 1}`
|
||
}
|
||
const whole = digits.slice(0, digits.length - magnitude * 3)
|
||
const fraction = digits.slice(whole.length, whole.length + 2)
|
||
return `${whole}.${fraction}${suffixes[magnitude]}`
|
||
}
|
||
|
||
/** A 0–1 share as a percentage. Small shares keep a decimal so they aren't 0%. */
|
||
export function share(fraction: number): string {
|
||
const pct = fraction * 100
|
||
if (pct === 0) return '0%'
|
||
if (pct < 0.1) return '<0.1%'
|
||
return `${pct.toFixed(pct < 10 ? 1 : 0)}%`
|
||
}
|
||
|
||
/** Seconds, phrased the way a block interval reads. */
|
||
export function seconds(value: number | null | undefined): string {
|
||
if (value === null || value === undefined || !Number.isFinite(value)) return '—'
|
||
if (value < 10) return `${value.toFixed(1)}s`
|
||
if (value < 120) return `${Math.round(value)}s`
|
||
return `${(value / 60).toFixed(1)}m`
|
||
}
|
||
|
||
/** How long ago, in the shortest form that is still unambiguous. */
|
||
export function ago(iso: string, now: number = Date.now()): string {
|
||
const delta = Math.max(0, (now - new Date(iso).getTime()) / 1000)
|
||
if (delta < 5) return 'just now'
|
||
if (delta < 60) return `${Math.floor(delta)}s ago`
|
||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`
|
||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`
|
||
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')
|
||
}
|
||
|
||
/** The abbreviated form of a reward preimage, matching the backend's. */
|
||
export function shortMiner(miner: string): string {
|
||
return miner.length > 14 ? `${miner.slice(0, 10)}…${miner.slice(-4)}` : miner
|
||
}
|
||
|
||
/**
|
||
* Approximately how long a window of `blocks` covers, at a measured or target
|
||
* interval.
|
||
*
|
||
* The window is a block count, not a duration — deliberately, because a
|
||
* duration is meaningless while a node is catching up. This is the human gloss
|
||
* on it, and it says "about" for a reason.
|
||
*/
|
||
export function windowSpan(blocks: number, intervalSeconds: number): string {
|
||
const total = blocks * intervalSeconds
|
||
if (total < 5400) return `~${Math.round(total / 60)} min`
|
||
if (total < 172800) return `~${Math.round(total / 3600)} h`
|
||
return `~${Math.round(total / 86400)} d`
|
||
}
|
||
|
||
/**
|
||
* How far back a window actually reached, from the chain's own clock.
|
||
*
|
||
* No `~`, because nothing is being estimated: this is the authored-time span of
|
||
* exactly the blocks that were tallied. `windowSpan` above multiplies a block
|
||
* count by the *current* interval, which describes the rate now rather than the
|
||
* period covered — on Planck those differed by a factor of five even before its
|
||
* measured interval was itself wrong.
|
||
*
|
||
* Falls back to the estimate when the window holds too few timestamped blocks
|
||
* to span anything, which is only true in the first moments after a start.
|
||
*/
|
||
export function measuredSpan(
|
||
seconds: number | null,
|
||
blocks: number,
|
||
intervalSeconds: number,
|
||
): string {
|
||
if (seconds === null || seconds <= 0) return windowSpan(blocks, intervalSeconds)
|
||
if (seconds < 5400) return `${Math.round(seconds / 60)} min`
|
||
if (seconds < 172800) return `${Math.round(seconds / 3600)} h`
|
||
return `${Math.round(seconds / 86400)} d`
|
||
}
|
||
|
||
/**
|
||
* A balance in the chain's smallest unit, as a readable token amount.
|
||
*
|
||
* The value arrives as a decimal string because it is `u128` and a JSON number
|
||
* would round it, so the division is done on the digits rather than by parsing:
|
||
* `Number(…) / 1e12` is exact for the first fifteen digits and quietly wrong
|
||
* after that, which is a range this chain's cumulative rewards reach within
|
||
* weeks.
|
||
*
|
||
* Four decimal places, not twelve: a reward is 0.31 QUAN and the eight zeroes
|
||
* after it are noise. The `title` on the element carries the exact figure.
|
||
*/
|
||
export function tokens(raw: string | null | undefined, decimals: number, places = 4): string {
|
||
if (!raw) return '—'
|
||
const digits = raw.replace(/^0+(?=\d)/, '')
|
||
const padded = digits.padStart(decimals + 1, '0')
|
||
const whole = padded.slice(0, padded.length - decimals)
|
||
const fraction = padded.slice(padded.length - decimals)
|
||
const shown = fraction.slice(0, places).replace(/0+$/, '')
|
||
return `${BigInt(whole).toLocaleString('en-US')}${shown ? `.${shown}` : ''}`
|
||
}
|
||
|
||
/** The exact amount, every decimal place, for a `title` attribute. */
|
||
export function exactTokens(raw: string, decimals: number): string {
|
||
const padded = raw.replace(/^0+(?=\d)/, '').padStart(decimals + 1, '0')
|
||
const whole = padded.slice(0, padded.length - decimals)
|
||
return `${BigInt(whole).toString()}.${padded.slice(padded.length - decimals)}`
|
||
}
|
||
|
||
/** An SS58 address short enough for a table cell, keeping both ends. */
|
||
export function shortAddress(address: string): string {
|
||
return address.length > 14 ? `${address.slice(0, 8)}…${address.slice(-4)}` : address
|
||
}
|
||
|
||
/** An absolute timestamp, for a history where "3 days ago" is not enough. */
|
||
export function when(iso: string | null | undefined): string {
|
||
if (!iso) return '—'
|
||
return new Date(iso).toLocaleString('en-GB', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
/**
|
||
* How much to trust a hashrate, from the sample it was estimated over.
|
||
*
|
||
* Block finding is Poisson, so `k` blocks carries a relative standard error of
|
||
* `1/√k`: one block is ±100%, four is ±50%, twenty-five is ±20%. The API sends
|
||
* that fraction beside every estimate.
|
||
*
|
||
* Rendered as a plain ± because the alternative — three significant figures and
|
||
* silence — is a claim the data does not support. A miner measuring 1.5 GH/s at
|
||
* its own hardware was shown as "7.64 GH/s" on the strength of two blocks, and
|
||
* nothing on the row said that number was a coin toss.
|
||
*/
|
||
export function uncertainty(relativeError: number | null | undefined): string | null {
|
||
if (relativeError === null || relativeError === undefined) return null
|
||
if (relativeError < 0.2) return null // ±20% or better: the figure stands on its own
|
||
return `± ${Math.round(relativeError * 100)}%`
|
||
}
|
||
|
||
/** Whether an estimate rests on too few blocks to print as a number. */
|
||
export function tooFewBlocks(relativeError: number | null | undefined): boolean {
|
||
// 1/√4 = 0.5. Below four blocks the error bar is wider than the value is
|
||
// useful, and a range says more than a midpoint does.
|
||
return relativeError !== null && relativeError !== undefined && relativeError > 0.5
|
||
}
|