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:
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +133,27 @@
|
|||||||
</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="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
|
||||||
|
<!-- 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="stat-value">{formatDifficulty(data.best_diff)}</div>
|
||||||
<div
|
<div
|
||||||
class="luck-sub"
|
class="luck-sub"
|
||||||
@@ -111,6 +164,12 @@
|
|||||||
<span class="luck-value">{luckLabel}</span>
|
<span class="luck-value">{luckLabel}</span>
|
||||||
<span class="luck-label">luck</span>
|
<span class="luck-label">luck</span>
|
||||||
</div>
|
</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
|
<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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user