From e622f1a81a758ab166a8e058bb879d9143fcc869 Mon Sep 17 00:00:00 2001 From: satoshi Date: Sun, 10 May 2026 18:02:08 +0300 Subject: [PATCH] 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. --- api/internal/ckpool/types.go | 1 + ...expose-worker-shares-in-runtime-json.patch | 18 + ui/src/App.svelte | 3 + ui/src/app.css | 2 +- ui/src/lib/MinersTable.svelte | 26 +- ui/src/lib/UserDetailPage.svelte | 26 +- ui/src/lib/WorkerDetailPage.svelte | 353 ++++++++++++++++++ ui/src/stores/selection.svelte.ts | 55 +-- ui/src/types.ts | 1 + 9 files changed, 458 insertions(+), 27 deletions(-) create mode 100644 ckpool/patches/0004-expose-worker-shares-in-runtime-json.patch create mode 100644 ui/src/lib/WorkerDetailPage.svelte diff --git a/api/internal/ckpool/types.go b/api/internal/ckpool/types.go index d5ab39d..3d36e36 100644 --- a/api/internal/ckpool/types.go +++ b/api/internal/ckpool/types.go @@ -69,6 +69,7 @@ type Worker struct { LastShare int64 `json:"lastshare"` BestDiff float64 `json:"bestdiff"` BestEver float64 `json:"bestever"` + Shares int64 `json:"shares"` MinDiff float64 `json:"mindiff"` Idle bool `json:"idle"` } diff --git a/ckpool/patches/0004-expose-worker-shares-in-runtime-json.patch b/ckpool/patches/0004-expose-worker-shares-in-runtime-json.patch new file mode 100644 index 0000000..359181e --- /dev/null +++ b/ckpool/patches/0004-expose-worker-shares-in-runtime-json.patch @@ -0,0 +1,18 @@ +diff --git a/src/stratifier.c b/src/stratifier.c +index c94ca645..a1b2c3e4 100644 +--- a/src/stratifier.c ++++ b/src/stratifier.c +@@ -4129,11 +4129,12 @@ static json_t *workerinfo(const user_instance_t *user, const worker_instance_t * + { + json_t *val; + +- JSON_CPACK(val, "{ss,ss,si,sf,sf,sf,sf,si,sf,sf,si,sb}", ++ JSON_CPACK(val, "{ss,ss,si,sf,sf,sf,sf,si,sf,sf,sI,si,sb}", + "user", user->username, "worker", worker->workername, "id", user->id, + "dsps1", worker->dsps1, "dsps5", worker->dsps5, "dsps60", worker->dsps60, + "dsps1440", worker->dsps1440, "lastshare", worker->last_share.tv_sec, + "bestdiff", worker->best_diff, "bestever", (double)worker->best_ever, ++ "shares", (int64_t)worker->shares, + "mindiff", worker->mindiff, "idle", worker->idle); + return val; + } diff --git a/ui/src/App.svelte b/ui/src/App.svelte index f2604ae..c7ead45 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -10,6 +10,7 @@ import BlocksTable from "./lib/BlocksTable.svelte"; import BestShares from "./lib/BestShares.svelte"; import UserDetailPage from "./lib/UserDetailPage.svelte"; + import WorkerDetailPage from "./lib/WorkerDetailPage.svelte"; import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte"; onMount(() => { @@ -32,6 +33,8 @@
{snap.error}
{/if} + {:else if selection.worker} + {:else if selection.user} {:else} diff --git a/ui/src/app.css b/ui/src/app.css index 8b7b886..7486e9f 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -140,7 +140,7 @@ tbody tr:hover { } td.num, th.num { - text-align: right; + text-align: center; } .badge { diff --git a/ui/src/lib/MinersTable.svelte b/ui/src/lib/MinersTable.svelte index 5f45d5a..b2c0f14 100644 --- a/ui/src/lib/MinersTable.svelte +++ b/ui/src/lib/MinersTable.svelte @@ -1,6 +1,6 @@ + + + +
+ + +
+
Worker
+

{workername}

+
+ + {#if tls} + + + TLS + + {/if} + {#if openSource} + + {/if} +
+
+ +
+
+
Hashrate
+
{formatHashrate(hs1m)}
+
+ 5m {formatHashrate(hs5m)} · + 1h {formatHashrate(hs1h)} · + 24h {formatHashrate(hs24h)} +
+
+ +
+
Status
+
+ {#if !online} + Offline + {:else if idle} + Idle + {:else} + Online + {/if} +
+
+ {hardware} + {#if client?.address} + · {client.address} + {/if} +
+
+ +
+
Best share
+
{formatDifficulty(bestEver)}
+
100} + class:unlucky={luckPct > 0 && luckPct < 100} + title="Luck = best share / total work for this worker. 100% means your best share matches the work done. Above 100% is lucky, below is unlucky." + > + {luckLabel} + luck +
+
+ +
+
Total work
+
{formatWork(workerHashes)}
+
+ {workShare.toFixed(1)}% of pool · + last share {formatAgo(lastShare)} +
+
+
+ +
+

Session

+ {#if !online} +
Worker is offline. Last seen {formatAgo(lastShare)}.
+ {:else} +
+
+ Session best + {formatDifficulty(bestSession)} +
+
+ Current diff + {formatDifficulty(client?.diff ?? 0)} +
+
+ Min diff + {formatDifficulty(worker?.mindiff ?? 0)} +
+ {#if client?.useragent} +
+ User agent + {client.useragent} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/ui/src/stores/selection.svelte.ts b/ui/src/stores/selection.svelte.ts index c6be297..6c1443c 100644 --- a/ui/src/stores/selection.svelte.ts +++ b/ui/src/stores/selection.svelte.ts @@ -1,30 +1,38 @@ -// Simple hash-based routing for user pages. The URL hash is the +// Simple hash-based routing for detail pages. The URL hash is the // source of truth: -// #/ -> dashboard -// #/user/
-> per-user page +// #/ -> dashboard +// #/user/
-> per-user page +// #/worker/ -> per-worker page // -// selectUser / clearSelection just mutate the hash; a hashchange -// listener reads it back into reactive state so any component in the -// tree re-renders when the route changes. +// selectUser / selectWorker / clearSelection just mutate the hash; +// a hashchange listener reads it back into reactive state so any +// component in the tree re-renders when the route changes. const USER_PREFIX = "#/user/"; +const WORKER_PREFIX = "#/worker/"; -export const selection = $state<{ user: string | null }>({ - user: readHash(), -}); +type Selection = { user: string | null; worker: string | null }; -function readHash(): string | null { - if (typeof window === "undefined") return null; +export const selection = $state(readHash()); + +function readHash(): Selection { + if (typeof window === "undefined") return { user: null, worker: null }; const h = window.location.hash; - if (h.startsWith(USER_PREFIX)) { - const addr = decodeURIComponent(h.slice(USER_PREFIX.length)); - return addr || null; + if (h.startsWith(WORKER_PREFIX)) { + const w = decodeURIComponent(h.slice(WORKER_PREFIX.length)); + return { user: null, worker: w || null }; } - return null; + if (h.startsWith(USER_PREFIX)) { + const u = decodeURIComponent(h.slice(USER_PREFIX.length)); + return { user: u || null, worker: null }; + } + return { user: null, worker: null }; } function syncFromHash(): void { - selection.user = readHash(); + const parsed = readHash(); + selection.user = parsed.user; + selection.worker = parsed.worker; } if (typeof window !== "undefined") { @@ -32,16 +40,19 @@ if (typeof window !== "undefined") { } export function selectUser(address: string): void { - // Set the hash; the hashchange listener updates selection.user so - // routing-style state stays in one place. window.location.hash = USER_PREFIX + encodeURIComponent(address); } +export function selectWorker(workername: string): void { + window.location.hash = WORKER_PREFIX + encodeURIComponent(workername); +} + export function clearSelection(): void { - // Prefer history.back when we arrived here via selectUser so the - // browser back arrow does the obvious thing; otherwise just drop - // the hash so a fresh page load starts on the dashboard. - if (window.history.length > 1 && window.location.hash.startsWith(USER_PREFIX)) { + if ( + window.history.length > 1 && + (window.location.hash.startsWith(USER_PREFIX) || + window.location.hash.startsWith(WORKER_PREFIX)) + ) { window.history.back(); } else { window.location.hash = ""; diff --git a/ui/src/types.ts b/ui/src/types.ts index f257504..100cc00 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -44,6 +44,7 @@ export type Worker = { lastshare: number; bestdiff: number; bestever: number; + shares: number; mindiff: number; idle: boolean; };