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:
205
server/config.go
Normal file
205
server/config.go
Normal 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
27
server/go.mod
Normal 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
59
server/go.sum
Normal 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=
|
||||
62
server/internal/api/config_handler.go
Normal file
62
server/internal/api/config_handler.go
Normal 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)
|
||||
}
|
||||
113
server/internal/api/handlers.go
Normal file
113
server/internal/api/handlers.go
Normal 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)
|
||||
}
|
||||
107
server/internal/api/router.go
Normal file
107
server/internal/api/router.go
Normal 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
|
||||
}
|
||||
373
server/internal/api/websocket.go
Normal file
373
server/internal/api/websocket.go
Normal 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
|
||||
}
|
||||
379
server/internal/builder/handler.go
Normal file
379
server/internal/builder/handler.go
Normal 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)
|
||||
}
|
||||
342
server/internal/db/sqlite.go
Normal file
342
server/internal/db/sqlite.go
Normal 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
|
||||
}
|
||||
72
server/internal/models/agent.go
Normal file
72
server/internal/models/agent.go
Normal 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"`
|
||||
}
|
||||
630
server/internal/pool/proxy.go
Normal file
630
server/internal/pool/proxy.go
Normal 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, ¬if); 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, ¶ms); 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
263
server/main.go
Normal 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
13
server/web/index.html
Normal 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
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
24
server/web/package.json
Normal 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
22
server/web/src/App.tsx
Normal 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;
|
||||
53
server/web/src/api/client.ts
Normal file
53
server/web/src/api/client.ts
Normal 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'),
|
||||
};
|
||||
127
server/web/src/components/Layout/Layout.css
Normal file
127
server/web/src/components/Layout/Layout.css
Normal 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;
|
||||
}
|
||||
}
|
||||
49
server/web/src/components/Layout/Layout.tsx
Normal file
49
server/web/src/components/Layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
server/web/src/hooks/useWebSocket.ts
Normal file
122
server/web/src/hooks/useWebSocket.ts
Normal 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
13
server/web/src/main.tsx
Normal 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>
|
||||
);
|
||||
191
server/web/src/pages/AgentsPage.tsx
Normal file
191
server/web/src/pages/AgentsPage.tsx
Normal 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`;
|
||||
}
|
||||
419
server/web/src/pages/BuilderPage.tsx
Normal file
419
server/web/src/pages/BuilderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
server/web/src/pages/DashboardPage.tsx
Normal file
163
server/web/src/pages/DashboardPage.tsx
Normal 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();
|
||||
}
|
||||
616
server/web/src/pages/Pages.css
Normal file
616
server/web/src/pages/Pages.css
Normal 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;
|
||||
}
|
||||
363
server/web/src/pages/SettingsPage.tsx
Normal file
363
server/web/src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
server/web/src/styles/global.css
Normal file
264
server/web/src/styles/global.css
Normal 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;
|
||||
}
|
||||
150
server/web/src/types/index.ts
Normal file
150
server/web/src/types/index.ts
Normal 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
21
server/web/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
server/web/tsconfig.node.json
Normal file
10
server/web/tsconfig.node.json
Normal 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
23
server/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user