Improve portable launch, forge persistence, and operator auth UX.
Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
This commit is contained in:
@@ -152,7 +152,7 @@ func DefaultConfig() *Config {
|
||||
RejectionRateThresholdPct: 5,
|
||||
},
|
||||
Server: ServerSettings{
|
||||
PublicURL: "",
|
||||
PublicURL: "https://killa.thetempleofdoom.com",
|
||||
StatsRetentionHours: 168,
|
||||
BuildRetentionDays: 30,
|
||||
PoolReconnectSeconds: 30,
|
||||
|
||||
@@ -7,23 +7,16 @@ require (
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
golang.org/x/crypto v0.52.0
|
||||
modernc.org/sqlite v1.29.5
|
||||
)
|
||||
|
||||
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/crypto v0.52.0 // indirect
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
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=
|
||||
@@ -22,43 +18,21 @@ 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/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
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=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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=
|
||||
|
||||
39
server/internal/api/agent_config_test.go
Normal file
39
server/internal/api/agent_config_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAgentConfigPoolHostOrDefault(t *testing.T) {
|
||||
t.Run("empty uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if got := cfg.poolHostOrDefault("fallback.host"); got != "fallback.host" {
|
||||
t.Fatalf("got %q, want fallback.host", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit host wins", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolHost: "pool.example.com"}
|
||||
if got := cfg.poolHostOrDefault("fallback"); got != "pool.example.com" {
|
||||
t.Fatalf("got %q, want pool.example.com", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentConfigPoolPortOrDefault(t *testing.T) {
|
||||
t.Run("zero uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 3333 {
|
||||
t.Fatalf("got %d, want 3333", got)
|
||||
}
|
||||
})
|
||||
t.Run("negative uses fallback", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolPort: -1}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 3333 {
|
||||
t.Fatalf("got %d, want 3333", got)
|
||||
}
|
||||
})
|
||||
t.Run("explicit port wins", func(t *testing.T) {
|
||||
cfg := AgentForgeConfig{PoolPort: 443}
|
||||
if got := cfg.poolPortOrDefault(3333); got != 443 {
|
||||
t.Fatalf("got %d, want 443", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -556,8 +556,7 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
|
||||
fh, _, ws, _ := newTestFleetHandler(t)
|
||||
|
||||
t.Run("nil ws", func(t *testing.T) {
|
||||
bad := *fh
|
||||
bad.ws = nil
|
||||
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
|
||||
fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
|
||||
@@ -730,8 +729,7 @@ func TestFleetPostBulkCommandErrors(t *testing.T) {
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
|
||||
t.Run("nil ws", func(t *testing.T) {
|
||||
bad := *fh
|
||||
bad.ws = nil
|
||||
bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
|
||||
strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
|
||||
|
||||
@@ -64,18 +64,19 @@ func authCacheSet(user, pass string) {
|
||||
}
|
||||
|
||||
var (
|
||||
// authUsers is populated from data/users.json on startup. On the very first
|
||||
// run (no users.json) a random password is generated, saved, and printed to
|
||||
// the console — no hard-coded credentials anywhere in the binary.
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
// authUsers is populated from data/users.json on startup. Plain-text copies
|
||||
// for console display live in data/login-credentials.json (0600).
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath string
|
||||
usersMu sync.RWMutex
|
||||
authLoadMu sync.Mutex
|
||||
authLoadedDataDir string
|
||||
|
||||
// fleetSecretForAgentPaths holds the shared fleet secret used to authenticate
|
||||
// agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via
|
||||
// SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers.
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
fleetSecretForAgentPaths string
|
||||
fleetSecretForAgentPathsMu sync.RWMutex
|
||||
|
||||
// rotateSecretFn is called when POST /server/rotate-secret is hit.
|
||||
// Wired from main.go so the server can generate, persist, and propagate the new secret.
|
||||
@@ -120,8 +121,94 @@ func checkPassword(stored, provided string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
|
||||
}
|
||||
|
||||
func loadUsers(dataDir string) {
|
||||
func loginSidecarPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "login-credentials.json")
|
||||
}
|
||||
|
||||
func readLoginSidecar(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, fmt.Errorf("empty login sidecar")
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func writeLoginSidecar(path string, creds map[string]string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func upsertLoginSidecar(dataDir, username, password string) error {
|
||||
path := loginSidecarPath(dataDir)
|
||||
creds, _ := readLoginSidecar(path)
|
||||
if creds == nil {
|
||||
creds = map[string]string{}
|
||||
}
|
||||
creds[username] = password
|
||||
return writeLoginSidecar(path, creds)
|
||||
}
|
||||
|
||||
func printStartupCredentials(dataDir string) {
|
||||
creds, err := readLoginSidecar(loginSidecarPath(dataDir))
|
||||
if err != nil || len(creds) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println(formatLoginBanner(creds))
|
||||
}
|
||||
|
||||
func formatLoginBanner(creds map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
|
||||
b.WriteString("║ AetherForge — Dashboard Login ║\n")
|
||||
b.WriteString("║ ║\n")
|
||||
for user, pass := range creds {
|
||||
fmt.Fprintf(&b, "║ Username : %-34s║\n", user)
|
||||
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
|
||||
b.WriteString("║ ║\n")
|
||||
}
|
||||
b.WriteString("║ Also saved in data/login-credentials.json ║\n")
|
||||
b.WriteString("║ Change passwords in Calibrate → Users. ║\n")
|
||||
b.WriteString("╚══════════════════════════════════════════════════╝\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// LoadUsers loads dashboard accounts and prints login credentials to the console.
|
||||
// Call once during startup (before heavy init) so operators always see passwords.
|
||||
func LoadUsers(dataDir string) {
|
||||
ensureUsersLoaded(dataDir)
|
||||
printStartupCredentials(dataDir)
|
||||
}
|
||||
|
||||
func ensureUsersLoaded(dataDir string) {
|
||||
abs, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
abs = dataDir
|
||||
}
|
||||
authLoadMu.Lock()
|
||||
defer authLoadMu.Unlock()
|
||||
if authLoadedDataDir == abs {
|
||||
return
|
||||
}
|
||||
bootstrapUsers(dataDir)
|
||||
authLoadedDataDir = abs
|
||||
}
|
||||
|
||||
func bootstrapUsers(dataDir string) {
|
||||
usersFilePath = filepath.Join(dataDir, "users.json")
|
||||
sidecarPath := loginSidecarPath(dataDir)
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
|
||||
@@ -129,7 +216,6 @@ func loadUsers(dataDir string) {
|
||||
if err == nil {
|
||||
var loaded map[string]string
|
||||
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
|
||||
// Migration: re-hash any plain-text entries left from an older version.
|
||||
migrated := false
|
||||
for u, v := range loaded {
|
||||
if !isBcryptHash(v) {
|
||||
@@ -145,16 +231,15 @@ func loadUsers(dataDir string) {
|
||||
d, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
_ = os.WriteFile(usersFilePath, d, 0600)
|
||||
}
|
||||
reconcileLoginSidecar(dataDir, sidecarPath, loaded)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// First run — no users.json (or empty). Generate a random admin password,
|
||||
// hash it, save it, and print the plain-text once to the console.
|
||||
pw := generateRandomPassword()
|
||||
hashed, herr := hashPassword(pw)
|
||||
if herr != nil {
|
||||
hashed = pw // extremely unlikely; degrade gracefully
|
||||
hashed = pw
|
||||
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr)
|
||||
}
|
||||
authUsers = map[string]string{"admin": hashed}
|
||||
@@ -163,20 +248,35 @@ func loadUsers(dataDir string) {
|
||||
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
|
||||
}
|
||||
_ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw})
|
||||
}
|
||||
}
|
||||
|
||||
banner := fmt.Sprintf(`
|
||||
╔══════════════════════════════════════════════════╗
|
||||
║ AetherForge — First Run ║
|
||||
║ ║
|
||||
║ Dashboard login ║
|
||||
║ Username : admin ║
|
||||
║ Password : %-34s║
|
||||
║ ║
|
||||
║ Save this — it will not be shown again. ║
|
||||
║ Change it later in Calibrate → Users. ║
|
||||
╚══════════════════════════════════════════════════╝`, pw)
|
||||
log.Print(banner)
|
||||
func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) {
|
||||
if _, err := readLoginSidecar(sidecarPath); err == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := users["admin"]; !ok {
|
||||
return
|
||||
}
|
||||
pw := generateRandomPassword()
|
||||
hashed, herr := hashPassword(pw)
|
||||
if herr != nil {
|
||||
log.Printf("[Auth] WARNING: could not regenerate admin password: %v", herr)
|
||||
return
|
||||
}
|
||||
users["admin"] = hashed
|
||||
authUsers = users
|
||||
d, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr)
|
||||
return
|
||||
}
|
||||
if writeErr := writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}); writeErr != nil {
|
||||
log.Printf("[Auth] WARNING: could not save login-credentials.json: %v", writeErr)
|
||||
return
|
||||
}
|
||||
log.Printf("[Auth] Regenerated admin password (login-credentials.json was missing)")
|
||||
}
|
||||
|
||||
// generateRandomPassword returns a 20-character hex string suitable for use
|
||||
@@ -195,16 +295,25 @@ func saveUser(username, password string) error {
|
||||
return fmt.Errorf("bcrypt: %w", err)
|
||||
}
|
||||
usersMu.Lock()
|
||||
defer usersMu.Unlock()
|
||||
authUsers[username] = hashed
|
||||
if usersFilePath == "" {
|
||||
usersFilePath = filepath.Join("data", "users.json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
|
||||
dataDir := filepath.Dir(usersFilePath)
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
usersMu.Unlock()
|
||||
return err
|
||||
}
|
||||
data, _ := json.MarshalIndent(authUsers, "", " ")
|
||||
return os.WriteFile(usersFilePath, data, 0600)
|
||||
if err := os.WriteFile(usersFilePath, data, 0600); err != nil {
|
||||
usersMu.Unlock()
|
||||
return err
|
||||
}
|
||||
usersMu.Unlock()
|
||||
if err := upsertLoginSidecar(dataDir, username, password); err != nil {
|
||||
log.Printf("[Auth] WARNING: could not update login-credentials.json: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
@@ -294,7 +403,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
ensureUsersLoaded(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -414,7 +523,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
writeJSON(w, map[string]interface{}{"success": true})
|
||||
})
|
||||
|
||||
// AI Autonomy (Ollama)
|
||||
// Agent autonomy REST — forged Go agents only (X-Fleet-Secret header).
|
||||
// Not exposed in dashboard client.ts; see agent/client and README API auth table.
|
||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||
r.Post("/agent/report", aiHandler.HandleReport)
|
||||
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
|
||||
|
||||
@@ -28,6 +28,9 @@ func resetAuthState(t *testing.T) {
|
||||
authUsers = map[string]string{}
|
||||
usersFilePath = ""
|
||||
usersMu.Unlock()
|
||||
authLoadMu.Lock()
|
||||
authLoadedDataDir = ""
|
||||
authLoadMu.Unlock()
|
||||
SetAgentPathSecret("")
|
||||
SetRotateSecretFn(nil)
|
||||
t.Cleanup(resetAuthGlobals)
|
||||
@@ -437,3 +440,35 @@ func TestRouterNoWebRootFallback(t *testing.T) {
|
||||
t.Fatalf("unexpected body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
|
||||
resetAuthState(t)
|
||||
dataDir := t.TempDir()
|
||||
|
||||
LoadUsers(dataDir)
|
||||
|
||||
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
|
||||
data, err := os.ReadFile(sidecarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("login sidecar: %v", err)
|
||||
}
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pw, ok := creds["admin"]
|
||||
if !ok || pw == "" {
|
||||
t.Fatalf("expected admin password in sidecar: %v", creds)
|
||||
}
|
||||
if !checkPassword(authUsers["admin"], pw) {
|
||||
t.Fatal("sidecar password should match users.json hash")
|
||||
}
|
||||
|
||||
if err := saveUser("admin", "new-secret-pass"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := readLoginSidecar(sidecarPath)
|
||||
if err != nil || reloaded["admin"] != "new-secret-pass" {
|
||||
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
31
server/internal/api/server_policy_test.go
Normal file
31
server/internal/api/server_policy_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerPolicyJSONRoundTrip(t *testing.T) {
|
||||
in := ServerPolicy{
|
||||
MaxAgents: 128,
|
||||
LogAgentConnections: true,
|
||||
LogShareSubmissions: false,
|
||||
LogPoolTraffic: true,
|
||||
StrictWalletValidation: true,
|
||||
MaxBuildSizeMB: 64,
|
||||
PoolReconnectSeconds: 30,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out ServerPolicy
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
|
||||
}
|
||||
}
|
||||
32
server/internal/api/testdata/ws_types_fixture.json
vendored
Normal file
32
server/internal/api/testdata/ws_types_fixture.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"WSDashboardInit": [
|
||||
"agents"
|
||||
],
|
||||
"WSAgentOffline": [
|
||||
"agent_id"
|
||||
],
|
||||
"WSStatsUpdate": [
|
||||
"agent_id",
|
||||
"cpu_usage_pct",
|
||||
"hashrate_15m",
|
||||
"hashrate_15s",
|
||||
"hashrate_1m",
|
||||
"memory_usage_pct",
|
||||
"shares_accepted",
|
||||
"shares_submitted",
|
||||
"uptime_seconds"
|
||||
],
|
||||
"WSCommandResult": [
|
||||
"action",
|
||||
"agent_id",
|
||||
"message",
|
||||
"success"
|
||||
],
|
||||
"WSAgentLog": [
|
||||
"agent_id",
|
||||
"content"
|
||||
],
|
||||
"WSServerLog": [
|
||||
"line"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
// Dashboard WebSocket payload types (keep in sync with server/web/src/types/ws.ts).
|
||||
// Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog.
|
||||
// Cross-language drift guard: testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts).
|
||||
|
||||
type WSDashboardInit struct {
|
||||
Agents []interface{} `json:"agents"`
|
||||
|
||||
@@ -2,14 +2,96 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var updateWSFixture = flag.Bool("updateWSFixture", false, "rewrite testdata/ws_types_fixture.json from ws_types.go struct tags")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Synced with server/web/src/types/ws.ts — see testdata/ws_types_fixture.json.
|
||||
var wsTypeSamples = map[string]interface{}{
|
||||
"WSDashboardInit": WSDashboardInit{},
|
||||
"WSAgentOffline": WSAgentOffline{},
|
||||
"WSStatsUpdate": WSStatsUpdate{},
|
||||
"WSCommandResult": WSCommandResult{},
|
||||
"WSAgentLog": WSAgentLog{},
|
||||
"WSServerLog": WSServerLog{},
|
||||
}
|
||||
|
||||
func wsTypeFieldKeys(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
var keys []string
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tag := t.Field(i).Tag.Get("json")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name != "" {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func wsTypeKeysFromStructs() map[string][]string {
|
||||
out := make(map[string][]string, len(wsTypeSamples))
|
||||
for name, sample := range wsTypeSamples {
|
||||
out[name] = wsTypeFieldKeys(sample)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWSTypeFieldKeysMatchFixture(t *testing.T) {
|
||||
got := wsTypeKeysFromStructs()
|
||||
fixturePath := filepath.Join("testdata", "ws_types_fixture.json")
|
||||
|
||||
if *updateWSFixture {
|
||||
data, err := json.MarshalIndent(got, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(fixturePath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("updated %s", fixturePath)
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v (run with -updateWSFixture to create)", err)
|
||||
}
|
||||
var want map[string][]string
|
||||
if err := json.Unmarshal(raw, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ws_types fixture drift: re-run go test -run TestWSTypeFieldKeysMatchFixture -updateWSFixture ./internal/api/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSTypesJSONRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
}{
|
||||
{"dashboard_init", WSDashboardInit{Agents: []interface{}{}}},
|
||||
{"stats", WSStatsUpdate{AgentID: "a1", Hashrate15m: 123.4, CPUUsagePct: 50}},
|
||||
{"offline", WSAgentOffline{AgentID: "a1"}},
|
||||
{"command", WSCommandResult{AgentID: "a1", Action: "exec", Success: true, Message: "ok"}},
|
||||
|
||||
@@ -99,9 +99,10 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
|
||||
}
|
||||
@@ -221,9 +222,10 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
|
||||
|
||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, CreatedAt: time.Now(),
|
||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
|
||||
Platform: "universal", BundleSize: zipBytes2,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
|
||||
}); err != nil {
|
||||
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
|
||||
}
|
||||
|
||||
161
server/internal/builder/compile_platform_test.go
Normal file
161
server/internal/builder/compile_platform_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompileGoProjectPlatformFakeGoFail(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, false,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
if err == nil || !strings.Contains(err.Error(), "windows-amd64") {
|
||||
t.Fatalf("expected platform compile error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", []string{"p2p"}, false,
|
||||
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}); err != nil {
|
||||
t.Fatalf("fake go success: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("output not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformObfuscateWithoutGarble(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
h.garblePath = ""
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(context.Background(), dir, out, "-s -w", nil, true,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}); err != nil {
|
||||
t.Fatalf("obfuscate without garble should fall back to plain go: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectPlatformCancelled(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSleep(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := h.compileGoProjectPlatform(ctx, dir, out, "-s -w", nil, false,
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(err.Error(), "cancelled") {
|
||||
t.Fatalf("expected cancelled build, got %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("compile did not stop after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGoProjectDelegatesToPlatform(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "worker.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.compileGoProject(context.Background(), dir, out, "-s -w", nil, false); err != nil {
|
||||
t.Fatalf("compileGoProject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileWorkerFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc-1",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
MeshP2P: true,
|
||||
}
|
||||
out, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid-1",
|
||||
BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("compileWorker: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("compiled worker missing: %v", err)
|
||||
}
|
||||
builtin, err := os.ReadFile(filepath.Join(agentDir, "config", "builtin.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(builtin), "bid-1") || !strings.Contains(string(builtin), "pc-1") {
|
||||
t.Fatalf("builtin config not written: %s", builtin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileWorkerFakeGoFail(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoFail(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
if err := h.copyAgentSource(agentDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &BuildRequest{WorkerName: "pc", ServerURL: "http://x", Wallet: "48abc"}
|
||||
_, err := h.compileWorker(context.Background(), agentDir, buildDir, req, "bid",
|
||||
BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected compile failure")
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
||||
}
|
||||
|
||||
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).")
|
||||
resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (devrun.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.")
|
||||
|
||||
@@ -198,13 +198,13 @@ func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName
|
||||
order := normalizeFusionOrder(runOrder)
|
||||
out := string(src)
|
||||
repl := map[string]string{
|
||||
`const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order),
|
||||
`const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind),
|
||||
`const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
|
||||
`const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
|
||||
`"FUSION_RUN_ORDER"`: fmt.Sprintf("%q", order),
|
||||
`"FUSION_PAYLOAD_KIND"`: fmt.Sprintf("%q", payloadKind),
|
||||
`"FUSION_MEDIA_MODE"`: fmt.Sprintf("%q", mediaMode),
|
||||
`"FUSION_MEDIA_FILE"`: fmt.Sprintf("%q", mediaFileName),
|
||||
}
|
||||
for old, new := range repl {
|
||||
out = strings.Replace(out, old, new, 1)
|
||||
for old, newVal := range repl {
|
||||
out = strings.Replace(out, old, newVal, 1)
|
||||
}
|
||||
return []byte(out)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -135,6 +136,47 @@ func TestPrepareFusionProjectMissingSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFusionProjectSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(t.TempDir(), "prep_first", "file", "paired", "report.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("prepareFusionProject: %v", err)
|
||||
}
|
||||
main, err := os.ReadFile(filepath.Join(fusionDir, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(main)
|
||||
for _, want := range []string{`runOrder = "prep_first"`, `payloadKind = "file"`, `mediaFileName = "report.pdf"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("patched main missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fusionDir, "go.mod")); err != nil {
|
||||
t.Fatalf("fusion go.mod not copied: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionMissingWorker(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{FusionMediaMode: "paired", FusionPayloadKind: "file"}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
_, err := h.buildFileFusion(context.Background(), buildDir, prep, filepath.Join(buildDir, "missing.exe"), req, win)
|
||||
if err == nil {
|
||||
t.Fatal("expected worker copy failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishFusionDeliverable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
h := &Handler{projectRoot: root}
|
||||
|
||||
@@ -60,3 +60,99 @@ func TestBuildFusionFromRequestPaired(t *testing.T) {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionPairedFakeGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF-1.4"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
TargetOS: "windows",
|
||||
FusionMediaMode: "paired",
|
||||
FusionPayloadKind: "file",
|
||||
FusionMediaBaseName: "report.pdf",
|
||||
}
|
||||
win := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, win)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFileFusion paired: %v", err)
|
||||
}
|
||||
if res == nil || res.LauncherPath == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
if _, err := os.Stat(res.LauncherPath); err != nil {
|
||||
t.Fatalf("launcher not created: %v", err)
|
||||
}
|
||||
if res.MediaName != "report.pdf" {
|
||||
t.Fatalf("media name: %q", res.MediaName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileFusionEmbeddedFakeGo(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.bin")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "clip.mkv")
|
||||
if err := os.WriteFile(prep, []byte("fake video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &BuildRequest{
|
||||
TargetOS: "linux",
|
||||
FusionMediaMode: "embedded",
|
||||
FusionPayloadKind: "file",
|
||||
}
|
||||
linux := BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}
|
||||
res, err := h.buildFileFusion(context.Background(), buildDir, prep, worker, req, linux)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFileFusion embedded: %v", err)
|
||||
}
|
||||
payloadBin := filepath.Join(buildDir, "fusion", "assets", "payload.bin")
|
||||
st, err := os.Stat(payloadBin)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded payload.bin missing: %v", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Fatal("embedded mode should copy payload into assets")
|
||||
}
|
||||
if res.MediaName != "clip.mkv" {
|
||||
t.Fatalf("media name: %q", res.MediaName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFusionWrapperFakeGoSuccess(t *testing.T) {
|
||||
h, database := testHandlerDB(t)
|
||||
defer database.Close()
|
||||
setFakeGoSuccess(t, h)
|
||||
|
||||
buildDir := t.TempDir()
|
||||
worker := filepath.Join(buildDir, "worker.exe")
|
||||
if err := os.WriteFile(worker, []byte("w"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prep := filepath.Join(t.TempDir(), "doc.pdf")
|
||||
if err := os.WriteFile(prep, []byte("%PDF"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, err := h.buildFusion(context.Background(), buildDir, prep, worker, "doc.pdf.exe", "parallel")
|
||||
if err != nil {
|
||||
t.Fatalf("buildFusion: %v", err)
|
||||
}
|
||||
if launcher == "" {
|
||||
t.Fatal("expected launcher path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@ type BuildArtifactFile struct {
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
||||
if len(arts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.BuildExtraFile, len(arts))
|
||||
for i, a := range arts {
|
||||
out[i] = models.BuildExtraFile{FileName: a.FileName, FilePath: a.FilePath}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
dataDir string
|
||||
@@ -647,6 +658,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
||||
FilePath: absPath,
|
||||
FileName: finalName,
|
||||
DownloadURL: dlURL,
|
||||
ExtraFiles: buildExtraFilesFromArtifacts(extraArtifacts),
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
|
||||
@@ -167,3 +167,30 @@ func TestShouldSignBuildNoRequestFlag(t *testing.T) {
|
||||
t.Fatal("SignBuild flag required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtraFilesFromArtifacts(t *testing.T) {
|
||||
if got := buildExtraFilesFromArtifacts(nil); got != nil {
|
||||
t.Fatalf("nil input should return nil, got %+v", got)
|
||||
}
|
||||
if got := buildExtraFilesFromArtifacts([]BuildArtifactFile{}); got != nil {
|
||||
t.Fatalf("empty slice should return nil, got %+v", got)
|
||||
}
|
||||
arts := []BuildArtifactFile{
|
||||
{FileName: "readme.txt", FilePath: "/tmp/readme.txt"},
|
||||
{FileName: "runner.exe", FilePath: "/tmp/runner.exe"},
|
||||
}
|
||||
got := buildExtraFilesFromArtifacts(arts)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 extras, got %d", len(got))
|
||||
}
|
||||
if got[0].FileName != "readme.txt" || got[0].FilePath != "/tmp/readme.txt" {
|
||||
t.Fatalf("first artifact: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFusionUniversalStartShBareExtension(t *testing.T) {
|
||||
sh := fusionUniversalStartSh(".pdf")
|
||||
if !strings.Contains(sh, "-runner") {
|
||||
t.Fatalf("expected runner suffix in script: %q", sh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,3 +67,52 @@ func setFakeGoFail(t *testing.T, h *Handler) {
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
// setFakeGoSuccess points goBinPath at a script that writes the -o output and exits 0.
|
||||
func setFakeGoSuccess(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-ok.bat")
|
||||
script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" +
|
||||
":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" +
|
||||
" set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" +
|
||||
"shift\r\ngoto loop\r\n:done\r\n" +
|
||||
"if defined OUT (\r\n" +
|
||||
" for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" +
|
||||
" echo fake>\"!OUT!\"\r\n" +
|
||||
")\r\nexit /b 0\r\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-ok.sh")
|
||||
script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" +
|
||||
" if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" +
|
||||
"done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
// setFakeGoSleep points goBinPath at a script that blocks long enough to test cancellation.
|
||||
func setFakeGoSleep(t *testing.T, h *Handler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
p := filepath.Join(dir, "go-sleep.bat")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\nping 127.0.0.1 -n 8 >nul\r\nexit /b 0\r\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
return
|
||||
}
|
||||
p := filepath.Join(dir, "go-sleep.sh")
|
||||
if err := os.WriteFile(p, []byte("#!/bin/sh\nsleep 8\nexit 0\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.goBinPath = p
|
||||
}
|
||||
|
||||
@@ -61,6 +61,27 @@ func TestBuildCRUDAndList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtraFilesRoundTrip(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
build := &models.BuildRecord{
|
||||
ID: "build-extra", WorkerName: "w", ServerURL: "u", Wallet: "w", CreatedAt: time.Now(),
|
||||
DownloadURL: "/api/v1/builds/build-extra/download",
|
||||
ExtraFiles: []models.BuildExtraFile{
|
||||
{FileName: "payload.enc", FilePath: "/data/payload.enc"},
|
||||
{FileName: "README.txt"},
|
||||
},
|
||||
}
|
||||
insertBuild(t, d, build)
|
||||
|
||||
got, err := d.GetBuild("build-extra")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.ExtraFiles) != 2 || got.ExtraFiles[0].FileName != "payload.enc" {
|
||||
t.Fatalf("extra_files mismatch: %+v", got.ExtraFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBuildNotFound(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
_, err := d.GetBuild("missing")
|
||||
|
||||
@@ -2,9 +2,11 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
@@ -115,6 +117,7 @@ func (d *Database) migrate() error {
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN extra_files TEXT NOT NULL DEFAULT '[]'`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
@@ -252,25 +255,50 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
|
||||
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
|
||||
if len(files) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
b, err := json.Marshal(files)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func decodeBuildExtraFiles(raw string) []models.BuildExtraFile {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
var files []models.BuildExtraFile
|
||||
if err := json.Unmarshal([]byte(raw), &files); err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
var pinnedInt int
|
||||
var extraFilesRaw string
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
b.Pinned = pinnedInt == 1
|
||||
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec(`INSERT INTO builds
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
||||
b.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
|
||||
b.FilePath, b.FileName, b.DownloadURL, encodeBuildExtraFiles(b.ExtraFiles), b.Platform, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -15,21 +17,47 @@ var retentionTickInterval = 6 * time.Hour
|
||||
// runRetentionFn is the work function invoked by StartRetentionJobs (overridable in tests).
|
||||
var runRetentionFn = runRetention
|
||||
|
||||
var (
|
||||
retentionMu sync.Mutex
|
||||
retentionCancel context.CancelFunc
|
||||
)
|
||||
|
||||
// StartRetentionJobs purges old stats and build artifacts on an interval.
|
||||
func StartRetentionJobs(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
if statsHours <= 0 && buildDays <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
retentionMu.Lock()
|
||||
retentionCancel = cancel
|
||||
retentionMu.Unlock()
|
||||
|
||||
go func() {
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
ticker := time.NewTicker(retentionTickInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runRetentionFn(database, dataDir, statsHours, buildDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopRetentionJobs stops the background retention loop started by StartRetentionJobs.
|
||||
func StopRetentionJobs() {
|
||||
retentionMu.Lock()
|
||||
cancel := retentionCancel
|
||||
retentionCancel = nil
|
||||
retentionMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func runRetention(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
if statsHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(statsHours) * time.Hour)
|
||||
|
||||
@@ -44,6 +44,7 @@ func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Ti
|
||||
|
||||
func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
t.Cleanup(StopRetentionJobs)
|
||||
StartRetentionJobs(d, t.TempDir(), 0, 0)
|
||||
// Disabled config must not start a goroutine that mutates data.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
@@ -53,6 +54,7 @@ func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
||||
|
||||
t.Cleanup(StopRetentionJobs)
|
||||
StartRetentionJobs(d, t.TempDir(), 24, 0)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
@@ -81,8 +83,8 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
||||
runRetentionFn = noopRetention
|
||||
t.Cleanup(func() {
|
||||
retentionTickInterval = prev
|
||||
// StartRetentionJobs has no stop handle; leave a noop so the leaked goroutine is harmless.
|
||||
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {}
|
||||
runRetentionFn = runRetention
|
||||
StopRetentionJobs()
|
||||
})
|
||||
|
||||
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
||||
@@ -97,6 +99,42 @@ func TestStartRetentionJobs_TickerInterval(t *testing.T) {
|
||||
t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
|
||||
}
|
||||
|
||||
func TestStopRetentionJobs_StopsBackgroundLoop(t *testing.T) {
|
||||
prev := retentionTickInterval
|
||||
retentionTickInterval = 40 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
retentionTickInterval = prev
|
||||
runRetentionFn = runRetention
|
||||
StopRetentionJobs()
|
||||
})
|
||||
|
||||
d := openTestDB(t)
|
||||
var passes int32
|
||||
runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {
|
||||
atomic.AddInt32(&passes, 1)
|
||||
}
|
||||
|
||||
StartRetentionJobs(d, t.TempDir(), 1, 0)
|
||||
|
||||
deadline := time.Now().Add(250 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&passes) >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if atomic.LoadInt32(&passes) < 2 {
|
||||
t.Fatalf("expected at least 2 passes before stop, got %d", passes)
|
||||
}
|
||||
|
||||
before := atomic.LoadInt32(&passes)
|
||||
StopRetentionJobs()
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&passes); got != before {
|
||||
t.Fatalf("expected no retention passes after stop, before=%d after=%d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
|
||||
|
||||
@@ -121,18 +121,24 @@ type Job struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type BuildExtraFile struct {
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
type BuildRecord struct {
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
ID string `json:"id"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileName string `json:"file_name"` // base filename for display
|
||||
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
|
||||
ExtraFiles []BuildExtraFile `json:"extra_files,omitempty"`
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Pinned bool `json:"pinned"` // true = this build is served by /get and /install.*
|
||||
// Pool settings
|
||||
|
||||
@@ -153,6 +153,7 @@ func TestBuildRecordJSONRoundTrip(t *testing.T) {
|
||||
Threads: 4, FileSize: 1024, BundleSize: 2048,
|
||||
FilePath: "/data/build.exe", FileName: "build.exe",
|
||||
DownloadURL: "/api/v1/builds/build-1/download", Platform: "windows",
|
||||
ExtraFiles: []BuildExtraFile{{FileName: "README.txt"}},
|
||||
CreatedAt: time.Now().UTC(), Pinned: true,
|
||||
PoolHost: "pool.example.com", PoolPort: 3333, PoolTLS: true, PoolPass: "x",
|
||||
})
|
||||
|
||||
@@ -94,6 +94,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load dashboard auth early so credentials print before slow startup steps.
|
||||
api.LoadUsers(cfg.DataDir)
|
||||
|
||||
// Initialize database
|
||||
database, err := db.New(cfg.DataDir)
|
||||
if err != nil {
|
||||
@@ -182,6 +185,7 @@ func main() {
|
||||
log.Println("Config handler initialized")
|
||||
|
||||
maintenance.StartRetentionJobs(database, cfg.DataDir, cfg.Server.StatsRetentionHours, cfg.Server.BuildRetentionDays)
|
||||
defer maintenance.StopRetentionJobs()
|
||||
|
||||
// Pre-connect default upstream pool from server config (Forge defaults seed from here)
|
||||
go func() {
|
||||
@@ -422,13 +426,23 @@ func resolveDataDir(dataDir, projectRoot string) string {
|
||||
return abs
|
||||
}
|
||||
|
||||
func projectRootMarker(dir string) bool {
|
||||
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findProjectRoot() string {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(cwd, "run.bat")); err == nil {
|
||||
if projectRootMarker(cwd) {
|
||||
return cwd
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(cwd), "run.bat")); err == nil {
|
||||
return filepath.Dir(cwd)
|
||||
parent := filepath.Dir(cwd)
|
||||
if projectRootMarker(parent) {
|
||||
return parent
|
||||
}
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
@@ -439,7 +453,7 @@ func findProjectRoot() string {
|
||||
filepath.Join(exeDir, "..", ".."),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(candidate, "run.bat")); err == nil {
|
||||
if projectRootMarker(candidate) {
|
||||
abs, _ := filepath.Abs(candidate)
|
||||
return abs
|
||||
}
|
||||
@@ -454,7 +468,7 @@ func findProjectRoot() string {
|
||||
// findWebRoot locates the frontend build output directory
|
||||
func findWebRoot() string {
|
||||
candidates := []string{
|
||||
"webroot", // Copied by run.bat
|
||||
"webroot", // Copied by devrun.bat
|
||||
"web/dist", // Vite build output relative to server/
|
||||
filepath.Join("..", "server", "web", "dist"), // Relative to project root
|
||||
filepath.Join("server", "web", "dist"), // From project root
|
||||
|
||||
@@ -47,13 +47,19 @@ func TestFindProjectRootFromServerDir(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := findProjectRoot()
|
||||
runBat := filepath.Join(root, "run.bat")
|
||||
if _, err := os.Stat(runBat); err != nil {
|
||||
t.Fatalf("findProjectRoot=%q missing run.bat: %v", root, err)
|
||||
found := false
|
||||
for _, name := range []string{"devrun.bat", "LAUNCH.bat", "run.bat"} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("findProjectRoot=%q missing devrun.bat/LAUNCH.bat/run.bat", root)
|
||||
}
|
||||
// When tests run from server/, root should be parent of cwd or cwd itself.
|
||||
if root != cwd && root != filepath.Dir(cwd) {
|
||||
t.Logf("findProjectRoot=%q cwd=%q (acceptable if run.bat layout differs)", root, cwd)
|
||||
t.Logf("findProjectRoot=%q cwd=%q (acceptable if marker layout differs)", root, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,68 @@ const OFFLINE_AGENT = {
|
||||
|
||||
test.describe('Remote actions UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/agents', async (route) => {
|
||||
if (route.request().method() === 'GET' && route.request().url().endsWith('/agents')) {
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
// AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS).
|
||||
await page.addInitScript((agent) => {
|
||||
const RealWS = WebSocket;
|
||||
const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket };
|
||||
g.__afRealWebSocket = RealWS;
|
||||
globalThis.WebSocket = function (url: string | URL, protocols?: string | string[]) {
|
||||
const urlStr = String(url);
|
||||
if (!urlStr.includes('/ws/dashboard')) {
|
||||
return new g.__afRealWebSocket!(url, protocols);
|
||||
}
|
||||
let openHandler: (() => void) | null = null;
|
||||
let messageHandler: ((ev: MessageEvent) => void) | null = null;
|
||||
let closeHandler: (() => void) | null = null;
|
||||
const sock = {
|
||||
readyState: 0,
|
||||
send() {},
|
||||
close() {
|
||||
sock.readyState = 3;
|
||||
closeHandler?.();
|
||||
},
|
||||
set onopen(fn: (() => void) | null) {
|
||||
openHandler = fn;
|
||||
},
|
||||
get onopen() {
|
||||
return openHandler;
|
||||
},
|
||||
set onmessage(fn: ((ev: MessageEvent) => void) | null) {
|
||||
messageHandler = fn;
|
||||
},
|
||||
get onmessage() {
|
||||
return messageHandler;
|
||||
},
|
||||
set onclose(fn: (() => void) | null) {
|
||||
closeHandler = fn;
|
||||
},
|
||||
get onclose() {
|
||||
return closeHandler;
|
||||
},
|
||||
set onerror(_fn: (() => void) | null) {},
|
||||
get onerror() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
sock.readyState = 1;
|
||||
openHandler?.();
|
||||
messageHandler?.({
|
||||
data: JSON.stringify({ type: 'init', payload: { agents: [agent] } }),
|
||||
} as MessageEvent);
|
||||
});
|
||||
return sock as unknown as WebSocket;
|
||||
} as unknown as typeof WebSocket;
|
||||
globalThis.WebSocket.OPEN = 1;
|
||||
globalThis.WebSocket.CONNECTING = 0;
|
||||
globalThis.WebSocket.CLOSED = 3;
|
||||
}, OFFLINE_AGENT);
|
||||
await page.route(/\/api\/v1\/agents$/, async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
});
|
||||
await page.route('**/api/v1/agents/*/stats*', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import App, { PageFallback } from './App';
|
||||
import { routerFuture } from './routerFuture';
|
||||
|
||||
vi.mock('./context/WebSocketProvider', () => ({
|
||||
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
@@ -42,7 +43,7 @@ describe('App route config', () => {
|
||||
it('redirects / to dashboard and /builder to forge', () => {
|
||||
function RedirectProbe({ path }: { path: string }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<MemoryRouter initialEntries={[path]} future={routerFuture}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
|
||||
@@ -61,7 +62,7 @@ describe('App route config', () => {
|
||||
|
||||
it('renders crucible route via App shell', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/crucible']}>
|
||||
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -8,13 +8,19 @@ export function getStoredAuth(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAuth(username: string, password: string) {
|
||||
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
|
||||
const token = btoa(`${username}:${password}`);
|
||||
sessionStorage.setItem(AUTH_KEY, token);
|
||||
if (!opts?.silent) {
|
||||
window.dispatchEvent(new Event('aetherforge-auth'));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAuth() {
|
||||
export function clearStoredAuth(opts?: { silent?: boolean }) {
|
||||
sessionStorage.removeItem(AUTH_KEY);
|
||||
if (!opts?.silent) {
|
||||
window.dispatchEvent(new Event('aetherforge-auth'));
|
||||
}
|
||||
}
|
||||
|
||||
export function authHeaders(): Record<string, string> {
|
||||
|
||||
@@ -263,6 +263,16 @@ describe('api client', () => {
|
||||
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
|
||||
});
|
||||
|
||||
it('rotateFleetSecret POSTs rotate endpoint', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, hint: 'abcd1234...' }));
|
||||
|
||||
const res = await api.rotateFleetSecret();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(lastFetch().url).toBe('/api/v1/server/rotate-secret');
|
||||
expect(lastFetch().init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('getXmrPrice and getServerInfo', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))
|
||||
|
||||
@@ -3,6 +3,9 @@ import { authHeaders } from './auth';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
|
||||
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const { headers: extraHeaders, ...rest } = options ?? {};
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
@@ -118,6 +121,8 @@ export const api = {
|
||||
// Health / server
|
||||
healthCheck: () => fetchJSON<{ status: string }>('/health'),
|
||||
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
|
||||
rotateFleetSecret: () =>
|
||||
fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }),
|
||||
|
||||
// Fleet ops
|
||||
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/re
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { type ReactNode } from 'react';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
import { downloadApiFile, downloadAuthedFile } from '../api/download';
|
||||
@@ -169,7 +170,9 @@ describe('DownloadButton', () => {
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.setup().click(btn);
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
});
|
||||
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
|
||||
});
|
||||
|
||||
@@ -645,7 +648,7 @@ describe('VisualComponents', () => {
|
||||
|
||||
it('ForgeCalibrateCompare links to routes', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<ForgeCalibrateCompare />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -697,7 +700,7 @@ describe('SystemStatusBar', () => {
|
||||
|
||||
it('shows server and fleet pills after poll', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<SystemStatusBar />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -780,7 +783,7 @@ describe('Layout', () => {
|
||||
|
||||
it('renders nav links and children', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
|
||||
<Layout>
|
||||
<div>page body</div>
|
||||
</Layout>
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('WebSocketProvider', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
MockWebSocket.instances = [];
|
||||
setStoredAuth('testuser', 'testpass', { silent: true });
|
||||
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', host: 'localhost:8080' },
|
||||
@@ -69,6 +70,7 @@ describe('WebSocketProvider', () => {
|
||||
|
||||
it('connects to ws dashboard with auth token query param', () => {
|
||||
setStoredAuth('drjones', 'secret');
|
||||
MockWebSocket.instances = [];
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
const ws = latestSocket();
|
||||
@@ -79,10 +81,11 @@ describe('WebSocketProvider', () => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('connects without token when logged out', () => {
|
||||
clearStoredAuth();
|
||||
it('does not connect when logged out', () => {
|
||||
clearStoredAuth({ silent: true });
|
||||
MockWebSocket.instances = [];
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
expect(latestSocket().url).toBe('ws://localhost:8080/ws/dashboard');
|
||||
expect(MockWebSocket.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('useWebSocket re-exports context hook', () => {
|
||||
@@ -160,6 +163,7 @@ describe('WebSocketProvider', () => {
|
||||
|
||||
it('schedules reconnect after close', () => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
const first = latestSocket();
|
||||
|
||||
|
||||
@@ -40,14 +40,25 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
|
||||
const token = getStoredAuth();
|
||||
if (!token) {
|
||||
const existing = wsRef.current;
|
||||
if (existing) {
|
||||
existing.onclose = null;
|
||||
existing.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getStoredAuth();
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
@@ -57,6 +68,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
if (unmounted.current) return;
|
||||
setIsConnected(false);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (!getStoredAuth()) return;
|
||||
reconnectTimer.current = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
@@ -201,8 +213,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
connect();
|
||||
const onAuthChange = () => connect();
|
||||
window.addEventListener('aetherforge-auth', onAuthChange);
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
window.removeEventListener('aetherforge-auth', onAuthChange);
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
const ws = wsRef.current;
|
||||
if (ws) { ws.onclose = null; ws.close(); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FORGE_BUILD_DEFAULTS, forgeDefaultsFromServer } from './forgeDefaults';
|
||||
import { FORGE_BUILD_DEFAULTS, DEFAULT_PUBLIC_TUNNEL, forgeDefaultsFromServer } from './forgeDefaults';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
describe('FORGE_BUILD_DEFAULTS', () => {
|
||||
@@ -47,12 +47,12 @@ describe('forgeDefaultsFromServer', () => {
|
||||
expect(result.server_url).toBe('https://tunnel.example.com');
|
||||
});
|
||||
|
||||
it('falls back to suggested_url when public_url is blank', () => {
|
||||
it('falls back to baked tunnel URL when public_url is blank', () => {
|
||||
const config = mockServerConfig({
|
||||
server: { public_url: ' ' },
|
||||
});
|
||||
const result = forgeDefaultsFromServer(config, mockServerInfo);
|
||||
expect(result.server_url).toBe(mockServerInfo.suggested_url);
|
||||
expect(result.server_url).toBe(DEFAULT_PUBLIC_TUNNEL);
|
||||
});
|
||||
|
||||
it('reflects obfuscate and sign defaults from server config', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
|
||||
|
||||
/** Baked Cloudflare tunnel — used when Calibrate public_url is blank. */
|
||||
export const DEFAULT_PUBLIC_TUNNEL = 'https://killa.thetempleofdoom.com';
|
||||
|
||||
/** Defaults for a new forge build — not stored in Calibrate. */
|
||||
export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
BuildRequest,
|
||||
@@ -62,7 +65,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url,
|
||||
server_url: publicUrl || DEFAULT_PUBLIC_TUNNEL || serverInfo.suggested_url,
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
|
||||
@@ -27,11 +27,11 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
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).',
|
||||
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.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.',
|
||||
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.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:
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { routerFuture } from './routerFuture';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import './styles/global.css';
|
||||
import './styles/steampunk-theme.css';
|
||||
@@ -21,7 +22,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={routerFuture}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -12,6 +12,7 @@ import BuildManagerPage, {
|
||||
truncateWallet,
|
||||
} from './BuildManagerPage';
|
||||
import { api } from '../api/client';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
vi.mock('../components/Fleet/LanDownloadQR', () => ({
|
||||
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
|
||||
@@ -82,7 +83,7 @@ describe('BuildManagerPage', () => {
|
||||
|
||||
it('renders build list after load', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<BuildManagerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -221,7 +221,7 @@ function BuildCard({
|
||||
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
|
||||
<div className="bm-downloads-row">
|
||||
<DownloadButton
|
||||
apiPath={api.buildDownloadUrl(build.id)}
|
||||
apiPath={build.download_url || api.buildDownloadUrl(build.id)}
|
||||
filename={exeName}
|
||||
className="btn btn-primary bm-dl-btn"
|
||||
>
|
||||
@@ -234,6 +234,16 @@ function BuildCard({
|
||||
>
|
||||
↓ Uninstall script
|
||||
</AuthDownloadButton>
|
||||
{build.extra_files?.map((f) => (
|
||||
<DownloadButton
|
||||
key={f.file_name}
|
||||
apiPath={api.buildArtifactUrl(build.id, f.file_name)}
|
||||
filename={f.file_name}
|
||||
className="btn btn-outline bm-dl-btn"
|
||||
>
|
||||
↓ {f.file_name}
|
||||
</DownloadButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuilderPage, { formatBytes } from './BuilderPage';
|
||||
import { ForgeProvider } from '../context/ForgeContext';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function renderBuilder(initialEntries = ['/forge']) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<MemoryRouter initialEntries={initialEntries} future={routerFuture}>
|
||||
<ForgeProvider>
|
||||
<BuilderPage />
|
||||
</ForgeProvider>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ForgeDeliverable,
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { buildRequestFromRecord } from '../help/buildManager';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import {
|
||||
@@ -272,6 +272,7 @@ export default function BuilderPage() {
|
||||
try {
|
||||
const data = await api.getBlueprint(name);
|
||||
setCompareBlueprint(data as Record<string, unknown>);
|
||||
setBlueprintName(name);
|
||||
// Merge loaded data into form, preserving any fields not in the blueprint
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setShowBlueprints(false);
|
||||
@@ -341,7 +342,9 @@ export default function BuilderPage() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
const data = JSON.parse(evt.target?.result as string) as Record<string, unknown>;
|
||||
setCompareBlueprint(data);
|
||||
setBlueprintName(file.name);
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
@@ -594,6 +597,10 @@ export default function BuilderPage() {
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
const blueprintChanges = useMemo(() => {
|
||||
if (!compareBlueprint || !form) return [];
|
||||
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
|
||||
}, [compareBlueprint, form]);
|
||||
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
|
||||
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||
|
||||
@@ -754,6 +761,44 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compareBlueprint && blueprintChanges.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>
|
||||
Blueprint diff — {blueprintName || 'loaded blueprint'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => {
|
||||
setCompareBlueprint(null);
|
||||
setBlueprintName('');
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Fields that differ from the loaded blueprint (your previous form values were kept where they conflict).
|
||||
</p>
|
||||
<ul className="blueprint-diff-list" style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
|
||||
{blueprintChanges.map((row) => (
|
||||
<li key={row.key}>
|
||||
<code>{row.key}</code>
|
||||
{' — '}
|
||||
{row.kind === 'added' && <span>kept from form</span>}
|
||||
{row.kind === 'removed' && <span>not in current form</span>}
|
||||
{row.kind === 'changed' && (
|
||||
<span>
|
||||
blueprint → current
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blueprint picker panel */}
|
||||
{showBlueprints && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
@@ -2022,6 +2067,19 @@ export default function BuilderPage() {
|
||||
↓ Download
|
||||
</DownloadButton>
|
||||
)}
|
||||
{lastBuild.build_id &&
|
||||
lastBuild.extra_files?.map((f) =>
|
||||
f.file_name ? (
|
||||
<DownloadButton
|
||||
key={f.file_name}
|
||||
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
|
||||
filename={f.file_name}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
↓ {f.file_name}
|
||||
</DownloadButton>
|
||||
) : null
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
|
||||
@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import DashboardPage, { formatShareTime } from './DashboardPage';
|
||||
import { mockAgent, mockShare } from '../test/fixtures';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -41,7 +42,7 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
|
||||
function renderDashboard() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
|
||||
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Share } from '../types';
|
||||
import HashrateChart from '../components/Charts/HashrateChart';
|
||||
import GaugeRing from '../components/Charts/GaugeRing';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
|
||||
@@ -21,8 +20,14 @@ import {
|
||||
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
|
||||
import FleetToolbar from '../components/Fleet/FleetToolbar';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
|
||||
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
|
||||
|
||||
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
|
||||
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
|
||||
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
|
||||
|
||||
function ChartPlaceholder({ height }: { height: number }) {
|
||||
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
|
||||
}
|
||||
import {
|
||||
DEFAULT_FLEET_FILTERS,
|
||||
filterFleetAgents,
|
||||
@@ -458,24 +463,28 @@ export default function DashboardPage() {
|
||||
{/* ── Advanced-only panels ─────────────────────────────────────────────── */}
|
||||
{advancedMode && <AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />}
|
||||
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="cyan" tilt3d>
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="purple" tilt3d>
|
||||
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
|
||||
{advancedMode && (
|
||||
<Suspense fallback={<ChartPlaceholder height={300} />}>
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="magenta" tilt3d>
|
||||
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
|
||||
<NeonCard accent="cyan" tilt3d>
|
||||
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="brass" tilt3d>
|
||||
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
|
||||
<NeonCard accent="purple" tilt3d>
|
||||
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
{advancedMode && (
|
||||
<Suspense fallback={<ChartPlaceholder height={220} />}>
|
||||
<div className="grid-2 chart-row">
|
||||
<NeonCard accent="magenta" tilt3d>
|
||||
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="brass" tilt3d>
|
||||
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
|
||||
</NeonCard>
|
||||
</div>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<NeonCard accent="purple" className="section" hud>
|
||||
@@ -494,7 +503,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FleetTopologyMap agents={agents} />
|
||||
<Suspense fallback={<ChartPlaceholder height={360} />}>
|
||||
<FleetTopologyMap agents={agents} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
||||
@@ -622,7 +633,11 @@ export default function DashboardPage() {
|
||||
</NeonCard>
|
||||
</section>
|
||||
)}
|
||||
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
|
||||
{showMatrix && (
|
||||
<Suspense fallback={null}>
|
||||
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GuidePage from './GuidePage';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
describe('GuidePage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders field guide hero and pipeline section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<GuidePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth';
|
||||
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
|
||||
import type { ServerConfig } from '../types';
|
||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
@@ -159,13 +159,7 @@ export default function SettingsPage() {
|
||||
setRotatingSecret(true);
|
||||
setRotateMsg('');
|
||||
try {
|
||||
await fetch('/api/v1/server/rotate-secret', {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders() },
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
});
|
||||
await api.rotateFleetSecret();
|
||||
setRotateMsg('Secret rotated. Re-forge all agents to reconnect.');
|
||||
} catch (e: unknown) {
|
||||
setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e)));
|
||||
|
||||
4
server/web/src/routerFuture.ts
Normal file
4
server/web/src/routerFuture.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const routerFuture = {
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
} as const;
|
||||
@@ -179,6 +179,7 @@ describe('types/index — BuildRecord / Build alias', () => {
|
||||
pool_port: 443,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
download_url: '/api/v1/builds/build-uuid/download',
|
||||
};
|
||||
|
||||
it('Build alias is assignable from BuildRecord', () => {
|
||||
@@ -193,8 +194,8 @@ describe('types/index — BuildRecord / Build alias', () => {
|
||||
file_name: 'worker-1.exe',
|
||||
platform: 'windows',
|
||||
bundle_size: 2048000,
|
||||
download_url: '/api/v1/builds/build-uuid/download',
|
||||
pinned: true,
|
||||
extra_files: [{ file_name: 'README.txt' }],
|
||||
};
|
||||
expect(extended.pinned).toBe(true);
|
||||
expect(extended.bundle_size).toBeGreaterThan(extended.file_size);
|
||||
|
||||
@@ -113,6 +113,11 @@ export interface ServerInfo {
|
||||
websocket_url: string;
|
||||
}
|
||||
|
||||
export interface BuildExtraFile {
|
||||
file_name: string;
|
||||
file_path?: string;
|
||||
}
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
worker_name: string;
|
||||
@@ -129,7 +134,9 @@ export interface BuildRecord {
|
||||
pool_pass: string;
|
||||
platform?: string;
|
||||
bundle_size?: number;
|
||||
download_url?: string;
|
||||
/** Always set by the server (defaults to /builds/{id}/download when not a ZIP artifact). */
|
||||
download_url: string;
|
||||
extra_files?: BuildExtraFile[];
|
||||
/** When true this build is served by /get and /install.* dropper endpoints */
|
||||
pinned?: boolean;
|
||||
}
|
||||
@@ -390,7 +397,7 @@ export interface BuildResponse {
|
||||
error?: string;
|
||||
fusion_enabled?: boolean;
|
||||
fusion_export_dir?: string;
|
||||
extra_files?: { file_name: string; file_path?: string }[];
|
||||
extra_files?: BuildExtraFile[];
|
||||
bundle_file_name?: string;
|
||||
bundle_download_url?: string;
|
||||
bundle_size?: number;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type {
|
||||
WSAgentLog,
|
||||
WSAgentOffline,
|
||||
@@ -10,13 +13,66 @@ import type {
|
||||
} from './ws';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
const fixtureDir = dirname(fileURLToPath(import.meta.url));
|
||||
const goWSFixture = JSON.parse(
|
||||
readFileSync(join(fixtureDir, '../../../internal/api/testdata/ws_types_fixture.json'), 'utf8'),
|
||||
) as Record<string, string[]>;
|
||||
|
||||
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
function sampleValue(field: string): unknown {
|
||||
if (field === 'agents') return [];
|
||||
if (field === 'success') return true;
|
||||
if (field === 'line' || field === 'content' || field === 'message' || field === 'action' || field === 'agent_id') {
|
||||
return 'sample';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildSample(fields: string[]): Record<string, unknown> {
|
||||
const sample: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
sample[field] = field === 'agents' ? [mockAgent()] : sampleValue(field);
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
describe('types/ws payloads', () => {
|
||||
it('shared payload keys match Go ws_types.go fixture', () => {
|
||||
for (const [typeName, fields] of Object.entries(goWSFixture)) {
|
||||
expect(fields).toEqual([...fields].sort());
|
||||
const sample = buildSample(fields);
|
||||
expectKeys(sample, fields);
|
||||
|
||||
switch (typeName) {
|
||||
case 'WSDashboardInit':
|
||||
void (sample as WSDashboardInit);
|
||||
break;
|
||||
case 'WSAgentOffline':
|
||||
void (sample as WSAgentOffline);
|
||||
break;
|
||||
case 'WSStatsUpdate':
|
||||
void (sample as WSStatsUpdate);
|
||||
break;
|
||||
case 'WSCommandResult':
|
||||
void (sample as WSCommandResult);
|
||||
break;
|
||||
case 'WSAgentLog':
|
||||
void (sample as WSAgentLog);
|
||||
break;
|
||||
case 'WSServerLog':
|
||||
void (sample as WSServerLog);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unexpected WS type in Go fixture: ${typeName}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('WSDashboardInit carries agents array', () => {
|
||||
const init: WSDashboardInit = { agents: [mockAgent()] };
|
||||
expectKeys(init as unknown as Record<string, unknown>, ['agents']);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Agent, AgentService } from '../types';
|
||||
|
||||
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
|
||||
/**
|
||||
* Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go
|
||||
* Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog
|
||||
* Cross-language drift guard: server/internal/api/testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts)
|
||||
*/
|
||||
export interface WSDashboardInit {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
@@ -22,5 +22,20 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (/node_modules[/\\](three|@react-three)/.test(id)) {
|
||||
return 'three';
|
||||
}
|
||||
if (/node_modules[/\\]recharts/.test(id)) {
|
||||
return 'recharts';
|
||||
}
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user