Files
KamadoPool/ui/src/lib/PoolOverview.svelte
T

513 lines
15 KiB
Svelte

<script lang="ts">
import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte";
import { selectBestShare } from "../stores/selection.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_1h, 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 (measured between block timestamps
// from the epoch's first block), as does the epoch's observed mean
// block time — projecting the ETA at the pace the network is actually
// keeping rather than a flat 600s, which is what mempool.space shows.
const retarget = $derived.by(() => {
if (!height) return { remaining: 2016, eta: "", progress: 0 };
const inEpoch = height % 2016;
const remaining = 2016 - inEpoch;
const avgBlockSec = data.epoch_avg_block_seconds || 600;
const etaSec = remaining * avgBlockSec;
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;
});
// New best share glow — derived from the persisted acked_best_diff.
// Glows whenever best_diff > acked_best_diff. Survives restarts and
// works even if multiple new bests are found before the user opens the UI.
//
// Local override: buttons set localAcked immediately so the UI reacts
// in the same frame. The override holds until the server's snapshot
// reflects the change (next WS push after the POST is processed).
let localAcked = $state<number | null>(null);
const effectiveAcked = $derived(
localAcked !== null ? localAcked : (data.acked_best_diff ?? 0),
);
$effect(() => {
const server = data.acked_best_diff ?? 0;
if (localAcked !== null && server === localAcked) {
localAcked = null;
}
});
const bestGlow = $derived(
(data.best_diff ?? 0) > 0 && (data.best_diff ?? 0) > effectiveAcked,
);
const bestPrevValue = $derived(effectiveAcked);
function ackBest(): void {
localAcked = data.best_diff ?? 0;
fetch("/api/admin/ack-best", { method: "POST" });
}
</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>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card best-card"
class:best-glow={bestGlow}
>
{#if bestGlow}
<div class="best-tag">New best share! Previous: {formatDifficulty(bestPrevValue)}</div>
{/if}
<div class="shimmer" aria-hidden="true"></div>
<div class="sparkles" aria-hidden="true">
{#each {length: 6} as _, i}
<span class="spark" style="--i:{i}"></span>
{/each}
</div>
<div class="best-inner">
<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>
{#if bestGlow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ack-btn" onclick={ackBest} title="Dismiss the new best share notification">Nice</div>
{/if}
{#if data.best_diff > 0}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="inspect-btn" onclick={selectBestShare} title="Inspect best share hash and leading zeros">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
Inspect
</div>
{/if}
</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 === "main" ? "mainnet" : data.chain?.chain ?? "—"}
{#if data.peer_count > 0}
<span class="peers" title="{data.peers_in} inbound · {data.peers_out} outbound">&nbsp;· {data.peer_count} peers</span>
{/if}
</div>
</div>
<div class="card">
<div class="stat-label">Block reward</div>
<div class="stat-value" title="{data.next_block_reward_btc ? Math.round(data.next_block_reward_btc * 1e8).toLocaleString() + ' sats' : ''}">
{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));
}
}
@media (max-width: 480px) {
.grid-5 {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.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);
}
/* Best share persistent glow */
.best-card {
position: relative;
overflow: hidden;
transition: border-color 0.4s, box-shadow 0.4s;
}
.best-inner {
position: relative;
z-index: 1;
}
.shimmer {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(
105deg,
transparent 20%,
rgba(245, 196, 71, 0.08) 35%,
rgba(255, 220, 80, 0.28) 50%,
rgba(245, 196, 71, 0.08) 65%,
transparent 80%
);
}
.best-card.best-glow {
padding-top: calc(1.25rem + 1.4em);
border-color: rgba(245, 196, 71, 0.7);
box-shadow:
0 0 20px rgba(245, 196, 71, 0.35),
0 0 40px rgba(245, 196, 71, 0.12),
inset 0 0 12px rgba(245, 196, 71, 0.06);
animation: best-border-pulse 2s ease-in-out infinite alternate;
}
@keyframes best-border-pulse {
0% { box-shadow: 0 0 16px rgba(245, 196, 71, 0.25), 0 0 30px rgba(245, 196, 71, 0.08); }
100% { box-shadow: 0 0 24px rgba(245, 196, 71, 0.45), 0 0 50px rgba(245, 196, 71, 0.15); }
}
.best-card.best-glow .shimmer {
opacity: 1;
animation: best-shimmer 2s ease-in-out infinite;
}
@keyframes best-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* Floating sparkle dots */
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
.best-card.best-glow .sparkles {
opacity: 1;
}
.spark {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 220, 100, 0.9);
box-shadow: 0 0 6px rgba(255, 220, 100, 0.6);
animation: sparkle-float 3s ease-in-out infinite;
animation-delay: calc(var(--i) * 0.5s);
}
.spark:nth-child(1) { left: 12%; top: 20%; }
.spark:nth-child(2) { left: 85%; top: 35%; }
.spark:nth-child(3) { left: 45%; top: 80%; }
.spark:nth-child(4) { left: 70%; top: 15%; }
.spark:nth-child(5) { left: 25%; top: 65%; }
.spark:nth-child(6) { left: 90%; top: 75%; }
@keyframes sparkle-float {
0%, 100% { opacity: 0; transform: scale(0.5) translateY(0); }
30% { opacity: 1; transform: scale(1.2) translateY(-4px); }
60% { opacity: 0.6; transform: scale(0.8) translateY(2px); }
}
/* "NEW BEST SHARE!" tag */
.best-tag {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
padding: 0.25em 0.6em;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
text-align: center;
color: rgb(30, 22, 5);
background: linear-gradient(90deg, rgba(245, 196, 71, 0.9), rgba(255, 220, 100, 0.95), rgba(245, 196, 71, 0.9));
border-radius: 10px 10px 0 0;
}
/* "Nice" dismiss button */
.ack-btn {
display: block;
width: fit-content;
margin: 0.5em auto 0;
padding: 0.25em 1em;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(245, 196, 71);
border: 1px solid rgba(245, 196, 71, 0.4);
border-radius: 5px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, transform 0.15s;
}
.ack-btn:hover {
background: rgba(245, 196, 71, 0.15);
border-color: rgba(245, 196, 71, 0.7);
transform: scale(1.05);
}
.inspect-btn {
display: flex;
align-items: center;
gap: 0.3em;
width: fit-content;
margin: 0.4em auto 0;
padding: 0.2em 0.7em;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-dim);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.inspect-btn:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--bg-hover);
}
.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>