Files
KamadoPool/ui/src/lib/PoolOverview.svelte
T
satoshi 6acff6280f 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)
2026-05-11 02:12:16 +03:00

296 lines
8.5 KiB
Svelte

<script lang="ts">
import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte";
import {
formatHashrate,
formatUptime,
formatDifficulty,
formatWork,
expectedBlockSeconds,
formatDuration,
} from "../format";
const data = $derived(snap.data!);
const height = $derived(data.chain?.blocks ?? 0);
const poolShare = $derived.by(() => {
const net = data.network_hashrate_hs;
if (!net || net <= 0) return 0;
return data.hashrate_hs_1m / net;
});
const expected = $derived.by(() => {
const diff = data.chain?.difficulty ?? 0;
return expectedBlockSeconds(data.hashrate_hs_1m, data.network_hashrate_hs, diff);
});
// Round effort: share of the expected work-to-find-a-block that the
// pool has already submitted, at the current network difficulty.
// Matches ckpool's own accounted_diff_shares / network_diff * 100
// (stratifier.c:8201).
const roundEffort = $derived.by(() => {
const d = data.chain?.difficulty ?? 0;
if (!d || d <= 0) return 0;
return (data.cumulative_shares / d) * 100;
});
// Blocks remaining + ETA until the retarget. The predicted adjustment
// percent comes from the backend (computed from the epoch start time).
const retarget = $derived.by(() => {
if (!height) return { remaining: 2016, eta: "", progress: 0 };
const inEpoch = height % 2016;
const remaining = 2016 - inEpoch;
const etaSec = remaining * 600;
const progress = (inEpoch / 2016) * 100;
return { remaining, eta: formatDuration(etaSec), progress };
});
const diffPct = $derived(data.next_difficulty_percent ?? 0);
const diffPctLabel = $derived(
diffPct === 0
? "—"
: (diffPct > 0 ? "+" : "") + diffPct.toFixed(2) + "%",
);
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(() => {
const best = data.best_diff;
const work = data.cumulative_shares;
if (!best || !work || work <= 0) return 0;
return (best / work) * 100;
});
const luckLabel = $derived.by(() => {
if (!data.best_diff || !data.cumulative_shares) return "—";
if (luckPct >= 1000) return `${(luckPct / 100).toFixed(1)}x`;
return luckPct.toFixed(0) + "%";
});
// Block-height flash on network-wide new block.
let prevHeight = $state(0);
let heightFlash = $state(false);
$effect(() => {
const h = height;
const prev = untrack(() => prevHeight);
if (h > 0 && prev > 0 && h !== prev) {
heightFlash = true;
setTimeout(() => { heightFlash = false; }, 1800);
}
prevHeight = h;
});
</script>
<section class="grid-5">
<!-- Row 1 -->
<div class="card">
<div class="stat-label">Hashrate</div>
<div class="stat-value">{formatHashrate(data.hashrate_hs_1m)}</div>
<div class="stat-sub">
5m {formatHashrate(data.hashrate_hs_5m)} · 1h {formatHashrate(data.hashrate_hs_1h)}
· 24h {formatHashrate(data.hashrate_hs_24h)}
</div>
</div>
<div class="card">
<div class="stat-label">Best share</div>
<div class="stat-value">{formatDifficulty(data.best_diff)}</div>
<div
class="luck-sub"
class:lucky={luckPct > 100}
class:unlucky={luckPct > 0 && luckPct < 100}
title="Luck = best share / total work. 100% means your best share matches the work done. Above 100% is lucky (found a harder share than expected), below is unlucky."
>
<span class="luck-value">{luckLabel}</span>
<span class="luck-label">luck</span>
</div>
</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">
<div class="stat-label">Network</div>
<div class="stat-value">{formatHashrate(data.network_hashrate_hs)}</div>
<div class="stat-sub">pool share {(poolShare * 1e9).toFixed(2)} ppb</div>
</div>
<div class="card">
<div class="stat-label">Difficulty</div>
<div class="stat-value">{formatDifficulty(data.chain?.difficulty ?? 0)}</div>
<div class="stat-sub">network target</div>
</div>
<!-- Row 2 -->
<div class="card height-card" class:new-block={heightFlash}>
<div class="stat-label">Block height</div>
<div class="stat-value">{height ? height.toLocaleString() : "—"}</div>
<div class="stat-sub">{data.chain?.chain ?? "—"}</div>
</div>
<div class="card">
<div class="stat-label">Block reward</div>
<div class="stat-value">
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"}
<span class="unit">BTC</span>
</div>
<div class="stat-sub">subsidy + fees (next block)</div>
</div>
<div class="card">
<div class="stat-label">Total work</div>
<div class="stat-value">{formatWork(totalHashes)}</div>
<div class="stat-sub">
round effort {roundEffort.toFixed(1)}% of current difficulty
</div>
</div>
<div class="card">
<div class="stat-label">Expected block</div>
<div class="stat-value">{formatDuration(expected)}</div>
<div class="stat-sub">uptime {formatUptime(data.uptime_seconds)}</div>
</div>
<div class="card">
<div class="stat-label">Next adjustment</div>
<div
class="stat-value adj"
class:up={diffPct > 0}
class:down={diffPct < 0}
>{diffPctLabel}</div>
<div class="stat-sub">
<div class="retarget-bar">
<div class="retarget-fill" style="width:{retarget.progress}%"></div>
</div>
<span>{retarget.remaining} blocks · ~{retarget.eta}</span>
</div>
</div>
</section>
<style>
.grid-5 {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1rem;
}
@media (max-width: 1100px) {
.grid-5 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.grid-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.retarget-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin: 0.4em 0 0.4em;
}
.retarget-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.5s ease;
}
.unit {
font-size: 0.65em;
color: var(--fg-dim);
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);
}
.adj.down {
color: var(--bad);
}
.luck-sub {
display: flex;
align-items: baseline;
gap: 0.4em;
margin-top: 0.45em;
cursor: help;
}
.luck-value {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.luck-label {
color: var(--fg-dim);
font-size: 0.85rem;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.luck-sub.lucky .luck-value {
color: var(--good);
text-shadow: 0 0 10px rgba(92, 224, 168, 0.35);
}
.luck-sub.unlucky .luck-value {
color: var(--bad);
text-shadow: 0 0 10px rgba(255, 107, 107, 0.25);
}
.height-card {
transition: box-shadow 0.3s, border-color 0.3s;
}
.height-card.new-block {
animation: height-flash 1.8s ease-out;
}
@keyframes height-flash {
0% {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent), 0 0 32px rgba(255, 122, 58, 0.55);
transform: scale(1.03);
}
40% {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 0 20px rgba(255, 122, 58, 0.35);
transform: scale(1);
}
100% {
border-color: var(--border);
box-shadow: none;
transform: scale(1);
}
}
</style>