Dashboard polish: per-user modal, block-found animation, readable chart

Block-reward tile was cached for 60s, so users saw it sit still even
as bitcoind's CreateNewBlock fired every few seconds with updated
fee totals. Drop the template cache TTL to 15s — bitcoind caches
the template internally, so the extra RPC cost is trivial.

Hashrate chart was rendering 1440 raw per-minute points across a
~780px plot, which collapsed into a noisy smear. Bucket-average to
~96 points so the chart actually communicates a trend. Raw samples
under the target pass through unchanged (early process lifetime).

Layout: Blocks found is now full-width, with the Best shares
leaderboard stacked below it. The previous side-by-side was
squeezing both tables on typical displays.

New user-detail modal. Clicking a BTC address in the Miners table
opens a focused view: per-user aggregate hashrate (1m/5m/1h/24h),
best round / best ever, and a worker-level breakdown with the same
columns as the main table. Driven by a small selection store so any
component in the tree can open it. Closes on backdrop click or Esc.

New block-found animation. When the network tip advances, a brief
full-screen overlay plays: a radial Hinokami Kagura ember burst
above the header, a faint sun-ray sweep, and ~22 falling sakura
petals with randomised drift / rotation / delay so no two blocks
look identical. The existing block-height tile flash still fires
alongside; the overlay is pointer-events: none so nothing in the
UI becomes unreachable during the ~3.6s animation.
This commit is contained in:
satoshi
2026-04-24 00:46:16 +03:00
parent cbea202929
commit c104c1eefa
7 changed files with 557 additions and 10 deletions
+7 -4
View File
@@ -7,6 +7,8 @@
import MinersTable from "./lib/MinersTable.svelte";
import BlocksTable from "./lib/BlocksTable.svelte";
import BestShares from "./lib/BestShares.svelte";
import UserDetailModal from "./lib/UserDetailModal.svelte";
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
onMount(() => {
connect();
@@ -27,14 +29,15 @@
{:else}
<PoolOverview />
<HashrateChart />
<div class="grid grid-2">
<BlocksTable />
<BestShares />
</div>
<BlocksTable />
<BestShares />
<MinersTable />
{/if}
</main>
<UserDetailModal />
<BlockFoundAnimation />
<style>
main {
max-width: 1280px;
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte";
// One entry per falling petal; (re)generated on each block-found
// trigger so the motion isn't perfectly synchronised across blocks.
type Petal = {
id: number;
left: number; // viewport %
delay: number; // seconds before this petal starts falling
duration: number; // seconds for the full fall
drift: number; // horizontal drift in vw over the fall
rotate: number; // final rotation in deg
scale: number; // final render scale
hue: number; // pink/red hue shift
};
const ANIMATION_MS = 3600;
const PETAL_COUNT = 22;
let active = $state(false);
let petals = $state<Petal[]>([]);
let height = $state(0);
const currentHeight = $derived(snap.data?.chain?.blocks ?? 0);
$effect(() => {
const h = currentHeight;
const prev = untrack(() => height);
if (h > 0 && prev > 0 && h !== prev) {
trigger();
}
height = h;
});
function trigger(): void {
active = true;
petals = Array.from({ length: PETAL_COUNT }, (_, i) => ({
id: Date.now() + i,
left: Math.random() * 100,
delay: Math.random() * 0.6,
duration: 2.2 + Math.random() * 1.1,
drift: (Math.random() - 0.5) * 24,
rotate: (Math.random() - 0.5) * 720,
scale: 0.6 + Math.random() * 0.8,
hue: -10 + Math.random() * 30,
}));
setTimeout(() => {
active = false;
petals = [];
}, ANIMATION_MS);
}
</script>
{#if active}
<div class="overlay" aria-hidden="true">
<!-- Radial ember burst from the top centre where the header sits;
fills the viewport briefly in Hinokami Kagura orange/red. -->
<div class="burst"></div>
<!-- A faint sun-ray sweep rotating once behind the content. -->
<div class="rays"></div>
<!-- Individually-animated petals falling across the viewport. -->
{#each petals as p (p.id)}
<span
class="petal"
style="
left: {p.left}vw;
animation-delay: {p.delay}s;
animation-duration: {p.duration}s;
--drift: {p.drift}vw;
--rot: {p.rotate}deg;
--scale: {p.scale};
--hue: {p.hue}deg;
"
>
<!-- Five-petal cherry-blossom silhouette. -->
<svg viewBox="0 0 32 32">
<g fill="currentColor">
{#each [0, 72, 144, 216, 288] as a}
<ellipse
cx="16" cy="7" rx="4.5" ry="8"
transform="rotate({a} 16 16)"
/>
{/each}
<circle cx="16" cy="16" r="1.6" fill="#6b1f1f" />
</g>
</svg>
</span>
{/each}
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 150;
}
/* Radial ember burst. Starts bright, expands, and fades. */
.burst {
position: absolute;
left: 50%;
top: -20vh;
transform: translate(-50%, -50%);
width: 40vw;
height: 40vw;
border-radius: 50%;
background:
radial-gradient(circle, rgba(255, 160, 70, 0.75) 0%,
rgba(255, 90, 30, 0.45) 30%,
rgba(180, 40, 20, 0.18) 55%,
transparent 75%);
filter: blur(20px);
animation: burst 1.6s ease-out forwards;
}
@keyframes burst {
0% { transform: translate(-50%, -50%) scale(0.2); opacity: 0; }
15% { opacity: 1; }
100% { transform: translate(-50%, -50%) scale(6); opacity: 0; }
}
/* A spinning sun-ray pattern. Subtle — doesn't dominate the page. */
.rays {
position: absolute;
left: 50%;
top: 50%;
width: 200vmax;
height: 200vmax;
transform: translate(-50%, -50%);
background: repeating-conic-gradient(
from 0deg,
rgba(255, 140, 50, 0.08) 0deg 4deg,
transparent 4deg 14deg
);
mix-blend-mode: screen;
opacity: 0;
animation: rays 2.4s ease-out forwards;
}
@keyframes rays {
0% { opacity: 0; transform: translate(-50%, -50%) rotate(-20deg); }
20% { opacity: 0.8; }
100% { opacity: 0; transform: translate(-50%, -50%) rotate(40deg); }
}
/* Falling sakura petals. Each gets a slightly different path via
* CSS custom properties for drift / rotation / scale. */
.petal {
position: absolute;
top: -8vh;
width: 26px;
height: 26px;
color: hsl(340, 70%, 78%);
filter:
hue-rotate(var(--hue, 0deg))
drop-shadow(0 1px 2px rgba(200, 60, 90, 0.35));
transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
opacity: 0;
animation-name: petalFall;
animation-timing-function: cubic-bezier(0.4, 0.1, 0.4, 1);
animation-fill-mode: forwards;
}
.petal svg {
width: 100%;
height: 100%;
}
@keyframes petalFall {
0% { transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
opacity: 0; }
10% { opacity: 1; }
100% { transform: translateX(var(--drift, 0)) translateY(110vh) rotate(var(--rot, 0))
scale(var(--scale, 1));
opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.burst, .rays, .petal {
animation-duration: 0.6s;
}
}
</style>
+26 -1
View File
@@ -8,7 +8,32 @@
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const points = $derived(snap.data?.hashrate_history ?? []);
const rawPoints = $derived(snap.data?.hashrate_history ?? []);
// Bucket raw per-minute samples into ~96 time buckets for a readable
// 24h chart. Averaging each bucket smooths out short-term jitter
// without losing the trend. When we have fewer raw points than the
// target (early in the process's life), just pass them through.
const TARGET_POINTS = 96;
const points = $derived.by(() => {
if (rawPoints.length <= TARGET_POINTS) return rawPoints;
const bucketSize = Math.ceil(rawPoints.length / TARGET_POINTS);
const out: { t: number; v: number }[] = [];
for (let i = 0; i < rawPoints.length; i += bucketSize) {
let sum = 0;
let count = 0;
let tSum = 0;
for (let j = i; j < Math.min(i + bucketSize, rawPoints.length); j++) {
sum += rawPoints[j].v;
tSum += rawPoints[j].t;
count++;
}
if (count > 0) {
out.push({ t: Math.round(tSum / count), v: sum / count });
}
}
return out;
});
const chart = $derived.by(() => {
if (points.length < 2) return null;
+22 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { selectUser } from "../stores/selection.svelte";
import { formatHashrate, formatDifficulty, formatAgo, detectHardware } from "../format";
import type { StratumClient, Worker } from "../types";
@@ -97,7 +98,12 @@
<tr class:offline={!r.online} class:idle={r.idle}>
<td>
<div class="wname mono">{r.workerName}</div>
<div class="addr">{r.address}</div>
<button
type="button"
class="addr-btn mono"
onclick={() => selectUser(r.address)}
title="View per-user stats"
>{r.address}</button>
</td>
<td>{r.hardware}</td>
<td class="num">{formatHashrate(r.hashrate1m)}</td>
@@ -130,10 +136,23 @@
.wname {
font-size: 0.95em;
}
.addr {
.addr-btn {
display: inline-block;
color: var(--fg-dim);
font-size: 0.8em;
font-family: "JetBrains Mono", ui-monospace, monospace;
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
text-align: left;
text-decoration: none;
border-bottom: 1px dashed transparent;
}
.addr-btn:hover {
color: var(--accent);
border-bottom-color: var(--accent);
background: transparent;
}
tr.offline {
opacity: 0.4;
+302
View File
@@ -0,0 +1,302 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { selection, clearSelection } from "../stores/selection.svelte";
import {
formatHashrate,
formatDifficulty,
formatAgo,
detectHardware,
} from "../format";
import type { User, Worker, StratumClient } from "../types";
const address = $derived(selection.user);
// User row from snapshot.users matching the selected address.
const user = $derived.by<User | null>(() => {
if (!address) return null;
return (snap.data?.users ?? []).find((u) => u.user === address) ?? null;
});
// All workers + live stratum clients for this user.
const workerRows = $derived.by(() => {
if (!address) return [];
const workers = (snap.data?.workers ?? []).filter((w) => w.user === address);
const clients = (snap.data?.clients ?? []).filter(
(c: StratumClient) => c.address === address,
);
const clientByWorker = new Map<string, StratumClient>();
for (const c of clients) {
const wname = c.workername || `${c.address}.unnamed`;
clientByWorker.set(wname, c);
}
const seen = new Set<string>();
const rows: Array<{
worker: string;
hardware: string;
hashrate1m: number;
hashrate1h: number;
diff: number;
bestRound: number;
bestEver: number;
lastShare: number;
online: boolean;
idle: boolean;
}> = [];
for (const w of workers as Worker[]) {
const c = clientByWorker.get(w.worker);
seen.add(w.worker);
rows.push({
worker: w.worker,
hardware: c ? detectHardware(c.useragent) : "offline",
hashrate1m: (c?.dsps1 ?? w.dsps1) * 2 ** 32,
hashrate1h: (c?.dsps60 ?? w.dsps60) * 2 ** 32,
diff: c?.diff ?? w.mindiff,
bestRound: c?.bestdiff ?? w.bestdiff,
bestEver: w.bestever,
lastShare: c?.lastshare ?? w.lastshare,
online: !!c,
idle: !!(c?.idle ?? w.idle),
});
}
// Any live client without a matching worker (rare — usually
// means workers[] hasn't refreshed yet).
for (const c of clients) {
const wname = c.workername || `${c.address}.unnamed`;
if (seen.has(wname)) continue;
rows.push({
worker: wname,
hardware: detectHardware(c.useragent),
hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32,
diff: c.diff,
bestRound: c.bestdiff,
bestEver: 0,
lastShare: c.lastshare,
online: true,
idle: c.idle,
});
}
rows.sort((a, b) => b.hashrate1h - a.hashrate1h);
return rows;
});
// Per-user aggregates, falling back to summed worker values if the
// server hasn't populated the user row yet.
const totals = $derived.by(() => {
if (user) {
return {
hs1m: user.dsps1 * 2 ** 32,
hs5m: user.dsps5 * 2 ** 32,
hs1h: user.dsps60 * 2 ** 32,
hs24h: user.dsps1440 * 2 ** 32,
bestDiff: user.bestdiff,
bestEver: user.bestever,
workers: user.workers,
lastShare: user.lastshare,
};
}
let hs1m = 0, hs1h = 0;
let bestDiff = 0, bestEver = 0, lastShare = 0;
for (const r of workerRows) {
hs1m += r.hashrate1m;
hs1h += r.hashrate1h;
if (r.bestRound > bestDiff) bestDiff = r.bestRound;
if (r.bestEver > bestEver) bestEver = r.bestEver;
if (r.lastShare > lastShare) lastShare = r.lastShare;
}
return {
hs1m, hs5m: 0, hs1h, hs24h: 0,
bestDiff, bestEver,
workers: workerRows.length,
lastShare,
};
});
function onBackdrop(ev: MouseEvent): void {
if (ev.target === ev.currentTarget) clearSelection();
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
</script>
<svelte:window onkeydown={onKey} />
{#if address}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="backdrop"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={onBackdrop}
>
<div class="modal card">
<header class="head">
<div class="title">
<div class="stat-label">User</div>
<div class="addr mono">{address}</div>
</div>
<button class="close" onclick={clearSelection} aria-label="Close">&times;</button>
</header>
<section class="totals grid-3">
<div>
<div class="stat-label">Hashrate</div>
<div class="stat-value">{formatHashrate(totals.hs1m)}</div>
<div class="stat-sub">
{#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if}
1h {formatHashrate(totals.hs1h)}
{#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if}
</div>
</div>
<div>
<div class="stat-label">Best share</div>
<div class="stat-value">{formatDifficulty(totals.bestEver || totals.bestDiff)}</div>
<div class="stat-sub">round {formatDifficulty(totals.bestDiff)}</div>
</div>
<div>
<div class="stat-label">Workers</div>
<div class="stat-value">{totals.workers}</div>
<div class="stat-sub">last share {formatAgo(totals.lastShare)}</div>
</div>
</section>
<section>
<h3>Workers</h3>
{#if workerRows.length === 0}
<div class="empty">No worker data yet for this address.</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Hardware</th>
<th class="num">Hashrate (1m)</th>
<th class="num">Hashrate (1h)</th>
<th class="num">Diff</th>
<th class="num">Best (round)</th>
<th class="num">Best (ever)</th>
<th class="num">Last share</th>
</tr>
</thead>
<tbody>
{#each workerRows as r (r.worker)}
<tr class:offline={!r.online} class:idle={r.idle}>
<td class="mono">{r.worker}</td>
<td>{r.hardware}</td>
<td class="num">{formatHashrate(r.hashrate1m)}</td>
<td class="num">{formatHashrate(r.hashrate1h)}</td>
<td class="num">{formatDifficulty(r.diff)}</td>
<td class="num">{formatDifficulty(r.bestRound)}</td>
<td class="num">{formatDifficulty(r.bestEver)}</td>
<td class="num">{formatAgo(r.lastShare)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(3, 6, 10, 0.72);
backdrop-filter: blur(3px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 4rem 1rem;
z-index: 200;
animation: fade 0.2s ease-out;
}
.modal {
width: min(960px, 100%);
max-height: calc(100vh - 8rem);
overflow: auto;
border: 1px solid var(--accent-dim);
box-shadow: 0 0 40px rgba(255, 122, 58, 0.18);
animation: slide 0.25s ease-out;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.title {
min-width: 0;
flex: 1;
}
.addr {
font-size: 1.05rem;
font-weight: 600;
word-break: break-all;
margin-top: 0.3em;
}
.close {
background: transparent;
border: 1px solid var(--border);
color: var(--fg-dim);
width: 2rem;
height: 2rem;
padding: 0;
border-radius: 6px;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.close:hover {
color: var(--fg);
border-color: var(--accent);
}
.totals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
@media (max-width: 640px) {
.totals {
grid-template-columns: 1fr;
}
}
h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
}
.empty {
color: var(--fg-dim);
padding: 1rem 0;
}
.table-wrap {
overflow-x: auto;
}
tr.offline {
opacity: 0.5;
}
tr.idle td {
color: var(--fg-dim);
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide {
from { transform: translateY(-12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
+13
View File
@@ -0,0 +1,13 @@
// Cross-component selection state. Currently just tracks the user
// whose detail modal is open; App renders the modal off this store
// so any component in the tree can open it by setting `user`.
export const selection = $state<{ user: string | null }>({ user: null });
export function selectUser(address: string): void {
selection.user = address;
}
export function clearSelection(): void {
selection.user = null;
}