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.
265 lines
7.3 KiB
Svelte
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>
|