Add block-update latency tracking, raw share counters, and shares bar UI

Log ZMQ→mining.notify latency in ckpool (patch 0005), expose a raw
reject counter (patch 0006), and surface both in the dashboard:

- Block latency card shows avg/last ms, wasted work, and block count
- SharesBar component shows session + all-time accepted/rejected with
  a Demon Slayer flame-slash animation on new shares
- Miners card info moved to MinersTable section header
- Latency stats and share counts persist across restarts via kv store
- Removed misleading pool-wide share stats from per-worker/user pages
  (ckpool doesn't expose per-user raw counts)
This commit is contained in:
satoshi
2026-05-11 02:12:16 +03:00
parent e622f1a81a
commit 6acff6280f
14 changed files with 555 additions and 18 deletions
+2
View File
@@ -11,6 +11,7 @@
import BestShares from "./lib/BestShares.svelte";
import UserDetailPage from "./lib/UserDetailPage.svelte";
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
import SharesBar from "./lib/SharesBar.svelte";
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
onMount(() => {
@@ -39,6 +40,7 @@
<UserDetailPage />
{:else}
<PoolOverview />
<SharesBar />
<HashrateChart />
<BlocksTable />
<BestShares />
+19 -2
View File
@@ -96,7 +96,14 @@
</script>
<section class="card">
<h2>Miners</h2>
<div class="section-head">
<h2>Miners</h2>
<span class="head-meta">
{rows.filter(r => r.online).length} online ·
{rows.length} workers ·
{new Set(rows.map(r => r.btcAddress)).size} users
</span>
</div>
{#if rows.length === 0}
<div class="empty">No miners connected.</div>
{:else}
@@ -165,11 +172,21 @@
</section>
<style>
.section-head {
display: flex;
align-items: baseline;
gap: 0.75em;
margin-bottom: 1rem;
}
h2 {
margin: 0 0 1rem;
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
.head-meta {
color: var(--fg-dim);
font-size: 0.85em;
}
.table-wrap {
overflow-x: auto;
}
+30 -6
View File
@@ -11,8 +11,6 @@
} from "../format";
const data = $derived(snap.data!);
const miners = $derived(data.clients?.length ?? 0);
const workerCount = $derived(data.workers?.length ?? 0);
const height = $derived(data.chain?.blocks ?? 0);
const poolShare = $derived.by(() => {
@@ -56,6 +54,12 @@
const totalHashes = $derived(data.cumulative_shares * 2 ** 32);
// Block update latency (ZMQ → mining.notify)
const latencyAvg = $derived(data.latency_avg_ms ?? 0);
const latencyLast = $derived(data.latency_last_ms ?? 0);
const latencyCount = $derived(data.latency_count ?? 0);
const staleWork = $derived(data.stale_work_hashes ?? 0);
// Luck: best_share / cumulative_work * 100%.
// 100% = exactly expected, >100% = lucky, <100% = unlucky.
const luckPct = $derived.by(() => {
@@ -109,10 +113,22 @@
</div>
</div>
<div class="card">
<div class="stat-label">Miners</div>
<div class="stat-value">{miners}</div>
<div class="stat-sub">{workerCount} workers · {data.users?.length ?? 0} users</div>
<div
class="card"
title="Time from receiving a new block via ZMQ to pushing mining.notify to all miners. Wasted work = hashes spent mining the old block during this window."
>
<div class="stat-label">Block latency</div>
{#if latencyCount > 0}
<div class="stat-value">{latencyAvg}<span class="unit">ms</span></div>
<div class="stat-sub">
last {latencyLast}ms ·
<span class="wasted">{formatWork(staleWork)} wasted</span>
<span class="stat-dim">({latencyCount} blocks)</span>
</div>
{:else}
<div class="stat-value"></div>
<div class="stat-sub">waiting for the next block</div>
{/if}
</div>
<div class="card">
@@ -171,6 +187,7 @@
<span>{retarget.remaining} blocks · ~{retarget.eta}</span>
</div>
</div>
</section>
<style>
@@ -209,6 +226,13 @@
font-weight: 400;
margin-left: 0.15em;
}
.stat-dim {
color: var(--fg-dim);
font-size: 0.9em;
}
.wasted {
color: var(--bad);
}
.adj.up {
color: var(--good);
}
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte";
const data = $derived(snap.data!);
const sessionAcc = $derived(data.session_accepted ?? 0);
const sessionRej = $derived(data.session_rejected ?? 0);
const allAcc = $derived(data.alltime_accepted ?? 0);
const allRej = $derived(data.alltime_rejected ?? 0);
const sessionTotal = $derived(sessionAcc + sessionRej);
const sessionRejectPct = $derived(
sessionTotal > 0 ? (sessionRej / sessionTotal) * 100 : 0,
);
const allTotal = $derived(allAcc + allRej);
const allRejectPct = $derived(
allTotal > 0 ? (allRej / allTotal) * 100 : 0,
);
// Detect new accepted shares to trigger animation
let prevAccepted = $state(0);
let pulse = $state(false);
$effect(() => {
const cur = sessionAcc;
const prev = untrack(() => prevAccepted);
if (cur > 0 && prev > 0 && cur > prev) {
pulse = true;
setTimeout(() => { pulse = false; }, 700);
}
prevAccepted = cur;
});
function fmtCount(n: number): string {
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return n.toLocaleString();
}
</script>
<section class="shares-bar" class:pulse>
<div class="slash-effect" aria-hidden="true"></div>
<div class="content">
<h3 class="title">Shares</h3>
<div class="stats">
<div class="group">
<span class="group-label">Session</span>
<span class="accepted">{fmtCount(sessionAcc)}</span>
<span class="sep">/</span>
<span class="rejected">{fmtCount(sessionRej)}</span>
{#if sessionTotal > 0}
<span class="pct" class:warn={sessionRejectPct > 1}>
{sessionRejectPct.toFixed(2)}% rejected
</span>
{/if}
</div>
<div class="divider"></div>
<div class="group">
<span class="group-label">All-time</span>
<span class="accepted">{fmtCount(allAcc)}</span>
<span class="sep">/</span>
<span class="rejected">{fmtCount(allRej)}</span>
{#if allTotal > 0}
<span class="pct" class:warn={allRejectPct > 1}>
{allRejectPct.toFixed(2)}% rejected
</span>
{/if}
</div>
</div>
</div>
</section>
<style>
.shares-bar {
position: relative;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
padding: 0.85rem 1.25rem;
}
.content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
.title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg);
}
.stats {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.group {
display: flex;
align-items: baseline;
gap: 0.4em;
}
.group-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-dim);
margin-right: 0.3em;
}
.accepted {
color: var(--good);
font-weight: 600;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.rejected {
color: var(--bad);
font-weight: 600;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.sep {
color: var(--fg-dim);
font-size: 0.9rem;
}
.pct {
font-size: 1.1rem;
color: var(--fg-dim);
margin-left: 0.3em;
}
.pct.warn {
color: var(--bad);
}
.divider {
width: 1px;
height: 1.6rem;
background: var(--border);
}
/* Demon Slayer flame-slash animation on new accepted share */
.slash-effect {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 122, 58, 0.0) 30%,
rgba(255, 122, 58, 0.15) 48%,
rgba(255, 200, 50, 0.25) 50%,
rgba(255, 122, 58, 0.15) 52%,
rgba(255, 122, 58, 0.0) 70%,
transparent 100%
);
}
.shares-bar.pulse .slash-effect {
animation: flame-slash 0.7s ease-out;
}
@keyframes flame-slash {
0% {
opacity: 1;
transform: translateX(-100%) scaleY(1);
}
40% {
opacity: 1;
transform: translateX(0%) scaleY(1.2);
}
100% {
opacity: 0;
transform: translateX(100%) scaleY(1);
}
}
.shares-bar.pulse {
border-color: rgba(255, 122, 58, 0.5);
box-shadow: 0 0 12px rgba(255, 122, 58, 0.2);
}
@media (max-width: 600px) {
.content {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.divider {
width: 100%;
height: 1px;
}
}
</style>
+2
View File
@@ -134,6 +134,7 @@
};
});
const explorerBase = $derived(
explorerBaseFor(snap.data?.chain?.chain, snap.data?.mempool_base_url),
);
@@ -198,6 +199,7 @@
</div>
</section>
<section class="card">
<h3>Workers</h3>
{#if workerRows.length === 0}
+1
View File
@@ -62,6 +62,7 @@
return (workerShares / poolShares) * 100;
});
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
+13
View File
@@ -10,6 +10,7 @@ export type PoolStats = {
shares: number;
accepted: number;
rejected: number;
rejectcount: number;
dsps1: number;
dsps5: number;
dsps15: number;
@@ -140,6 +141,18 @@ export type Snapshot = {
zmq_enabled: boolean;
has_last_zmq_event: boolean;
last_zmq_event_age?: number; // seconds
// Share counters (raw, 1 submission = 1 share).
session_accepted: number;
session_rejected: number;
alltime_accepted: number;
alltime_rejected: number;
// Block update latency diagnostics (ZMQ → mining.notify).
latency_count: number;
latency_avg_ms: number;
latency_last_ms: number;
stale_work_hashes: number;
ckpool_ok: boolean;
bitcoin_ok: boolean;
last_error?: string;