From f1dc0a8a7903701f0235c87c5e9cce6b10aaa9af Mon Sep 17 00:00:00 2001 From: satoshi Date: Tue, 12 May 2026 21:56:02 +0300 Subject: [PATCH] Persist best share glow, share animations, and UI polish Backend: - Persist best_diff as a high-water mark in kv store so it survives restarts and transient ckpool gaps - Persist acked_best_diff so the "new best share" glow survives pool restarts and works across multiple new bests before dismissal - Add POST /api/admin/ack-best and /api/admin/reset-ack-best endpoints Frontend: - Best share card: golden glow, shimmer, sparkles, "NEW BEST SHARE!" tag with previous value, "Nice" dismiss button - Optimistic local override so Nice/test buttons respond instantly instead of waiting for next WebSocket push - Fix shimmer sweep to go left-to-right (not start from middle) - Share animations: reject (red flash + shake) suppresses accept; reject effect declared first so guard reads correct state - Share tooltips show exact counts on hover - Block reward tooltip shows full amount in satoshis - Expected block uses 1h hashrate instead of 1m - formatDuration uses full unit names; years always show 1 decimal - formatWork always shows 2 decimal places - Map chain "main" to "mainnet" in block height card --- api/internal/httpapi/server.go | 12 ++ api/internal/state/aggregator.go | 71 +++++++++- ui/src/format.ts | 26 +++- ui/src/lib/PoolOverview.svelte | 217 +++++++++++++++++++++++++++++-- ui/src/lib/SharesBar.svelte | 95 ++++++++++++-- ui/src/types.ts | 1 + 6 files changed, 392 insertions(+), 30 deletions(-) diff --git a/api/internal/httpapi/server.go b/api/internal/httpapi/server.go index 5c724a6..b060bcd 100644 --- a/api/internal/httpapi/server.go +++ b/api/internal/httpapi/server.go @@ -41,6 +41,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/ws", s.handleWS) mux.HandleFunc("GET /api/admin/debug-blocks", s.debugBlocks) mux.HandleFunc("POST /api/admin/reset-latency", s.resetLatency) + mux.HandleFunc("POST /api/admin/ack-best", s.ackBest) + mux.HandleFunc("POST /api/admin/reset-ack-best", s.resetAckBest) mux.HandleFunc("POST /api/accelerate", s.accelerate) mux.HandleFunc("POST /api/accelerate/cancel", s.accelerateCancel) mux.HandleFunc("POST /api/accelerate/max", s.accelerateMax) @@ -220,3 +222,13 @@ func (s *Server) resetLatency(w http.ResponseWriter, _ *http.Request) { s.Agg.ResetLatency() writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } + +func (s *Server) ackBest(w http.ResponseWriter, _ *http.Request) { + s.Agg.AckBestDiff() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) resetAckBest(w http.ResponseWriter, _ *http.Request) { + s.Agg.ResetAckedBestDiff() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go index f231765..a88a654 100644 --- a/api/internal/state/aggregator.go +++ b/api/internal/state/aggregator.go @@ -43,6 +43,9 @@ type Snapshot struct { // Best share difficulty ever seen across all workers. BestDiff float64 `json:"best_diff"` + // Last best-diff value acknowledged by the user via the UI. + // The frontend shows a "new best" glow when best_diff > this. + AckedBestDiff float64 `json:"acked_best_diff"` // Cumulative work done by the pool across its entire lifetime, in // diff-1-normalized shares (multiply by 2^32 for total hashes). @@ -126,6 +129,9 @@ const ( kvLatencyLastMs = "latency_last_ms" kvStaleWorkHashes = "stale_work_hashes" + kvAckedBestDiff = "acked_best_diff" + kvBestDiff = "best_diff" + kvAllTimeAccepted = "alltime_accepted" kvAllTimeRejected = "alltime_rejected" @@ -236,6 +242,13 @@ type Aggregator struct { latencySumMs int64 // sum of all latencies in ms latencyLastMs int64 // most recent observation staleWorkHashes float64 // cumulative wasted hashes = sum(latency_s * hashrate_at_event) + + // High-water mark for the best share difficulty ever seen. + // Persisted to kv so it survives restarts and transient ckpool gaps. + bestDiff float64 + + // Last best-diff value acknowledged by the user in the UI. + ackedBestDiff float64 } func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog.Logger) *Aggregator { @@ -406,7 +419,9 @@ func (a *Aggregator) refresh(ctx context.Context) { } } - // Compute pool-wide best-ever share diff from workers. + // Compute pool-wide best-ever share diff from workers, then clamp + // to the persisted high-water mark so a ckpool restart or transient + // socket timeout can never lower the value. for _, w := range next.Workers { d := w.BestEver if d == 0 { @@ -418,6 +433,17 @@ func (a *Aggregator) refresh(ctx context.Context) { } a.mu.Lock() + + // High-water mark: only increases, never decreases. + if next.BestDiff > a.bestDiff { + a.bestDiff = next.BestDiff + if a.Store != nil { + val := strconv.FormatFloat(a.bestDiff, 'f', -1, 64) + _ = a.Store.SetKV(kvBestDiff, val) + } + } + next.BestDiff = a.bestDiff + next.AckedBestDiff = a.ackedBestDiff now := time.Now() // --- cumulative work tracking --- @@ -554,6 +580,31 @@ func (a *Aggregator) refresh(ctx context.Context) { a.markReady() } +// AckBestDiff records the current best_diff as acknowledged so the UI +// stops showing the "new best share" glow until a higher one appears. +func (a *Aggregator) AckBestDiff() { + a.mu.Lock() + a.ackedBestDiff = a.snap.BestDiff + a.mu.Unlock() + if a.Store != nil { + val := strconv.FormatFloat(a.snap.BestDiff, 'f', -1, 64) + if err := a.Store.SetKV(kvAckedBestDiff, val); err != nil { + a.Log.Warn("acked_best_diff persist failed", "err", err) + } + } +} + +// ResetAckedBestDiff clears the acknowledged best diff so the "new best +// share" glow reappears. Used by the debug/test UI button. +func (a *Aggregator) ResetAckedBestDiff() { + a.mu.Lock() + a.ackedBestDiff = 0 + a.mu.Unlock() + if a.Store != nil { + _ = a.Store.SetKV(kvAckedBestDiff, "0") + } +} + // loadPersistedState restores cumulative work and hashrate history from // the store so they survive process restarts. Safe to call with a nil // Store — becomes a no-op. @@ -604,6 +655,24 @@ func (a *Aggregator) loadPersistedState() { } } + // Restore best diff high-water mark. + if v, err := a.Store.GetKV(kvBestDiff); err == nil && v != "" { + if f, perr := strconv.ParseFloat(v, 64); perr == nil { + a.mu.Lock() + a.bestDiff = f + a.mu.Unlock() + } + } + + // Restore acknowledged best diff. + if v, err := a.Store.GetKV(kvAckedBestDiff); err == nil && v != "" { + if f, perr := strconv.ParseFloat(v, 64); perr == nil { + a.mu.Lock() + a.ackedBestDiff = f + a.mu.Unlock() + } + } + // Restore latency stats. a.mu.Lock() if v, err := a.Store.GetKV(kvLatencyCount); err == nil && v != "" { diff --git a/ui/src/format.ts b/ui/src/format.ts index 8b7d76f..765526f 100644 --- a/ui/src/format.ts +++ b/ui/src/format.ts @@ -40,8 +40,7 @@ export function formatWork(hashes: number): string { v /= 1000; i++; } - const digits = v >= 100 ? 0 : v >= 10 ? 1 : 2; - return `${v.toFixed(digits)} ${units[i]}`; + return `${v.toFixed(2)} ${units[i]}`; } export function formatUptime(seconds: number): string { @@ -164,9 +163,22 @@ export function expectedBlockSeconds( export function formatDuration(seconds: number): string { if (!isFinite(seconds)) return "∞"; - if (seconds < 60) return `${Math.round(seconds)}s`; - if (seconds < 3600) return `${Math.round(seconds / 60)}m`; - if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; - if (seconds < 31536000) return `${(seconds / 86400).toFixed(1)}d`; - return `${(seconds / 31536000).toFixed(1)}y`; + if (seconds < 60) return `${Math.round(seconds)} seconds`; + if (seconds < 3600) { + const m = Math.round(seconds / 60); + return `${m} minute${m === 1 ? "" : "s"}`; + } + if (seconds < 86400) { + const h = seconds / 3600; + const v = h >= 10 ? Math.round(h) : +h.toFixed(1); + return `${v} hour${v === 1 ? "" : "s"}`; + } + if (seconds < 31536000) { + const d = seconds / 86400; + const v = d >= 10 ? Math.round(d) : +d.toFixed(1); + return `${v} day${v === 1 ? "" : "s"}`; + } + const y = seconds / 31536000; + const v = +y.toFixed(1); + return `${v} year${v === 1 ? "" : "s"}`; } diff --git a/ui/src/lib/PoolOverview.svelte b/ui/src/lib/PoolOverview.svelte index 97cfc6c..f05b04e 100644 --- a/ui/src/lib/PoolOverview.svelte +++ b/ui/src/lib/PoolOverview.svelte @@ -21,7 +21,7 @@ const expected = $derived.by(() => { const diff = data.chain?.difficulty ?? 0; - return expectedBlockSeconds(data.hashrate_hs_1m, data.network_hashrate_hs, diff); + return expectedBlockSeconds(data.hashrate_hs_1h, data.network_hashrate_hs, diff); }); // Round effort: share of the expected work-to-find-a-block that the @@ -86,6 +86,40 @@ } 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(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" }); + } + + // DEBUG: remove before shipping + function debugBest(): void { + localAcked = 0; + fetch("/api/admin/reset-ack-best", { method: "POST" }); + }
@@ -99,17 +133,42 @@ -
-
Best share
-
{formatDifficulty(data.best_diff)}
-
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." - > - {luckLabel} - luck + + +
+ {#if bestGlow} +
New best share! Previous: {formatDifficulty(bestPrevValue)}
+ {/if} + + +
+
+ Best share + + +
+
{formatDifficulty(data.best_diff)}
+
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." + > + {luckLabel} + luck +
+ {#if bestGlow} + + +
Nice
+ {/if}
@@ -147,12 +206,12 @@
Block height
{height ? height.toLocaleString() : "—"}
-
{data.chain?.chain ?? "—"}
+
{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "—"}
Block reward
-
+
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"} BTC
@@ -269,6 +328,136 @@ 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); + } + + .debug-btn { + font-size: 0.6rem; + padding: 0.1em 0.4em; + opacity: 0.3; + border-radius: 3px; + margin-left: 0.5em; + vertical-align: middle; + } + .debug-btn:hover { + opacity: 1; + } + .height-card { transition: box-shadow 0.3s, border-color 0.3s; } diff --git a/ui/src/lib/SharesBar.svelte b/ui/src/lib/SharesBar.svelte index 11b78fc..ec410c6 100644 --- a/ui/src/lib/SharesBar.svelte +++ b/ui/src/lib/SharesBar.svelte @@ -18,19 +18,48 @@ allTotal > 0 ? (allRej / allTotal) * 100 : 0, ); - // Detect new accepted shares to trigger animation + // Detect new rejected shares to trigger shake animation. + // Skip the first snapshot (baseline), then fire on any increase + // — including from 0 after a pool restart. Declared BEFORE the + // accepted effect so `reject` is already set when the accept + // guard reads it. + let prevRejected = $state(0); + let rejHasBaseline = false; + let reject = $state(false); + $effect(() => { + const cur = sessionRej; + const prev = untrack(() => prevRejected); + if (rejHasBaseline && cur > prev) { + reject = true; + setTimeout(() => { reject = false; }, 1200); + } + rejHasBaseline = true; + prevRejected = cur; + }); + + // Detect new accepted shares to trigger flame-slash animation. + // Skip the first snapshot (baseline), then fire on any increase. + // Suppressed while a reject animation is active or starting in the + // same snapshot (rejects are rarer and more important). let prevAccepted = $state(0); + let accHasBaseline = false; let pulse = $state(false); $effect(() => { const cur = sessionAcc; const prev = untrack(() => prevAccepted); - if (cur > 0 && prev > 0 && cur > prev) { + const rejNow = untrack(() => reject); + if (accHasBaseline && cur > prev && !rejNow) { pulse = true; setTimeout(() => { pulse = false; }, 700); } + accHasBaseline = true; prevAccepted = cur; }); + // DEBUG: remove before shipping + function debugPulse(): void { pulse = true; setTimeout(() => { pulse = false; }, 700); } + function debugReject(): void { reject = true; setTimeout(() => { reject = false; }, 1200); } + function fmtCount(n: number): string { if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; @@ -38,16 +67,21 @@ } -
+
+
-

