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
+2 -1
View File
@@ -12,7 +12,8 @@
"blockpoll": ${BLOCKPOLL_MS}, "blockpoll": ${BLOCKPOLL_MS},
"update_interval": ${UPDATE_INTERVAL_S}, "update_interval": ${UPDATE_INTERVAL_S},
"serverurl": [ "serverurl": [
"0.0.0.0:${STRATUM_PORT}" "0.0.0.0:${STRATUM_PORT}",
"127.0.0.1:${TLS_INTERNAL_PORT}"
], ],
"mindiff": ${MINDIFF}, "mindiff": ${MINDIFF},
"startdiff": ${STARTDIFF}, "startdiff": ${STARTDIFF},
+19
View File
@@ -63,6 +63,25 @@ export function formatAgo(unixSeconds: number): string {
return `${Math.floor(ageSec / 86400)}d ago`; return `${Math.floor(ageSec / 86400)}d ago`;
} }
// Open-source ASIC hardware (Bitaxe family, NerdMiner, etc.) and
// open-source firmware projects (Braiins OS, cgminer/bfgminer/
// ckminer). Used to render a star next to the hardware label, like
// public-pool does, so visitors can spot DIY / OSS rigs at a glance.
export function isOpenSource(ua: string): boolean {
if (!ua) return false;
const s = ua.toLowerCase();
return /bitaxe|nerdaxe|nerdqaxe|nerdoctaxe|nerdminer|qaxe|mccm|lucky|braiins|cgminer|bfgminer|ckminer|esp32/.test(s);
}
// Extract the BTC payout address from a stratum username. Stratum
// usernames in solo mode are "<btc-address>" or "<btc-address>.label".
// ckpool stores this verbatim in the worker name.
export function btcAddressOf(workername: string): string {
if (!workername) return "";
const dot = workername.indexOf(".");
return dot < 0 ? workername : workername.slice(0, dot);
}
// Detect common Bitcoin mining hardware from the stratum useragent // Detect common Bitcoin mining hardware from the stratum useragent
// string. This is a best-effort heuristic; unknown agents fall back // string. This is a best-effort heuristic; unknown agents fall back
// to a stripped version of the raw string. Order matters — check // to a stripped version of the raw string. Order matters — check
+83 -29
View File
@@ -1,27 +1,43 @@
<script lang="ts"> <script lang="ts">
import { snap } from "../stores/snapshot.svelte"; import { snap } from "../stores/snapshot.svelte";
import { selectUser } from "../stores/selection.svelte"; import { selectUser } from "../stores/selection.svelte";
import { formatHashrate, formatDifficulty, formatAgo, detectHardware } from "../format"; import {
formatHashrate,
formatDifficulty,
formatAgo,
detectHardware,
isOpenSource,
btcAddressOf,
} from "../format";
import type { StratumClient, Worker } from "../types"; import type { StratumClient, Worker } from "../types";
// Join StratumClient (live session data: useragent, IP, diff) with
// Worker (best-share data). Clients without a matching worker still
// render; workers without an active client are dimmed.
type Row = { type Row = {
workerName: string; workerName: string;
user: string; btcAddress: string;
sourceIp: string;
hardware: string; hardware: string;
openSource: boolean;
tls: boolean;
hashrate1m: number; hashrate1m: number;
hashrate1h: number; hashrate1h: number;
bestDiff: number; bestSession: number;
bestEver: number; bestEver: number;
lastShare: number; lastShare: number;
difficulty: number; difficulty: number;
address: string;
idle: boolean; idle: boolean;
online: boolean; online: boolean;
}; };
// The "address" field on a ckpool stratum_instance is the SOURCE IP
// the connection arrived on (see connector.c:311 — it's set with
// inet_ntop). The miner's BTC payout address comes from the worker
// name (which is the stratum username, format <btc>[.label]) or
// from the joined worker_instance, which ckpool keys by full
// workername with user = the BTC.
//
// ckpool's serverurl array gives us a second piece of info: TLS
// traffic comes in via stunnel on the loopback-only bind (server
// index 1), so client.server === 1 means this miner is using TLS.
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const clients = snap.data?.clients ?? []; const clients = snap.data?.clients ?? [];
const workers = snap.data?.workers ?? []; const workers = snap.data?.workers ?? [];
@@ -35,23 +51,20 @@
const wname = c.workername || `${c.address}.unnamed`; const wname = c.workername || `${c.address}.unnamed`;
seen.add(wname); seen.add(wname);
const w = byWorker.get(wname); const w = byWorker.get(wname);
// "Session best" = best diff for *this stratum TCP session*. const btcAddress = w?.user ?? btcAddressOf(wname);
// ckpool zeroes stratum_instance.best_diff when the connection
// ends and on pool restart (in-memory only), so this is the
// value that should reset on miner disconnect / pool restart.
// Don't fall back to worker.best_diff — that's the persistent
// round counter and would defeat the reset semantics.
out.push({ out.push({
workerName: wname, workerName: wname,
user: c.address, btcAddress,
sourceIp: c.address,
hardware: detectHardware(c.useragent), hardware: detectHardware(c.useragent),
openSource: isOpenSource(c.useragent),
tls: c.server === 1,
hashrate1m: c.dsps1 * 2 ** 32, hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32, hashrate1h: c.dsps60 * 2 ** 32,
bestDiff: c.bestdiff, bestSession: c.bestdiff,
bestEver: w?.bestever ?? c.bestdiff, bestEver: w?.bestever ?? 0,
lastShare: c.lastshare, lastShare: c.lastshare,
difficulty: c.diff, difficulty: c.diff,
address: c.address,
idle: c.idle, idle: c.idle,
online: true, online: true,
}); });
@@ -59,19 +72,19 @@
for (const w of workers) { for (const w of workers) {
if (seen.has(w.worker)) continue; if (seen.has(w.worker)) continue;
// Offline worker — no current session, so session-best is 0.
// The lifetime best_ever still applies.
out.push({ out.push({
workerName: w.worker, workerName: w.worker,
user: w.user, btcAddress: w.user,
sourceIp: "",
hardware: "offline", hardware: "offline",
hashrate1m: w.dsps1 * 2 ** 32, openSource: false,
hashrate1h: w.dsps60 * 2 ** 32, tls: false,
bestDiff: 0, hashrate1m: 0,
hashrate1h: 0,
bestSession: 0,
bestEver: w.bestever, bestEver: w.bestever,
lastShare: w.lastshare, lastShare: w.lastshare,
difficulty: w.mindiff, difficulty: w.mindiff,
address: w.user,
idle: w.idle, idle: w.idle,
online: false, online: false,
}); });
@@ -105,19 +118,32 @@
{#each rows as r (r.workerName)} {#each rows as r (r.workerName)}
<tr class:offline={!r.online} class:idle={r.idle}> <tr class:offline={!r.online} class:idle={r.idle}>
<td> <td>
<div class="wname mono">{r.workerName}</div> <div class="wname mono">
{r.workerName}
{#if r.tls}
<span class="badge tls" title="Connected via TLS">TLS</span>
{/if}
</div>
<button <button
type="button" type="button"
class="addr-btn mono" class="addr-btn mono"
onclick={() => selectUser(r.address)} onclick={() => selectUser(r.btcAddress)}
title="View per-user stats" title="View per-user stats"
>{r.address}</button> >{r.btcAddress}</button>
{#if r.sourceIp}
<div class="src-ip mono" title="Source IP">{r.sourceIp}</div>
{/if}
</td>
<td>
{r.hardware}
{#if r.openSource}
<span class="oss" title="Open source hardware or firmware"></span>
{/if}
</td> </td>
<td>{r.hardware}</td>
<td class="num">{formatHashrate(r.hashrate1m)}</td> <td class="num">{formatHashrate(r.hashrate1m)}</td>
<td class="num">{formatHashrate(r.hashrate1h)}</td> <td class="num">{formatHashrate(r.hashrate1h)}</td>
<td class="num">{formatDifficulty(r.difficulty)}</td> <td class="num">{formatDifficulty(r.difficulty)}</td>
<td class="num">{formatDifficulty(r.bestDiff)}</td> <td class="num">{formatDifficulty(r.bestSession)}</td>
<td class="num">{formatDifficulty(r.bestEver)}</td> <td class="num">{formatDifficulty(r.bestEver)}</td>
<td class="num">{formatAgo(r.lastShare)}</td> <td class="num">{formatAgo(r.lastShare)}</td>
</tr> </tr>
@@ -143,6 +169,10 @@
} }
.wname { .wname {
font-size: 0.95em; font-size: 0.95em;
display: flex;
align-items: center;
gap: 0.4em;
flex-wrap: wrap;
} }
.addr-btn { .addr-btn {
display: inline-block; display: inline-block;
@@ -165,6 +195,30 @@
background: transparent; background: transparent;
outline: none; outline: none;
} }
.src-ip {
color: var(--fg-dim);
font-size: 0.72em;
margin-top: 0.2em;
}
.badge.tls {
display: inline-block;
font-size: 0.62em;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--good);
border: 1px solid #2f5c48;
background: #0f2419;
padding: 0.05em 0.45em;
border-radius: 999px;
text-transform: none;
vertical-align: 1px;
}
.oss {
color: var(--accent);
font-size: 0.95em;
margin-left: 0.3em;
cursor: help;
}
tr.offline { tr.offline {
opacity: 0.4; opacity: 0.4;
} }
+200 -65
View File
@@ -6,69 +6,90 @@
formatDifficulty, formatDifficulty,
formatAgo, formatAgo,
detectHardware, detectHardware,
isOpenSource,
} from "../format"; } from "../format";
import type { User, Worker, StratumClient } from "../types"; import type { Worker, StratumClient } from "../types";
const address = $derived(selection.user); const address = $derived(selection.user);
const user = $derived.by<User | null>(() => { // Workers belonging to this user. ckpool keys workers by full
if (!address) return null; // workername (e.g. <btc>.label) and tracks user = the BTC string.
return (snap.data?.users ?? []).find((u) => u.user === address) ?? null; 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 []; if (!address) return [];
const workers = (snap.data?.workers ?? []).filter((w) => w.user === address); const prefix = address + ".";
const clients = (snap.data?.clients ?? []).filter( return (snap.data?.clients ?? []).filter(
(c: StratumClient) => c.address === address, (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>(); const clientByWorker = new Map<string, StratumClient>();
for (const c of clients) { for (const c of myClients) {
const wname = c.workername || `${c.address}.unnamed`; const wname = c.workername || `${address}.unnamed`;
clientByWorker.set(wname, c); clientByWorker.set(wname, c);
} }
const seen = new Set<string>(); const seen = new Set<string>();
const rows: Array<{ type Row = {
worker: string; worker: string;
hardware: string; hardware: string;
openSource: boolean;
tls: boolean;
sourceIp: string;
hashrate1m: number; hashrate1m: number;
hashrate1h: number; hashrate1h: number;
diff: number; diff: number;
bestRound: number; bestSession: number;
bestEver: number; bestEver: number;
lastShare: number; lastShare: number;
online: boolean; online: boolean;
idle: boolean; idle: boolean;
}> = []; };
for (const w of workers as Worker[]) { const rows: Row[] = [];
for (const w of myWorkers) {
const c = clientByWorker.get(w.worker); const c = clientByWorker.get(w.worker);
seen.add(w.worker); seen.add(w.worker);
rows.push({ rows.push({
worker: w.worker, worker: w.worker,
hardware: c ? detectHardware(c.useragent) : "offline", hardware: c ? detectHardware(c.useragent) : "offline",
hashrate1m: (c?.dsps1 ?? w.dsps1) * 2 ** 32, openSource: c ? isOpenSource(c.useragent) : false,
hashrate1h: (c?.dsps60 ?? w.dsps60) * 2 ** 32, 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, diff: c?.diff ?? w.mindiff,
// Session best = ckpool's stratum_instance.best_diff for the bestSession: c?.bestdiff ?? 0,
// currently-connected client only. Resets on miner disconnect
// and pool restart. 0 for offline workers (no current session).
bestRound: c?.bestdiff ?? 0,
bestEver: w.bestever, bestEver: w.bestever,
lastShare: c?.lastshare ?? w.lastshare, lastShare: c?.lastshare ?? w.lastshare,
online: !!c, online: !!c,
idle: !!(c?.idle ?? w.idle), idle: !!(c?.idle ?? w.idle),
}); });
} }
for (const c of clients) { // A live client without a worker_instance entry yet (rare —
const wname = c.workername || `${c.address}.unnamed`; // 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; if (seen.has(wname)) continue;
rows.push({ rows.push({
worker: wname, worker: wname,
hardware: detectHardware(c.useragent), hardware: detectHardware(c.useragent),
openSource: isOpenSource(c.useragent),
tls: c.server === 1,
sourceIp: c.address,
hashrate1m: c.dsps1 * 2 ** 32, hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32, hashrate1h: c.dsps60 * 2 ** 32,
diff: c.diff, diff: c.diff,
bestRound: c.bestdiff, bestSession: c.bestdiff,
bestEver: 0, bestEver: 0,
lastShare: c.lastshare, lastShare: c.lastshare,
online: true, online: true,
@@ -79,36 +100,46 @@
return rows; 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(() => { const totals = $derived.by(() => {
if (user) { const onlineCount = myClients.length;
return { const hs1m = myClients.reduce((s, c) => s + c.dsps1 * 2 ** 32, 0);
hs1m: user.dsps1 * 2 ** 32, const hs5m = myClients.reduce((s, c) => s + c.dsps5 * 2 ** 32, 0);
hs5m: user.dsps5 * 2 ** 32, const hs1h = myClients.reduce((s, c) => s + c.dsps60 * 2 ** 32, 0);
hs1h: user.dsps60 * 2 ** 32, const hs24h = myClients.reduce((s, c) => s + c.dsps1440 * 2 ** 32, 0);
hs24h: user.dsps1440 * 2 ** 32, const bestEver = myWorkers.reduce(
bestDiff: user.bestdiff, (m, w) => Math.max(m, w.bestever || 0),
bestEver: user.bestever, 0,
workers: user.workers, );
lastShare: user.lastshare, const bestSession = myClients.reduce(
}; (m, c) => Math.max(m, c.bestdiff || 0),
} 0,
let hs1m = 0, hs1h = 0; );
let bestDiff = 0, bestEver = 0, lastShare = 0; const lastShare = Math.max(
for (const r of workerRows) { 0,
hs1m += r.hashrate1m; ...myWorkers.map((w) => w.lastshare),
hs1h += r.hashrate1h; ...myClients.map((c) => c.lastshare),
if (r.bestRound > bestDiff) bestDiff = r.bestRound; );
if (r.bestEver > bestEver) bestEver = r.bestEver;
if (r.lastShare > lastShare) lastShare = r.lastShare;
}
return { return {
hs1m, hs5m: 0, hs1h, hs24h: 0, hs1m, hs5m, hs1h, hs24h,
bestDiff, bestEver, bestEver,
workers: workerRows.length, bestSession,
onlineWorkers: onlineCount,
totalWorkers: myWorkers.length,
lastShare, 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 { function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection(); if (ev.key === "Escape") clearSelection();
} }
@@ -126,32 +157,46 @@
<header class="head"> <header class="head">
<div class="stat-label">User</div> <div class="stat-label">User</div>
<h2 class="addr mono">{address}</h2> <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> </header>
<section class="totals grid-4"> <section class="totals">
<div class="card"> <div class="card">
<div class="stat-label">Hashrate</div> <div class="stat-label">Hashrate</div>
<div class="stat-value">{formatHashrate(totals.hs1m)}</div> <div class="stat-value">{formatHashrate(totals.hs1m)}</div>
<div class="stat-sub"> <div class="stat-sub">
{#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if} 5m {formatHashrate(totals.hs5m)} ·
1h {formatHashrate(totals.hs1h)} 1h {formatHashrate(totals.hs1h)} ·
{#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if} 24h {formatHashrate(totals.hs24h)}
</div> </div>
</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="card">
<div class="stat-label">Workers</div> <div class="stat-label">Workers</div>
<div class="stat-value">{totals.workers}</div> <div class="stat-value">{totals.onlineWorkers} / {totals.totalWorkers}</div>
<div class="stat-sub">{workerRows.filter(r => r.online).length} online</div> <div class="stat-sub">online / total seen</div>
</div> </div>
<div class="card"> <div class="card">
<div class="stat-label">Last share</div> <div class="stat-label">Best (session)</div>
<div class="stat-value">{formatAgo(totals.lastShare)}</div> <div class="stat-value">{formatDifficulty(totals.bestSession)}</div>
<div class="stat-sub">across all this user's workers</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> </div>
</section> </section>
@@ -166,6 +211,7 @@
<tr> <tr>
<th>Worker</th> <th>Worker</th>
<th>Hardware</th> <th>Hardware</th>
<th>Status</th>
<th class="num">Hashrate (1m)</th> <th class="num">Hashrate (1m)</th>
<th class="num">Hashrate (1h)</th> <th class="num">Hashrate (1h)</th>
<th class="num">Diff</th> <th class="num">Diff</th>
@@ -177,12 +223,36 @@
<tbody> <tbody>
{#each workerRows as r (r.worker)} {#each workerRows as r (r.worker)}
<tr class:offline={!r.online} class:idle={r.idle}> <tr class:offline={!r.online} class:idle={r.idle}>
<td class="mono">{r.worker}</td> <td>
<td>{r.hardware}</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.hashrate1m)}</td>
<td class="num">{formatHashrate(r.hashrate1h)}</td> <td class="num">{formatHashrate(r.hashrate1h)}</td>
<td class="num">{formatDifficulty(r.diff)}</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">{formatDifficulty(r.bestEver)}</td>
<td class="num">{formatAgo(r.lastShare)}</td> <td class="num">{formatAgo(r.lastShare)}</td>
</tr> </tr>
@@ -230,6 +300,19 @@
word-break: break-all; word-break: break-all;
line-height: 1.25; 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 { .totals {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -257,8 +340,60 @@
.table-wrap { .table-wrap {
overflow-x: auto; 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 { tr.offline {
opacity: 0.5; opacity: 0.55;
} }
tr.idle td { tr.idle td {
color: var(--fg-dim); color: var(--fg-dim);
+8
View File
@@ -58,6 +58,9 @@ export type StratumClient = {
dsps1440: number; dsps1440: number;
lastshare: number; lastshare: number;
starttime: number; starttime: number;
// Source IP, NOT the BTC payout address. ckpool sets this from
// inet_ntop(client_addr) in connector.c — see stratum_add_instance.
// The BTC address comes from `workername` (or the joined Worker.user).
address: string; address: string;
subscribed: boolean; subscribed: boolean;
authorised: boolean; authorised: boolean;
@@ -65,6 +68,11 @@ export type StratumClient = {
useragent: string; useragent: string;
workername: string; workername: string;
userid: number; userid: number;
// Index into ckpool's serverurl[] array; identifies which stratum
// bind the client connected on. We use this to tag TLS clients:
// index 0 is the public plaintext bind, index 1 is the loopback-only
// bind that stunnel forwards TLS traffic to.
server: number;
bestdiff: number; bestdiff: number;
}; };