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
This commit is contained in:
satoshi
2026-05-12 21:56:02 +03:00
parent 467fe5e2ef
commit f1dc0a8a79
6 changed files with 392 additions and 30 deletions
+12
View File
@@ -41,6 +41,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/ws", s.handleWS) mux.HandleFunc("GET /api/ws", s.handleWS)
mux.HandleFunc("GET /api/admin/debug-blocks", s.debugBlocks) mux.HandleFunc("GET /api/admin/debug-blocks", s.debugBlocks)
mux.HandleFunc("POST /api/admin/reset-latency", s.resetLatency) 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", s.accelerate)
mux.HandleFunc("POST /api/accelerate/cancel", s.accelerateCancel) mux.HandleFunc("POST /api/accelerate/cancel", s.accelerateCancel)
mux.HandleFunc("POST /api/accelerate/max", s.accelerateMax) mux.HandleFunc("POST /api/accelerate/max", s.accelerateMax)
@@ -220,3 +222,13 @@ func (s *Server) resetLatency(w http.ResponseWriter, _ *http.Request) {
s.Agg.ResetLatency() s.Agg.ResetLatency()
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) 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})
}
+70 -1
View File
@@ -43,6 +43,9 @@ type Snapshot struct {
// Best share difficulty ever seen across all workers. // Best share difficulty ever seen across all workers.
BestDiff float64 `json:"best_diff"` 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 // Cumulative work done by the pool across its entire lifetime, in
// diff-1-normalized shares (multiply by 2^32 for total hashes). // diff-1-normalized shares (multiply by 2^32 for total hashes).
@@ -126,6 +129,9 @@ const (
kvLatencyLastMs = "latency_last_ms" kvLatencyLastMs = "latency_last_ms"
kvStaleWorkHashes = "stale_work_hashes" kvStaleWorkHashes = "stale_work_hashes"
kvAckedBestDiff = "acked_best_diff"
kvBestDiff = "best_diff"
kvAllTimeAccepted = "alltime_accepted" kvAllTimeAccepted = "alltime_accepted"
kvAllTimeRejected = "alltime_rejected" kvAllTimeRejected = "alltime_rejected"
@@ -236,6 +242,13 @@ type Aggregator struct {
latencySumMs int64 // sum of all latencies in ms latencySumMs int64 // sum of all latencies in ms
latencyLastMs int64 // most recent observation latencyLastMs int64 // most recent observation
staleWorkHashes float64 // cumulative wasted hashes = sum(latency_s * hashrate_at_event) 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 { 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 { for _, w := range next.Workers {
d := w.BestEver d := w.BestEver
if d == 0 { if d == 0 {
@@ -418,6 +433,17 @@ func (a *Aggregator) refresh(ctx context.Context) {
} }
a.mu.Lock() 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() now := time.Now()
// --- cumulative work tracking --- // --- cumulative work tracking ---
@@ -554,6 +580,31 @@ func (a *Aggregator) refresh(ctx context.Context) {
a.markReady() 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 // loadPersistedState restores cumulative work and hashrate history from
// the store so they survive process restarts. Safe to call with a nil // the store so they survive process restarts. Safe to call with a nil
// Store — becomes a no-op. // 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. // Restore latency stats.
a.mu.Lock() a.mu.Lock()
if v, err := a.Store.GetKV(kvLatencyCount); err == nil && v != "" { if v, err := a.Store.GetKV(kvLatencyCount); err == nil && v != "" {
+19 -7
View File
@@ -40,8 +40,7 @@ export function formatWork(hashes: number): string {
v /= 1000; v /= 1000;
i++; i++;
} }
const digits = v >= 100 ? 0 : v >= 10 ? 1 : 2; return `${v.toFixed(2)} ${units[i]}`;
return `${v.toFixed(digits)} ${units[i]}`;
} }
export function formatUptime(seconds: number): string { export function formatUptime(seconds: number): string {
@@ -164,9 +163,22 @@ export function expectedBlockSeconds(
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {
if (!isFinite(seconds)) return "∞"; if (!isFinite(seconds)) return "∞";
if (seconds < 60) return `${Math.round(seconds)}s`; if (seconds < 60) return `${Math.round(seconds)} seconds`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`; if (seconds < 3600) {
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; const m = Math.round(seconds / 60);
if (seconds < 31536000) return `${(seconds / 86400).toFixed(1)}d`; return `${m} minute${m === 1 ? "" : "s"}`;
return `${(seconds / 31536000).toFixed(1)}y`; }
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"}`;
} }
+203 -14
View File
@@ -21,7 +21,7 @@
const expected = $derived.by(() => { const expected = $derived.by(() => {
const diff = data.chain?.difficulty ?? 0; 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 // Round effort: share of the expected work-to-find-a-block that the
@@ -86,6 +86,40 @@
} }
prevHeight = h; 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" });
}
// DEBUG: remove before shipping
function debugBest(): void {
localAcked = 0;
fetch("/api/admin/reset-ack-best", { method: "POST" });
}
</script> </script>
<section class="grid-5"> <section class="grid-5">
@@ -99,17 +133,42 @@
</div> </div>
</div> </div>
<div class="card"> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="stat-label">Best share</div> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="stat-value">{formatDifficulty(data.best_diff)}</div> <div
<div class="card best-card"
class="luck-sub" class:best-glow={bestGlow}
class:lucky={luckPct > 100} >
class:unlucky={luckPct > 0 && luckPct < 100} {#if bestGlow}
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." <div class="best-tag">New best share! Previous: {formatDifficulty(bestPrevValue)}</div>
> {/if}
<span class="luck-value">{luckLabel}</span> <div class="shimmer" aria-hidden="true"></div>
<span class="luck-label">luck</span> <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
<!-- DEBUG: remove before shipping -->
<button class="debug-btn" onclick={(e: MouseEvent) => { e.stopPropagation(); debugBest(); }}>test</button>
</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 animation">Nice</div>
{/if}
</div> </div>
</div> </div>
@@ -147,12 +206,12 @@
<div class="card height-card" class:new-block={heightFlash}> <div class="card height-card" class:new-block={heightFlash}>
<div class="stat-label">Block height</div> <div class="stat-label">Block height</div>
<div class="stat-value">{height ? height.toLocaleString() : "—"}</div> <div class="stat-value">{height ? height.toLocaleString() : "—"}</div>
<div class="stat-sub">{data.chain?.chain ?? "—"}</div> <div class="stat-sub">{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "—"}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="stat-label">Block reward</div> <div class="stat-label">Block reward</div>
<div class="stat-value"> <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) : "—"} {data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"}
<span class="unit">BTC</span> <span class="unit">BTC</span>
</div> </div>
@@ -269,6 +328,136 @@
text-shadow: 0 0 10px rgba(255, 107, 107, 0.25); 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 { .height-card {
transition: box-shadow 0.3s, border-color 0.3s; transition: box-shadow 0.3s, border-color 0.3s;
} }
+87 -8
View File
@@ -18,19 +18,48 @@
allTotal > 0 ? (allRej / allTotal) * 100 : 0, 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 prevAccepted = $state(0);
let accHasBaseline = false;
let pulse = $state(false); let pulse = $state(false);
$effect(() => { $effect(() => {
const cur = sessionAcc; const cur = sessionAcc;
const prev = untrack(() => prevAccepted); const prev = untrack(() => prevAccepted);
if (cur > 0 && prev > 0 && cur > prev) { const rejNow = untrack(() => reject);
if (accHasBaseline && cur > prev && !rejNow) {
pulse = true; pulse = true;
setTimeout(() => { pulse = false; }, 700); setTimeout(() => { pulse = false; }, 700);
} }
accHasBaseline = true;
prevAccepted = cur; 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 { function fmtCount(n: number): string {
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
@@ -38,16 +67,21 @@
} }
</script> </script>
<section class="shares-bar" class:pulse> <section class="shares-bar" class:pulse class:reject>
<div class="slash-effect" aria-hidden="true"></div> <div class="slash-effect" aria-hidden="true"></div>
<div class="reject-flash" aria-hidden="true"></div>
<div class="content"> <div class="content">
<h3 class="title">Shares</h3> <h3 class="title">Shares
<!-- DEBUG: remove before shipping -->
<button class="debug-btn" onclick={debugPulse}>acc</button>
<button class="debug-btn" onclick={debugReject}>rej</button>
</h3>
<div class="stats"> <div class="stats">
<div class="group"> <div class="group">
<span class="group-label">Session</span> <span class="group-label">Session</span>
<span class="accepted">{fmtCount(sessionAcc)}</span> <span class="accepted" title="{sessionAcc.toLocaleString()} accepted">{fmtCount(sessionAcc)}</span>
<span class="sep">/</span> <span class="sep">/</span>
<span class="rejected">{fmtCount(sessionRej)}</span> <span class="rejected" title="{sessionRej.toLocaleString()} rejected">{fmtCount(sessionRej)}</span>
{#if sessionTotal > 0} {#if sessionTotal > 0}
<span class="pct" class:warn={sessionRejectPct > 1}> <span class="pct" class:warn={sessionRejectPct > 1}>
{sessionRejectPct.toFixed(2)}% rejected {sessionRejectPct.toFixed(2)}% rejected
@@ -57,9 +91,9 @@
<div class="divider"></div> <div class="divider"></div>
<div class="group"> <div class="group">
<span class="group-label">All-time</span> <span class="group-label">All-time</span>
<span class="accepted">{fmtCount(allAcc)}</span> <span class="accepted" title="{allAcc.toLocaleString()} accepted">{fmtCount(allAcc)}</span>
<span class="sep">/</span> <span class="sep">/</span>
<span class="rejected">{fmtCount(allRej)}</span> <span class="rejected" title="{allRej.toLocaleString()} rejected">{fmtCount(allRej)}</span>
{#if allTotal > 0} {#if allTotal > 0}
<span class="pct" class:warn={allRejectPct > 1}> <span class="pct" class:warn={allRejectPct > 1}>
{allRejectPct.toFixed(2)}% rejected {allRejectPct.toFixed(2)}% rejected
@@ -95,6 +129,18 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--fg); 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 { .stats {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -182,6 +228,39 @@
box-shadow: 0 0 12px rgba(255, 122, 58, 0.2); 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) { @media (max-width: 600px) {
.content { .content {
flex-direction: column; flex-direction: column;
+1
View File
@@ -122,6 +122,7 @@ export type Snapshot = {
hashrate_hs_1h: number; hashrate_hs_1h: number;
hashrate_hs_24h: number; hashrate_hs_24h: number;
best_diff: number; best_diff: number;
acked_best_diff: number;
cumulative_shares: number; cumulative_shares: number;
next_block_reward_btc: number; next_block_reward_btc: number;
next_difficulty_percent: number; next_difficulty_percent: number;