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)
296 lines
8.5 KiB
Svelte
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>
|