diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go index 689cde2..f231765 100644 --- a/api/internal/state/aggregator.go +++ b/api/internal/state/aggregator.go @@ -87,6 +87,7 @@ type Snapshot struct { ZMQEnabled bool `json:"zmq_enabled"` LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0 HasLastZMQEvent bool `json:"has_last_zmq_event"` + TipChangedAge float64 `json:"tip_changed_age"` // seconds since tip height last changed // Share counters: raw counts (1 submission = 1 share regardless of diff). // Session = since ckpool started; AllTime = persisted across restarts. @@ -214,6 +215,12 @@ type Aggregator struct { zmqEnabled bool lastZMQEventTime time.Time + // Tip-change tracking: the height and time when we last saw the + // chain tip advance. Used to distinguish "ZMQ stale" from "no + // blocks on the network". + lastTipHeight int64 + lastTipChangedAt time.Time + // All-time share counters (raw, not diff-weighted). Accumulated // using the same delta-integration pattern as cumulative_shares. allTimeAccepted int64 @@ -337,6 +344,14 @@ func (a *Aggregator) refresh(ctx context.Context) { if bi, err := a.RPC.GetBlockchainInfo(ctx); err == nil { next.Chain = bi next.BitcoinOK = true + // Track when the tip height last changed. + if bi.Blocks != a.lastTipHeight { + a.lastTipHeight = bi.Blocks + a.lastTipChangedAt = time.Now() + } + if !a.lastTipChangedAt.IsZero() { + next.TipChangedAge = time.Since(a.lastTipChangedAt).Seconds() + } if nh, err := a.RPC.GetNetworkHashPS(ctx, -1, int(bi.Blocks)); err == nil { next.NetworkHashrateHs = nh } diff --git a/ui/src/lib/BlockFoundAnimation.svelte b/ui/src/lib/BlockFoundAnimation.svelte index 7d1790f..1255d21 100644 --- a/ui/src/lib/BlockFoundAnimation.svelte +++ b/ui/src/lib/BlockFoundAnimation.svelte @@ -2,92 +2,167 @@ 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 = { + type Particle = { 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 + left: number; // viewport % + delay: number; // seconds + duration: number; // seconds + drift: number; // horizontal drift in vw + rotate: number; // degrees + scale: number; + kind: "confetti" | "petal"; + color: string; + shape: "square" | "rect" | "circle"; }; - const ANIMATION_MS = 3600; - const PETAL_COUNT = 22; + const CONFETTI_COUNT = 60; + const PETAL_COUNT = 18; let active = $state(false); - let petals = $state([]); - let height = $state(0); + let particles = $state([]); + let blockCount = $state(0); + let foundBlock = $state<{ height: number; reward?: number; hash?: string } | null>(null); - const currentHeight = $derived(snap.data?.chain?.blocks ?? 0); + const currentBlockCount = $derived( + (snap.data?.recent_blocks ?? []).length, + ); + // Detect when the pool finds a new block (recent_blocks grows). $effect(() => { - const h = currentHeight; - const prev = untrack(() => height); - if (h > 0 && prev > 0 && h !== prev) { - trigger(); + const count = currentBlockCount; + const prev = untrack(() => blockCount); + if (count > 0 && prev > 0 && count > prev) { + const blocks = snap.data?.recent_blocks ?? []; + const latest = blocks[blocks.length - 1]; + trigger(latest); } - height = h; + blockCount = count; }); - function trigger(): void { + const confettiColors = [ + "#ff7a3a", "#ffb347", "#ff6b6b", "#5ce0a8", "#47b8f5", + "#f5c447", "#ff5ecd", "#a78bfa", "#34d399", "#fb923c", + ]; + + function trigger(block?: { height: number; reward_btc?: number; hash?: string }): void { + if (block) { + foundBlock = { height: block.height, reward: block.reward_btc, hash: block.hash }; + } 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); + + // Stagger delays across a full cycle so the rain looks continuous + // when looping. Each particle's delay spreads it evenly over its + // own duration window. + const confetti: Particle[] = Array.from({ length: CONFETTI_COUNT }, (_, i) => { + const dur = 2.0 + Math.random() * 2.5; + return { + id: Date.now() + i, + left: Math.random() * 100, + delay: (i / CONFETTI_COUNT) * dur, + duration: dur, + drift: (Math.random() - 0.5) * 30, + rotate: (Math.random() - 0.5) * 1080, + scale: 0.5 + Math.random() * 0.7, + kind: "confetti" as const, + color: confettiColors[Math.floor(Math.random() * confettiColors.length)], + shape: (["square", "rect", "circle"] as const)[Math.floor(Math.random() * 3)], + }; + }); + + const petals: Particle[] = Array.from({ length: PETAL_COUNT }, (_, i) => { + const dur = 2.5 + Math.random() * 1.5; + return { + id: Date.now() + CONFETTI_COUNT + i, + left: Math.random() * 100, + delay: (i / PETAL_COUNT) * dur, + duration: dur, + drift: (Math.random() - 0.5) * 20, + rotate: (Math.random() - 0.5) * 720, + scale: 0.6 + Math.random() * 0.8, + kind: "petal" as const, + color: "", + shape: "circle" as const, + }; + }); + + particles = [...confetti, ...petals]; + } + + function dismiss(): void { + active = false; + particles = []; + foundBlock = null; } {#if active} + + + {/if} diff --git a/ui/src/lib/HealthBanners.svelte b/ui/src/lib/HealthBanners.svelte index 0952fbf..5f54ee7 100644 --- a/ui/src/lib/HealthBanners.svelte +++ b/ui/src/lib/HealthBanners.svelte @@ -13,13 +13,17 @@ return Math.max(0, (d.block_submit_attempts ?? 0) - (d.block_submits_confirmed ?? 0)); }); - // ZMQ stale = configured but no event in 30+ minutes. Bitcoin's avg - // block interval is 10 min; 30 min covers normal variance without - // false-alarming on quiet stretches. + // ZMQ stale = the tip has advanced but ZMQ didn't fire. ZMQ + // delivers within milliseconds, so if the tip changed recently + // but the last ZMQ event is much older, the subscriber is broken. + // When no block has been mined, both ages grow together → no alarm. const zmqStale = $derived.by(() => { const d = snap.data; if (!d || !d.zmq_enabled || !d.has_last_zmq_event) return false; - return (d.last_zmq_event_age ?? 0) > 1800; + const zmqAge = d.last_zmq_event_age ?? 0; + const tipAge = d.tip_changed_age ?? 0; + // Alarm when ZMQ is 3+ min older than the last tip change. + return zmqAge > tipAge + 180; }); @@ -67,8 +71,8 @@ line-height: 1.4; } .banner.warn { - background: rgba(220, 170, 60, 0.10); - border-color: rgba(220, 170, 60, 0.40); + background: rgb(50, 40, 15); + border-color: rgb(220, 170, 60); color: rgb(220, 180, 100); } .icon { diff --git a/ui/src/types.ts b/ui/src/types.ts index b6b2620..7dec4b6 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -141,6 +141,7 @@ export type Snapshot = { zmq_enabled: boolean; has_last_zmq_event: boolean; last_zmq_event_age?: number; // seconds + tip_changed_age: number; // seconds since tip height last changed // Share counters (raw, 1 submission = 1 share). session_accepted: number; session_rejected: number;