diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go index f4d0b04..b719c8d 100644 --- a/api/internal/state/aggregator.go +++ b/api/internal/state/aggregator.go @@ -53,6 +53,11 @@ type Snapshot struct { // in BTC. Refreshed at most once per minute. NextBlockRewardBTC float64 `json:"next_block_reward_btc"` + // Predicted percent change at the next difficulty retarget, clamped + // to Bitcoin's consensus range of [-75, +300]. Derived from the + // observed block interval since the current epoch started. + NextDifficultyPercent float64 `json:"next_difficulty_percent"` + // Bitcoin Core fields Chain *bitcoind.BlockchainInfo `json:"chain"` NetworkHashrateHs float64 `json:"network_hashrate_hs"` @@ -115,6 +120,12 @@ type Aggregator struct { nextBlockReward float64 lastTemplateFetch time.Time + // Retarget epoch-start timestamp cache. Updated when we cross a + // new retarget boundary (every 2016 blocks); within an epoch we + // just re-use the cached value and re-extrapolate each refresh. + retargetEpoch int64 + retargetStartUnix int64 + // ckFailStreak counts consecutive refreshes where CKPool returned an // error. The first failure after a streak of successes is logged at // DEBUG (likely a transient warm-up or lock hiccup); repeated failures @@ -220,6 +231,45 @@ func (a *Aggregator) refresh(ctx context.Context) { } } + // --- predicted difficulty adjustment --- + // Fetch the timestamp of the first block in the current retarget + // epoch (height - height%2016) once per epoch and cache it, then + // compute (expected / elapsed - 1) * 100 where expected = blocks_in * + // 600 seconds. Bitcoin clamps the consensus adjustment factor to + // [1/4, 4x], i.e. [-75%, +300%]. + if next.Chain != nil && next.Chain.Blocks > 0 { + height := next.Chain.Blocks + epoch := height / 2016 + inEpoch := height % 2016 + if inEpoch > 0 { + if a.retargetEpoch != epoch || a.retargetStartUnix == 0 { + startHeight := epoch * 2016 + lookupCtx, cancelLookup := context.WithTimeout(ctx, 3*time.Second) + if hash, err := a.RPC.GetBlockHash(lookupCtx, startHeight); err == nil { + if blk, err := a.RPC.GetBlock(lookupCtx, hash); err == nil { + a.retargetEpoch = epoch + a.retargetStartUnix = blk.Time + } + } + cancelLookup() + } + if a.retargetStartUnix > 0 { + elapsed := float64(time.Now().Unix() - a.retargetStartUnix) + expected := float64(inEpoch) * 600 + if elapsed > 0 { + factor := expected / elapsed + if factor > 4 { + factor = 4 + } + if factor < 0.25 { + factor = 0.25 + } + next.NextDifficultyPercent = (factor - 1) * 100 + } + } + } + } + // Compute pool-wide best-ever share diff from workers. for _, w := range next.Workers { d := w.BestEver diff --git a/ui/src/app.css b/ui/src/app.css index 141a96d..8b7b886 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -27,13 +27,26 @@ html, body { margin: 0; padding: 0; - background: var(--bg); color: var(--fg); min-height: 100vh; } +/* Kamado-inspired backdrop: a warm ember glow at the top (the Hinokami + * Kagura / hearth flame) layered over a very low-opacity checker + * pattern nodding to Tanjiro's haori. Fixed to the viewport so it + * doesn't scroll away from the content. */ body { font-variant-numeric: tabular-nums; + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1400px 820px at 50% -120px, rgba(255, 100, 40, 0.22), transparent 62%), + radial-gradient(circle 420px at 8% 14%, rgba(180, 40, 20, 0.10), transparent 70%), + radial-gradient(circle 520px at 92% 22%, rgba(30, 90, 60, 0.07), transparent 70%), + url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat, no-repeat, no-repeat, repeat; + background-size: 100% auto, auto, auto, 96px 96px; + background-position: 0 0, 0 0, 0 0, 0 0; + background-attachment: fixed, fixed, fixed, fixed; } a { @@ -65,10 +78,12 @@ code, } .card { - background: var(--bg-card); + background: rgba(21, 26, 36, 0.88); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem 1.5rem; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); } .grid { @@ -85,20 +100,22 @@ code, .stat-label { color: var(--fg-dim); - font-size: 0.85em; + font-size: 0.82em; text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 0.35em; + letter-spacing: 0.05em; + margin-bottom: 0.4em; } .stat-value { font-size: 1.7rem; font-weight: 600; letter-spacing: -0.01em; + line-height: 1.15; } .stat-sub { color: var(--fg-dim); - font-size: 0.85em; - margin-top: 0.25em; + font-size: 0.95em; + margin-top: 0.4em; + line-height: 1.4; } table { diff --git a/ui/src/lib/Header.svelte b/ui/src/lib/Header.svelte index 53745e2..b600b71 100644 --- a/ui/src/lib/Header.svelte +++ b/ui/src/lib/Header.svelte @@ -1,5 +1,4 @@ @@ -31,12 +21,9 @@ Kamado Pool -
- ws: {snap.status} - {chain} - - height {height.toLocaleString()} - +
+ + {statusLabel}
@@ -45,9 +32,8 @@ display: flex; justify-content: space-between; align-items: center; - padding: 0.25rem 0; + padding: 0.25rem 0 1rem; border-bottom: 1px solid var(--border); - padding-bottom: 1rem; } .brand { display: flex; @@ -62,27 +48,29 @@ } .meta { display: flex; + align-items: center; gap: 0.5rem; - flex-wrap: wrap; + font-size: 0.9rem; + color: var(--fg-dim); } - .height-badge { - transition: background 0.3s, border-color 0.3s, color 0.3s; + .ws-dot { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; + background: var(--fg-dim); } - .new-block { - animation: block-flash 1.5s ease-out; + .ws-dot.good { + background: var(--good); + box-shadow: 0 0 8px var(--good); } - @keyframes block-flash { - 0% { - background: var(--accent); - border-color: var(--accent); - color: #fff; - box-shadow: 0 0 12px var(--accent), 0 0 24px rgba(255, 122, 58, 0.3); - } - 100% { - background: var(--bg-alt); - border-color: var(--border); - color: var(--fg); - box-shadow: none; - } + .ws-dot.warn { + background: var(--warn); + } + .ws-dot.bad { + background: var(--bad); + } + .ws-label { + text-transform: lowercase; } diff --git a/ui/src/lib/PoolOverview.svelte b/ui/src/lib/PoolOverview.svelte index 1f56de2..7d55f25 100644 --- a/ui/src/lib/PoolOverview.svelte +++ b/ui/src/lib/PoolOverview.svelte @@ -33,16 +33,24 @@ return (uptime / expected) * 100; }); + // Blocks remaining + ETA until the retarget. The predicted adjustment + // percent comes from the backend (computed from the epoch start time). const retarget = $derived.by(() => { - if (!height) return { progress: 0, remaining: 2016, eta: "" }; + if (!height) return { remaining: 2016, eta: "", progress: 0 }; const inEpoch = height % 2016; const remaining = 2016 - inEpoch; - const progress = (inEpoch / 2016) * 100; const etaSec = remaining * 600; - return { progress, remaining, eta: formatDuration(etaSec) }; + const progress = (inEpoch / 2016) * 100; + return { remaining, eta: formatDuration(etaSec), progress }; }); - // Total hashes done by the pool = cumulative_shares * 2^32. + const diffPct = $derived(data.next_difficulty_percent ?? 0); + const diffPctLabel = $derived( + diffPct === 0 + ? "—" + : (diffPct > 0 ? "+" : "") + diffPct.toFixed(2) + "%", + ); + const totalHashes = $derived(data.cumulative_shares * 2 ** 32); // Block-height flash on network-wide new block. @@ -59,7 +67,7 @@ }); -
+
Hashrate
@@ -85,26 +93,16 @@
Network
{formatHashrate(data.network_hashrate_hs)}
-
- pool share {(poolShare * 1e9).toFixed(2)} ppb -
+
pool share {(poolShare * 1e9).toFixed(2)} ppb
-
-
Expected block
-
{formatDuration(expected)}
-
- effort {effort.toFixed(1)}% · uptime {formatUptime(data.uptime_seconds)} -
-
- -
Difficulty
{formatDifficulty(data.chain?.difficulty ?? 0)}
network target
+
Block height
{height ? height.toLocaleString() : "—"}
@@ -127,27 +125,52 @@
-
Diff adjustment
-
{retarget.remaining} blocks
+
Expected block
+
{formatDuration(expected)}
+
+ effort {effort.toFixed(1)}% · uptime {formatUptime(data.uptime_seconds)} +
+
+ +
+
Next adjustment
+
0} + class:down={diffPct < 0} + >{diffPctLabel}
- {retarget.progress.toFixed(1)}% · ~{retarget.eta} + {retarget.remaining} blocks · ~{retarget.eta}