Add best share analysis page, miner column, and UI improvements

- Best share page: hex + binary hash comparison against network target,
  per-bit coloring showing exactly which bits prevented a valid block,
  toggle between network diff at time of finding vs current diff
- Capture best share hash from ckpool logs with one-time backfill
- Persist network difficulty at time of best share for historical accuracy
- Add miner (worker) column to blocks table via coinbase address matching
- Truncate block hashes in table with full hash on hover
- Increase hashrate chart Y-axis to 7 ticks for better readability
This commit is contained in:
satoshi
2026-05-18 17:19:35 +03:00
parent f1dc0a8a79
commit 1157f3501a
17 changed files with 1499 additions and 81 deletions
+47 -9
View File
@@ -3,6 +3,7 @@ package state
import (
"context"
"strconv"
"strings"
"time"
"github.com/kamadopool/kamado-api/internal/logmon"
@@ -24,6 +25,7 @@ type BlockRecord struct {
ShareDiff float64 `json:"share_diff,omitempty"`
OrphanedAt *time.Time `json:"orphaned_at,omitempty"`
Chain string `json:"chain,omitempty"` // "main", "test", "signet"
Miner string `json:"miner,omitempty"` // workername (address.worker) who found the block
}
// timePtr returns a pointer to t if non-zero, nil otherwise.
@@ -157,17 +159,18 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
ShareDiff: ev.ShareDiff,
Chain: currentChain,
}
// Best-effort enrich with hash + coinbase reward via bitcoind.
// Best-effort enrich with hash + coinbase reward + miner via bitcoind.
// We look up the hash from height, then fetch the full block
// (verbosity 2) to sum the coinbase outputs. Both are fire-
// and-forget — if bitcoind is down we still record the block
// with whatever we have.
// (verbosity 2) to sum the coinbase outputs and extract the
// payout address. Both are fire-and-forget — if bitcoind is
// down we still record the block with whatever we have.
if a.RPC != nil {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
if hash, err := a.RPC.GetBlockHash(lookupCtx, ev.Height); err == nil {
rec.Hash = hash
if blk, err := a.RPC.GetBlock(lookupCtx, hash); err == nil {
rec.RewardBT = blk.CoinbaseReward()
rec.Miner = a.minerFromCoinbase(blk.CoinbaseAddress())
} else {
a.Log.Warn("bitcoind getblock failed", "hash", hash, "err", err)
}
@@ -186,6 +189,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
Source: rec.Source,
ShareDiff: rec.ShareDiff,
Chain: rec.Chain,
Miner: rec.Miner,
})
if err != nil {
a.Log.Warn("block persist failed", "height", rec.Height, "err", err)
@@ -273,6 +277,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
hash := b.Hash
reward := b.RewardBT
miner := b.Miner
if hash == "" {
if h, herr := a.RPC.GetBlockHash(lookupCtx, b.Height); herr == nil {
hash = h
@@ -281,18 +286,23 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
continue
}
}
if reward == 0 && hash != "" {
if (reward == 0 || miner == "") && hash != "" {
if blk, berr := a.RPC.GetBlock(lookupCtx, hash); berr == nil {
reward = blk.CoinbaseReward()
if reward == 0 {
reward = blk.CoinbaseReward()
}
if miner == "" {
miner = a.minerFromCoinbase(blk.CoinbaseAddress())
}
}
}
cancel()
if hash != b.Hash || reward != b.RewardBT {
if err := a.Store.UpdateEnrichment(b.Height, hash, reward); err != nil {
if hash != b.Hash || reward != b.RewardBT || miner != b.Miner {
if err := a.Store.UpdateEnrichment(b.Height, hash, reward, miner); err != nil {
a.Log.Warn("reconcile: update enrichment failed", "height", b.Height, "err", err)
continue
}
a.Log.Info("reconcile: enriched block", "height", b.Height, "hash", hash, "reward", reward)
a.Log.Info("reconcile: enriched block", "height", b.Height, "hash", hash, "reward", reward, "miner", miner)
enrichedAny = true
}
}
@@ -480,6 +490,32 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
}
}
// minerFromCoinbase matches a coinbase payout address to a full workername
// from the current worker list. In solo mode, stratum usernames are
// "address" or "address.label", and the coinbase pays the address portion.
// Returns the first matching workername (address.worker), or just the
// address if no worker match is found.
func (a *Aggregator) minerFromCoinbase(addr string) string {
if addr == "" {
return ""
}
a.mu.RLock()
workers := a.snap.Workers
a.mu.RUnlock()
for _, w := range workers {
// Worker.User is "address.workername" in ckpool. The address
// portion is everything before the first dot.
wAddr := w.User
if dot := strings.IndexByte(wAddr, '.'); dot >= 0 {
wAddr = wAddr[:dot]
}
if wAddr == addr {
return w.User + "." + w.Worker
}
}
return addr
}
// maxBlockHistory caps in-memory block history. Persistence comes in
// Phase 2b.5 via SQLite; for now recent blocks survive only this
// process's lifetime.
@@ -526,6 +562,7 @@ func (a *Aggregator) loadPersistedBlocks() {
ShareDiff: r.ShareDiff,
OrphanedAt: timePtr(r.OrphanedAt),
Chain: r.Chain,
Miner: r.Miner,
})
}
a.mu.Lock()
@@ -581,6 +618,7 @@ func (a *Aggregator) BlocksFromStore() []BlockRecord {
ShareDiff: r.ShareDiff,
OrphanedAt: timePtr(r.OrphanedAt),
Chain: r.Chain,
Miner: r.Miner,
})
}
return out