Fix BTC-vs-source-IP confusion; redesign user page; OSS + TLS icons

ckpool's stratum_instance.address (exposed as the "address" field in
the runtime JSON) is the SOURCE IP of the connection — set from
inet_ntop in connector.c — not the BTC payout address. The miner's
BTC payout comes from the stratum username, which ckpool stores in
worker.user (and in the dotted prefix of worker.workername).

Every "user" reference in the dashboard was reading c.address and
treating it as the BTC. Effects:

  • clicking an online miner navigated to #/user/<source-ip>; the
    UserDetailPage filters never matched and the page rendered with
    junk values (or the user struct's stale residual hashrate when
    the BTC happened to come from a sibling row).
  • offline-miner clicks worked, but totals came from user.dsps*
    which decay slowly inside ckpool, so a miner that had just
    disconnected still showed positive hashrate for several minutes.
  • the "Best (ever)" tile fell back to bestdiff (session) when
    bestever was zero, so it lied about its semantics.

Fixes:

  • Add btcAddressOf() helper and use w.user (or it) when extracting
    the BTC for the user-link button. The source IP gets its own
    sub-line under the worker name, clearly labelled.
  • Redesign UserDetailPage: filter clients by workername prefix
    against the BTC, never by c.address; compute hashrate totals by
    SUMMING the user's currently-connected clients (so 0 online
    clients => 0 hashrate, no stale decay artifacts); compute
    best_ever as max across the user's worker.bestever values; show
    online/total worker counts and a per-worker status pill.
  • Add an explorer link (mempool.space) for the user's BTC.

TLS detection moves to a clean signal: ckpool now binds two stratum
sockets — public plaintext and loopback-only. stunnel forwards to
the loopback bind, so TLS clients arrive with c.server == 1. The
dashboard reads that and renders a green TLS pill next to the
worker name. No source-IP heuristics needed.

Open-source mark: new isOpenSource() heuristic over the stratum
useragent matches Bitaxe family (NerdAxe / NerdQAxe / NerdMiner /
NerdOctaxe / Lucky / QAxe / MCCM), Braiins OS, cgminer / bfgminer /
ckminer, and ESP32 builds. Renders as an orange ★ next to the
hardware label, matching public-pool's convention.

