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
+1555
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -3,6 +3,7 @@
import { connect, snap } from "./stores/snapshot.svelte";
import Header from "./lib/Header.svelte";
import PoolOverview from "./lib/PoolOverview.svelte";
import HashrateChart from "./lib/HashrateChart.svelte";
import MinersTable from "./lib/MinersTable.svelte";
import BlocksTable from "./lib/BlocksTable.svelte";
import BestShares from "./lib/BestShares.svelte";
@@ -25,6 +26,7 @@
</div>
{:else}
<PoolOverview />
<HashrateChart />
<div class="grid grid-2">
<BlocksTable />
<BestShares />
+15
View File
@@ -29,6 +29,21 @@ export function formatDifficulty(d: number): string {
return `${v.toFixed(digits)}${units[i]}`;
}
// Cumulative hashes — scale up to ZH / YH since pools accumulate fast.
// Input is plain hashes (a diff-1 share is 2^32 hashes).
export function formatWork(hashes: number): string {
if (!hashes || hashes <= 0 || !isFinite(hashes)) return "0 H";
const units = ["H", "kH", "MH", "GH", "TH", "PH", "EH", "ZH", "YH"];
let i = 0;
let v = hashes;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
const digits = v >= 100 ? 0 : v >= 10 ? 1 : 2;
return `${v.toFixed(digits)} ${units[i]}`;
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return "—";
const d = Math.floor(seconds / 86400);
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { formatHashrate } from "../format";
const W = 800;
const H = 200;
const PAD = { top: 10, right: 10, bottom: 24, left: 60 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const points = $derived(snap.data?.hashrate_history ?? []);
const chart = $derived.by(() => {
if (points.length < 2) return null;
const tMin = points[0].t;
const tMax = points[points.length - 1].t;
const tRange = tMax - tMin || 1;
let vMax = 0;
for (const p of points) {
if (p.v > vMax) vMax = p.v;
}
if (vMax <= 0) vMax = 1;
vMax *= 1.1;
const toX = (t: number) => PAD.left + ((t - tMin) / tRange) * plotW;
const toY = (v: number) => PAD.top + plotH - (v / vMax) * plotH;
let line = "";
let area = "";
for (let i = 0; i < points.length; i++) {
const x = toX(points[i].t).toFixed(1);
const y = toY(points[i].v).toFixed(1);
if (i === 0) {
line = `M${x},${y}`;
area = `M${x},${(PAD.top + plotH).toFixed(1)} L${x},${y}`;
} else {
line += ` L${x},${y}`;
area += ` L${x},${y}`;
}
}
area += ` L${toX(tMax).toFixed(1)},${(PAD.top + plotH).toFixed(1)} Z`;
const yTicks = [0, vMax * 0.5, vMax].map((v) => ({
y: toY(v),
label: formatHashrate(v),
}));
const xTicks: Array<{ x: number; label: string }> = [];
const count = Math.min(6, points.length);
for (let i = 0; i < count; i++) {
const idx = Math.round((i / (count - 1)) * (points.length - 1));
const p = points[idx];
const d = new Date(p.t * 1000);
xTicks.push({
x: toX(p.t),
label: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
});
}
const last = points[points.length - 1];
const dotX = toX(last.t);
const dotY = toY(last.v);
return { line, area, yTicks, xTicks, dotX, dotY };
});
</script>
<section class="card chart-card">
<h2>Hashrate (24h)</h2>
{#if !chart}
<div class="empty">Collecting data... chart appears after 2 minutes.</div>
{:else}
<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" class="chart">
{#each chart.yTicks as tick}
<line
x1={PAD.left} y1={tick.y}
x2={W - PAD.right} y2={tick.y}
class="grid-line"
/>
<text x={PAD.left - 6} y={tick.y + 3} class="y-label">{tick.label}</text>
{/each}
<path d={chart.area} class="area" />
<path d={chart.line} class="line" />
<circle cx={chart.dotX} cy={chart.dotY} r="3.5" class="dot-pulse" />
<circle cx={chart.dotX} cy={chart.dotY} r="2.5" class="dot" />
{#each chart.xTicks as tick}
<text x={tick.x} y={H - 4} class="x-label">{tick.label}</text>
{/each}
</svg>
{/if}
</section>
<style>
.chart-card {
padding-bottom: 0.75rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
font-weight: 600;
}
.empty {
color: var(--fg-dim);
padding: 1rem 0;
text-align: center;
}
.chart {
width: 100%;
height: auto;
display: block;
}
.grid-line {
stroke: var(--border);
stroke-width: 0.5;
stroke-dasharray: 3 3;
}
.area {
fill: var(--accent);
opacity: 0.12;
}
.line {
fill: none;
stroke: var(--accent);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.y-label {
fill: var(--fg-dim);
font-size: 10px;
text-anchor: end;
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.x-label {
fill: var(--fg-dim);
font-size: 10px;
text-anchor: middle;
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.dot {
fill: var(--accent);
}
.dot-pulse {
fill: var(--accent);
opacity: 0.4;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { r: 3.5; opacity: 0.4; }
50% { r: 6; opacity: 0; }
}
</style>
+41 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte";
const statusClass = $derived.by(() => {
@@ -6,19 +7,36 @@
if (snap.status === "connecting") return "warn";
return "bad";
});
const chain = $derived(snap.data?.chain?.chain ?? "");
const chain = $derived(snap.data?.chain?.chain ?? "\u2014");
const height = $derived(snap.data?.chain?.blocks ?? 0);
// Track the previous height so we can trigger the animation.
// prevHeight is $state but read via untrack() to avoid dependency loops.
let prevHeight = $state(0);
let flash = $state(false);
$effect(() => {
const h = height; // tracked dependency
const prev = untrack(() => prevHeight);
if (h > 0 && prev > 0 && h !== prev) {
flash = true;
setTimeout(() => { flash = false; }, 1500);
}
prevHeight = h;
});
</script>
<header class="bar">
<div class="brand">
<span class="logo">🔥</span>
<span class="logo">&#x1F525;</span>
<span class="name">Kamado Pool</span>
</div>
<div class="meta">
<span class="badge {statusClass}">ws: {snap.status}</span>
<span class="badge">{chain}</span>
<span class="badge">height {height}</span>
<span class="badge height-badge" class:new-block={flash}>
height {height.toLocaleString()}
</span>
</div>
</header>
@@ -47,4 +65,24 @@
gap: 0.5rem;
flex-wrap: wrap;
}
.height-badge {
transition: background 0.3s, border-color 0.3s, color 0.3s;
}
.new-block {
animation: block-flash 1.5s ease-out;
}
@keyframes block-flash {
0% {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(255, 122, 58, 0.3);
}
100% {
background: var(--bg-alt);
border-color: var(--border);
color: var(--fg);
box-shadow: none;
}
}
</style>
+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>
+9
View File
@@ -88,6 +88,11 @@ export type BlockRecord = {
share_diff?: number;
};
export type HashratePoint = {
t: number; // unix seconds
v: number; // H/s
};
export type Snapshot = {
generated_at: string;
pool: PoolStats | null;
@@ -99,9 +104,13 @@ export type Snapshot = {
hashrate_hs_5m: number;
hashrate_hs_1h: number;
hashrate_hs_24h: number;
best_diff: number;
cumulative_shares: number;
next_block_reward_btc: number;
chain: BlockchainInfo | null;
network_hashrate_hs: number;
recent_blocks?: BlockRecord[];
hashrate_history?: HashratePoint[];
ckpool_ok: boolean;
bitcoin_ok: boolean;
last_error?: string;