Polish dashboard: real retarget prediction, symmetric 5x2, Kamado theme
The diff-adjustment tile was showing raw epoch progress (blocks-so-far / 2016 * 100%), which is never what anyone wants to see there. Compute the real predicted change server-side: fetch the timestamp of the first block in the current retarget epoch once per epoch, then each refresh derive (expected_interval / actual_interval - 1) * 100, clamped to Bitcoin's consensus bounds of [-75%, +300%]. The tile now renders the signed percent, green when positive, red when negative. Tile layout is forced to an explicit 5x2 grid so the number of columns doesn't depend on viewport width. Reordering puts the difficulty tile at (row 1, col 5) with the next-adjustment tile directly below it at (row 2, col 5); block height, block reward, total work, and expected block fill the rest of the second row. Mobile breakpoints fall back to 3 and then 2 columns. Header keeps only a live/connecting/offline status dot — the chain name and block height badges were redundant now that the height tile exists and animates on its own. Stat-sub text bumped from 0.85em to 0.95em for readability. Background: a warm ember-glow radial at the top of the viewport layered with two accent radials and a low-opacity haori-checker tile pattern (inline SVG data URI). Cards get a translucent backdrop so the texture peeks through between them.
This commit is contained in:
@@ -53,6 +53,11 @@ type Snapshot struct {
|
|||||||
// in BTC. Refreshed at most once per minute.
|
// in BTC. Refreshed at most once per minute.
|
||||||
NextBlockRewardBTC float64 `json:"next_block_reward_btc"`
|
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
|
// Bitcoin Core fields
|
||||||
Chain *bitcoind.BlockchainInfo `json:"chain"`
|
Chain *bitcoind.BlockchainInfo `json:"chain"`
|
||||||
NetworkHashrateHs float64 `json:"network_hashrate_hs"`
|
NetworkHashrateHs float64 `json:"network_hashrate_hs"`
|
||||||
@@ -115,6 +120,12 @@ type Aggregator struct {
|
|||||||
nextBlockReward float64
|
nextBlockReward float64
|
||||||
lastTemplateFetch time.Time
|
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
|
// ckFailStreak counts consecutive refreshes where CKPool returned an
|
||||||
// error. The first failure after a streak of successes is logged at
|
// error. The first failure after a streak of successes is logged at
|
||||||
// DEBUG (likely a transient warm-up or lock hiccup); repeated failures
|
// 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.
|
// Compute pool-wide best-ever share diff from workers.
|
||||||
for _, w := range next.Workers {
|
for _, w := range next.Workers {
|
||||||
d := w.BestEver
|
d := w.BestEver
|
||||||
|
|||||||
+24
-7
@@ -27,13 +27,26 @@ html,
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
min-height: 100vh;
|
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 {
|
body {
|
||||||
font-variant-numeric: tabular-nums;
|
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,<svg xmlns='http://www.w3.org/2000/svg' width='96' height='96'><rect x='0' y='0' width='48' height='48' fill='%23284030' fill-opacity='0.08'/><rect x='48' y='48' width='48' height='48' fill='%23284030' fill-opacity='0.08'/></svg>");
|
||||||
|
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 {
|
a {
|
||||||
@@ -65,10 +78,12 @@ code,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
background: rgba(21, 26, 36, 0.88);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -85,20 +100,22 @@ code,
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-size: 0.85em;
|
font-size: 0.82em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.05em;
|
||||||
margin-bottom: 0.35em;
|
margin-bottom: 0.4em;
|
||||||
}
|
}
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.7rem;
|
font-size: 1.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
.stat-sub {
|
.stat-sub {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-size: 0.85em;
|
font-size: 0.95em;
|
||||||
margin-top: 0.25em;
|
margin-top: 0.4em;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
|
|||||||
+27
-39
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
|
||||||
import { snap } from "../stores/snapshot.svelte";
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
|
|
||||||
const statusClass = $derived.by(() => {
|
const statusClass = $derived.by(() => {
|
||||||
@@ -7,22 +6,13 @@
|
|||||||
if (snap.status === "connecting") return "warn";
|
if (snap.status === "connecting") return "warn";
|
||||||
return "bad";
|
return "bad";
|
||||||
});
|
});
|
||||||
const chain = $derived(snap.data?.chain?.chain ?? "\u2014");
|
|
||||||
const height = $derived(snap.data?.chain?.blocks ?? 0);
|
|
||||||
|
|
||||||
// Track the previous height so we can trigger the animation.
|
const statusLabel = $derived.by(() => {
|
||||||
// prevHeight is $state but read via untrack() to avoid dependency loops.
|
switch (snap.status) {
|
||||||
let prevHeight = $state(0);
|
case "open": return "live";
|
||||||
let flash = $state(false);
|
case "connecting": return "connecting";
|
||||||
|
default: return "offline";
|
||||||
$effect(() => {
|
|
||||||
const h = height; // tracked dependency
|
|
||||||
const prev = untrack(() => prevHeight);
|
|
||||||
if (h > 0 && prev > 0 && h !== prev) {
|
|
||||||
flash = true;
|
|
||||||
setTimeout(() => { flash = false; }, 1500);
|
|
||||||
}
|
}
|
||||||
prevHeight = h;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,12 +21,9 @@
|
|||||||
<span class="logo">🔥</span>
|
<span class="logo">🔥</span>
|
||||||
<span class="name">Kamado Pool</span>
|
<span class="name">Kamado Pool</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta" title="WebSocket status: {statusLabel}">
|
||||||
<span class="badge {statusClass}">ws: {snap.status}</span>
|
<span class="ws-dot {statusClass}" aria-hidden="true"></span>
|
||||||
<span class="badge">{chain}</span>
|
<span class="ws-label">{statusLabel}</span>
|
||||||
<span class="badge height-badge" class:new-block={flash}>
|
|
||||||
height {height.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -45,9 +32,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0 1rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -62,27 +48,29 @@
|
|||||||
}
|
}
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
font-size: 0.9rem;
|
||||||
|
color: var(--fg-dim);
|
||||||
}
|
}
|
||||||
.height-badge {
|
.ws-dot {
|
||||||
transition: background 0.3s, border-color 0.3s, color 0.3s;
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--fg-dim);
|
||||||
}
|
}
|
||||||
.new-block {
|
.ws-dot.good {
|
||||||
animation: block-flash 1.5s ease-out;
|
background: var(--good);
|
||||||
|
box-shadow: 0 0 8px var(--good);
|
||||||
}
|
}
|
||||||
@keyframes block-flash {
|
.ws-dot.warn {
|
||||||
0% {
|
background: var(--warn);
|
||||||
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% {
|
.ws-dot.bad {
|
||||||
background: var(--bg-alt);
|
background: var(--bad);
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--fg);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
.ws-label {
|
||||||
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,16 +33,24 @@
|
|||||||
return (uptime / expected) * 100;
|
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(() => {
|
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 inEpoch = height % 2016;
|
||||||
const remaining = 2016 - inEpoch;
|
const remaining = 2016 - inEpoch;
|
||||||
const progress = (inEpoch / 2016) * 100;
|
|
||||||
const etaSec = remaining * 600;
|
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);
|
const totalHashes = $derived(data.cumulative_shares * 2 ** 32);
|
||||||
|
|
||||||
// Block-height flash on network-wide new block.
|
// Block-height flash on network-wide new block.
|
||||||
@@ -59,7 +67,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="grid grid-5">
|
<section class="grid-5">
|
||||||
<!-- Row 1 -->
|
<!-- Row 1 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Hashrate</div>
|
<div class="stat-label">Hashrate</div>
|
||||||
@@ -85,26 +93,16 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Network</div>
|
<div class="stat-label">Network</div>
|
||||||
<div class="stat-value">{formatHashrate(data.network_hashrate_hs)}</div>
|
<div class="stat-value">{formatHashrate(data.network_hashrate_hs)}</div>
|
||||||
<div class="stat-sub">
|
<div class="stat-sub">pool share {(poolShare * 1e9).toFixed(2)} ppb</div>
|
||||||
pool share {(poolShare * 1e9).toFixed(2)} ppb
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="stat-label">Expected block</div>
|
|
||||||
<div class="stat-value">{formatDuration(expected)}</div>
|
|
||||||
<div class="stat-sub">
|
|
||||||
effort {effort.toFixed(1)}% · uptime {formatUptime(data.uptime_seconds)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Row 2 -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Difficulty</div>
|
<div class="stat-label">Difficulty</div>
|
||||||
<div class="stat-value">{formatDifficulty(data.chain?.difficulty ?? 0)}</div>
|
<div class="stat-value">{formatDifficulty(data.chain?.difficulty ?? 0)}</div>
|
||||||
<div class="stat-sub">network target</div>
|
<div class="stat-sub">network target</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2 -->
|
||||||
<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>
|
||||||
@@ -127,27 +125,52 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Diff adjustment</div>
|
<div class="stat-label">Expected block</div>
|
||||||
<div class="stat-value">{retarget.remaining} blocks</div>
|
<div class="stat-value">{formatDuration(expected)}</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
effort {effort.toFixed(1)}% · uptime {formatUptime(data.uptime_seconds)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Next adjustment</div>
|
||||||
|
<div
|
||||||
|
class="stat-value adj"
|
||||||
|
class:up={diffPct > 0}
|
||||||
|
class:down={diffPct < 0}
|
||||||
|
>{diffPctLabel}</div>
|
||||||
<div class="stat-sub">
|
<div class="stat-sub">
|
||||||
<div class="retarget-bar">
|
<div class="retarget-bar">
|
||||||
<div class="retarget-fill" style="width:{retarget.progress}%"></div>
|
<div class="retarget-fill" style="width:{retarget.progress}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span>{retarget.progress.toFixed(1)}% · ~{retarget.eta}</span>
|
<span>{retarget.remaining} blocks · ~{retarget.eta}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.grid-5 {
|
.grid-5 {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.grid-5 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid-5 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.retarget-bar {
|
.retarget-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 0.35em;
|
margin: 0.4em 0 0.4em;
|
||||||
}
|
}
|
||||||
.retarget-fill {
|
.retarget-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -161,6 +184,13 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-left: 0.15em;
|
margin-left: 0.15em;
|
||||||
}
|
}
|
||||||
|
.adj.up {
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
.adj.down {
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
.height-card {
|
.height-card {
|
||||||
transition: box-shadow 0.3s, border-color 0.3s;
|
transition: box-shadow 0.3s, border-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export type Snapshot = {
|
|||||||
best_diff: number;
|
best_diff: number;
|
||||||
cumulative_shares: number;
|
cumulative_shares: number;
|
||||||
next_block_reward_btc: number;
|
next_block_reward_btc: number;
|
||||||
|
next_difficulty_percent: number;
|
||||||
chain: BlockchainInfo | null;
|
chain: BlockchainInfo | null;
|
||||||
network_hashrate_hs: number;
|
network_hashrate_hs: number;
|
||||||
recent_blocks?: BlockRecord[];
|
recent_blocks?: BlockRecord[];
|
||||||
|
|||||||
Reference in New Issue
Block a user