Files
KamadoPool/ui/src/lib/MinersTable.svelte
T
satoshi e622f1a81a Add per-worker stats page with cumulative work and luck
Expose ckpool's per-worker cumulative shares via patch 0004, add a
WorkerDetailPage with hashrate, status, best share with per-worker luck
(bestever/shares*100), and total work with pool share percentage.
Worker names are now clickable in MinersTable and UserDetailPage.
Also centers numeric columns below their headers.
2026-05-10 18:02:08 +03:00

265 lines
7.3 KiB
Svelte

<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { selectUser, selectWorker } from "../stores/selection.svelte";
import {
formatHashrate,
formatDifficulty,
formatAgo,
detectHardware,
isOpenSource,
btcAddressOf,
} from "../format";
import type { StratumClient, Worker } from "../types";
type Row = {
workerName: string;
btcAddress: string;
sourceIp: string;
hardware: string;
openSource: boolean;
tls: boolean;
hashrate1m: number;
hashrate1h: number;
bestSession: number;
bestEver: number;
lastShare: number;
difficulty: number;
idle: 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 clients = snap.data?.clients ?? [];
const workers = snap.data?.workers ?? [];
const byWorker = new Map<string, Worker>();
for (const w of workers) byWorker.set(w.worker, w);
const seen = new Set<string>();
const out: Row[] = [];
for (const c of clients as StratumClient[]) {
const wname = c.workername || `${c.address}.unnamed`;
seen.add(wname);
const w = byWorker.get(wname);
const btcAddress = w?.user ?? btcAddressOf(wname);
out.push({
workerName: wname,
btcAddress,
sourceIp: c.address,
hardware: detectHardware(c.useragent),
openSource: isOpenSource(c.useragent),
tls: c.server === 1,
hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32,
bestSession: c.bestdiff,
bestEver: w?.bestever ?? 0,
lastShare: c.lastshare,
difficulty: c.diff,
idle: c.idle,
online: true,
});
}
for (const w of workers) {
if (seen.has(w.worker)) continue;
out.push({
workerName: w.worker,
btcAddress: w.user,
sourceIp: "",
hardware: "offline",
openSource: false,
tls: false,
hashrate1m: 0,
hashrate1h: 0,
bestSession: 0,
bestEver: w.bestever,
lastShare: w.lastshare,
difficulty: w.mindiff,
idle: w.idle,
online: false,
});
}
out.sort((a, b) => b.hashrate1h - a.hashrate1h);
return out;
});
</script>
<section class="card">
<h2>Miners</h2>
{#if rows.length === 0}
<div class="empty">No miners connected.</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Hardware</th>
<th class="num">Hashrate (1m)</th>
<th class="num">Hashrate (1h)</th>
<th class="num">Diff</th>
<th class="num">Best (session)</th>
<th class="num">Best (ever)</th>
<th class="num">Last share</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.workerName)}
<tr class:offline={!r.online} class:idle={r.idle}>
<td>
<div class="wname mono">
<button
type="button"
class="worker-btn"
onclick={() => selectWorker(r.workerName)}
title="View per-worker stats"
>{r.workerName}</button>
{#if r.tls}
<span class="badge tls" title="Connected via TLS">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/>
</svg>
<span>TLS</span>
</span>
{/if}
</div>
<button
type="button"
class="addr-btn mono"
onclick={() => selectUser(r.btcAddress)}
title="View per-user stats"
>{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 class="num">{formatHashrate(r.hashrate1m)}</td>
<td class="num">{formatHashrate(r.hashrate1h)}</td>
<td class="num">{formatDifficulty(r.difficulty)}</td>
<td class="num">{formatDifficulty(r.bestSession)}</td>
<td class="num">{formatDifficulty(r.bestEver)}</td>
<td class="num">{formatAgo(r.lastShare)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<style>
h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: 600;
}
.table-wrap {
overflow-x: auto;
}
.empty {
color: var(--fg-dim);
padding: 1rem 0;
}
.wname {
font-size: 0.95em;
display: flex;
align-items: center;
gap: 0.4em;
flex-wrap: wrap;
}
.worker-btn {
font: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
border-bottom: 1px dashed transparent;
transition: color 0.15s, border-bottom-color 0.15s;
}
.worker-btn:hover,
.worker-btn:focus-visible {
color: var(--accent);
border-bottom-color: var(--accent);
outline: none;
}
.addr-btn {
display: inline-block;
color: var(--accent);
font-size: 0.82em;
background: transparent;
border: none;
padding: 0;
margin-top: 0.1em;
cursor: pointer;
text-align: left;
text-decoration: none;
border-bottom: 1px dashed var(--accent-dim);
transition: color 0.15s, border-bottom-color 0.15s;
}
.addr-btn:hover,
.addr-btn:focus-visible {
color: #ff9a5f;
border-bottom-color: var(--accent);
background: transparent;
outline: none;
}
.src-ip {
color: var(--fg-dim);
font-size: 0.72em;
margin-top: 0.2em;
}
.badge.tls {
display: inline-flex;
align-items: center;
gap: 0.3em;
font-size: 0.72em;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--good);
border: 1px solid #3a7558;
background: #112e22;
padding: 0.15em 0.5em;
border-radius: 4px;
text-transform: uppercase;
line-height: 1;
vertical-align: middle;
}
.badge.tls svg {
width: 0.9em;
height: 0.9em;
fill: currentColor;
flex-shrink: 0;
}
.oss {
color: var(--accent);
font-size: 0.95em;
margin-left: 0.3em;
cursor: help;
}
tr.offline {
opacity: 0.4;
}
tr.idle td {
color: var(--fg-dim);
}
</style>