Block found celebration + smarter ZMQ stale detection

Replace the subtle petal animation with a full block-found party:
confetti rain, sakura petals, ember burst, and a dismissible banner
showing block height and reward. Triggers on pool block finds (not
every network block). Animations loop until the user clicks dismiss.

Fix ZMQ stale banner false-positiving during long block intervals by
comparing ZMQ age against tip-change age instead of a fixed 30-min
threshold. Add tip_changed_age to the snapshot so the UI can tell
"no blocks on the network" from "ZMQ is broken". Use solid background
colors on health banners instead of transparent rgba.
This commit is contained in:
satoshi
2026-05-12 01:36:33 +03:00
parent 5e7be22e8e
commit 467fe5e2ef
4 changed files with 352 additions and 91 deletions
+15
View File
@@ -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
}
+297 -56
View File
@@ -2,65 +2,119 @@
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
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<Petal[]>([]);
let height = $state(0);
let particles = $state<Particle[]>([]);
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) => ({
// 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: Math.random() * 0.6,
duration: 2.2 + Math.random() * 1.1,
drift: (Math.random() - 0.5) * 24,
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,
hue: -10 + Math.random() * 30,
}));
setTimeout(() => {
kind: "petal" as const,
color: "",
shape: "circle" as const,
};
});
particles = [...confetti, ...petals];
}
function dismiss(): void {
active = false;
petals = [];
}, ANIMATION_MS);
particles = [];
foundBlock = null;
}
</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)}
{#each particles as p (p.id)}
{#if p.kind === "confetti"}
<span
class="confetti {p.shape}"
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};
--color: {p.color};
"
></span>
{:else}
<span
class="petal"
style="
@@ -70,10 +124,8 @@
--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}
@@ -86,8 +138,31 @@
</g>
</svg>
</span>
{/if}
{/each}
</div>
<!-- Banner — above confetti, interactive -->
<div class="banner-wrap">
<div class="banner">
<div class="party-row">
<span class="party-emoji">&#x1F389;</span>
</div>
<div class="banner-title">BLOCK FOUND!</div>
{#if foundBlock}
<div class="banner-details">
<span class="detail">Height <strong>{foundBlock.height.toLocaleString()}</strong></span>
{#if foundBlock.reward}
<span class="detail-sep">|</span>
<span class="detail">Reward <strong>{foundBlock.reward.toFixed(4)} BTC</strong></span>
{/if}
</div>
{/if}
<button class="dismiss-btn" onclick={dismiss}>
LET'S GOOO
</button>
</div>
</div>
{/if}
<style>
@@ -99,7 +174,6 @@
z-index: 150;
}
/* Radial ember burst. Starts bright, expands, and fades. */
.burst {
position: absolute;
left: 50%;
@@ -114,7 +188,7 @@
rgba(180, 40, 20, 0.18) 55%,
transparent 75%);
filter: blur(20px);
animation: burst 1.6s ease-out forwards;
animation: burst 2s ease-out forwards;
}
@keyframes burst {
0% { transform: translate(-50%, -50%) scale(0.2); opacity: 0; }
@@ -122,7 +196,6 @@
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%;
@@ -132,35 +205,71 @@
transform: translate(-50%, -50%);
background: repeating-conic-gradient(
from 0deg,
rgba(255, 140, 50, 0.08) 0deg 4deg,
rgba(255, 140, 50, 0.06) 0deg 4deg,
transparent 4deg 14deg
);
mix-blend-mode: screen;
opacity: 0;
animation: rays 2.4s ease-out forwards;
animation: rays-spin 20s linear infinite;
opacity: 0.6;
}
@keyframes rays {
0% { opacity: 0; transform: translate(-50%, -50%) rotate(-20deg); }
20% { opacity: 0.8; }
100% { opacity: 0; transform: translate(-50%, -50%) rotate(40deg); }
@keyframes rays-spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* Falling sakura petals. Each gets a slightly different path via
* CSS custom properties for drift / rotation / scale. */
/* Confetti pieces */
.confetti {
position: absolute;
top: -5vh;
background: var(--color);
opacity: 0;
animation-name: confettiFall;
animation-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1);
animation-fill-mode: none;
animation-iteration-count: infinite;
}
.confetti.square {
width: 10px;
height: 10px;
border-radius: 1px;
}
.confetti.rect {
width: 8px;
height: 14px;
border-radius: 1px;
}
.confetti.circle {
width: 10px;
height: 10px;
border-radius: 50%;
}
@keyframes confettiFall {
0% {
transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
opacity: 0;
}
8% { opacity: 1; }
85% { opacity: 1; }
100% {
transform: translateX(var(--drift, 0)) translateY(110vh) rotate(var(--rot, 0))
scale(var(--scale, 1));
opacity: 0;
}
}
/* Sakura petals */
.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));
filter: drop-shadow(0 1px 2px rgba(200, 60, 90, 0.35));
opacity: 0;
animation-name: petalFall;
animation-timing-function: cubic-bezier(0.4, 0.1, 0.4, 1);
animation-fill-mode: forwards;
animation-fill-mode: none;
animation-iteration-count: infinite;
}
.petal svg {
width: 100%;
@@ -170,14 +279,146 @@
0% { transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
opacity: 0; }
10% { opacity: 1; }
85% { opacity: 0.8; }
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;
/* Banner */
.banner-wrap {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
animation: banner-enter 0.5s ease-out both;
}
@keyframes banner-enter {
0% { opacity: 0; transform: scale(0.7); }
50% { transform: scale(1.05); }
100% { opacity: 1; transform: scale(1); }
}
.banner {
pointer-events: auto;
background: rgb(20, 24, 32);
border: 2px solid var(--accent);
border-radius: 16px;
padding: 2.5rem 3.5rem;
text-align: center;
box-shadow:
0 0 60px rgba(255, 122, 58, 0.3),
0 0 120px rgba(255, 122, 58, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
animation: banner-glow 2s ease-in-out infinite alternate;
}
@keyframes banner-glow {
0% { box-shadow: 0 0 40px rgba(255, 122, 58, 0.25),
0 0 80px rgba(255, 122, 58, 0.08); }
100% { box-shadow: 0 0 60px rgba(255, 122, 58, 0.4),
0 0 120px rgba(255, 122, 58, 0.15); }
}
.party-row {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 0.8rem;
}
.party-emoji {
font-size: 3rem;
animation: party-bounce 0.6s ease-in-out infinite alternate;
}
@keyframes party-bounce {
0% { transform: translateY(0) rotate(-5deg); }
100% { transform: translateY(-12px) rotate(8deg); }
}
.banner-title {
font-size: 2.2rem;
font-weight: 800;
letter-spacing: 0.04em;
background: linear-gradient(135deg, #ff7a3a, #f5c447, #ff7a3a);
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.banner-details {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
margin-top: 0.8rem;
color: var(--fg-dim);
font-size: 1rem;
}
.detail strong {
color: var(--fg);
font-weight: 600;
}
.detail-sep {
color: var(--border);
}
.dismiss-btn {
margin-top: 1.5rem;
padding: 0.8em 2.5em;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
background: linear-gradient(135deg, #ff7a3a, #ff5722);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 4px 20px rgba(255, 87, 34, 0.4);
}
.dismiss-btn:hover {
transform: translateY(-2px) scale(1.04);
box-shadow: 0 6px 28px rgba(255, 87, 34, 0.55);
}
.dismiss-btn:active {
transform: translateY(0) scale(0.98);
}
@media (max-width: 600px) {
.banner {
padding: 2rem 1.5rem;
margin: 0 1rem;
}
.banner-title {
font-size: 1.6rem;
}
.party-emoji {
font-size: 2.2rem;
}
.banner-details {
flex-direction: column;
gap: 0.3rem;
}
.detail-sep {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.burst, .rays, .confetti, .petal, .party-emoji, .banner-title {
animation-duration: 0.01s;
animation-iteration-count: 1;
}
.rays { animation: none; opacity: 0; }
}
</style>
+10 -6
View File
@@ -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;
});
</script>
@@ -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 {
+1
View File
@@ -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;