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:
@@ -87,6 +87,7 @@ type Snapshot struct {
|
|||||||
ZMQEnabled bool `json:"zmq_enabled"`
|
ZMQEnabled bool `json:"zmq_enabled"`
|
||||||
LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0
|
LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0
|
||||||
HasLastZMQEvent bool `json:"has_last_zmq_event"`
|
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).
|
// Share counters: raw counts (1 submission = 1 share regardless of diff).
|
||||||
// Session = since ckpool started; AllTime = persisted across restarts.
|
// Session = since ckpool started; AllTime = persisted across restarts.
|
||||||
@@ -214,6 +215,12 @@ type Aggregator struct {
|
|||||||
zmqEnabled bool
|
zmqEnabled bool
|
||||||
lastZMQEventTime time.Time
|
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
|
// All-time share counters (raw, not diff-weighted). Accumulated
|
||||||
// using the same delta-integration pattern as cumulative_shares.
|
// using the same delta-integration pattern as cumulative_shares.
|
||||||
allTimeAccepted int64
|
allTimeAccepted int64
|
||||||
@@ -337,6 +344,14 @@ func (a *Aggregator) refresh(ctx context.Context) {
|
|||||||
if bi, err := a.RPC.GetBlockchainInfo(ctx); err == nil {
|
if bi, err := a.RPC.GetBlockchainInfo(ctx); err == nil {
|
||||||
next.Chain = bi
|
next.Chain = bi
|
||||||
next.BitcoinOK = true
|
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 {
|
if nh, err := a.RPC.GetNetworkHashPS(ctx, -1, int(bi.Blocks)); err == nil {
|
||||||
next.NetworkHashrateHs = nh
|
next.NetworkHashrateHs = nh
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,65 +2,119 @@
|
|||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { snap } from "../stores/snapshot.svelte";
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
|
|
||||||
// One entry per falling petal; (re)generated on each block-found
|
type Particle = {
|
||||||
// trigger so the motion isn't perfectly synchronised across blocks.
|
|
||||||
type Petal = {
|
|
||||||
id: number;
|
id: number;
|
||||||
left: number; // viewport %
|
left: number; // viewport %
|
||||||
delay: number; // seconds before this petal starts falling
|
delay: number; // seconds
|
||||||
duration: number; // seconds for the full fall
|
duration: number; // seconds
|
||||||
drift: number; // horizontal drift in vw over the fall
|
drift: number; // horizontal drift in vw
|
||||||
rotate: number; // final rotation in deg
|
rotate: number; // degrees
|
||||||
scale: number; // final render scale
|
scale: number;
|
||||||
hue: number; // pink/red hue shift
|
kind: "confetti" | "petal";
|
||||||
|
color: string;
|
||||||
|
shape: "square" | "rect" | "circle";
|
||||||
};
|
};
|
||||||
|
|
||||||
const ANIMATION_MS = 3600;
|
const CONFETTI_COUNT = 60;
|
||||||
const PETAL_COUNT = 22;
|
const PETAL_COUNT = 18;
|
||||||
|
|
||||||
let active = $state(false);
|
let active = $state(false);
|
||||||
let petals = $state<Petal[]>([]);
|
let particles = $state<Particle[]>([]);
|
||||||
let height = $state(0);
|
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(() => {
|
$effect(() => {
|
||||||
const h = currentHeight;
|
const count = currentBlockCount;
|
||||||
const prev = untrack(() => height);
|
const prev = untrack(() => blockCount);
|
||||||
if (h > 0 && prev > 0 && h !== prev) {
|
if (count > 0 && prev > 0 && count > prev) {
|
||||||
trigger();
|
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;
|
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,
|
id: Date.now() + i,
|
||||||
left: Math.random() * 100,
|
left: Math.random() * 100,
|
||||||
delay: Math.random() * 0.6,
|
delay: (i / CONFETTI_COUNT) * dur,
|
||||||
duration: 2.2 + Math.random() * 1.1,
|
duration: dur,
|
||||||
drift: (Math.random() - 0.5) * 24,
|
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,
|
rotate: (Math.random() - 0.5) * 720,
|
||||||
scale: 0.6 + Math.random() * 0.8,
|
scale: 0.6 + Math.random() * 0.8,
|
||||||
hue: -10 + Math.random() * 30,
|
kind: "petal" as const,
|
||||||
}));
|
color: "",
|
||||||
setTimeout(() => {
|
shape: "circle" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
particles = [...confetti, ...petals];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
active = false;
|
active = false;
|
||||||
petals = [];
|
particles = [];
|
||||||
}, ANIMATION_MS);
|
foundBlock = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if active}
|
{#if active}
|
||||||
<div class="overlay" aria-hidden="true">
|
<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>
|
<div class="burst"></div>
|
||||||
<!-- A faint sun-ray sweep rotating once behind the content. -->
|
|
||||||
<div class="rays"></div>
|
<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
|
<span
|
||||||
class="petal"
|
class="petal"
|
||||||
style="
|
style="
|
||||||
@@ -70,10 +124,8 @@
|
|||||||
--drift: {p.drift}vw;
|
--drift: {p.drift}vw;
|
||||||
--rot: {p.rotate}deg;
|
--rot: {p.rotate}deg;
|
||||||
--scale: {p.scale};
|
--scale: {p.scale};
|
||||||
--hue: {p.hue}deg;
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Five-petal cherry-blossom silhouette. -->
|
|
||||||
<svg viewBox="0 0 32 32">
|
<svg viewBox="0 0 32 32">
|
||||||
<g fill="currentColor">
|
<g fill="currentColor">
|
||||||
{#each [0, 72, 144, 216, 288] as a}
|
{#each [0, 72, 144, 216, 288] as a}
|
||||||
@@ -86,8 +138,31 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Banner — above confetti, interactive -->
|
||||||
|
<div class="banner-wrap">
|
||||||
|
<div class="banner">
|
||||||
|
<div class="party-row">
|
||||||
|
<span class="party-emoji">🎉</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}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -99,7 +174,6 @@
|
|||||||
z-index: 150;
|
z-index: 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Radial ember burst. Starts bright, expands, and fades. */
|
|
||||||
.burst {
|
.burst {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -114,7 +188,7 @@
|
|||||||
rgba(180, 40, 20, 0.18) 55%,
|
rgba(180, 40, 20, 0.18) 55%,
|
||||||
transparent 75%);
|
transparent 75%);
|
||||||
filter: blur(20px);
|
filter: blur(20px);
|
||||||
animation: burst 1.6s ease-out forwards;
|
animation: burst 2s ease-out forwards;
|
||||||
}
|
}
|
||||||
@keyframes burst {
|
@keyframes burst {
|
||||||
0% { transform: translate(-50%, -50%) scale(0.2); opacity: 0; }
|
0% { transform: translate(-50%, -50%) scale(0.2); opacity: 0; }
|
||||||
@@ -122,7 +196,6 @@
|
|||||||
100% { transform: translate(-50%, -50%) scale(6); opacity: 0; }
|
100% { transform: translate(-50%, -50%) scale(6); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A spinning sun-ray pattern. Subtle — doesn't dominate the page. */
|
|
||||||
.rays {
|
.rays {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -132,35 +205,71 @@
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
background: repeating-conic-gradient(
|
background: repeating-conic-gradient(
|
||||||
from 0deg,
|
from 0deg,
|
||||||
rgba(255, 140, 50, 0.08) 0deg 4deg,
|
rgba(255, 140, 50, 0.06) 0deg 4deg,
|
||||||
transparent 4deg 14deg
|
transparent 4deg 14deg
|
||||||
);
|
);
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
opacity: 0;
|
animation: rays-spin 20s linear infinite;
|
||||||
animation: rays 2.4s ease-out forwards;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
@keyframes rays {
|
@keyframes rays-spin {
|
||||||
0% { opacity: 0; transform: translate(-50%, -50%) rotate(-20deg); }
|
from { transform: translate(-50%, -50%) rotate(0deg); }
|
||||||
20% { opacity: 0.8; }
|
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||||
100% { opacity: 0; transform: translate(-50%, -50%) rotate(40deg); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Falling sakura petals. Each gets a slightly different path via
|
/* Confetti pieces */
|
||||||
* CSS custom properties for drift / rotation / scale. */
|
.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 {
|
.petal {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8vh;
|
top: -8vh;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
color: hsl(340, 70%, 78%);
|
color: hsl(340, 70%, 78%);
|
||||||
filter:
|
filter: drop-shadow(0 1px 2px rgba(200, 60, 90, 0.35));
|
||||||
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));
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation-name: petalFall;
|
animation-name: petalFall;
|
||||||
animation-timing-function: cubic-bezier(0.4, 0.1, 0.4, 1);
|
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 {
|
.petal svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -170,14 +279,146 @@
|
|||||||
0% { transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
|
0% { transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
|
||||||
opacity: 0; }
|
opacity: 0; }
|
||||||
10% { opacity: 1; }
|
10% { opacity: 1; }
|
||||||
|
85% { opacity: 0.8; }
|
||||||
100% { transform: translateX(var(--drift, 0)) translateY(110vh) rotate(var(--rot, 0))
|
100% { transform: translateX(var(--drift, 0)) translateY(110vh) rotate(var(--rot, 0))
|
||||||
scale(var(--scale, 1));
|
scale(var(--scale, 1));
|
||||||
opacity: 0; }
|
opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
/* Banner */
|
||||||
.burst, .rays, .petal {
|
.banner-wrap {
|
||||||
animation-duration: 0.6s;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -13,13 +13,17 @@
|
|||||||
return Math.max(0, (d.block_submit_attempts ?? 0) - (d.block_submits_confirmed ?? 0));
|
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
|
// ZMQ stale = the tip has advanced but ZMQ didn't fire. ZMQ
|
||||||
// block interval is 10 min; 30 min covers normal variance without
|
// delivers within milliseconds, so if the tip changed recently
|
||||||
// false-alarming on quiet stretches.
|
// 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 zmqStale = $derived.by(() => {
|
||||||
const d = snap.data;
|
const d = snap.data;
|
||||||
if (!d || !d.zmq_enabled || !d.has_last_zmq_event) return false;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -67,8 +71,8 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.banner.warn {
|
.banner.warn {
|
||||||
background: rgba(220, 170, 60, 0.10);
|
background: rgb(50, 40, 15);
|
||||||
border-color: rgba(220, 170, 60, 0.40);
|
border-color: rgb(220, 170, 60);
|
||||||
color: rgb(220, 180, 100);
|
color: rgb(220, 180, 100);
|
||||||
}
|
}
|
||||||
.icon {
|
.icon {
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export type Snapshot = {
|
|||||||
zmq_enabled: boolean;
|
zmq_enabled: boolean;
|
||||||
has_last_zmq_event: boolean;
|
has_last_zmq_event: boolean;
|
||||||
last_zmq_event_age?: number; // seconds
|
last_zmq_event_age?: number; // seconds
|
||||||
|
tip_changed_age: number; // seconds since tip height last changed
|
||||||
// Share counters (raw, 1 submission = 1 share).
|
// Share counters (raw, 1 submission = 1 share).
|
||||||
session_accepted: number;
|
session_accepted: number;
|
||||||
session_rejected: number;
|
session_rejected: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user