Revamp dashboard and persist pool stats across restarts

Dashboard now renders 10 tiles in a 5x2 overview: hashrate, best
share, miners, network hashrate, and expected block on the top row;
difficulty, block height, block reward, total work, and the
difficulty-adjustment countdown on the bottom row. Difficulty is
rendered with T/P suffixes instead of scientific notation, the main
hashrate card shows the 1-minute value, and the block-height tile
pulses orange when the network tip advances.

Added a 24-hour hashrate area chart below the overview, sampled
once per minute. Samples are persisted to a new hashrate_samples
SQLite table and restored on startup so the chart doesn't reset
every time kamado-api is restarted.

Cumulative pool work (sum of accepted diff-1-normalized shares) is
now tracked across ckpool restarts. The aggregator integrates only
positive deltas on pool.Shares — a regression means ckpool's
counter reset to zero and the baseline is refreshed without losing
the running total. A hasPoolSharesBaseline flag prevents double-
counting on the first refresh after a kamado-api restart. The
value is persisted to a new kv table once per minute.

Next-block reward (subsidy + fees) is fetched from bitcoind
getblocktemplate at most once per minute and surfaced as a tile.

Header's block-height badge now reads prevHeight via untrack() so
the effect doesn't form a dependency cycle with its own write.
This commit is contained in:
satoshi
2026-04-22 21:07:22 +03:00
parent 8de767646d
commit e881bc930d
10 changed files with 2180 additions and 14 deletions
+140 -11
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte";
import {
formatHashrate,
formatUptime,
formatDifficulty,
formatWork,
expectedBlockSeconds,
formatDuration,
} from "../format";
@@ -10,31 +13,69 @@
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);
// Pool share of the network hashrate, used both for display and for
// the expected-block calculation. Network diff comes from bitcoind.
const poolShare = $derived.by(() => {
const net = data.network_hashrate_hs;
if (!net || net <= 0) return 0;
return data.hashrate_hs_1h / net;
return data.hashrate_hs_1m / net;
});
const expected = $derived.by(() => {
const diff = data.chain?.difficulty ?? 0;
return expectedBlockSeconds(data.hashrate_hs_1h, data.network_hashrate_hs, diff);
return expectedBlockSeconds(data.hashrate_hs_1m, data.network_hashrate_hs, diff);
});
const effort = $derived.by(() => {
if (!isFinite(expected) || expected <= 0) return 0;
const uptime = data.uptime_seconds;
if (!uptime || uptime <= 0) return 0;
return (uptime / expected) * 100;
});
const retarget = $derived.by(() => {
if (!height) return { progress: 0, remaining: 2016, eta: "" };
const inEpoch = height % 2016;
const remaining = 2016 - inEpoch;
const progress = (inEpoch / 2016) * 100;
const etaSec = remaining * 600;
return { progress, remaining, eta: formatDuration(etaSec) };
});
// Total hashes done by the pool = cumulative_shares * 2^32.
const totalHashes = $derived(data.cumulative_shares * 2 ** 32);
// 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 grid-4">
<section class="grid grid-5">
<!-- Row 1 -->
<div class="card">
<div class="stat-label">Hashrate (1h)</div>
<div class="stat-value">{formatHashrate(data.hashrate_hs_1h)}</div>
<div class="stat-label">Hashrate</div>
<div class="stat-value">{formatHashrate(data.hashrate_hs_1m)}</div>
<div class="stat-sub">
1m {formatHashrate(data.hashrate_hs_1m)} · 5m {formatHashrate(data.hashrate_hs_5m)}
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="stat-sub">all-time pool record</div>
</div>
<div class="card">
<div class="stat-label">Miners</div>
<div class="stat-value">{miners}</div>
@@ -45,14 +86,102 @@
<div class="stat-label">Network</div>
<div class="stat-value">{formatHashrate(data.network_hashrate_hs)}</div>
<div class="stat-sub">
diff {data.chain?.difficulty.toExponential(2) ?? "—"} ·
share {(poolShare * 1e9).toFixed(2)} ppb
pool share {(poolShare * 1e9).toFixed(2)} ppb
</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 class="stat-sub">
effort {effort.toFixed(1)}% · uptime {formatUptime(data.uptime_seconds)}
</div>
</div>
<!-- Row 2 -->
<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>
<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">hashes submitted by pool</div>
</div>
<div class="card">
<div class="stat-label">Diff adjustment</div>
<div class="stat-value">{retarget.remaining} blocks</div>
<div class="stat-sub">
<div class="retarget-bar">
<div class="retarget-fill" style="width:{retarget.progress}%"></div>
</div>
<span>{retarget.progress.toFixed(1)}% · ~{retarget.eta}</span>
</div>
</div>
</section>
<style>
.grid-5 {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.retarget-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 0.35em;
}
.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;
}
.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>