Shares

+

Shares + + + +

Session - {fmtCount(sessionAcc)} + {fmtCount(sessionAcc)} / - {fmtCount(sessionRej)} + {fmtCount(sessionRej)} {#if sessionTotal > 0} 1}> {sessionRejectPct.toFixed(2)}% rejected @@ -57,9 +91,9 @@
All-time - {fmtCount(allAcc)} + {fmtCount(allAcc)} / - {fmtCount(allRej)} + {fmtCount(allRej)} {#if allTotal > 0} 1}> {allRejectPct.toFixed(2)}% rejected @@ -95,6 +129,18 @@ letter-spacing: 0.05em; color: var(--fg); } + .debug-btn { + font-size: 0.6rem; + padding: 0.1em 0.4em; + opacity: 0.3; + border-radius: 3px; + margin-left: 0.3em; + vertical-align: middle; + text-transform: none; + } + .debug-btn:hover { + opacity: 1; + } .stats { display: flex; align-items: center; @@ -182,6 +228,39 @@ box-shadow: 0 0 12px rgba(255, 122, 58, 0.2); } + /* Red flash + shake on rejected share */ + .reject-flash { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + background: rgba(255, 60, 60, 0.12); + } + .shares-bar.reject .reject-flash { + animation: reject-flash 1.2s ease-out; + } + .shares-bar.reject { + animation: reject-shake 0.6s ease-out; + border-color: rgba(255, 80, 80, 0.6); + box-shadow: 0 0 16px rgba(255, 60, 60, 0.3); + } + @keyframes reject-flash { + 0% { opacity: 1; } + 40% { opacity: 0.5; } + 100% { opacity: 0; } + } + @keyframes reject-shake { + 0%, 100% { transform: translateX(0); } + 10% { transform: translateX(-4px); } + 20% { transform: translateX(4px); } + 30% { transform: translateX(-3px); } + 40% { transform: translateX(3px); } + 50% { transform: translateX(-2px); } + 60% { transform: translateX(2px); } + 70% { transform: translateX(-1px); } + 80% { transform: translateX(1px); } + } + @media (max-width: 600px) { .content { flex-direction: column; diff --git a/ui/src/types.ts b/ui/src/types.ts index 7dec4b6..13e3f0c 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -122,6 +122,7 @@ export type Snapshot = { hashrate_hs_1h: number; hashrate_hs_24h: number; best_diff: number; + acked_best_diff: number; cumulative_shares: number; next_block_reward_btc: number; next_difficulty_percent: number;