Make built exe a one-run Windows installer pointing at LAN dashboard.
Install copies miner to AppData, registers autostart, relaunches silently, and builder defaults server URL to local IP.
This commit is contained in:
23
README.md
23
README.md
@@ -5,13 +5,26 @@ Private Monero (XMR) fleet control server for your own network. Run the control
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. Install [Go 1.21+](https://go.dev/dl/) and [Node.js 20+](https://nodejs.org/) (or let `run.bat` install them).
|
1. Install [Go 1.21+](https://go.dev/dl/) and [Node.js 20+](https://nodejs.org/) (or let `run.bat` install them).
|
||||||
2. Double-click **`run.bat`** or run it from a terminal.
|
2. Double-click **`run.bat`** on your control PC.
|
||||||
3. Open **http://localhost:8989**
|
3. Open **http://YOUR-LOCAL-IP:8989** (shown when the server starts).
|
||||||
4. Go to **Settings** and set your wallet + pool.
|
4. Go to **Settings** and set your wallet + pool.
|
||||||
5. Go to **Miner Builder**, name a worker, click **Build Miner .exe**.
|
5. Go to **Miner Builder**, enter a worker name, click **Build Installer .exe**.
|
||||||
6. Copy the built file from the path shown (under `data/builds/`) to target Windows machines and run it.
|
6. Copy `install-{worker}.exe` to each Windows machine on your LAN and **run it once**.
|
||||||
|
|
||||||
## What Gets Built
|
Each installer:
|
||||||
|
|
||||||
|
- Copies the miner to `%LOCALAPPDATA%\CryptoMiner\{worker}-{build}\miner.exe`
|
||||||
|
- Registers Windows auto-start (and optional scheduled task)
|
||||||
|
- Connects back to your dashboard at your **LAN IP:8989**
|
||||||
|
- Appears on the **Agents** and **Dashboard** pages automatically
|
||||||
|
|
||||||
|
## End Result
|
||||||
|
|
||||||
|
| Piece | What it does |
|
||||||
|
|-------|----------------|
|
||||||
|
| Control PC | Runs the web dashboard on local IP + port **8989** |
|
||||||
|
| Dashboard | Configure pool/wallet/defaults, build installers, track all miners |
|
||||||
|
| `install-{name}.exe` | Single file you run once on each worker PC to install + start mining |
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
|
|||||||
185
agent/deploy/install.go
Normal file
185
agent/deploy/install.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package deploy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-agent/config"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runFlag = "--run"
|
||||||
|
|
||||||
|
// InstallIfNeeded copies the installer to a permanent location, registers auto-start,
|
||||||
|
// and relaunches the miner from there. Returns true when the current process should exit.
|
||||||
|
func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||||
|
currentExe, err := CurrentExecutable()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arg := range os.Args[1:] {
|
||||||
|
if arg == runFlag {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir, err := InstallDir(cfg.WorkerName, cfg.BuildID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
installedExe := filepath.Join(installDir, "miner.exe")
|
||||||
|
|
||||||
|
if samePath(currentExe, installedExe) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(installDir, 0755); err != nil {
|
||||||
|
return false, fmt.Errorf("create install dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyFile(currentExe, installedExe); err != nil {
|
||||||
|
return false, fmt.Errorf("copy miner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(installDir, "miner.log")
|
||||||
|
_ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf(
|
||||||
|
"worker=%s\nbuild=%s\nserver=%s\ninstalled_exe=%s\n",
|
||||||
|
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, installedExe,
|
||||||
|
)), 0644)
|
||||||
|
|
||||||
|
if cfg.AutoStart {
|
||||||
|
if err := configureAutoStart(cfg.WorkerName, installedExe); err != nil {
|
||||||
|
return false, fmt.Errorf("auto-start: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := configureRunMode(cfg, installedExe); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := relaunch(installedExe, logPath); err != nil {
|
||||||
|
return false, fmt.Errorf("start installed miner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallDir(workerName, buildID string) (string, error) {
|
||||||
|
base, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
safeWorker := sanitizeName(workerName)
|
||||||
|
shortBuild := buildID
|
||||||
|
if len(shortBuild) > 8 {
|
||||||
|
shortBuild = shortBuild[:8]
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "CryptoMiner", fmt.Sprintf("%s-%s", safeWorker, shortBuild)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureAutoStart(workerName, exePath string) error {
|
||||||
|
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
return k.SetStringValue(registryValueName(workerName), fmt.Sprintf(`"%s" %s`, exePath, runFlag))
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureRunMode(cfg config.RuntimeConfig, installedExe string) error {
|
||||||
|
switch cfg.RunAs {
|
||||||
|
case "scheduled":
|
||||||
|
return createScheduledTask(cfg.WorkerName, installedExe)
|
||||||
|
case "service":
|
||||||
|
// Windows service requires a service wrapper; scheduled task at logon is the practical equivalent.
|
||||||
|
return createScheduledTask(cfg.WorkerName, installedExe)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createScheduledTask(workerName, exePath string) error {
|
||||||
|
taskName := sanitizeName(workerName)
|
||||||
|
if taskName == "" {
|
||||||
|
taskName = "CryptoMinerAgent"
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf(
|
||||||
|
`$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable; Register-ScheduledTask -TaskName 'CryptoMiner-%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`,
|
||||||
|
strings.ReplaceAll(exePath, `'`, `''`),
|
||||||
|
runFlag,
|
||||||
|
taskName,
|
||||||
|
)
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func relaunch(exePath, logPath string) error {
|
||||||
|
cmd := exec.Command(exePath, runFlag)
|
||||||
|
cmd.Dir = filepath.Dir(exePath)
|
||||||
|
cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath)
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryValueName(workerName string) string {
|
||||||
|
name := sanitizeName(workerName)
|
||||||
|
if name == "" {
|
||||||
|
return "CryptoMinerAgent"
|
||||||
|
}
|
||||||
|
return "CryptoMiner-" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeName(name string) string {
|
||||||
|
replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "")
|
||||||
|
return replacer.Replace(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func samePath(a, b string) bool {
|
||||||
|
a = filepath.Clean(a)
|
||||||
|
b = filepath.Clean(b)
|
||||||
|
if strings.EqualFold(a, b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
aAbs, errA := filepath.Abs(a)
|
||||||
|
bAbs, errB := filepath.Abs(b)
|
||||||
|
if errA != nil || errB != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(aAbs, bAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dest string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigureAutoStart(exePath string, enabled bool) error {
|
||||||
|
return configureAutoStart("default", exePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAutoStart() error {
|
||||||
|
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
_ = k.DeleteValue("CryptoMinerAgent")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -5,34 +5,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"golang.org/x/sys/windows/registry"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConfigureAutoStart(exePath string, enabled bool) error {
|
|
||||||
if !enabled {
|
|
||||||
return removeAutoStart()
|
|
||||||
}
|
|
||||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer k.Close()
|
|
||||||
return k.SetStringValue("CryptoMinerAgent", exePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAutoStart() error {
|
|
||||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer k.Close()
|
|
||||||
_ = k.DeleteValue("CryptoMinerAgent")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetProcessPriority(priority string) error {
|
func SetProcessPriority(priority string) error {
|
||||||
// Best-effort on Windows using PowerShell for the current process.
|
|
||||||
class := "BelowNormal"
|
class := "BelowNormal"
|
||||||
switch priority {
|
switch priority {
|
||||||
case "idle":
|
case "idle":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"crypto-miner-agent/client"
|
"crypto-miner-agent/client"
|
||||||
"crypto-miner-agent/config"
|
"crypto-miner-agent/config"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
setupLogging(cfg)
|
||||||
|
|
||||||
if cfg.Wallet == "" {
|
if cfg.Wallet == "" {
|
||||||
log.Fatal("wallet address is required in built-in configuration")
|
log.Fatal("wallet address is required in built-in configuration")
|
||||||
@@ -20,19 +22,20 @@ func main() {
|
|||||||
log.Fatal("server URL is required in built-in configuration")
|
log.Fatal("server URL is required in built-in configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installed, err := deploy.InstallIfNeeded(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[installer] failed: %v", err)
|
||||||
|
}
|
||||||
|
if installed {
|
||||||
|
log.Printf("[installer] installed worker=%s to permanent location and started miner", cfg.WorkerName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := deploy.SetProcessPriority(cfg.CPUPriority); err != nil {
|
if err := deploy.SetProcessPriority(cfg.CPUPriority); err != nil {
|
||||||
log.Printf("[agent] could not set CPU priority: %v", err)
|
log.Printf("[agent] could not set CPU priority: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AutoStart {
|
log.Printf("[agent] running worker=%s build=%s server=%s threads=%d",
|
||||||
if exe, err := deploy.CurrentExecutable(); err == nil {
|
|
||||||
if err := deploy.ConfigureAutoStart(exe, true); err != nil {
|
|
||||||
log.Printf("[agent] auto-start setup failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[agent] starting worker=%s build=%s server=%s threads=%d",
|
|
||||||
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, cfg.Threads)
|
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, cfg.Threads)
|
||||||
|
|
||||||
agent := client.NewAgentClient(cfg)
|
agent := client.NewAgentClient(cfg)
|
||||||
@@ -41,12 +44,25 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func setupLogging(cfg config.RuntimeConfig) {
|
||||||
// Hide console when built with -H windowsgui by redirecting logs to file if needed.
|
|
||||||
if os.Getenv("MINER_LOG_FILE") != "" {
|
if os.Getenv("MINER_LOG_FILE") != "" {
|
||||||
f, err := os.OpenFile(os.Getenv("MINER_LOG_FILE"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
redirectLog(os.Getenv("MINER_LOG_FILE"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir, err := deploy.InstallDir(cfg.WorkerName, cfg.BuildID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectLog(filepath.Join(installDir, "miner.log"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectLog(path string) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.SetOutput(f)
|
log.SetOutput(f)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
h := NewHandler(database)
|
h := NewHandler(database)
|
||||||
|
|
||||||
r.Get("/health", h.HealthCheck)
|
r.Get("/health", h.HealthCheck)
|
||||||
|
r.Get("/server/info", h.GetServerInfo)
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
r.Get("/dashboard/stats", h.GetDashboardStats)
|
r.Get("/dashboard/stats", h.GetDashboardStats)
|
||||||
|
|||||||
115
server/internal/api/server_info.go
Normal file
115
server/internal/api/server_info.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerInfo struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
LocalIPs []string `json:"local_ips"`
|
||||||
|
SuggestedURL string `json:"suggested_url"`
|
||||||
|
DashboardURL string `json:"dashboard_url"`
|
||||||
|
WebSocketURL string `json:"websocket_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.Host
|
||||||
|
if idx := strings.Index(host, ":"); idx > 0 {
|
||||||
|
host = host[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
localIPs := listLocalIPv4()
|
||||||
|
suggestedHost := host
|
||||||
|
if isLoopbackHost(host) && len(localIPs) > 0 {
|
||||||
|
suggestedHost = localIPs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
port := 8989
|
||||||
|
if idx := strings.LastIndex(r.Host, ":"); idx > 0 {
|
||||||
|
if p := r.Host[idx+1:]; p != "" {
|
||||||
|
port = parsePort(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestedURL := "http://" + net.JoinHostPort(suggestedHost, itoa(port))
|
||||||
|
info := ServerInfo{
|
||||||
|
Port: port,
|
||||||
|
Host: host,
|
||||||
|
LocalIPs: localIPs,
|
||||||
|
SuggestedURL: suggestedURL,
|
||||||
|
DashboardURL: suggestedURL,
|
||||||
|
WebSocketURL: strings.Replace(strings.Replace(suggestedURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/ws/agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listLocalIPv4() []string {
|
||||||
|
var ips []string
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := addr.(type) {
|
||||||
|
case *net.IPNet:
|
||||||
|
ip = v.IP
|
||||||
|
case *net.IPAddr:
|
||||||
|
ip = v.IP
|
||||||
|
}
|
||||||
|
if ip == nil || ip.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip = ip.To4()
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips = append(ips, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePort(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, ch := range s {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 8989
|
||||||
|
}
|
||||||
|
n = n*10 + int(ch-'0')
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return 8989
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [16]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
|||||||
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
outputName := fmt.Sprintf("xmr-worker-%s.exe", sanitizeFileName(req.WorkerName))
|
outputName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
|
||||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
||||||
|
|
||||||
ldflags := "-s -w"
|
ldflags := "-s -w"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse } from '../types';
|
import type { Agent, Share, HashrateSample, BuildRecord, FleetStats, ServerConfig, BuildRequest, BuildResponse, ServerInfo } from '../types';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ export const api = {
|
|||||||
|
|
||||||
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
||||||
|
|
||||||
// Health
|
// Health / server
|
||||||
healthCheck: () => fetchJSON<{ status: string }>('/health'),
|
healthCheck: () => fetchJSON<{ status: string }>('/health'),
|
||||||
|
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig } from '../types';
|
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
function defaultsFromConfig(config: ServerConfig, origin: string): BuildRequest {
|
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
|
||||||
return {
|
return {
|
||||||
worker_name: '',
|
worker_name: '',
|
||||||
server_url: origin,
|
server_url: serverInfo.suggested_url,
|
||||||
wallet: config.wallet.address,
|
wallet: config.wallet.address,
|
||||||
threads: config.default_agent_config.threads,
|
threads: config.default_agent_config.threads,
|
||||||
cpu_priority: config.default_agent_config.cpu_priority,
|
cpu_priority: config.default_agent_config.cpu_priority,
|
||||||
@@ -37,8 +37,8 @@ export default function BuilderPage() {
|
|||||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getConfig()
|
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||||
.then((config) => setForm(defaultsFromConfig(config, window.location.origin)))
|
.then(([config, serverInfo]) => setForm(defaultsFromConfig(config, serverInfo)))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setError('Failed to load server defaults from Settings');
|
setError('Failed to load server defaults from Settings');
|
||||||
@@ -114,10 +114,10 @@ export default function BuilderPage() {
|
|||||||
|
|
||||||
<div className="builder-layout">
|
<div className="builder-layout">
|
||||||
<div className="card builder-form">
|
<div className="card builder-form">
|
||||||
<h2>Build Custom Miner</h2>
|
<h2>Build Miner Installer</h2>
|
||||||
<p className="form-description">
|
<p className="form-description">
|
||||||
Defaults come from Settings. Adjust per worker, then build. The server compiles a Windows
|
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once.
|
||||||
`.exe` with all values baked in and saves it under `data/builds/`.
|
It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -143,7 +143,7 @@ export default function BuilderPage() {
|
|||||||
onChange={(e) => updateField('server_url', e.target.value)}
|
onChange={(e) => updateField('server_url', e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="form-hint">Control server URL agents connect to (LAN or tunneled domain)</span>
|
<span className="form-hint">Your control server on the LAN — workers use this to reach the dashboard ({form.server_url})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="label">XMR Wallet Address</label>
|
<label className="label">XMR Wallet Address</label>
|
||||||
@@ -359,15 +359,16 @@ export default function BuilderPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="submit" className="btn btn-success build-btn" disabled={building}>
|
<button type="submit" className="btn btn-success build-btn" disabled={building}>
|
||||||
{building ? 'Building...' : 'Build Miner .exe'}
|
{building ? 'Building...' : 'Build Installer .exe'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lastBuild?.success && (
|
{lastBuild?.success && (
|
||||||
<div className="card recent-builds">
|
<div className="card recent-builds">
|
||||||
<h2>Build Complete</h2>
|
<h2>Installer Ready</h2>
|
||||||
<div className="build-success">
|
<div className="build-success">
|
||||||
|
<p><strong>Run this once on each Windows machine:</strong></p>
|
||||||
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
||||||
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
||||||
<p><strong>Absolute path:</strong></p>
|
<p><strong>Absolute path:</strong></p>
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export interface HashrateSample {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerInfo {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
local_ips: string[];
|
||||||
|
suggested_url: string;
|
||||||
|
dashboard_url: string;
|
||||||
|
websocket_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildRecord {
|
export interface BuildRecord {
|
||||||
id: string;
|
id: string;
|
||||||
worker_name: string;
|
worker_name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user