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
+2
View File
@@ -65,6 +65,7 @@ func main() {
agg := state.New(ck, rpc, cfg.PollInterval, log) agg := state.New(ck, rpc, cfg.PollInterval, log)
agg.Store = blockStore agg.Store = blockStore
agg.MempoolBaseURL = cfg.MempoolBaseURL agg.MempoolBaseURL = cfg.MempoolBaseURL
agg.LogFilePath = cfg.CKPoolLogFile
// Transaction accelerator (prioritisetransaction). // Transaction accelerator (prioritisetransaction).
var accSvc *accelerator.Service var accSvc *accelerator.Service
@@ -136,6 +137,7 @@ func main() {
go agg.IngestBlockEvents(ctx, tailer.Events) go agg.IngestBlockEvents(ctx, tailer.Events)
go agg.IngestAttemptEvents(ctx, tailer.Attempts) go agg.IngestAttemptEvents(ctx, tailer.Attempts)
go agg.IngestLatencyEvents(ctx, tailer.Latencies) go agg.IngestLatencyEvents(ctx, tailer.Latencies)
go agg.IngestShareEvents(ctx, tailer.Shares)
if accSvc != nil { if accSvc != nil {
go accSvc.Cleanup(ctx) go accSvc.Cleanup(ctx)
+14
View File
@@ -224,6 +224,11 @@ type BlockTx struct {
type BlockVout struct { type BlockVout struct {
Value float64 `json:"value"` Value float64 `json:"value"`
N int `json:"n"` N int `json:"n"`
ScriptPubKey BlockScriptPubKey `json:"scriptPubKey"`
}
type BlockScriptPubKey struct {
Address string `json:"address"`
} }
// GetBlock returns a verbose-level-2 block decode for the given hash. // GetBlock returns a verbose-level-2 block decode for the given hash.
@@ -249,6 +254,15 @@ func (b *BlockVerbose2) CoinbaseReward() float64 {
return total return total
} }
// CoinbaseAddress returns the address of the first coinbase output.
// In ckpool-solo mode this is the miner's BTC payout address.
func (b *BlockVerbose2) CoinbaseAddress() string {
if len(b.Tx) == 0 || len(b.Tx[0].Vout) == 0 {
return ""
}
return b.Tx[0].Vout[0].ScriptPubKey.Address
}
// NetworkHashPS returns the network hashrate at the given block height. // NetworkHashPS returns the network hashrate at the given block height.
// `blocks` is a window (default 120). Pass -1 to use the default. // `blocks` is a window (default 120). Pass -1 to use the default.
func (c *RPC) GetNetworkHashPS(ctx context.Context, blocks, height int) (float64, error) { func (c *RPC) GetNetworkHashPS(ctx context.Context, blocks, height int) (float64, error) {
+106
View File
@@ -21,6 +21,7 @@ import (
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -57,6 +58,17 @@ type LatencyEvent struct {
LatencyMs int64 LatencyMs int64
} }
// ShareEvent is emitted for every individual share submission logged by
// ckpool (both accepted and rejected). Used to build rejection-reason
// breakdowns and accepted-share difficulty distributions.
type ShareEvent struct {
SeenAt time.Time
Diff float64 // share difficulty (always set for accepted; 0 for rejected)
Hash string // block header hash (hex, accepted shares only)
Rejected bool
Reason string // rejection reason (e.g. "Stale", "Duplicate"); empty for accepted
}
// Tailer follows a log file, surviving rotation/truncation, and emits // Tailer follows a log file, surviving rotation/truncation, and emits
// parsed events. Create with New, then Run in a goroutine. // parsed events. Create with New, then Run in a goroutine.
type Tailer struct { type Tailer struct {
@@ -64,6 +76,7 @@ type Tailer struct {
Events chan BlockEvent Events chan BlockEvent
Attempts chan AttemptEvent Attempts chan AttemptEvent
Latencies chan LatencyEvent Latencies chan LatencyEvent
Shares chan ShareEvent
Log *slog.Logger Log *slog.Logger
PollWait time.Duration // how long to sleep between EOF polls PollWait time.Duration // how long to sleep between EOF polls
@@ -90,6 +103,7 @@ func New(path string, log *slog.Logger) *Tailer {
Events: make(chan BlockEvent, 16), Events: make(chan BlockEvent, 16),
Attempts: make(chan AttemptEvent, 16), Attempts: make(chan AttemptEvent, 16),
Latencies: make(chan LatencyEvent, 16), Latencies: make(chan LatencyEvent, 16),
Shares: make(chan ShareEvent, 64),
Log: log, Log: log,
PollWait: 500 * time.Millisecond, PollWait: 500 * time.Millisecond,
} }
@@ -107,6 +121,19 @@ var (
// Matches the latency line from our patch 0005: // Matches the latency line from our patch 0005:
// "Block update latency: 92ms (ZMQ trigger to mining.notify broadcast)" // "Block update latency: 92ms (ZMQ trigger to mining.notify broadcast)"
latencyRE = regexp.MustCompile(`Block update latency:\s+(\d+)ms`) latencyRE = regexp.MustCompile(`Block update latency:\s+(\d+)ms`)
// Share events from ckpool's stratifier.c (v1.0 / cfb0f83b):
// "Accepted client 42 share diff 1234.5/65536/1.234G: <hexhash>"
// "Rejected client 42 dupe diff 1234.5/65536/1.234G: <hexhash>"
// "Rejected client 42 high diff 1234.5/65536/1.234G: <hexhash>"
// "Rejected client 42 invalid share Stale"
// The first number after "diff " is sdiff (share difficulty solved).
acceptedShareRE = regexp.MustCompile(`Accepted client \S+ share diff ([0-9.]+)/[^:]+:\s*([0-9a-fA-F]+)`)
// Rejected shares come in two forms:
// 1. "dupe diff" / "high diff" — share was valid but duplicate or below target
// 2. "invalid share <reason>" — share was structurally invalid (Stale, etc.)
rejectedDiffRE = regexp.MustCompile(`Rejected client \S+ (\w+) diff [0-9.]+/`)
rejectedInvalRE = regexp.MustCompile(`Rejected client \S+ invalid share (.+)`)
) )
// Run blocks until ctx is cancelled. It opens the file, seeks to either // Run blocks until ctx is cancelled. It opens the file, seeks to either
@@ -118,6 +145,7 @@ func (t *Tailer) Run(ctx context.Context) {
defer close(t.Events) defer close(t.Events)
defer close(t.Attempts) defer close(t.Attempts)
defer close(t.Latencies) defer close(t.Latencies)
defer close(t.Shares)
var ( var (
f *os.File f *os.File
@@ -256,6 +284,35 @@ func (t *Tailer) Run(ctx context.Context) {
} }
func (t *Tailer) handleLine(line string) { func (t *Tailer) handleLine(line string) {
// Accepted share: extract share difficulty and block header hash.
if m := acceptedShareRE.FindStringSubmatch(line); m != nil {
if d, err := strconv.ParseFloat(m[1], 64); err == nil {
select {
case t.Shares <- ShareEvent{SeenAt: time.Now(), Diff: d, Hash: m[2]}:
default:
}
}
// Don't return — an accepted share may also be a block solve,
// so let the solve-diff regex below get a chance to match too.
}
// Rejected share with diff info: "Rejected client <id> dupe|high diff ..."
if m := rejectedDiffRE.FindStringSubmatch(line); m != nil {
reason := rejectKeyword(m[1])
select {
case t.Shares <- ShareEvent{SeenAt: time.Now(), Rejected: true, Reason: reason}:
default:
}
return
}
// Rejected share without diff: "Rejected client <id> invalid share <reason>"
if m := rejectedInvalRE.FindStringSubmatch(line); m != nil {
reason := strings.TrimSpace(m[1])
select {
case t.Shares <- ShareEvent{SeenAt: time.Now(), Rejected: true, Reason: reason}:
default:
}
return
}
if m := latencyRE.FindStringSubmatch(line); m != nil { if m := latencyRE.FindStringSubmatch(line); m != nil {
if ms, err := strconv.ParseInt(m[1], 10, 64); err == nil { if ms, err := strconv.ParseInt(m[1], 10, 64); err == nil {
select { select {
@@ -301,6 +358,55 @@ func (t *Tailer) handleLine(line string) {
} }
} }
// rejectKeyword maps the short keyword ckpool uses in "Rejected client
// <id> <keyword> diff ..." log lines to a human-readable reason.
func rejectKeyword(kw string) string {
switch strings.ToLower(kw) {
case "dupe":
return "Duplicate"
case "high":
return "Above target"
default:
return kw
}
}
// FindBestShareHash scans the log file for the accepted share with the
// highest difficulty and returns its diff and block header hash. Used as
// a one-time backfill when bestDiff is persisted but the hash is not
// (pre-upgrade shares). Returns (0, "") if no accepted shares are found.
func FindBestShareHash(path string) (float64, string) {
f, err := os.Open(path)
if err != nil {
return 0, ""
}
defer f.Close()
var bestDiff float64
var bestHash string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "Accepted client") {
continue
}
m := acceptedShareRE.FindStringSubmatch(line)
if m == nil {
continue
}
d, err := strconv.ParseFloat(m[1], 64)
if err != nil {
continue
}
if d >= bestDiff {
bestDiff = d
bestHash = m[2]
}
}
return bestDiff, bestHash
}
// sleep returns false if ctx was cancelled during the wait. // sleep returns false if ctx was cancelled during the wait.
func sleep(ctx context.Context, d time.Duration) bool { func sleep(ctx context.Context, d time.Duration) bool {
select { select {
+258 -1
View File
@@ -6,6 +6,7 @@ package state
import ( import (
"context" "context"
"encoding/json"
"log/slog" "log/slog"
"strconv" "strconv"
"sync" "sync"
@@ -13,6 +14,7 @@ import (
"github.com/kamadopool/kamado-api/internal/bitcoind" "github.com/kamadopool/kamado-api/internal/bitcoind"
"github.com/kamadopool/kamado-api/internal/ckpool" "github.com/kamadopool/kamado-api/internal/ckpool"
"github.com/kamadopool/kamado-api/internal/logmon"
"github.com/kamadopool/kamado-api/internal/store" "github.com/kamadopool/kamado-api/internal/store"
"github.com/kamadopool/kamado-api/internal/zmqmon" "github.com/kamadopool/kamado-api/internal/zmqmon"
) )
@@ -43,6 +45,10 @@ type Snapshot struct {
// Best share difficulty ever seen across all workers. // Best share difficulty ever seen across all workers.
BestDiff float64 `json:"best_diff"` BestDiff float64 `json:"best_diff"`
// Block header hash of the best share ever found (hex string).
BestShareHash string `json:"best_share_hash,omitempty"`
// Network difficulty at the time the best share was found.
BestShareNetDiff float64 `json:"best_share_net_diff,omitempty"`
// Last best-diff value acknowledged by the user via the UI. // Last best-diff value acknowledged by the user via the UI.
// The frontend shows a "new best" glow when best_diff > this. // The frontend shows a "new best" glow when best_diff > this.
AckedBestDiff float64 `json:"acked_best_diff"` AckedBestDiff float64 `json:"acked_best_diff"`
@@ -99,6 +105,17 @@ type Snapshot struct {
AllTimeAccepted int64 `json:"alltime_accepted"` AllTimeAccepted int64 `json:"alltime_accepted"`
AllTimeRejected int64 `json:"alltime_rejected"` AllTimeRejected int64 `json:"alltime_rejected"`
// Share statistics: rejection reasons and difficulty distribution.
// Session resets on ckpool restart; AllTime persisted across restarts.
RejectReasons map[string]int64 `json:"reject_reasons_session,omitempty"`
RejectReasonsAll map[string]int64 `json:"reject_reasons_alltime,omitempty"`
// Difficulty distribution buckets: [<1M, 1M-100M, 100M-1G, 1G-100G, 100G-1T, >1T]
DiffDist [6]int64 `json:"diff_dist_session"`
DiffDistAll [6]int64 `json:"diff_dist_alltime"`
// Average share difficulty (arithmetic mean of all accepted shares).
AvgDiffSession float64 `json:"avg_diff_session"`
AvgDiffAlltime float64 `json:"avg_diff_alltime"`
// Block update latency diagnostics (ZMQ trigger → mining.notify). // Block update latency diagnostics (ZMQ trigger → mining.notify).
LatencyCount int64 `json:"latency_count"` LatencyCount int64 `json:"latency_count"`
LatencyAvgMs int64 `json:"latency_avg_ms"` LatencyAvgMs int64 `json:"latency_avg_ms"`
@@ -131,10 +148,15 @@ const (
kvAckedBestDiff = "acked_best_diff" kvAckedBestDiff = "acked_best_diff"
kvBestDiff = "best_diff" kvBestDiff = "best_diff"
kvBestShareHash = "best_share_hash"
kvBestShareNetDiff = "best_share_net_diff"
kvAllTimeAccepted = "alltime_accepted" kvAllTimeAccepted = "alltime_accepted"
kvAllTimeRejected = "alltime_rejected" kvAllTimeRejected = "alltime_rejected"
kvRejectReasonsAll = "reject_reasons_alltime"
kvDiffDistAll = "diff_dist_alltime"
// reconcileInterval is how often we sweep recent blocks looking // reconcileInterval is how often we sweep recent blocks looking
// for missing hash/reward enrichment and reorg-orphaned hashes. // for missing hash/reward enrichment and reorg-orphaned hashes.
// 60s is fast enough to recover from a transient bitcoind hiccup // 60s is fast enough to recover from a transient bitcoind hiccup
@@ -159,6 +181,11 @@ type Aggregator struct {
// MempoolBaseURL leaves the UI on its mempool.space defaults. // MempoolBaseURL leaves the UI on its mempool.space defaults.
MempoolBaseURL string MempoolBaseURL string
// LogFilePath is the ckpool log path, used for one-time backfill
// of the best share hash when upgrading from a version that didn't
// capture it. Set by main before calling Run.
LogFilePath string
// OnRefresh, if set, is called (non-blocking) after each snapshot // OnRefresh, if set, is called (non-blocking) after each snapshot
// refresh. Used by the WebSocket hub to push updates to clients. // refresh. Used by the WebSocket hub to push updates to clients.
OnRefresh func(Snapshot) OnRefresh func(Snapshot)
@@ -246,9 +273,25 @@ type Aggregator struct {
// High-water mark for the best share difficulty ever seen. // High-water mark for the best share difficulty ever seen.
// Persisted to kv so it survives restarts and transient ckpool gaps. // Persisted to kv so it survives restarts and transient ckpool gaps.
bestDiff float64 bestDiff float64
// Block header hash of the best share ever found. Captured from the
// ckpool log "Accepted client ... : <hash>" line for the share that
// set the bestDiff record. Persisted alongside bestDiff.
bestShareHash string
bestShareNetDiff float64 // network difficulty when best share was found
// Last best-diff value acknowledged by the user in the UI. // Last best-diff value acknowledged by the user in the UI.
ackedBestDiff float64 ackedBestDiff float64
// Share statistics from log parsing.
sessionRejectReasons map[string]int64
alltimeRejectReasons map[string]int64
sessionDiffDist [6]int64
alltimeDiffDist [6]int64
sessionDiffCount int64 // total accepted shares (session)
alltimeDiffCount int64 // total accepted shares (alltime)
sessionDiffSum float64 // sum of share difficulties (session)
alltimeDiffSum float64 // sum of share difficulties (alltime)
lastShareStatsSave time.Time
} }
func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog.Logger) *Aggregator { func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog.Logger) *Aggregator {
@@ -257,6 +300,8 @@ func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog
RPC: rpc, RPC: rpc,
Interval: interval, Interval: interval,
Log: log, Log: log,
sessionRejectReasons: make(map[string]int64),
alltimeRejectReasons: make(map[string]int64),
ready: make(chan struct{}), ready: make(chan struct{}),
} }
} }
@@ -443,6 +488,8 @@ func (a *Aggregator) refresh(ctx context.Context) {
} }
} }
next.BestDiff = a.bestDiff next.BestDiff = a.bestDiff
next.BestShareHash = a.bestShareHash
next.BestShareNetDiff = a.bestShareNetDiff
next.AckedBestDiff = a.ackedBestDiff next.AckedBestDiff = a.ackedBestDiff
now := time.Now() now := time.Now()
@@ -483,6 +530,9 @@ func (a *Aggregator) refresh(ctx context.Context) {
if a.hasShareCountBaseline { if a.hasShareCountBaseline {
if curAcc >= a.lastPoolAcceptedRaw { if curAcc >= a.lastPoolAcceptedRaw {
a.allTimeAccepted += curAcc - a.lastPoolAcceptedRaw a.allTimeAccepted += curAcc - a.lastPoolAcceptedRaw
} else {
// ckpool restarted — reset session share stats.
a.resetSessionShareStats()
} }
if curRej >= a.lastPoolRejectedRaw { if curRej >= a.lastPoolRejectedRaw {
a.allTimeRejected += curRej - a.lastPoolRejectedRaw a.allTimeRejected += curRej - a.lastPoolRejectedRaw
@@ -569,6 +619,31 @@ func (a *Aggregator) refresh(ctx context.Context) {
} }
next.LatencyLastMs = a.latencyLastMs next.LatencyLastMs = a.latencyLastMs
next.StaleWorkHashes = a.staleWorkHashes next.StaleWorkHashes = a.staleWorkHashes
// Share statistics (from log parsing).
if len(a.sessionRejectReasons) > 0 {
m := make(map[string]int64, len(a.sessionRejectReasons))
for k, v := range a.sessionRejectReasons {
m[k] = v
}
next.RejectReasons = m
}
if len(a.alltimeRejectReasons) > 0 {
m := make(map[string]int64, len(a.alltimeRejectReasons))
for k, v := range a.alltimeRejectReasons {
m[k] = v
}
next.RejectReasonsAll = m
}
next.DiffDist = a.sessionDiffDist
next.DiffDistAll = a.alltimeDiffDist
if a.sessionDiffCount > 0 {
next.AvgDiffSession = a.sessionDiffSum / float64(a.sessionDiffCount)
}
if a.alltimeDiffCount > 0 {
next.AvgDiffAlltime = a.alltimeDiffSum / float64(a.alltimeDiffCount)
}
a.snap = next a.snap = next
cb := a.OnRefresh cb := a.OnRefresh
pushed := next pushed := next
@@ -605,6 +680,126 @@ func (a *Aggregator) ResetAckedBestDiff() {
} }
} }
// diffBucket returns the index [0..5] for a share difficulty:
//
// 0: < 1M 1: 1M100M 2: 100M1G
// 3: 1G100G 4: 100G1T 5: ≥ 1T
func diffBucket(d float64) int {
switch {
case d < 1e6:
return 0
case d < 1e8:
return 1
case d < 1e9:
return 2
case d < 1e11:
return 3
case d < 1e12:
return 4
default:
return 5
}
}
// DiffBucketLabels are the human-readable labels for each difficulty bucket.
var DiffBucketLabels = [6]string{"< 1M", "1M 100M", "100M 1G", "1G 100G", "100G 1T", "≥ 1T"}
// IngestShareEvents reads individual share events from the log tailer
// and maintains rejection-reason counts and difficulty-distribution
// histograms. Session counters reset when ckpool restarts (detected by
// the aggregator's existing pool.Shares decrease logic). Alltime
// counters are persisted to the kv store.
func (a *Aggregator) IngestShareEvents(ctx context.Context, events <-chan logmon.ShareEvent) {
for {
select {
case <-ctx.Done():
return
case ev, ok := <-events:
if !ok {
return
}
a.mu.Lock()
if ev.Rejected {
reason := ev.Reason
if reason == "" {
reason = "Unknown"
}
a.sessionRejectReasons[reason]++
a.alltimeRejectReasons[reason]++
} else {
bucket := diffBucket(ev.Diff)
a.sessionDiffDist[bucket]++
a.alltimeDiffDist[bucket]++
a.sessionDiffCount++
a.alltimeDiffCount++
a.sessionDiffSum += ev.Diff
a.alltimeDiffSum += ev.Diff
// Track hash of the best share from log parsing.
if ev.Hash != "" && ev.Diff > a.bestDiff {
a.bestDiff = ev.Diff
a.bestShareHash = ev.Hash
// Capture the current network difficulty at time of finding.
if a.snap.Chain != nil && a.snap.Chain.Difficulty > 0 {
a.bestShareNetDiff = a.snap.Chain.Difficulty
}
if a.Store != nil {
_ = a.Store.SetKV(kvBestDiff, strconv.FormatFloat(ev.Diff, 'f', -1, 64))
_ = a.Store.SetKV(kvBestShareHash, ev.Hash)
if a.bestShareNetDiff > 0 {
_ = a.Store.SetKV(kvBestShareNetDiff, strconv.FormatFloat(a.bestShareNetDiff, 'f', -1, 64))
}
}
}
}
save := a.Store != nil && time.Since(a.lastShareStatsSave) >= time.Minute
if save {
a.lastShareStatsSave = time.Now()
}
a.mu.Unlock()
if save {
a.persistShareStats()
}
}
}
}
// ResetSessionShareStats clears session-level share statistics. Called
// when a ckpool restart is detected (pool.Shares decreases).
func (a *Aggregator) resetSessionShareStats() {
a.sessionRejectReasons = make(map[string]int64)
a.sessionDiffDist = [6]int64{}
a.sessionDiffCount = 0
a.sessionDiffSum = 0
}
func (a *Aggregator) persistShareStats() {
if a.Store == nil {
return
}
a.mu.Lock()
reasonsCopy := make(map[string]int64, len(a.alltimeRejectReasons))
for k, v := range a.alltimeRejectReasons {
reasonsCopy[k] = v
}
distCopy := a.alltimeDiffDist
countCopy := a.alltimeDiffCount
sumCopy := a.alltimeDiffSum
a.mu.Unlock()
if data, err := json.Marshal(reasonsCopy); err == nil {
_ = a.Store.SetKV(kvRejectReasonsAll, string(data))
}
type distPersist struct {
Buckets [6]int64 `json:"b"`
Count int64 `json:"n"`
Sum float64 `json:"s"`
}
if data, err := json.Marshal(distPersist{Buckets: distCopy, Count: countCopy, Sum: sumCopy}); err == nil {
_ = a.Store.SetKV(kvDiffDistAll, string(data))
}
}
// loadPersistedState restores cumulative work and hashrate history from // loadPersistedState restores cumulative work and hashrate history from
// the store so they survive process restarts. Safe to call with a nil // the store so they survive process restarts. Safe to call with a nil
// Store — becomes a no-op. // Store — becomes a no-op.
@@ -655,7 +850,7 @@ func (a *Aggregator) loadPersistedState() {
} }
} }
// Restore best diff high-water mark. // Restore best diff high-water mark and its block header hash.
if v, err := a.Store.GetKV(kvBestDiff); err == nil && v != "" { if v, err := a.Store.GetKV(kvBestDiff); err == nil && v != "" {
if f, perr := strconv.ParseFloat(v, 64); perr == nil { if f, perr := strconv.ParseFloat(v, 64); perr == nil {
a.mu.Lock() a.mu.Lock()
@@ -663,6 +858,44 @@ func (a *Aggregator) loadPersistedState() {
a.mu.Unlock() a.mu.Unlock()
} }
} }
if v, err := a.Store.GetKV(kvBestShareHash); err == nil && v != "" {
a.mu.Lock()
a.bestShareHash = v
a.mu.Unlock()
}
if v, err := a.Store.GetKV(kvBestShareNetDiff); err == nil && v != "" {
if f, perr := strconv.ParseFloat(v, 64); perr == nil {
a.mu.Lock()
a.bestShareNetDiff = f
a.mu.Unlock()
}
}
// One-time backfill: if we have no hash (pre-upgrade), scan the
// ckpool log for the highest-difficulty accepted share and use its hash.
a.mu.RLock()
needsBackfill := a.bestShareHash == "" && a.LogFilePath != ""
a.mu.RUnlock()
if needsBackfill {
if logDiff, h := logmon.FindBestShareHash(a.LogFilePath); h != "" {
a.mu.Lock()
a.bestShareHash = h
// The log may contain the true best — adopt it if higher.
if logDiff > a.bestDiff {
a.bestDiff = logDiff
if a.Store != nil {
_ = a.Store.SetKV(kvBestDiff, strconv.FormatFloat(logDiff, 'f', -1, 64))
}
}
a.mu.Unlock()
if a.Store != nil {
_ = a.Store.SetKV(kvBestShareHash, h)
}
a.Log.Info("backfilled best share hash from log", "diff", logDiff, "hash", h)
} else {
a.Log.Info("best share hash backfill: no accepted shares found in log")
}
}
// Restore acknowledged best diff. // Restore acknowledged best diff.
if v, err := a.Store.GetKV(kvAckedBestDiff); err == nil && v != "" { if v, err := a.Store.GetKV(kvAckedBestDiff); err == nil && v != "" {
@@ -701,6 +934,30 @@ func (a *Aggregator) loadPersistedState() {
} }
a.mu.Unlock() a.mu.Unlock()
// Restore share statistics.
if v, err := a.Store.GetKV(kvRejectReasonsAll); err == nil && v != "" {
var m map[string]int64
if jerr := json.Unmarshal([]byte(v), &m); jerr == nil {
a.mu.Lock()
a.alltimeRejectReasons = m
a.mu.Unlock()
}
}
if v, err := a.Store.GetKV(kvDiffDistAll); err == nil && v != "" {
var dp struct {
Buckets [6]int64 `json:"b"`
Count int64 `json:"n"`
Sum float64 `json:"s"`
}
if jerr := json.Unmarshal([]byte(v), &dp); jerr == nil {
a.mu.Lock()
a.alltimeDiffDist = dp.Buckets
a.alltimeDiffCount = dp.Count
a.alltimeDiffSum = dp.Sum
a.mu.Unlock()
}
}
cutoff := time.Now().Add(-24 * time.Hour).Unix() cutoff := time.Now().Add(-24 * time.Hour).Unix()
if samples, err := a.Store.HashrateSince(cutoff); err != nil { if samples, err := a.Store.HashrateSince(cutoff); err != nil {
a.Log.Warn("hashrate history load failed", "err", err) a.Log.Warn("hashrate history load failed", "err", err)
+46 -8
View File
@@ -3,6 +3,7 @@ package state
import ( import (
"context" "context"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/kamadopool/kamado-api/internal/logmon" "github.com/kamadopool/kamado-api/internal/logmon"
@@ -24,6 +25,7 @@ type BlockRecord struct {
ShareDiff float64 `json:"share_diff,omitempty"` ShareDiff float64 `json:"share_diff,omitempty"`
OrphanedAt *time.Time `json:"orphaned_at,omitempty"` OrphanedAt *time.Time `json:"orphaned_at,omitempty"`
Chain string `json:"chain,omitempty"` // "main", "test", "signet" 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. // 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, ShareDiff: ev.ShareDiff,
Chain: currentChain, 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 // We look up the hash from height, then fetch the full block
// (verbosity 2) to sum the coinbase outputs. Both are fire- // (verbosity 2) to sum the coinbase outputs and extract the
// and-forget — if bitcoind is down we still record the block // payout address. Both are fire-and-forget — if bitcoind is
// with whatever we have. // down we still record the block with whatever we have.
if a.RPC != nil { if a.RPC != nil {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second) lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
if hash, err := a.RPC.GetBlockHash(lookupCtx, ev.Height); err == nil { if hash, err := a.RPC.GetBlockHash(lookupCtx, ev.Height); err == nil {
rec.Hash = hash rec.Hash = hash
if blk, err := a.RPC.GetBlock(lookupCtx, hash); err == nil { if blk, err := a.RPC.GetBlock(lookupCtx, hash); err == nil {
rec.RewardBT = blk.CoinbaseReward() rec.RewardBT = blk.CoinbaseReward()
rec.Miner = a.minerFromCoinbase(blk.CoinbaseAddress())
} else { } else {
a.Log.Warn("bitcoind getblock failed", "hash", hash, "err", err) 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, Source: rec.Source,
ShareDiff: rec.ShareDiff, ShareDiff: rec.ShareDiff,
Chain: rec.Chain, Chain: rec.Chain,
Miner: rec.Miner,
}) })
if err != nil { if err != nil {
a.Log.Warn("block persist failed", "height", rec.Height, "err", err) 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) lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
hash := b.Hash hash := b.Hash
reward := b.RewardBT reward := b.RewardBT
miner := b.Miner
if hash == "" { if hash == "" {
if h, herr := a.RPC.GetBlockHash(lookupCtx, b.Height); herr == nil { if h, herr := a.RPC.GetBlockHash(lookupCtx, b.Height); herr == nil {
hash = h hash = h
@@ -281,18 +286,23 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
continue continue
} }
} }
if reward == 0 && hash != "" { if (reward == 0 || miner == "") && hash != "" {
if blk, berr := a.RPC.GetBlock(lookupCtx, hash); berr == nil { if blk, berr := a.RPC.GetBlock(lookupCtx, hash); berr == nil {
if reward == 0 {
reward = blk.CoinbaseReward() reward = blk.CoinbaseReward()
} }
if miner == "" {
miner = a.minerFromCoinbase(blk.CoinbaseAddress())
}
}
} }
cancel() cancel()
if hash != b.Hash || reward != b.RewardBT { if hash != b.Hash || reward != b.RewardBT || miner != b.Miner {
if err := a.Store.UpdateEnrichment(b.Height, hash, reward); err != nil { if err := a.Store.UpdateEnrichment(b.Height, hash, reward, miner); err != nil {
a.Log.Warn("reconcile: update enrichment failed", "height", b.Height, "err", err) a.Log.Warn("reconcile: update enrichment failed", "height", b.Height, "err", err)
continue 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 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 // maxBlockHistory caps in-memory block history. Persistence comes in
// Phase 2b.5 via SQLite; for now recent blocks survive only this // Phase 2b.5 via SQLite; for now recent blocks survive only this
// process's lifetime. // process's lifetime.
@@ -526,6 +562,7 @@ func (a *Aggregator) loadPersistedBlocks() {
ShareDiff: r.ShareDiff, ShareDiff: r.ShareDiff,
OrphanedAt: timePtr(r.OrphanedAt), OrphanedAt: timePtr(r.OrphanedAt),
Chain: r.Chain, Chain: r.Chain,
Miner: r.Miner,
}) })
} }
a.mu.Lock() a.mu.Lock()
@@ -581,6 +618,7 @@ func (a *Aggregator) BlocksFromStore() []BlockRecord {
ShareDiff: r.ShareDiff, ShareDiff: r.ShareDiff,
OrphanedAt: timePtr(r.OrphanedAt), OrphanedAt: timePtr(r.OrphanedAt),
Chain: r.Chain, Chain: r.Chain,
Miner: r.Miner,
}) })
} }
return out return out
+18 -12
View File
@@ -29,6 +29,7 @@ type Block struct {
ShareDiff float64 ShareDiff float64
OrphanedAt time.Time // zero value = not orphaned OrphanedAt time.Time // zero value = not orphaned
Chain string // "main", "test", "signet", or "" for legacy rows Chain string // "main", "test", "signet", or "" for legacy rows
Miner string // workername (address.worker) who found the block
} }
const schema = ` const schema = `
@@ -90,6 +91,11 @@ func (s *BlockStore) migrate() error {
return fmt.Errorf("store: add chain: %w", err) return fmt.Errorf("store: add chain: %w", err)
} }
} }
if !have["miner"] {
if _, err := s.db.Exec(`ALTER TABLE blocks ADD COLUMN miner TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("store: add miner: %w", err)
}
}
return nil return nil
} }
@@ -132,9 +138,9 @@ func (s *BlockStore) Close() error {
// unnoticed. // unnoticed.
func (s *BlockStore) InsertBlock(b Block) (bool, error) { func (s *BlockStore) InsertBlock(b Block) (bool, error) {
res, err := s.db.Exec( res, err := s.db.Exec(
`INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff, chain) `INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff, chain, miner)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, b.Chain, b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, b.Chain, b.Miner,
) )
if err != nil { if err != nil {
return false, err return false, err
@@ -212,7 +218,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
limit = 256 limit = 256
} }
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain, miner
FROM blocks ORDER BY height DESC LIMIT ?`, FROM blocks ORDER BY height DESC LIMIT ?`,
limit, limit,
) )
@@ -224,7 +230,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
for rows.Next() { for rows.Next() {
var b Block var b Block
var foundUnix, orphanedUnix int64 var foundUnix, orphanedUnix int64
if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain); err != nil { if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain, &b.Miner); err != nil {
return nil, err return nil, err
} }
b.FoundAt = time.Unix(foundUnix, 0).UTC() b.FoundAt = time.Unix(foundUnix, 0).UTC()
@@ -245,9 +251,9 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
// retrying will keep failing. // retrying will keep failing.
func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) { func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain, miner
FROM blocks FROM blocks
WHERE found_at >= ? AND (hash = '' OR reward_btc = 0) WHERE found_at >= ? AND (hash = '' OR reward_btc = 0 OR miner = '')
ORDER BY height ASC`, ORDER BY height ASC`,
since.Unix(), since.Unix(),
) )
@@ -259,7 +265,7 @@ func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
for rows.Next() { for rows.Next() {
var b Block var b Block
var foundUnix, orphanedUnix int64 var foundUnix, orphanedUnix int64
if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain); err != nil { if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain, &b.Miner); err != nil {
return nil, err return nil, err
} }
b.FoundAt = time.Unix(foundUnix, 0).UTC() b.FoundAt = time.Unix(foundUnix, 0).UTC()
@@ -271,12 +277,12 @@ func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
return out, rows.Err() return out, rows.Err()
} }
// UpdateEnrichment fills in hash and reward for an already-recorded // UpdateEnrichment fills in hash, reward, and miner for an already-recorded
// block. No-op if the row doesn't exist. // block. No-op if the row doesn't exist.
func (s *BlockStore) UpdateEnrichment(height int64, hash string, reward float64) error { func (s *BlockStore) UpdateEnrichment(height int64, hash string, reward float64, miner string) error {
_, err := s.db.Exec( _, err := s.db.Exec(
`UPDATE blocks SET hash = ?, reward_btc = ? WHERE height = ?`, `UPDATE blocks SET hash = ?, reward_btc = ?, miner = ? WHERE height = ?`,
hash, reward, height, hash, reward, miner, height,
) )
return err return err
} }
+5 -2
View File
@@ -124,7 +124,7 @@ func TestUpdateEnrichment(t *testing.T) {
s := openTemp(t) s := openTemp(t)
s.InsertBlock(Block{Height: 300, FoundAt: time.Now(), Chain: "main"}) s.InsertBlock(Block{Height: 300, FoundAt: time.Now(), Chain: "main"})
if err := s.UpdateEnrichment(300, "newhash", 3.125); err != nil { if err := s.UpdateEnrichment(300, "newhash", 3.125, "bc1qtest.rig1"); err != nil {
t.Fatalf("UpdateEnrichment: %v", err) t.Fatalf("UpdateEnrichment: %v", err)
} }
@@ -135,6 +135,9 @@ func TestUpdateEnrichment(t *testing.T) {
if blocks[0].RewardBT != 3.125 { if blocks[0].RewardBT != 3.125 {
t.Errorf("RewardBT = %v, want 3.125", blocks[0].RewardBT) t.Errorf("RewardBT = %v, want 3.125", blocks[0].RewardBT)
} }
if blocks[0].Miner != "bc1qtest.rig1" {
t.Errorf("Miner = %q, want bc1qtest.rig1", blocks[0].Miner)
}
} }
func TestBlocksNeedingEnrichment(t *testing.T) { func TestBlocksNeedingEnrichment(t *testing.T) {
@@ -146,7 +149,7 @@ func TestBlocksNeedingEnrichment(t *testing.T) {
// Block with hash but no reward → needs enrichment. // Block with hash but no reward → needs enrichment.
s.InsertBlock(Block{Height: 2, Hash: "abc", RewardBT: 0, FoundAt: now, Chain: "main"}) s.InsertBlock(Block{Height: 2, Hash: "abc", RewardBT: 0, FoundAt: now, Chain: "main"})
// Block fully enriched → does NOT need enrichment. // Block fully enriched → does NOT need enrichment.
s.InsertBlock(Block{Height: 3, Hash: "def", RewardBT: 3.125, FoundAt: now, Chain: "main"}) s.InsertBlock(Block{Height: 3, Hash: "def", RewardBT: 3.125, FoundAt: now, Chain: "main", Miner: "bc1q.rig1"})
since := now.Add(-time.Hour) since := now.Add(-time.Hour)
missing, err := s.BlocksNeedingEnrichment(since) missing, err := s.BlocksNeedingEnrichment(since)
+5 -2
View File
@@ -48,6 +48,7 @@ ZMQ_BLOCK="${ZMQ_BLOCK:-}"
LOGDIR="${LOGDIR:-/var/log/ckpool}" LOGDIR="${LOGDIR:-/var/log/ckpool}"
SOCKET_DIR="${SOCKET_DIR:-/run/ckpool}" SOCKET_DIR="${SOCKET_DIR:-/run/ckpool}"
SHARE_LOG="${SHARE_LOG:-1}" SHARE_LOG="${SHARE_LOG:-1}"
CKPOOL_LOGLEVEL="${CKPOOL_LOGLEVEL:-6}"
mkdir -p "$LOGDIR" "$SOCKET_DIR" mkdir -p "$LOGDIR" "$SOCKET_DIR"
@@ -86,8 +87,10 @@ echo "[kamado] rendered ckpool.conf:"
sed "s|\"pass\": \".*\"|\"pass\": \"<redacted>\"|" "$CONF" sed "s|\"pass\": \".*\"|\"pass\": \"<redacted>\"|" "$CONF"
echo echo
# Build command line # Build command line.
CMD="/usr/local/bin/ckpool --btcsolo --config $CONF --sockdir $SOCKET_DIR" # -l 6 = LOG_INFO: enables per-share Accepted/Rejected log lines
# that the kamado-api log tailer uses for share statistics.
CMD="/usr/local/bin/ckpool --btcsolo --config $CONF --sockdir $SOCKET_DIR -l $CKPOOL_LOGLEVEL"
if [ "$SHARE_LOG" = "1" ]; then if [ "$SHARE_LOG" = "1" ]; then
CMD="$CMD --log-shares" CMD="$CMD --log-shares"
fi fi
+14
View File
@@ -12,6 +12,8 @@
import UserDetailPage from "./lib/UserDetailPage.svelte"; import UserDetailPage from "./lib/UserDetailPage.svelte";
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte"; import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
import AcceleratorPage from "./lib/AcceleratorPage.svelte"; import AcceleratorPage from "./lib/AcceleratorPage.svelte";
import StatsPage from "./lib/StatsPage.svelte";
import BestSharePage from "./lib/BestSharePage.svelte";
import SharesBar from "./lib/SharesBar.svelte"; import SharesBar from "./lib/SharesBar.svelte";
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte"; import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
@@ -41,6 +43,18 @@
<AcceleratorPage /> <AcceleratorPage />
</div> </div>
{/key} {/key}
{:else if selection.page === "stats"}
{#key 'stats'}
<div class="page-enter">
<StatsPage />
</div>
{/key}
{:else if selection.page === "bestshare"}
{#key 'bestshare'}
<div class="page-enter">
<BestSharePage />
</div>
{/key}
{:else if selection.worker} {:else if selection.worker}
{#key selection.worker} {#key selection.worker}
<div class="page-enter"> <div class="page-enter">
+575
View File
@@ -0,0 +1,575 @@
<script lang="ts">
import { clearSelection } from "../stores/selection.svelte";
import { snap } from "../stores/snapshot.svelte";
import { formatDifficulty } from "../format";
const data = $derived(snap.data!);
const hash = $derived(data.best_share_hash ?? "");
const diff = $derived(data.best_diff ?? 0);
const currentNetDiff = $derived(data.chain?.difficulty ?? 0);
// Network difficulty at the time the best share was found.
// Legacy fallback: hardcoded 136.6T for pre-upgrade shares.
const foundNetDiff = $derived(data.best_share_net_diff || 136_597_951_737_045);
const isLegacyNetDiff = $derived(!data.best_share_net_diff);
// Toggle: "found" = difficulty at time of finding, "current" = live network diff
let diffView: "found" | "current" = $state("found");
const netDiff = $derived(diffView === "found" ? foundNetDiff : currentNetDiff);
// --- Target computation ---
// Bitcoin's difficulty-1 target is 0x00000000FFFF << 208.
// Target for difficulty D = diff1_target / D.
// We work in hex strings (64 chars = 256 bits) so we can do a
// character-by-character comparison with the share hash.
//
// For display we only need ~20 hex chars (the significant prefix).
// We compute this via BigInt arithmetic for full precision.
const diff1Target = BigInt("0x00000000FFFF0000000000000000000000000000000000000000000000000000");
function targetHex(d: number): string {
if (!d || d <= 0) return "f".repeat(64);
// Scale difficulty to integer: multiply by 2^48 to preserve precision,
// then divide. target = diff1_target / D
// = diff1_target * 2^48 / (D * 2^48)
const scale = BigInt(1) << BigInt(48);
const dScaled = BigInt(Math.round(d * Number(scale)));
if (dScaled === BigInt(0)) return "f".repeat(64);
const t = (diff1Target * scale) / dScaled;
let s = t.toString(16);
// Pad to 64 hex chars.
while (s.length < 64) s = "0" + s;
if (s.length > 64) s = s.slice(0, 64);
return s;
}
const target = $derived(targetHex(netDiff));
// --- Per-character comparison ---
// Walk hex chars left-to-right. For each position:
// green = share char is lower than target char (share is winning here)
// yellow = share char is higher than target char (share fails here, needs to be lower)
// white = chars are equal (keep going) OR position is past the decided point
//
// Once we hit a position where they differ, all subsequent chars are decided:
// - if share was lower → the rest is green (share already won)
// - if share was higher → the rest is white (doesn't matter, already lost)
type CharInfo = { c: string; cls: string };
// Per-char hex coloring matching the binary approach:
// - Positions within the network's required leading-zero hex chars:
// green if '0' (correct), yellow if non-zero (problem char)
// - Boundary nibble (first non-zero target char): compare against target
// - Positions past the boundary: white
const hashChars = $derived.by((): CharInfo[] => {
if (!hash) return [];
return hash.split("").map((c, i) => {
const hVal = parseInt(c, 16);
const tVal = parseInt(target[i] ?? "f", 16);
if (i < networkHexZeros) {
// Must be zero for a valid block
return { c, cls: hVal === 0 ? "z-have" : "z-need" };
}
if (i === networkHexZeros) {
// Boundary char — must be <= target char
return { c, cls: hVal <= tVal ? "z-have" : "z-need" };
}
// Past the significant zone
return { c, cls: "z-rest" };
});
});
// Target row: color the significant zone (leading zeros + boundary)
// to align visually with the hash row.
const targetChars = $derived.by((): CharInfo[] => {
return target.split("").map((c, i) => {
if (i <= networkHexZeros) return { c, cls: "z-have" };
return { c, cls: "z-rest" };
});
});
// Count leading zero bits in the hash (not just hex zeros — count
// the actual zero bits in the first non-zero nibble too).
function leadingZeroBits(h: string): number {
let bits = 0;
for (const c of h) {
const v = parseInt(c, 16);
if (v === 0) { bits += 4; continue; }
if (v < 2) bits += 3;
else if (v < 4) bits += 2;
else if (v < 8) bits += 1;
break;
}
return bits;
}
function requiredZeroBits(d: number): number {
if (!d || d <= 0) return 0;
return leadingZeroBits(targetHex(d));
}
const shareZeroBits = $derived(hash ? leadingZeroBits(hash) : requiredZeroBits(diff));
const networkZeroBits = $derived(requiredZeroBits(netDiff));
// How many leading hex zeros the share has / network requires (for display).
function leadingHexZeros(h: string): number {
let n = 0;
for (const c of h) { if (c === "0") n++; else break; }
return n;
}
const shareHexZeros = $derived(hash ? leadingHexZeros(hash) : leadingHexZeros(targetHex(diff)));
const networkHexZeros = $derived(leadingHexZeros(target));
// Progress: linear ratio of share difficulty to network difficulty.
const progressPct = $derived(
netDiff > 0 && diff > 0
? Math.min(100, (diff / netDiff) * 100)
: 0,
);
// How many times harder the network target is.
const diffRatio = $derived(netDiff > 0 && diff > 0 ? netDiff / diff : 0);
// Hex → binary lookup.
const hexToBin: Record<string, string> = {
"0": "0000", "1": "0001", "2": "0010", "3": "0011",
"4": "0100", "5": "0101", "6": "0110", "7": "0111",
"8": "1000", "9": "1001", "a": "1010", "b": "1011",
"c": "1100", "d": "1101", "e": "1110", "f": "1111",
};
function hexToBits(h: string): string {
return h.split("").map(c => hexToBin[c.toLowerCase()] ?? "0000").join("");
}
// Per-bit coloring based on position relative to networkZeroBits:
// - Positions < networkZeroBits: green if '0' (correct), yellow if '1' (problem bit)
// - Positions >= networkZeroBits: white (past the required leading-zero zone)
// This shows exactly which bits prevented the hash from being a valid block.
const binaryChars = $derived.by(() => {
if (!hash) return [];
const hBits = hexToBits(hash);
const out: CharInfo[] = [];
for (let i = 0; i < hBits.length; i++) {
if (i > 0 && i % 16 === 0) {
out.push({ c: " ", cls: "z-sep" });
}
const hb = hBits[i];
let cls: string;
if (i < networkZeroBits) {
// Within the zone that must be zero for a valid block
cls = hb === "0" ? "z-have" : "z-need";
} else {
// Past the required zero zone — doesn't matter
cls = "z-rest";
}
out.push({ c: hb, cls });
}
return out;
});
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
</script>
<svelte:window onkeydown={onKey} />
<section class="page">
<nav class="crumbs">
<button type="button" class="back" onclick={clearSelection}>
&larr; Back to dashboard
</button>
</nav>
<header class="head">
<div class="stat-label">Best Share Analysis</div>
<h2>How close to a block?</h2>
</header>
<!-- Difficulty view toggle -->
<section class="toggle-bar">
<button
type="button"
class="toggle-btn"
class:active={diffView === "found"}
onclick={() => diffView = "found"}
>
At time of finding
</button>
<button
type="button"
class="toggle-btn"
class:active={diffView === "current"}
onclick={() => diffView = "current"}
>
Current network diff
</button>
</section>
{#if diffView === "found" && isLegacyNetDiff}
<div class="disclaimer">
Network difficulty at time of finding was not recorded for this share. Using approximate value of 136.6T based on historical data.
</div>
{/if}
{#if diffView === "current" && currentNetDiff !== foundNetDiff}
<div class="disclaimer">
Comparing against the <strong>current</strong> network difficulty ({formatDifficulty(currentNetDiff)}), which may differ from the difficulty when this share was found{isLegacyNetDiff ? "" : ` (${formatDifficulty(foundNetDiff)})`}.
</div>
{/if}
<!-- Explanation -->
<section class="card explainer">
<p>
To mine a block, you must find a hash whose <strong>numeric value</strong>
is less than the network's target. It's not just about leading zeros &mdash;
the entire hash must be smaller than the target. Think of it like a lottery:
you need to roll a number below a threshold, and the threshold gets lower as
difficulty rises.
</p>
<p>
Your best share has difficulty <strong>{formatDifficulty(diff)}</strong>,
producing a hash with <strong>{shareZeroBits}</strong> leading zero bits.
The current network difficulty of <strong>{formatDifficulty(netDiff)}</strong>
requires a hash with at least <strong>{networkZeroBits}</strong> leading zero bits.
{#if diffRatio <= 1}
Your share meets the network target &mdash; this would be a valid block!
{:else}
The network target is <strong>{diffRatio.toFixed(1)}x</strong> harder than your
best share.
{/if}
</p>
</section>
<!-- Difficulty comparison cards -->
<section class="totals">
<div class="card">
<div class="stat-label">Your Best Share</div>
<div class="stat-value">{formatDifficulty(diff)}</div>
<div class="stat-sub">{shareZeroBits} leading zero bits ({shareHexZeros} hex zeros){!hash ? " est." : ""}</div>
</div>
<div class="card">
<div class="stat-label">Network Target</div>
<div class="stat-value">{formatDifficulty(netDiff)}</div>
<div class="stat-sub">{networkZeroBits} leading zero bits ({networkHexZeros} hex zeros)</div>
</div>
<div class="card">
<div class="stat-label">Gap</div>
<div class="stat-value" class:complete={diffRatio <= 1}>
{diffRatio <= 1 ? "Block!" : diffRatio < 1000 ? diffRatio.toFixed(1) + "x" : formatDifficulty(diffRatio)}
</div>
<div class="stat-sub">
{#if diffRatio <= 1}
This share satisfies the network target
{:else}
{networkZeroBits - shareZeroBits} more leading zero {networkZeroBits - shareZeroBits === 1 ? "bit" : "bits"} needed
{/if}
</div>
</div>
</section>
<!-- Progress bar -->
<section class="card">
<h3>Progress to Network Target</h3>
<div class="progress-row">
<div class="progress-bar">
<div
class="progress-fill"
class:full={progressPct >= 100}
style="width:{Math.min(progressPct, 100)}%"
></div>
</div>
<span class="progress-label">{progressPct.toFixed(1)}%</span>
</div>
<div class="stat-sub">
{formatDifficulty(diff)} / {formatDifficulty(netDiff)}
</div>
</section>
{#if hash}
<!-- Hex comparison -->
<section class="card">
<h3>Share Hash vs Network Target (hex)</h3>
<div class="compare-row">
<span class="compare-label">YOUR HASH</span>
<div class="hash-vis mono">
{#each hashChars as ch}<!--
--><span class="hc {ch.cls}">{ch.c}</span><!--
-->{/each}
</div>
</div>
<div class="compare-row">
<span class="compare-label">TARGET</span>
<div class="hash-vis mono target-row">
{#each targetChars as ch}<!--
--><span class="hc {ch.cls}">{ch.c}</span><!--
-->{/each}
</div>
</div>
<div class="hash-legend">
<span class="legend-item"><span class="swatch have"></span> Below target (good)</span>
<span class="legend-item"><span class="swatch need"></span> Above target (too high)</span>
<span class="legend-item"><span class="swatch rest"></span> Remaining</span>
</div>
</section>
<!-- Binary hash -->
<section class="card">
<h3>Share Hash (binary)</h3>
<div class="hash-vis binary mono">
{#each binaryChars as ch}<!--
-->{#if ch.cls === "z-sep"}<span class="sep"> </span>{:else}<span class="hc {ch.cls}">{ch.c}</span>{/if}<!--
-->{/each}
</div>
<p class="bin-explain">
Every additional leading zero bit makes the hash <strong>2x harder</strong> to find.
The first {networkZeroBits} bits must all be zero for a valid block.
<span class="z-have" style="font-weight:700">Green</span> bits are already correct (zero),
<span class="z-need" style="font-weight:700">yellow</span> bits are the ones that
prevented this share from being a valid block.
{#if networkZeroBits > shareZeroBits}
The gap of <strong>{networkZeroBits - shareZeroBits} bits</strong> means the target is roughly
<strong>2<sup>{networkZeroBits - shareZeroBits}</sup> &asymp; {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x</strong>
harder &mdash; matching the {diffRatio.toFixed(1)}x difficulty ratio.
{/if}
</p>
</section>
{:else}
<div class="card">
<div class="empty">
Block header hash will appear here once a share is accepted after deploying this version.
The stats above are estimated from difficulty.
</div>
</div>
{/if}
</section>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.crumbs {
margin-bottom: 0.25rem;
}
.back {
font: inherit;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4em 0.85em;
cursor: pointer;
}
.back:hover {
color: var(--fg);
border-color: var(--accent);
background: var(--bg-hover);
}
/* Difficulty view toggle */
.toggle-bar {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
}
.toggle-btn {
font: inherit;
font-size: 0.85rem;
padding: 0.5em 1em;
border: none;
background: transparent;
color: var(--fg-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.toggle-btn:not(:last-child) {
border-right: 1px solid var(--border);
}
.toggle-btn.active {
background: var(--accent);
color: var(--bg);
font-weight: 600;
}
.toggle-btn:hover:not(.active) {
background: var(--bg-hover);
color: var(--fg);
}
.disclaimer {
font-size: 0.82rem;
color: var(--fg-dim);
background: rgba(245, 196, 71, 0.08);
border: 1px solid rgba(245, 196, 71, 0.25);
border-radius: 6px;
padding: 0.5em 0.85em;
line-height: 1.5;
}
.disclaimer strong {
color: var(--fg);
}
.head {
display: flex;
flex-direction: column;
gap: 0.4em;
margin-bottom: 0.25rem;
}
.head h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 600;
line-height: 1.25;
}
/* Explanation */
.explainer p {
margin: 0 0 0.6em;
line-height: 1.6;
color: var(--fg-dim);
}
.explainer p:last-child {
margin-bottom: 0;
}
.explainer strong {
color: var(--fg);
}
.totals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
@media (max-width: 700px) {
.totals { grid-template-columns: 1fr; }
}
h3 {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: 600;
}
.empty {
color: var(--fg-dim);
padding: 1rem 0;
}
.complete {
color: var(--good);
text-shadow: 0 0 12px rgba(92, 224, 168, 0.4);
}
/* Progress bar */
.progress-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-bar {
flex: 1;
height: 20px;
background: var(--border);
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 6px;
transition: width 0.5s ease;
}
.progress-fill.full {
background: var(--good);
box-shadow: 0 0 12px rgba(92, 224, 168, 0.4);
}
.progress-label {
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 4em;
text-align: right;
}
/* Hash comparison rows */
.compare-row {
margin-bottom: 0.5rem;
}
.compare-row:last-of-type {
margin-bottom: 0;
}
.compare-label {
display: inline-block;
font-size: 0.72rem;
color: var(--fg-dim);
letter-spacing: 0.05em;
margin-bottom: 0.2rem;
}
.target-row {
opacity: 1;
}
/* Hash visualization */
.hash-vis {
font-size: 1.1rem;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: break-all;
}
.hash-vis.binary {
font-size: 1rem;
line-height: 1.7;
letter-spacing: 0.02em;
}
.hc {
display: inline;
}
.z-have {
color: var(--good);
text-shadow: 0 0 6px rgba(92, 224, 168, 0.35);
font-weight: 700;
}
.z-need {
color: rgb(245, 196, 71);
text-shadow: 0 0 6px rgba(245, 196, 71, 0.3);
font-weight: 700;
}
.z-rest {
color: var(--fg);
}
.sep {
display: inline;
user-select: none;
width: 0.3em;
}
.bin-explain {
margin: 0.75rem 0 0;
line-height: 1.6;
color: var(--fg-dim);
font-size: 0.88rem;
}
.bin-explain strong {
color: var(--fg);
}
/* Legend */
.hash-legend {
display: flex;
gap: 1.2rem;
margin-top: 0.75rem;
font-size: 0.78em;
color: var(--fg-dim);
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4em;
}
.swatch {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.swatch.have { background: var(--good); }
.swatch.need { background: rgb(245, 196, 71); }
.swatch.rest { background: var(--fg); }
</style>
+33 -6
View File
@@ -19,6 +19,26 @@
); );
const currentChain = $derived(snap.data?.chain?.chain ?? ""); const currentChain = $derived(snap.data?.chain?.chain ?? "");
function truncHash(hash: string): string {
if (!hash || hash.length <= 16) return hash;
return hash.slice(0, 8) + "…" + hash.slice(-8);
}
function minerLabel(miner: string | undefined): string {
if (!miner) return "—";
// Show address.worker truncated
const dot = miner.indexOf(".");
if (dot < 0) {
// Just an address, truncate middle
if (miner.length > 20) return miner.slice(0, 8) + "…" + miner.slice(-6);
return miner;
}
const addr = miner.slice(0, dot);
const worker = miner.slice(dot + 1);
const shortAddr = addr.length > 12 ? addr.slice(0, 6) + "…" + addr.slice(-4) : addr;
return shortAddr + "." + worker;
}
</script> </script>
<section class="card"> <section class="card">
@@ -31,6 +51,7 @@
<thead> <thead>
<tr> <tr>
<th>Height</th> <th>Height</th>
<th>Miner</th>
<th>Chain</th> <th>Chain</th>
<th>Hash</th> <th>Hash</th>
<th class="num">Reward</th> <th class="num">Reward</th>
@@ -47,6 +68,7 @@
<span class="orphan-tag" title="Reorged out of the canonical chain at {b.orphaned_at}">orphaned</span> <span class="orphan-tag" title="Reorged out of the canonical chain at {b.orphaned_at}">orphaned</span>
{/if} {/if}
</td> </td>
<td class="miner-cell" title={b.miner ?? ""}>{minerLabel(b.miner)}</td>
<td> <td>
{#if b.chain && currentChain && b.chain !== currentChain} {#if b.chain && currentChain && b.chain !== currentChain}
<span class="chain-tag" title="Mined on {displayChain(b.chain)} - current node is on {displayChain(currentChain)}">{displayChain(b.chain)}</span> <span class="chain-tag" title="Mined on {displayChain(b.chain)} - current node is on {displayChain(currentChain)}">{displayChain(b.chain)}</span>
@@ -63,8 +85,8 @@
href="{explorerBase}/block/{b.hash}" href="{explorerBase}/block/{b.hash}"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="Open block on mempool.space" title={b.hash}
>{b.hash}</a> >{truncHash(b.hash)}</a>
{:else} {:else}
<span class="hash mono">&mdash;</span> <span class="hash mono">&mdash;</span>
{/if} {/if}
@@ -100,11 +122,16 @@
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: auto;
} }
.miner-cell {
font-size: 0.85em;
color: var(--fg-dim);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.hash-cell { .hash-cell {
/* Let the 64-char hash wrap inside the cell instead of stretching white-space: nowrap;
* the whole table. */
max-width: 620px;
word-break: break-all;
} }
.hash { .hash {
color: var(--fg-dim); color: var(--fg-dim);
+7 -4
View File
@@ -67,10 +67,13 @@
} }
area += ` L${toX(tMax).toFixed(1)},${(PAD.top + plotH).toFixed(1)} Z`; area += ` L${toX(tMax).toFixed(1)},${(PAD.top + plotH).toFixed(1)} Z`;
const yTicks = [0, vMax * 0.5, vMax].map((v) => ({ // Generate 5-7 evenly spaced Y ticks for better readability.
y: toY(v), const tickCount = 6;
label: formatHashrate(v), const yTicks: Array<{ y: number; label: string }> = [];
})); for (let i = 0; i <= tickCount; i++) {
const v = (i / tickCount) * vMax;
yTicks.push({ y: toY(v), label: formatHashrate(v) });
}
const xTicks: Array<{ x: number; label: string }> = []; const xTicks: Array<{ x: number; label: string }> = [];
const count = Math.min(6, points.length); const count = Math.min(6, points.length);
+33 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte"; import { snap } from "../stores/snapshot.svelte";
import { selectBestShare } from "../stores/selection.svelte";
import { import {
formatHashrate, formatHashrate,
formatUptime, formatUptime,
@@ -115,11 +116,6 @@
fetch("/api/admin/ack-best", { method: "POST" }); fetch("/api/admin/ack-best", { method: "POST" });
} }
// DEBUG: remove before shipping
function debugBest(): void {
localAcked = 0;
fetch("/api/admin/reset-ack-best", { method: "POST" });
}
</script> </script>
<section class="grid-5"> <section class="grid-5">
@@ -149,11 +145,7 @@
{/each} {/each}
</div> </div>
<div class="best-inner"> <div class="best-inner">
<div class="stat-label"> <div class="stat-label">Best share</div>
Best share
<!-- DEBUG: remove before shipping -->
<button class="debug-btn" onclick={(e: MouseEvent) => { e.stopPropagation(); debugBest(); }}>test</button>
</div>
<div class="stat-value">{formatDifficulty(data.best_diff)}</div> <div class="stat-value">{formatDifficulty(data.best_diff)}</div>
<div <div
class="luck-sub" class="luck-sub"
@@ -167,7 +159,17 @@
{#if bestGlow} {#if bestGlow}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ack-btn" onclick={ackBest} title="Dismiss the new best share animation">Nice</div> <div class="ack-btn" onclick={ackBest} title="Dismiss the new best share notification">Nice</div>
{/if}
{#if data.best_diff > 0}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="inspect-btn" onclick={selectBestShare} title="Inspect best share hash and leading zeros">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
Inspect
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -446,16 +448,27 @@
transform: scale(1.05); transform: scale(1.05);
} }
.debug-btn { .inspect-btn {
font-size: 0.6rem; display: flex;
padding: 0.1em 0.4em; align-items: center;
opacity: 0.3; gap: 0.3em;
border-radius: 3px; width: fit-content;
margin-left: 0.5em; margin: 0.4em auto 0;
vertical-align: middle; padding: 0.2em 0.7em;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-dim);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
} }
.debug-btn:hover { .inspect-btn:hover {
opacity: 1; color: var(--accent);
border-color: var(--accent);
background: var(--bg-hover);
} }
.height-card { .height-card {
+29 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { snap } from "../stores/snapshot.svelte"; import { snap } from "../stores/snapshot.svelte";
import { selectStats } from "../stores/selection.svelte";
const data = $derived(snap.data!); const data = $derived(snap.data!);
@@ -56,10 +57,6 @@
prevAccepted = cur; prevAccepted = cur;
}); });
// DEBUG: remove before shipping
function debugPulse(): void { pulse = true; setTimeout(() => { pulse = false; }, 700); }
function debugReject(): void { reject = true; setTimeout(() => { reject = false; }, 1200); }
function fmtCount(n: number): string { function fmtCount(n: number): string {
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
@@ -72,9 +69,13 @@
<div class="reject-flash" aria-hidden="true"></div> <div class="reject-flash" aria-hidden="true"></div>
<div class="content"> <div class="content">
<h3 class="title">Shares <h3 class="title">Shares
<!-- DEBUG: remove before shipping --> <button class="stats-btn" onclick={selectStats} title="Share Statistics">
<button class="debug-btn" onclick={debugPulse}>acc</button> <svg class="stats-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<button class="debug-btn" onclick={debugReject}>rej</button> <rect x="3" y="12" width="4" height="9" rx="1"/>
<rect x="10" y="7" width="4" height="14" rx="1"/>
<rect x="17" y="3" width="4" height="18" rx="1"/>
</svg>
</button>
</h3> </h3>
<div class="stats"> <div class="stats">
<div class="group"> <div class="group">
@@ -129,17 +130,29 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--fg); color: var(--fg);
} }
.debug-btn { .stats-btn {
font-size: 0.6rem; display: inline-flex;
padding: 0.1em 0.4em; align-items: center;
opacity: 0.3; justify-content: center;
border-radius: 3px; background: transparent;
margin-left: 0.3em; border: 1px solid var(--border);
border-radius: 8px;
padding: 0.35em;
margin-left: 0.6em;
vertical-align: middle; vertical-align: middle;
text-transform: none; cursor: pointer;
color: var(--fg-dim);
transition: color 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.2s;
} }
.debug-btn:hover { .stats-btn:hover {
opacity: 1; color: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 12px rgba(255, 122, 58, 0.5);
transform: translateY(-2px) scale(1.05);
}
.stats-icon {
width: 1.2em;
height: 1.2em;
} }
.stats { .stats {
display: flex; display: flex;
+313
View File
@@ -0,0 +1,313 @@
<script lang="ts">
import { clearSelection } from "../stores/selection.svelte";
import { snap } from "../stores/snapshot.svelte";
import { formatDifficulty } from "../format";
const data = $derived(snap.data!);
// --- Rejection reason formatting ---
const reasonInfo: Record<string, { label: string; tip: string }> = {
"Stale": { label: "Stale", tip: "The share was mined on a block that has already been found. Usually caused by network latency." },
"Duplicate": { label: "Duplicate", tip: "This exact share was already submitted. Indicates a bug in the mining software or a network retry." },
"Above target": { label: "Above Target", tip: "The share difficulty was below the required minimum target set by the pool." },
"Dupe": { label: "Duplicate", tip: "This exact share was already submitted." },
"High": { label: "Above Target", tip: "The share difficulty was below the required minimum target set by the pool." },
"Ntime out of range": { label: "Invalid nTime", tip: "The share timestamp was outside the acceptable range (too far in the future or past)." },
"Invalid JobID": { label: "Invalid Job", tip: "The share referenced a work unit that no longer exists. Likely stale work from a slow connection." },
"Invalid nonce2 length": { label: "Bad Nonce2", tip: "The extranonce2 field had an unexpected length. Indicates a stratum protocol mismatch." },
"Worker mismatch": { label: "Worker Mismatch", tip: "The share was submitted by a different worker than the one that requested the work." },
"No nonce": { label: "Missing Nonce", tip: "The share submission was missing the required nonce field." },
"No ntime": { label: "Missing nTime", tip: "The share submission was missing the required ntime field." },
"No nonce2": { label: "Missing Nonce2", tip: "The share submission was missing the required extranonce2 field." },
"No job_id": { label: "Missing Job ID", tip: "The share submission was missing the required job_id field." },
"No username": { label: "Missing Username", tip: "The share submission was missing the worker username." },
"Invalid array size": { label: "Bad Params Size", tip: "The mining.submit parameters had the wrong number of elements." },
"Params not array": { label: "Bad Params Format", tip: "The mining.submit parameters were not a JSON array." },
"Invalid version mask": { label: "Bad Version Mask", tip: "The version rolling mask in the share was invalid." },
};
function fmtReason(raw: string): string {
return reasonInfo[raw]?.label ?? raw;
}
function reasonTip(raw: string): string {
return reasonInfo[raw]?.tip ?? "";
}
// --- Rejection reason rows ---
type ReasonRow = { reason: string; count: number; pct: number };
function reasonRows(reasons: Record<string, number> | undefined): ReasonRow[] {
if (!reasons) return [];
const entries = Object.entries(reasons);
const total = entries.reduce((s, [, v]) => s + v, 0);
if (total === 0) return [];
return entries
.map(([reason, count]) => ({ reason, count, pct: (count / total) * 100 }))
.sort((a, b) => b.count - a.count);
}
const sessionReasons = $derived(reasonRows(data.reject_reasons_session));
const alltimeReasons = $derived(reasonRows(data.reject_reasons_alltime));
// --- Difficulty distribution ---
const bucketLabels = ["< 1M", "1M\u2013100M", "100M\u20131G", "1G\u2013100G", "100G\u20131T", "\u2265 1T"];
type BucketRow = {
label: string;
session: number; sessionPct: number;
alltime: number; alltimePct: number;
};
const bucketRows = $derived.by((): BucketRow[] => {
const sd = data.diff_dist_session ?? [0, 0, 0, 0, 0, 0];
const ad = data.diff_dist_alltime ?? [0, 0, 0, 0, 0, 0];
const sTotal = sd.reduce((s: number, v: number) => s + v, 0);
const aTotal = ad.reduce((s: number, v: number) => s + v, 0);
return bucketLabels.map((label, i) => ({
label,
session: sd[i] ?? 0,
sessionPct: sTotal > 0 ? ((sd[i] ?? 0) / sTotal) * 100 : 0,
alltime: ad[i] ?? 0,
alltimePct: aTotal > 0 ? ((ad[i] ?? 0) / aTotal) * 100 : 0,
}));
});
const hasDistData = $derived(
bucketRows.some(r => r.session > 0 || r.alltime > 0),
);
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") clearSelection();
}
</script>
<svelte:window onkeydown={onKey} />
<section class="page">
<nav class="crumbs">
<button type="button" class="back" onclick={clearSelection}>
&larr; Back to dashboard
</button>
</nav>
<header class="head">
<div class="stat-label">Share Statistics</div>
<h2>Difficulty &amp; Rejections</h2>
</header>
<!-- Average Difficulty Cards -->
<section class="totals">
<div class="card">
<div class="stat-label">Session Avg Difficulty</div>
<div class="stat-value">{data.avg_diff_session ? formatDifficulty(data.avg_diff_session) : "\u2014"}</div>
<div class="stat-sub">{(data.diff_dist_session ?? []).reduce((s: number, v: number) => s + v, 0).toLocaleString()} accepted shares</div>
</div>
<div class="card">
<div class="stat-label">All-time Avg Difficulty</div>
<div class="stat-value">{data.avg_diff_alltime ? formatDifficulty(data.avg_diff_alltime) : "\u2014"}</div>
<div class="stat-sub">{(data.diff_dist_alltime ?? []).reduce((s: number, v: number) => s + v, 0).toLocaleString()} accepted shares</div>
</div>
<div class="card">
<div class="stat-label">Session Rejected</div>
<div class="stat-value">{(data.session_rejected ?? 0).toLocaleString()}</div>
<div class="stat-sub">
{#if (data.session_accepted ?? 0) > 0}
{((data.session_rejected ?? 0) / ((data.session_accepted ?? 0) + (data.session_rejected ?? 0)) * 100).toFixed(2)}% reject rate
{:else}
no shares yet
{/if}
</div>
</div>
<div class="card">
<div class="stat-label">All-time Rejected</div>
<div class="stat-value">{(data.alltime_rejected ?? 0).toLocaleString()}</div>
<div class="stat-sub">
{#if (data.alltime_accepted ?? 0) > 0}
{((data.alltime_rejected ?? 0) / ((data.alltime_accepted ?? 0) + (data.alltime_rejected ?? 0)) * 100).toFixed(2)}% reject rate
{:else}
no shares yet
{/if}
</div>
</div>
</section>
<!-- Rejection Reasons -->
<div class="section-pair">
<section class="card">
<h3>Rejection Reasons (Session)</h3>
{#if sessionReasons.length === 0}
<div class="empty">No rejected shares this session</div>
{:else}
<div class="table-wrap">
<table>
<thead><tr><th>Reason</th><th class="num">Count</th><th class="num">%</th></tr></thead>
<tbody>
{#each sessionReasons as r}
<tr title={reasonTip(r.reason)}>
<td>{fmtReason(r.reason)}</td>
<td class="num">{r.count.toLocaleString()}</td>
<td class="num">{r.pct.toFixed(1)}%</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="card">
<h3>Rejection Reasons (All-time)</h3>
{#if alltimeReasons.length === 0}
<div class="empty">No rejected shares recorded</div>
{:else}
<div class="table-wrap">
<table>
<thead><tr><th>Reason</th><th class="num">Count</th><th class="num">%</th></tr></thead>
<tbody>
{#each alltimeReasons as r}
<tr title={reasonTip(r.reason)}>
<td>{fmtReason(r.reason)}</td>
<td class="num">{r.count.toLocaleString()}</td>
<td class="num">{r.pct.toFixed(1)}%</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>
<!-- Difficulty Distribution Table -->
<section class="card">
<h3>Difficulty Distribution</h3>
{#if !hasDistData}
<div class="empty">No accepted shares yet</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Range</th>
<th class="num">Session</th>
<th class="num">%</th>
<th class="num">All-time</th>
<th class="num">%</th>
</tr>
</thead>
<tbody>
{#each bucketRows as row}
<tr class:dim-row={row.session === 0 && row.alltime === 0}>
<td>{row.label}</td>
<td class="num">{row.session.toLocaleString()}</td>
<td class="num">{row.sessionPct.toFixed(1)}%</td>
<td class="num">{row.alltime.toLocaleString()}</td>
<td class="num">{row.alltimePct.toFixed(1)}%</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</section>
<style>
/* Page layout — identical to UserDetailPage / WorkerDetailPage */
.page {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.crumbs {
margin-bottom: 0.25rem;
}
.back {
font: inherit;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4em 0.85em;
cursor: pointer;
}
.back:hover {
color: var(--fg);
border-color: var(--accent);
background: var(--bg-hover);
}
.head {
display: flex;
flex-direction: column;
gap: 0.4em;
margin-bottom: 0.25rem;
}
.head h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 600;
line-height: 1.25;
}
/* Stat cards — matches .totals / .stats in other pages */
.totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
@media (max-width: 900px) {
.totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 520px) {
.totals { grid-template-columns: 1fr; }
}
.section-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 700px) {
.section-pair { grid-template-columns: 1fr; }
}
h3 {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: 600;
}
.empty {
color: var(--fg-dim);
padding: 1rem 0;
}
/* Tables */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
font-weight: 600;
color: var(--fg-dim);
border-bottom: 1px solid var(--border);
padding: 0.5em 0.6em;
font-size: 0.78em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
td {
padding: 0.5em 0.6em;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.num {
text-align: right !important;
font-variant-numeric: tabular-nums;
}
tr[title] { cursor: help; }
tr[title]:hover td { color: var(--accent); }
.dim-row td { opacity: 0.35; }
</style>
+19 -1
View File
@@ -12,6 +12,8 @@
const USER_PREFIX = "#/user/"; const USER_PREFIX = "#/user/";
const WORKER_PREFIX = "#/worker/"; const WORKER_PREFIX = "#/worker/";
const ACCELERATOR_HASH = "#/accelerator"; const ACCELERATOR_HASH = "#/accelerator";
const STATS_HASH = "#/stats";
const BESTSHARE_HASH = "#/bestshare";
type Selection = { user: string | null; worker: string | null; page: string | null }; type Selection = { user: string | null; worker: string | null; page: string | null };
@@ -23,6 +25,12 @@ function readHash(): Selection {
if (h === ACCELERATOR_HASH) { if (h === ACCELERATOR_HASH) {
return { user: null, worker: null, page: "accelerator" }; return { user: null, worker: null, page: "accelerator" };
} }
if (h === STATS_HASH) {
return { user: null, worker: null, page: "stats" };
}
if (h === BESTSHARE_HASH) {
return { user: null, worker: null, page: "bestshare" };
}
if (h.startsWith(WORKER_PREFIX)) { if (h.startsWith(WORKER_PREFIX)) {
const w = decodeURIComponent(h.slice(WORKER_PREFIX.length)); const w = decodeURIComponent(h.slice(WORKER_PREFIX.length));
return { user: null, worker: w || null, page: null }; return { user: null, worker: w || null, page: null };
@@ -57,12 +65,22 @@ export function selectAccelerator(): void {
window.location.hash = ACCELERATOR_HASH; window.location.hash = ACCELERATOR_HASH;
} }
export function selectStats(): void {
window.location.hash = STATS_HASH;
}
export function selectBestShare(): void {
window.location.hash = BESTSHARE_HASH;
}
export function clearSelection(): void { export function clearSelection(): void {
if ( if (
window.history.length > 1 && window.history.length > 1 &&
(window.location.hash.startsWith(USER_PREFIX) || (window.location.hash.startsWith(USER_PREFIX) ||
window.location.hash.startsWith(WORKER_PREFIX) || window.location.hash.startsWith(WORKER_PREFIX) ||
window.location.hash === ACCELERATOR_HASH) window.location.hash === ACCELERATOR_HASH ||
window.location.hash === STATS_HASH ||
window.location.hash === BESTSHARE_HASH)
) { ) {
window.history.back(); window.history.back();
} else { } else {
+13
View File
@@ -103,6 +103,8 @@ export type BlockRecord = {
// Bitcoin network the block was mined on ("main", "test", "signet"). // Bitcoin network the block was mined on ("main", "test", "signet").
// Absent for legacy rows recorded before this field was added. // Absent for legacy rows recorded before this field was added.
chain?: string; chain?: string;
// Workername (address.worker) of the miner who found the block.
miner?: string;
}; };
export type HashratePoint = { export type HashratePoint = {
@@ -122,6 +124,8 @@ export type Snapshot = {
hashrate_hs_1h: number; hashrate_hs_1h: number;
hashrate_hs_24h: number; hashrate_hs_24h: number;
best_diff: number; best_diff: number;
best_share_hash?: string;
best_share_net_diff?: number;
acked_best_diff: number; acked_best_diff: number;
cumulative_shares: number; cumulative_shares: number;
next_block_reward_btc: number; next_block_reward_btc: number;
@@ -149,6 +153,15 @@ export type Snapshot = {
alltime_accepted: number; alltime_accepted: number;
alltime_rejected: number; alltime_rejected: number;
// Share statistics: rejection reasons and difficulty distribution.
reject_reasons_session?: Record<string, number>;
reject_reasons_alltime?: Record<string, number>;
// Difficulty distribution buckets: [<1M, 1M-100M, 100M-1G, 1G-100G, 100G-1T, >=1T]
diff_dist_session: number[];
diff_dist_alltime: number[];
avg_diff_session: number;
avg_diff_alltime: number;
// Block update latency diagnostics (ZMQ → mining.notify). // Block update latency diagnostics (ZMQ → mining.notify).
latency_count: number; latency_count: number;
latency_avg_ms: number; latency_avg_ms: number;