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
+106
View File
@@ -21,6 +21,7 @@ import (
"os"
"regexp"
"strconv"
"strings"
"time"
)
@@ -57,6 +58,17 @@ type LatencyEvent struct {
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
// parsed events. Create with New, then Run in a goroutine.
type Tailer struct {
@@ -64,6 +76,7 @@ type Tailer struct {
Events chan BlockEvent
Attempts chan AttemptEvent
Latencies chan LatencyEvent
Shares chan ShareEvent
Log *slog.Logger
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),
Attempts: make(chan AttemptEvent, 16),
Latencies: make(chan LatencyEvent, 16),
Shares: make(chan ShareEvent, 64),
Log: log,
PollWait: 500 * time.Millisecond,
}
@@ -107,6 +121,19 @@ var (
// Matches the latency line from our patch 0005:
// "Block update latency: 92ms (ZMQ trigger to mining.notify broadcast)"
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
@@ -118,6 +145,7 @@ func (t *Tailer) Run(ctx context.Context) {
defer close(t.Events)
defer close(t.Attempts)
defer close(t.Latencies)
defer close(t.Shares)
var (
f *os.File
@@ -256,6 +284,35 @@ func (t *Tailer) Run(ctx context.Context) {
}
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 ms, err := strconv.ParseInt(m[1], 10, 64); err == nil {
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.
func sleep(ctx context.Context, d time.Duration) bool {
select {