Revamp dashboard and persist pool stats across restarts
Dashboard now renders 10 tiles in a 5x2 overview: hashrate, best share, miners, network hashrate, and expected block on the top row; difficulty, block height, block reward, total work, and the difficulty-adjustment countdown on the bottom row. Difficulty is rendered with T/P suffixes instead of scientific notation, the main hashrate card shows the 1-minute value, and the block-height tile pulses orange when the network tip advances. Added a 24-hour hashrate area chart below the overview, sampled once per minute. Samples are persisted to a new hashrate_samples SQLite table and restored on startup so the chart doesn't reset every time kamado-api is restarted. Cumulative pool work (sum of accepted diff-1-normalized shares) is now tracked across ckpool restarts. The aggregator integrates only positive deltas on pool.Shares — a regression means ckpool's counter reset to zero and the baseline is refreshed without losing the running total. A hasPoolSharesBaseline flag prevents double- counting on the first refresh after a kamado-api restart. The value is persisted to a new kv table once per minute. Next-block reward (subsidy + fees) is fetched from bitcoind getblocktemplate at most once per minute and surfaced as a tile. Header's block-height badge now reads prevHeight via untrack() so the effect doesn't form a dependency cycle with its own write.
This commit is contained in:
@@ -175,3 +175,21 @@ func (c *RPC) GetNetworkHashPS(ctx context.Context, blocks, height int) (float64
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlockTemplate is the subset of getblocktemplate we care about: the
|
||||||
|
// coinbase value (subsidy + fees) and height of the next block.
|
||||||
|
type BlockTemplate struct {
|
||||||
|
CoinbaseValue int64 `json:"coinbasevalue"` // satoshis
|
||||||
|
Height int64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlockTemplate fetches the next block template with segwit rules.
|
||||||
|
// The call is relatively expensive; rate-limit callers to ~1/min.
|
||||||
|
func (c *RPC) GetBlockTemplate(ctx context.Context) (*BlockTemplate, error) {
|
||||||
|
var out BlockTemplate
|
||||||
|
params := []any{map[string]any{"rules": []string{"segwit"}}}
|
||||||
|
if err := c.Call(ctx, "getblocktemplate", params, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package state
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,12 @@ import (
|
|||||||
"github.com/kamadopool/kamado-api/internal/zmqmon"
|
"github.com/kamadopool/kamado-api/internal/zmqmon"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// HashratePoint is a single timestamped hashrate sample for the 24h chart.
|
||||||
|
type HashratePoint struct {
|
||||||
|
T int64 `json:"t"` // unix seconds
|
||||||
|
V float64 `json:"v"` // H/s
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot is the merged view served to the UI. All fields are safe to
|
// Snapshot is the merged view served to the UI. All fields are safe to
|
||||||
// JSON-serialize directly.
|
// JSON-serialize directly.
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -34,6 +41,18 @@ type Snapshot struct {
|
|||||||
HashrateHs1h float64 `json:"hashrate_hs_1h"`
|
HashrateHs1h float64 `json:"hashrate_hs_1h"`
|
||||||
HashrateHs24h float64 `json:"hashrate_hs_24h"`
|
HashrateHs24h float64 `json:"hashrate_hs_24h"`
|
||||||
|
|
||||||
|
// Best share difficulty ever seen across all workers.
|
||||||
|
BestDiff float64 `json:"best_diff"`
|
||||||
|
|
||||||
|
// Cumulative work done by the pool across its entire lifetime, in
|
||||||
|
// diff-1-normalized shares (multiply by 2^32 for total hashes).
|
||||||
|
// Survives ckpool restarts via kv-store persistence.
|
||||||
|
CumulativeShares float64 `json:"cumulative_shares"`
|
||||||
|
|
||||||
|
// Next-block reward (subsidy + fees) from bitcoind getblocktemplate,
|
||||||
|
// in BTC. Refreshed at most once per minute.
|
||||||
|
NextBlockRewardBTC float64 `json:"next_block_reward_btc"`
|
||||||
|
|
||||||
// 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"`
|
||||||
@@ -41,12 +60,20 @@ type Snapshot struct {
|
|||||||
// Recent found blocks (in-memory history; persisted in Phase 2b.5).
|
// Recent found blocks (in-memory history; persisted in Phase 2b.5).
|
||||||
RecentBlocks []BlockRecord `json:"recent_blocks,omitempty"`
|
RecentBlocks []BlockRecord `json:"recent_blocks,omitempty"`
|
||||||
|
|
||||||
|
// 24-hour hashrate history, sampled once per minute (max 1440 points).
|
||||||
|
HashrateHistory []HashratePoint `json:"hashrate_history,omitempty"`
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
CKPoolOK bool `json:"ckpool_ok"`
|
CKPoolOK bool `json:"ckpool_ok"`
|
||||||
BitcoinOK bool `json:"bitcoin_ok"`
|
BitcoinOK bool `json:"bitcoin_ok"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxHistoryPoints = 1440 // 24h at 1 sample/min
|
||||||
|
kvCumulativeWork = "cumulative_shares"
|
||||||
|
)
|
||||||
|
|
||||||
// Aggregator refreshes a Snapshot on a ticker.
|
// Aggregator refreshes a Snapshot on a ticker.
|
||||||
type Aggregator struct {
|
type Aggregator struct {
|
||||||
CK *ckpool.Client
|
CK *ckpool.Client
|
||||||
@@ -63,6 +90,31 @@ type Aggregator struct {
|
|||||||
snap Snapshot
|
snap Snapshot
|
||||||
blocks []BlockRecord
|
blocks []BlockRecord
|
||||||
|
|
||||||
|
// 24h hashrate history ring buffer, sampled once per minute.
|
||||||
|
hrHistory []HashratePoint
|
||||||
|
lastHRSampleAt time.Time
|
||||||
|
|
||||||
|
// Cumulative pool work in diff-1-normalized shares, surviving
|
||||||
|
// ckpool restarts. Maintained by re-reading pool.Shares each
|
||||||
|
// refresh and integrating only the positive delta — a decrease
|
||||||
|
// means ckpool restarted and its counter reset to 0, so we reset
|
||||||
|
// our delta baseline without losing the accumulated total.
|
||||||
|
//
|
||||||
|
// hasPoolSharesBaseline guards against double-counting on api
|
||||||
|
// restart: cumulativeShares is loaded from disk, and the first
|
||||||
|
// post-load observation of pool.Shares only establishes the
|
||||||
|
// baseline — the current ckpool counter was already accounted for
|
||||||
|
// before we crashed.
|
||||||
|
cumulativeShares float64
|
||||||
|
lastPoolShares float64
|
||||||
|
hasPoolSharesBaseline bool
|
||||||
|
lastCumulativeSave time.Time
|
||||||
|
|
||||||
|
// Next-block reward cache; refreshed on its own cadence so we don't
|
||||||
|
// hammer bitcoind with getblocktemplate on every poll tick.
|
||||||
|
nextBlockReward float64
|
||||||
|
lastTemplateFetch time.Time
|
||||||
|
|
||||||
// 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
|
||||||
@@ -86,6 +138,7 @@ func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog
|
|||||||
// each received tip triggers an immediate refresh outside the poll cadence.
|
// each received tip triggers an immediate refresh outside the poll cadence.
|
||||||
func (a *Aggregator) Run(ctx context.Context, tipEvents <-chan zmqmon.TipEvent) {
|
func (a *Aggregator) Run(ctx context.Context, tipEvents <-chan zmqmon.TipEvent) {
|
||||||
a.loadPersistedBlocks()
|
a.loadPersistedBlocks()
|
||||||
|
a.loadPersistedState()
|
||||||
a.refresh(ctx)
|
a.refresh(ctx)
|
||||||
t := time.NewTicker(a.Interval)
|
t := time.NewTicker(a.Interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -167,6 +220,88 @@ func (a *Aggregator) refresh(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute pool-wide best-ever share diff from workers.
|
||||||
|
for _, w := range next.Workers {
|
||||||
|
d := w.BestEver
|
||||||
|
if d == 0 {
|
||||||
|
d = w.BestDiff
|
||||||
|
}
|
||||||
|
if d > next.BestDiff {
|
||||||
|
next.BestDiff = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// --- cumulative work tracking ---
|
||||||
|
// Integrate only positive deltas on pool.Shares. A decrease means
|
||||||
|
// ckpool restarted (or its state reset); reset the baseline without
|
||||||
|
// touching the accumulated total. The first observation after load
|
||||||
|
// only establishes the baseline — those shares were already
|
||||||
|
// accumulated before the previous shutdown.
|
||||||
|
if next.Pool != nil {
|
||||||
|
cur := float64(next.Pool.Shares)
|
||||||
|
if a.hasPoolSharesBaseline && cur >= a.lastPoolShares {
|
||||||
|
a.cumulativeShares += cur - a.lastPoolShares
|
||||||
|
}
|
||||||
|
a.lastPoolShares = cur
|
||||||
|
a.hasPoolSharesBaseline = true
|
||||||
|
}
|
||||||
|
next.CumulativeShares = a.cumulativeShares
|
||||||
|
|
||||||
|
// Persist cumulative_shares at most once per minute.
|
||||||
|
if a.Store != nil && now.Sub(a.lastCumulativeSave) >= time.Minute {
|
||||||
|
val := strconv.FormatFloat(a.cumulativeShares, 'f', -1, 64)
|
||||||
|
if err := a.Store.SetKV(kvCumulativeWork, val); err != nil {
|
||||||
|
a.Log.Warn("cumulative_shares persist failed", "err", err)
|
||||||
|
}
|
||||||
|
a.lastCumulativeSave = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hashrate history sample (once per minute) ---
|
||||||
|
if now.Sub(a.lastHRSampleAt) >= time.Minute {
|
||||||
|
p := HashratePoint{T: now.Unix(), V: next.HashrateHs}
|
||||||
|
a.hrHistory = append(a.hrHistory, p)
|
||||||
|
if len(a.hrHistory) > maxHistoryPoints {
|
||||||
|
a.hrHistory = a.hrHistory[len(a.hrHistory)-maxHistoryPoints:]
|
||||||
|
}
|
||||||
|
a.lastHRSampleAt = now
|
||||||
|
if a.Store != nil {
|
||||||
|
if err := a.Store.InsertHashrateSample(p.T, p.V); err != nil {
|
||||||
|
a.Log.Warn("hashrate persist failed", "err", err)
|
||||||
|
}
|
||||||
|
// Keep the persisted window bounded to 24h + a small slack.
|
||||||
|
cutoff := now.Add(-25 * time.Hour).Unix()
|
||||||
|
_ = a.Store.PruneHashrateBefore(cutoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.hrHistory) > 0 {
|
||||||
|
next.HashrateHistory = make([]HashratePoint, len(a.hrHistory))
|
||||||
|
copy(next.HashrateHistory, a.hrHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- next-block reward (at most once per minute) ---
|
||||||
|
next.NextBlockRewardBTC = a.nextBlockReward
|
||||||
|
needTemplate := now.Sub(a.lastTemplateFetch) >= time.Minute
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if needTemplate && a.RPC != nil && next.BitcoinOK {
|
||||||
|
tplCtx, cancelTpl := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
tpl, err := a.RPC.GetBlockTemplate(tplCtx)
|
||||||
|
cancelTpl()
|
||||||
|
if err == nil && tpl != nil {
|
||||||
|
reward := float64(tpl.CoinbaseValue) / 1e8
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nextBlockReward = reward
|
||||||
|
a.lastTemplateFetch = now
|
||||||
|
next.NextBlockRewardBTC = reward
|
||||||
|
a.mu.Unlock()
|
||||||
|
} else if err != nil {
|
||||||
|
a.Log.Debug("getblocktemplate failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
// Attach current block history so /api/snapshot and WebSocket
|
// Attach current block history so /api/snapshot and WebSocket
|
||||||
// pushes carry the same view.
|
// pushes carry the same view.
|
||||||
@@ -183,3 +318,38 @@ func (a *Aggregator) refresh(ctx context.Context) {
|
|||||||
cb(pushed)
|
cb(pushed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPersistedState restores cumulative work and hashrate history from
|
||||||
|
// the store so they survive process restarts. Safe to call with a nil
|
||||||
|
// Store — becomes a no-op.
|
||||||
|
func (a *Aggregator) loadPersistedState() {
|
||||||
|
if a.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := a.Store.GetKV(kvCumulativeWork); err != nil {
|
||||||
|
a.Log.Warn("cumulative_shares load failed", "err", err)
|
||||||
|
} else if v != "" {
|
||||||
|
if f, perr := strconv.ParseFloat(v, 64); perr == nil {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.cumulativeShares = f
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.Log.Info("cumulative_shares loaded", "value", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour).Unix()
|
||||||
|
if samples, err := a.Store.HashrateSince(cutoff); err != nil {
|
||||||
|
a.Log.Warn("hashrate history load failed", "err", err)
|
||||||
|
} else if len(samples) > 0 {
|
||||||
|
hist := make([]HashratePoint, 0, len(samples))
|
||||||
|
for _, s := range samples {
|
||||||
|
hist = append(hist, HashratePoint{T: s.T, V: s.V})
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
a.hrHistory = hist
|
||||||
|
a.lastHRSampleAt = time.Unix(hist[len(hist)-1].T, 0)
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.Log.Info("hashrate history loaded", "count", len(hist))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||||||
share_diff REAL NOT NULL DEFAULT 0
|
share_diff REAL NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at);
|
CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hashrate_samples (
|
||||||
|
t INTEGER PRIMARY KEY,
|
||||||
|
v REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kv (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// migrations additive only; safe to run every startup. SQLite ignores
|
// migrations additive only; safe to run every startup. SQLite ignores
|
||||||
@@ -103,6 +113,69 @@ func (s *BlockStore) InsertBlock(b Block) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashratePoint is one persisted hashrate sample.
|
||||||
|
type HashratePoint struct {
|
||||||
|
T int64
|
||||||
|
V float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertHashrateSample appends a sample; duplicate timestamps are ignored.
|
||||||
|
func (s *BlockStore) InsertHashrateSample(t int64, v float64) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO hashrate_samples(t, v) VALUES (?, ?)`,
|
||||||
|
t, v,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneHashrateBefore drops samples older than the given unix timestamp.
|
||||||
|
func (s *BlockStore) PruneHashrateBefore(cutoff int64) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM hashrate_samples WHERE t < ?`, cutoff)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashrateSince returns all samples with t >= from, oldest first.
|
||||||
|
func (s *BlockStore) HashrateSince(from int64) ([]HashratePoint, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT t, v FROM hashrate_samples WHERE t >= ? ORDER BY t ASC`,
|
||||||
|
from,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []HashratePoint
|
||||||
|
for rows.Next() {
|
||||||
|
var p HashratePoint
|
||||||
|
if err := rows.Scan(&p.T, &p.V); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKV reads a string value by key. Returns ("", nil) when the key is
|
||||||
|
// absent so callers can distinguish "no value yet" from a real error.
|
||||||
|
func (s *BlockStore) GetKV(key string) (string, error) {
|
||||||
|
var v string
|
||||||
|
err := s.db.QueryRow(`SELECT value FROM kv WHERE key = ?`, key).Scan(&v)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKV writes or replaces a key's value.
|
||||||
|
func (s *BlockStore) SetKV(key, value string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO kv(key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
key, value,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Recent returns up to limit blocks, newest first.
|
// Recent returns up to limit blocks, newest first.
|
||||||
func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
|
|||||||
Generated
+1555
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
import { connect, snap } from "./stores/snapshot.svelte";
|
import { connect, snap } from "./stores/snapshot.svelte";
|
||||||
import Header from "./lib/Header.svelte";
|
import Header from "./lib/Header.svelte";
|
||||||
import PoolOverview from "./lib/PoolOverview.svelte";
|
import PoolOverview from "./lib/PoolOverview.svelte";
|
||||||
|
import HashrateChart from "./lib/HashrateChart.svelte";
|
||||||
import MinersTable from "./lib/MinersTable.svelte";
|
import MinersTable from "./lib/MinersTable.svelte";
|
||||||
import BlocksTable from "./lib/BlocksTable.svelte";
|
import BlocksTable from "./lib/BlocksTable.svelte";
|
||||||
import BestShares from "./lib/BestShares.svelte";
|
import BestShares from "./lib/BestShares.svelte";
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<PoolOverview />
|
<PoolOverview />
|
||||||
|
<HashrateChart />
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
<BlocksTable />
|
<BlocksTable />
|
||||||
<BestShares />
|
<BestShares />
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ export function formatDifficulty(d: number): string {
|
|||||||
return `${v.toFixed(digits)}${units[i]}`;
|
return `${v.toFixed(digits)}${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cumulative hashes — scale up to ZH / YH since pools accumulate fast.
|
||||||
|
// Input is plain hashes (a diff-1 share is 2^32 hashes).
|
||||||
|
export function formatWork(hashes: number): string {
|
||||||
|
if (!hashes || hashes <= 0 || !isFinite(hashes)) return "0 H";
|
||||||
|
const units = ["H", "kH", "MH", "GH", "TH", "PH", "EH", "ZH", "YH"];
|
||||||
|
let i = 0;
|
||||||
|
let v = hashes;
|
||||||
|
while (v >= 1000 && i < units.length - 1) {
|
||||||
|
v /= 1000;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const digits = v >= 100 ? 0 : v >= 10 ? 1 : 2;
|
||||||
|
return `${v.toFixed(digits)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUptime(seconds: number): string {
|
export function formatUptime(seconds: number): string {
|
||||||
if (!seconds || seconds < 0) return "—";
|
if (!seconds || seconds < 0) return "—";
|
||||||
const d = Math.floor(seconds / 86400);
|
const d = Math.floor(seconds / 86400);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
|
import { formatHashrate } from "../format";
|
||||||
|
|
||||||
|
const W = 800;
|
||||||
|
const H = 200;
|
||||||
|
const PAD = { top: 10, right: 10, bottom: 24, left: 60 };
|
||||||
|
const plotW = W - PAD.left - PAD.right;
|
||||||
|
const plotH = H - PAD.top - PAD.bottom;
|
||||||
|
|
||||||
|
const points = $derived(snap.data?.hashrate_history ?? []);
|
||||||
|
|
||||||
|
const chart = $derived.by(() => {
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
|
||||||
|
const tMin = points[0].t;
|
||||||
|
const tMax = points[points.length - 1].t;
|
||||||
|
const tRange = tMax - tMin || 1;
|
||||||
|
|
||||||
|
let vMax = 0;
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.v > vMax) vMax = p.v;
|
||||||
|
}
|
||||||
|
if (vMax <= 0) vMax = 1;
|
||||||
|
vMax *= 1.1;
|
||||||
|
|
||||||
|
const toX = (t: number) => PAD.left + ((t - tMin) / tRange) * plotW;
|
||||||
|
const toY = (v: number) => PAD.top + plotH - (v / vMax) * plotH;
|
||||||
|
|
||||||
|
let line = "";
|
||||||
|
let area = "";
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const x = toX(points[i].t).toFixed(1);
|
||||||
|
const y = toY(points[i].v).toFixed(1);
|
||||||
|
if (i === 0) {
|
||||||
|
line = `M${x},${y}`;
|
||||||
|
area = `M${x},${(PAD.top + plotH).toFixed(1)} L${x},${y}`;
|
||||||
|
} else {
|
||||||
|
line += ` L${x},${y}`;
|
||||||
|
area += ` L${x},${y}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
area += ` L${toX(tMax).toFixed(1)},${(PAD.top + plotH).toFixed(1)} Z`;
|
||||||
|
|
||||||
|
const yTicks = [0, vMax * 0.5, vMax].map((v) => ({
|
||||||
|
y: toY(v),
|
||||||
|
label: formatHashrate(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const xTicks: Array<{ x: number; label: string }> = [];
|
||||||
|
const count = Math.min(6, points.length);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const idx = Math.round((i / (count - 1)) * (points.length - 1));
|
||||||
|
const p = points[idx];
|
||||||
|
const d = new Date(p.t * 1000);
|
||||||
|
xTicks.push({
|
||||||
|
x: toX(p.t),
|
||||||
|
label: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
const dotX = toX(last.t);
|
||||||
|
const dotY = toY(last.v);
|
||||||
|
|
||||||
|
return { line, area, yTicks, xTicks, dotX, dotY };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="card chart-card">
|
||||||
|
<h2>Hashrate (24h)</h2>
|
||||||
|
{#if !chart}
|
||||||
|
<div class="empty">Collecting data... chart appears after 2 minutes.</div>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" class="chart">
|
||||||
|
{#each chart.yTicks as tick}
|
||||||
|
<line
|
||||||
|
x1={PAD.left} y1={tick.y}
|
||||||
|
x2={W - PAD.right} y2={tick.y}
|
||||||
|
class="grid-line"
|
||||||
|
/>
|
||||||
|
<text x={PAD.left - 6} y={tick.y + 3} class="y-label">{tick.label}</text>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<path d={chart.area} class="area" />
|
||||||
|
<path d={chart.line} class="line" />
|
||||||
|
|
||||||
|
<circle cx={chart.dotX} cy={chart.dotY} r="3.5" class="dot-pulse" />
|
||||||
|
<circle cx={chart.dotX} cy={chart.dotY} r="2.5" class="dot" />
|
||||||
|
|
||||||
|
{#each chart.xTicks as tick}
|
||||||
|
<text x={tick.x} y={H - 4} class="x-label">{tick.label}</text>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chart-card {
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.grid-line {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
.area {
|
||||||
|
fill: var(--accent);
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.y-label {
|
||||||
|
fill: var(--fg-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
text-anchor: end;
|
||||||
|
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.x-label {
|
||||||
|
fill: var(--fg-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
text-anchor: middle;
|
||||||
|
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
fill: var(--accent);
|
||||||
|
}
|
||||||
|
.dot-pulse {
|
||||||
|
fill: var(--accent);
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { r: 3.5; opacity: 0.4; }
|
||||||
|
50% { r: 6; opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<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(() => {
|
||||||
@@ -6,19 +7,36 @@
|
|||||||
if (snap.status === "connecting") return "warn";
|
if (snap.status === "connecting") return "warn";
|
||||||
return "bad";
|
return "bad";
|
||||||
});
|
});
|
||||||
const chain = $derived(snap.data?.chain?.chain ?? "—");
|
const chain = $derived(snap.data?.chain?.chain ?? "\u2014");
|
||||||
const height = $derived(snap.data?.chain?.blocks ?? 0);
|
const height = $derived(snap.data?.chain?.blocks ?? 0);
|
||||||
|
|
||||||
|
// Track the previous height so we can trigger the animation.
|
||||||
|
// prevHeight is $state but read via untrack() to avoid dependency loops.
|
||||||
|
let prevHeight = $state(0);
|
||||||
|
let flash = $state(false);
|
||||||
|
|
||||||
|
$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>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<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">
|
||||||
<span class="badge {statusClass}">ws: {snap.status}</span>
|
<span class="badge {statusClass}">ws: {snap.status}</span>
|
||||||
<span class="badge">{chain}</span>
|
<span class="badge">{chain}</span>
|
||||||
<span class="badge">height {height}</span>
|
<span class="badge height-badge" class:new-block={flash}>
|
||||||
|
height {height.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -47,4 +65,24 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.height-badge {
|
||||||
|
transition: background 0.3s, border-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
.new-block {
|
||||||
|
animation: block-flash 1.5s ease-out;
|
||||||
|
}
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+140
-11
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import { snap } from "../stores/snapshot.svelte";
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
import {
|
import {
|
||||||
formatHashrate,
|
formatHashrate,
|
||||||
formatUptime,
|
formatUptime,
|
||||||
|
formatDifficulty,
|
||||||
|
formatWork,
|
||||||
expectedBlockSeconds,
|
expectedBlockSeconds,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
} from "../format";
|
} from "../format";
|
||||||
@@ -10,31 +13,69 @@
|
|||||||
const data = $derived(snap.data!);
|
const data = $derived(snap.data!);
|
||||||
const miners = $derived(data.clients?.length ?? 0);
|
const miners = $derived(data.clients?.length ?? 0);
|
||||||
const workerCount = $derived(data.workers?.length ?? 0);
|
const workerCount = $derived(data.workers?.length ?? 0);
|
||||||
|
const height = $derived(data.chain?.blocks ?? 0);
|
||||||
|
|
||||||
// Pool share of the network hashrate, used both for display and for
|
|
||||||
// the expected-block calculation. Network diff comes from bitcoind.
|
|
||||||
const poolShare = $derived.by(() => {
|
const poolShare = $derived.by(() => {
|
||||||
const net = data.network_hashrate_hs;
|
const net = data.network_hashrate_hs;
|
||||||
if (!net || net <= 0) return 0;
|
if (!net || net <= 0) return 0;
|
||||||
return data.hashrate_hs_1h / net;
|
return data.hashrate_hs_1m / net;
|
||||||
});
|
});
|
||||||
|
|
||||||
const expected = $derived.by(() => {
|
const expected = $derived.by(() => {
|
||||||
const diff = data.chain?.difficulty ?? 0;
|
const diff = data.chain?.difficulty ?? 0;
|
||||||
return expectedBlockSeconds(data.hashrate_hs_1h, data.network_hashrate_hs, diff);
|
return expectedBlockSeconds(data.hashrate_hs_1m, data.network_hashrate_hs, diff);
|
||||||
|
});
|
||||||
|
|
||||||
|
const effort = $derived.by(() => {
|
||||||
|
if (!isFinite(expected) || expected <= 0) return 0;
|
||||||
|
const uptime = data.uptime_seconds;
|
||||||
|
if (!uptime || uptime <= 0) return 0;
|
||||||
|
return (uptime / expected) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
const retarget = $derived.by(() => {
|
||||||
|
if (!height) return { progress: 0, remaining: 2016, eta: "" };
|
||||||
|
const inEpoch = height % 2016;
|
||||||
|
const remaining = 2016 - inEpoch;
|
||||||
|
const progress = (inEpoch / 2016) * 100;
|
||||||
|
const etaSec = remaining * 600;
|
||||||
|
return { progress, remaining, eta: formatDuration(etaSec) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total hashes done by the pool = cumulative_shares * 2^32.
|
||||||
|
const totalHashes = $derived(data.cumulative_shares * 2 ** 32);
|
||||||
|
|
||||||
|
// Block-height flash on network-wide new block.
|
||||||
|
let prevHeight = $state(0);
|
||||||
|
let heightFlash = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const h = height;
|
||||||
|
const prev = untrack(() => prevHeight);
|
||||||
|
if (h > 0 && prev > 0 && h !== prev) {
|
||||||
|
heightFlash = true;
|
||||||
|
setTimeout(() => { heightFlash = false; }, 1800);
|
||||||
|
}
|
||||||
|
prevHeight = h;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="grid grid-4">
|
<section class="grid grid-5">
|
||||||
|
<!-- Row 1 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Hashrate (1h)</div>
|
<div class="stat-label">Hashrate</div>
|
||||||
<div class="stat-value">{formatHashrate(data.hashrate_hs_1h)}</div>
|
<div class="stat-value">{formatHashrate(data.hashrate_hs_1m)}</div>
|
||||||
<div class="stat-sub">
|
<div class="stat-sub">
|
||||||
1m {formatHashrate(data.hashrate_hs_1m)} · 5m {formatHashrate(data.hashrate_hs_5m)}
|
5m {formatHashrate(data.hashrate_hs_5m)} · 1h {formatHashrate(data.hashrate_hs_1h)}
|
||||||
· 24h {formatHashrate(data.hashrate_hs_24h)}
|
· 24h {formatHashrate(data.hashrate_hs_24h)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Best share</div>
|
||||||
|
<div class="stat-value">{formatDifficulty(data.best_diff)}</div>
|
||||||
|
<div class="stat-sub">all-time pool record</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Miners</div>
|
<div class="stat-label">Miners</div>
|
||||||
<div class="stat-value">{miners}</div>
|
<div class="stat-value">{miners}</div>
|
||||||
@@ -45,14 +86,102 @@
|
|||||||
<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">
|
||||||
diff {data.chain?.difficulty.toExponential(2) ?? "—"} ·
|
pool share {(poolShare * 1e9).toFixed(2)} ppb
|
||||||
share {(poolShare * 1e9).toFixed(2)} ppb
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="stat-label">Expected block</div>
|
<div class="stat-label">Expected block</div>
|
||||||
<div class="stat-value">{formatDuration(expected)}</div>
|
<div class="stat-value">{formatDuration(expected)}</div>
|
||||||
<div class="stat-sub">uptime {formatUptime(data.uptime_seconds)}</div>
|
<div class="stat-sub">
|
||||||
|
effort {effort.toFixed(1)}% · uptime {formatUptime(data.uptime_seconds)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2 -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Difficulty</div>
|
||||||
|
<div class="stat-value">{formatDifficulty(data.chain?.difficulty ?? 0)}</div>
|
||||||
|
<div class="stat-sub">network target</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card height-card" class:new-block={heightFlash}>
|
||||||
|
<div class="stat-label">Block height</div>
|
||||||
|
<div class="stat-value">{height ? height.toLocaleString() : "—"}</div>
|
||||||
|
<div class="stat-sub">{data.chain?.chain ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Block reward</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"}
|
||||||
|
<span class="unit">BTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-sub">subsidy + fees (next block)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Total work</div>
|
||||||
|
<div class="stat-value">{formatWork(totalHashes)}</div>
|
||||||
|
<div class="stat-sub">hashes submitted by pool</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Diff adjustment</div>
|
||||||
|
<div class="stat-value">{retarget.remaining} blocks</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
<div class="retarget-bar">
|
||||||
|
<div class="retarget-fill" style="width:{retarget.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<span>{retarget.progress.toFixed(1)}% · ~{retarget.eta}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-5 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.retarget-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.35em;
|
||||||
|
}
|
||||||
|
.retarget-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
.unit {
|
||||||
|
font-size: 0.65em;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
}
|
||||||
|
.height-card {
|
||||||
|
transition: box-shadow 0.3s, border-color 0.3s;
|
||||||
|
}
|
||||||
|
.height-card.new-block {
|
||||||
|
animation: height-flash 1.8s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes height-flash {
|
||||||
|
0% {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent), 0 0 32px rgba(255, 122, 58, 0.55);
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent), 0 0 20px rgba(255, 122, 58, 0.35);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
border-color: var(--border);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ export type BlockRecord = {
|
|||||||
share_diff?: number;
|
share_diff?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HashratePoint = {
|
||||||
|
t: number; // unix seconds
|
||||||
|
v: number; // H/s
|
||||||
|
};
|
||||||
|
|
||||||
export type Snapshot = {
|
export type Snapshot = {
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
pool: PoolStats | null;
|
pool: PoolStats | null;
|
||||||
@@ -99,9 +104,13 @@ export type Snapshot = {
|
|||||||
hashrate_hs_5m: number;
|
hashrate_hs_5m: number;
|
||||||
hashrate_hs_1h: number;
|
hashrate_hs_1h: number;
|
||||||
hashrate_hs_24h: number;
|
hashrate_hs_24h: number;
|
||||||
|
best_diff: number;
|
||||||
|
cumulative_shares: number;
|
||||||
|
next_block_reward_btc: number;
|
||||||
chain: BlockchainInfo | null;
|
chain: BlockchainInfo | null;
|
||||||
network_hashrate_hs: number;
|
network_hashrate_hs: number;
|
||||||
recent_blocks?: BlockRecord[];
|
recent_blocks?: BlockRecord[];
|
||||||
|
hashrate_history?: HashratePoint[];
|
||||||
ckpool_ok: boolean;
|
ckpool_ok: boolean;
|
||||||
bitcoin_ok: boolean;
|
bitcoin_ok: boolean;
|
||||||
last_error?: string;
|
last_error?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user