types.ts: document StratumClient.address (source IP, not BTC) and
add the previously-undeclared `server` field. Surfacing the runtime
value that has been there all along since cfb0f83.
This commit is contained in:
satoshi
2026-04-26 18:41:05 +03:00
parent ea4c514d78
commit 2f4ed4aa88
5 changed files with 312 additions and 95 deletions
+200 -65
View File
@@ -6,69 +6,90 @@
formatDifficulty,
formatAgo,
detectHardware,
isOpenSource,
} from "../format";
import type { User, Worker, StratumClient } from "../types";
import type { Worker, StratumClient } from "../types";
const address = $derived(selection.user);
const user = $derived.by<User | null>(() => {
if (!address) return null;
return (snap.data?.users ?? []).find((u) => u.user === address) ?? null;
// Workers belonging to this user. ckpool keys workers by full
// workername (e.g. <btc>.label) and tracks user = the BTC string.
const myWorkers = $derived.by<Worker[]>(() => {
if (!address) return [];
return (snap.data?.workers ?? []).filter((w) => w.user === address);
});
const workerRows = $derived.by(() => {
// Live stratum clients for those workers. ckpool's client.address
// is the SOURCE IP, never the BTC, so we cannot filter on it. The
// join key is workername — match the BTC bare or any "btc.label".
const myClients = $derived.by<StratumClient[]>(() => {
if (!address) return [];
const workers = (snap.data?.workers ?? []).filter((w) => w.user === address);
const clients = (snap.data?.clients ?? []).filter(
(c: StratumClient) => c.address === address,
const prefix = address + ".";
return (snap.data?.clients ?? []).filter(
(c) => c.workername === address || c.workername.startsWith(prefix),
);
});
// Per-worker rows joining client + worker. Worker's idle flag is
// separate from a client being absent: a worker is "online" only
// when a stratum_instance currently exists for it.
const workerRows = $derived.by(() => {
const clientByWorker = new Map<string, StratumClient>();
for (const c of clients) {
const wname = c.workername || `${c.address}.unnamed`;
for (const c of myClients) {
const wname = c.workername || `${address}.unnamed`;
clientByWorker.set(wname, c);
}
const seen = new Set<string>();
const rows: Array<{
type Row = {
worker: string;
hardware: string;
openSource: boolean;
tls: boolean;
sourceIp: string;
hashrate1m: number;
hashrate1h: number;
diff: number;
bestRound: number;
bestSession: number;
bestEver: number;
lastShare: number;
online: boolean;
idle: boolean;
}> = [];
for (const w of workers as Worker[]) {
};
const rows: Row[] = [];
for (const w of myWorkers) {
const c = clientByWorker.get(w.worker);
seen.add(w.worker);
rows.push({
worker: w.worker,
hardware: c ? detectHardware(c.useragent) : "offline",
hashrate1m: (c?.dsps1 ?? w.dsps1) * 2 ** 32,
hashrate1h: (c?.dsps60 ?? w.dsps60) * 2 ** 32,
openSource: c ? isOpenSource(c.useragent) : false,
tls: c?.server === 1,
sourceIp: c?.address ?? "",
hashrate1m: c ? c.dsps1 * 2 ** 32 : 0,
hashrate1h: c ? c.dsps60 * 2 ** 32 : 0,
diff: c?.diff ?? w.mindiff,
// Session best = ckpool's stratum_instance.best_diff for the
// currently-connected client only. Resets on miner disconnect
// and pool restart. 0 for offline workers (no current session).
bestRound: c?.bestdiff ?? 0,
bestSession: c?.bestdiff ?? 0,
bestEver: w.bestever,
lastShare: c?.lastshare ?? w.lastshare,
online: !!c,
idle: !!(c?.idle ?? w.idle),
});
}
for (const c of clients) {
const wname = c.workername || `${c.address}.unnamed`;
// A live client without a worker_instance entry yet (rare —
// can happen briefly right after a new miner connects).
for (const c of myClients) {
const wname = c.workername || `${address}.unnamed`;
if (seen.has(wname)) continue;
rows.push({
worker: wname,
hardware: detectHardware(c.useragent),
openSource: isOpenSource(c.useragent),
tls: c.server === 1,
sourceIp: c.address,
hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32,
diff: c.diff,
bestRound: c.bestdiff,
bestSession: c.bestdiff,
bestEver: 0,
lastShare: c.lastshare,
online: true,
@@ -79,36 +100,46 @@
return rows;
});
// Per-user totals: hashrate is summed across LIVE CLIENTS only,
// not derived from user.dsps* (which decay slowly and would show
// residual hashrate for a freshly-disconnected worker). Best ever
// is the lifetime persistent counter from the user's workers.
const totals = $derived.by(() => {
if (user) {
return {
hs1m: user.dsps1 * 2 ** 32,
hs5m: user.dsps5 * 2 ** 32,
hs1h: user.dsps60 * 2 ** 32,
hs24h: user.dsps1440 * 2 ** 32,
bestDiff: user.bestdiff,
bestEver: user.bestever,
workers: user.workers,
lastShare: user.lastshare,
};
}
let hs1m = 0, hs1h = 0;
let bestDiff = 0, bestEver = 0, lastShare = 0;
for (const r of workerRows) {
hs1m += r.hashrate1m;
hs1h += r.hashrate1h;
if (r.bestRound > bestDiff) bestDiff = r.bestRound;
if (r.bestEver > bestEver) bestEver = r.bestEver;
if (r.lastShare > lastShare) lastShare = r.lastShare;
}
const onlineCount = myClients.length;
const hs1m = myClients.reduce((s, c) => s + c.dsps1 * 2 ** 32, 0);
const hs5m = myClients.reduce((s, c) => s + c.dsps5 * 2 ** 32, 0);
const hs1h = myClients.reduce((s, c) => s + c.dsps60 * 2 ** 32, 0);
const hs24h = myClients.reduce((s, c) => s + c.dsps1440 * 2 ** 32, 0);
const bestEver = myWorkers.reduce(
(m, w) => Math.max(m, w.bestever || 0),
0,
);
const bestSession = myClients.reduce(
(m, c) => Math.max(m, c.bestdiff || 0),
0,
);
const lastShare = Math.max(
0,
...myWorkers.map((w) => w.lastshare),
...myClients.map((c) => c.lastshare),
);
return {
hs1m, hs5m: 0, hs1h, hs24h: 0,
bestDiff, bestEver,
workers: workerRows.length,
hs1m, hs5m, hs1h, hs24h,
bestEver,
bestSession,
onlineWorkers: onlineCount,
totalWorkers: myWorkers.length,
lastShare,
};
});
const explorerBase = $derived.by(() => {
const chain = snap.data?.chain?.chain ?? "main";
if (chain === "test" || chain === "testnet4") return "https://mempool.space/testnet4";
if (chain === "signet") return "https://mempool.space/signet";
return "https://mempool.space";
});
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
@@ -126,32 +157,46 @@
<header class="head">
<div class="stat-label">User</div>
<h2 class="addr mono">{address}</h2>
{#if address}
<a
class="explorer-link"
href="{explorerBase}/address/{address}"
target="_blank"
rel="noopener noreferrer"
>View on mempool.space &rarr;</a>
{/if}
</header>
<section class="totals grid-4">
<section class="totals">
<div class="card">
<div class="stat-label">Hashrate</div>
<div class="stat-value">{formatHashrate(totals.hs1m)}</div>
<div class="stat-sub">
{#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if}
1h {formatHashrate(totals.hs1h)}
{#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if}
5m {formatHashrate(totals.hs5m)} ·
1h {formatHashrate(totals.hs1h)} ·
24h {formatHashrate(totals.hs24h)}
</div>
</div>
<div class="card">
<div class="stat-label">Best share (ever)</div>
<div class="stat-value">{formatDifficulty(totals.bestEver || totals.bestDiff)}</div>
<div class="stat-sub">across all this user's workers</div>
</div>
<div class="card">
<div class="stat-label">Workers</div>
<div class="stat-value">{totals.workers}</div>
<div class="stat-sub">{workerRows.filter(r => r.online).length} online</div>
<div class="stat-value">{totals.onlineWorkers} / {totals.totalWorkers}</div>
<div class="stat-sub">online / total seen</div>
</div>
<div class="card">
<div class="stat-label">Last share</div>
<div class="stat-value">{formatAgo(totals.lastShare)}</div>
<div class="stat-sub">across all this user's workers</div>
<div class="stat-label">Best (session)</div>
<div class="stat-value">{formatDifficulty(totals.bestSession)}</div>
<div class="stat-sub">resets on disconnect or solve</div>
</div>
<div class="card">
<div class="stat-label">Best (ever)</div>
<div class="stat-value">{formatDifficulty(totals.bestEver)}</div>
<div class="stat-sub">
{#if totals.lastShare}
last share {formatAgo(totals.lastShare)}
{:else}
no shares yet
{/if}
</div>
</div>
</section>
@@ -166,6 +211,7 @@
<tr>
<th>Worker</th>
<th>Hardware</th>
<th>Status</th>
<th class="num">Hashrate (1m)</th>
<th class="num">Hashrate (1h)</th>
<th class="num">Diff</th>
@@ -177,12 +223,36 @@
<tbody>
{#each workerRows as r (r.worker)}
<tr class:offline={!r.online} class:idle={r.idle}>
<td class="mono">{r.worker}</td>
<td>{r.hardware}</td>
<td>
<div class="wname mono">
{r.worker}
{#if r.tls}
<span class="badge tls" title="Connected via TLS">TLS</span>
{/if}
</div>
{#if r.sourceIp}
<div class="src-ip mono">{r.sourceIp}</div>
{/if}
</td>
<td>
{r.hardware}
{#if r.openSource}
<span class="oss" title="Open source hardware or firmware"></span>
{/if}
</td>
<td>
{#if !r.online}
<span class="status offline">offline</span>
{:else if r.idle}
<span class="status idle">idle</span>
{:else}
<span class="status online">online</span>
{/if}
</td>
<td class="num">{formatHashrate(r.hashrate1m)}</td>
<td class="num">{formatHashrate(r.hashrate1h)}</td>
<td class="num">{formatDifficulty(r.diff)}</td>
<td class="num">{formatDifficulty(r.bestRound)}</td>
<td class="num">{formatDifficulty(r.bestSession)}</td>
<td class="num">{formatDifficulty(r.bestEver)}</td>
<td class="num">{formatAgo(r.lastShare)}</td>
</tr>
@@ -230,6 +300,19 @@
word-break: break-all;
line-height: 1.25;
}
.explorer-link {
color: var(--accent);
font-size: 0.9em;
text-decoration: none;
border-bottom: 1px dashed var(--accent-dim);
align-self: flex-start;
padding-bottom: 1px;
}
.explorer-link:hover {
color: #ff9a5f;
border-bottom-color: var(--accent);
text-decoration: none;
}
.totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -257,8 +340,60 @@
.table-wrap {
overflow-x: auto;
}
.wname {
font-size: 0.95em;
display: flex;
align-items: center;
gap: 0.4em;
flex-wrap: wrap;
}
.src-ip {
color: var(--fg-dim);
font-size: 0.75em;
margin-top: 0.2em;
}
.badge.tls {
display: inline-block;
font-size: 0.65em;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--good);
border: 1px solid #2f5c48;
background: #0f2419;
padding: 0.05em 0.45em;
border-radius: 999px;
vertical-align: 1px;
}
.oss {
color: var(--accent);
font-size: 0.95em;
margin-left: 0.3em;
cursor: help;
}
.status {
font-size: 0.78em;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1em 0.55em;
border-radius: 999px;
border: 1px solid var(--border);
}
.status.online {
color: var(--good);
border-color: #2f5c48;
background: #0f2419;
}
.status.idle {
color: var(--warn);
border-color: #5c502f;
background: #241f10;
}
.status.offline {
color: var(--fg-dim);
background: var(--bg-alt);
}
tr.offline {
opacity: 0.5;
opacity: 0.55;
}
tr.idle td {
color: var(--fg-dim);