Final sweep: Crucible fixes, Path Tracer polish, forge progress, tests green.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Align dashboard subtitle default and UpsertAgent tests with fleet label behavior; WebSocket coalesce and PathForge hardening; Crucible expanded ops and visual DV fixes; Vitest 610/610 and full test-suite pass; trim PROBLEMS.md to open items only.
This commit is contained in:
AetherForge
2026-06-06 18:07:47 -07:00
parent e65753ce49
commit 6372b07e6c
40 changed files with 1495 additions and 794 deletions

View File

@@ -224,7 +224,7 @@ func DefaultConfig() *Config {
LogShareSubmissions: false,
LogPoolTraffic: false,
StrictWalletValidation: false,
DashboardSubtitle: "security is just an emotion",
DashboardSubtitle: "Fleet Command & Control",
OpenFirewallOnStart: true,
ObfuscateDefault: false,
SignEnabled: false,

View File

@@ -50,7 +50,7 @@ func TestDefaultConfigServerDefaults(t *testing.T) {
if !cfg.Server.LogAgentConnections {
t.Fatal("log_agent_connections should default true")
}
if cfg.Server.DashboardSubtitle != "security is just an emotion" {
if cfg.Server.DashboardSubtitle != "Fleet Command & Control" {
t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle)
}
if cfg.DefaultAgent.Threads != 4 {

View File

@@ -623,6 +623,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
if pathForgeHandler != nil {
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
}
r.Get("/builder/progress/{token}", builderHandler.ServeProgress)
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
token := chi.URLParam(req, "token")
if builderHandler.CancelBuild(token) {

View File

@@ -688,11 +688,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
// Connect to pool in background — do NOT block the auth_response.
// The agent can start and the pool proxy will be ready by the time
// the first share is submitted.
// Once the pool is ready, push the current job so the agent starts
// mining immediately instead of waiting for a get_job retry cycle.
go func(pc pool.Config, bcs []pool.Config, aid string) {
if _, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs); err != nil {
proxy, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs)
if err != nil {
log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err)
return
}
if job := proxy.GetCurrentJob(); job != nil {
if wErr := h.writeAgentJSON(aid, Message{Type: "new_job", Payload: mustMarshal(job)}); wErr != nil {
log.Printf("[WS] Push initial job to agent %s: %v", aid, wErr)
}
}
}(poolCfg, backupCfgs, agentID)
}
@@ -794,10 +801,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
go h.runPingLoopAgent(ac)
}
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
"agent_id": agentID,
})})
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
"agent_id": agentID,
})})
// Auto-start mining: ensure the agent isn't stuck in a paused
// state from a previous session. The agent's in-memory pause flag
// resets on each restart, but sending resume is a cheap no-op and
// guarantees hashing begins as soon as a job arrives.
if h.agentPoolConfig(agentID).Wallet != "" {
_ = h.writeAgentJSON(agentID, Message{
Type: "command",
Payload: mustMarshal(map[string]interface{}{"action": "resume"}),
})
}
// Enrich agent with hostname before broadcasting so the dashboard
// immediately shows the correct machine-specific display name.

View File

@@ -420,3 +420,182 @@ func TestWSHubConnectedAgentCount(t *testing.T) {
t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
}
}
// TestAgentNamePreservedOnReconnect checks that an operator-assigned display
// name is not overwritten by the machine hostname when the agent reconnects.
func TestAgentNamePreservedOnReconnect(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
// Seed the DB with an agent whose name was customised by the operator.
// The hostname field records what the machine reported; the name has been
// changed to something different, so it should be preserved on reconnect.
if err := database.UpsertAgent(&models.Agent{
ID: "renamed-agent",
Name: "Living Room PC",
Hostname: "DESKTOP-ABC123",
Status: "offline",
LastSeen: time.Now().Add(-5 * time.Minute),
}); err != nil {
t.Fatal(err)
}
// Agent reconnects — it reports the same hostname.
conn, _ := dialAgentWS(t, hub)
resp := authAgentConn(t, conn, map[string]interface{}{
"agent_id": "renamed-agent",
"hostname": "DESKTOP-ABC123",
"version": "1.0",
})
var body map[string]interface{}
if err := json.Unmarshal(resp.Payload, &body); err != nil {
t.Fatal(err)
}
if body["success"] != true {
t.Fatalf("auth should succeed: %+v", body)
}
// Give the auth handler a moment to commit the upsert.
time.Sleep(30 * time.Millisecond)
agent, err := database.GetAgent("renamed-agent")
if err != nil {
t.Fatal(err)
}
if agent.Name != "Living Room PC" {
t.Errorf("operator name should be preserved; got %q", agent.Name)
}
}
// TestAgentNameUpdatesFromHostnameWhenDefault verifies that the name IS updated
// when it was never customised (name == hostname, i.e. the default).
// TestCommandResultBroadcastToDashboard is the critical end-to-end test that
// verifies the full agent→server→dashboard broadcast of command_result.
// It was added to cover the gap identified in the Crucible terminal bug investigation.
func TestCommandResultBroadcastToDashboard(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
// ── Connect dashboard WS ──────────────────────────────────────────────
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
// Read all dashboard messages in a goroutine to avoid blocking and to
// keep the connection alive (no SetReadDeadline, which would permanently
// corrupt the gorilla/websocket connection on timeout).
type msgResult struct {
body map[string]interface{}
err string
}
cmdResultCh := make(chan msgResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
cmdResultCh <- msgResult{err: err.Error()}
return
}
if msg.Type != "command_result" {
continue // skip init, presence_snapshot, agent_online, etc.
}
var body map[string]interface{}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()}
return
}
cmdResultCh <- msgResult{body: body}
return
}
}()
// ── Connect + authenticate agent WS ──────────────────────────────────
agentID := "e2e-agent-001"
agentConn := connectTestAgent(t, hub, agentID)
// ── Agent sends command_result ────────────────────────────────────────
cmdPayload, _ := json.Marshal(map[string]interface{}{
"action": "exec",
"success": true,
"message": "hello from agent",
})
if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
t.Fatalf("send command_result: %v", err)
}
// ── Dashboard must receive the broadcast ─────────────────────────────
select {
case r := <-cmdResultCh:
if r.err != "" {
t.Fatalf("dashboard did not receive command_result: %s", r.err)
}
if r.body["agent_id"] != agentID {
t.Errorf("agent_id: got %v, want %v", r.body["agent_id"], agentID)
}
if r.body["action"] != "exec" {
t.Errorf("action: got %v, want exec", r.body["action"])
}
if r.body["success"] != true {
t.Errorf("success: got %v, want true", r.body["success"])
}
if r.body["message"] != "hello from agent" {
t.Errorf("message: got %v, want 'hello from agent'", r.body["message"])
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for command_result broadcast")
}
}
func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
// Seed an agent whose name equals the old hostname (the default, un-renamed case).
if err := database.UpsertAgent(&models.Agent{
ID: "default-name-agent",
Name: "OLD-HOSTNAME",
Hostname: "OLD-HOSTNAME",
Status: "offline",
LastSeen: time.Now().Add(-5 * time.Minute),
}); err != nil {
t.Fatal(err)
}
// Agent reconnects with a new hostname (e.g. machine was renamed).
conn, _ := dialAgentWS(t, hub)
authAgentConn(t, conn, map[string]interface{}{
"agent_id": "default-name-agent",
"hostname": "NEW-HOSTNAME",
"version": "1.0",
})
time.Sleep(30 * time.Millisecond)
agent, err := database.GetAgent("default-name-agent")
if err != nil {
t.Fatal(err)
}
if agent.Name != "NEW-HOSTNAME" {
t.Errorf("default name should follow hostname update; got %q", agent.Name)
}
}

View File

@@ -25,6 +25,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
if err := os.MkdirAll(agentDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
}
h.setProgress(req.CancelToken, "Copying source files", 5)
if err := h.copyAgentSource(agentDir); err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
@@ -32,7 +33,10 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
platforms := platformsForRequest(req)
workerPaths := map[string]string{}
for _, p := range platforms {
total := len(platforms)
for i, p := range platforms {
pct := 14 + (i*56)/total
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct)
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil {
cleanupBuild()
@@ -40,6 +44,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
}
workerPaths[p.Label()] = wp
}
h.setProgress(req.CancelToken, "Compiled all targets", 72)
if req.SpreadKit && !req.FusionEnabled {
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
@@ -54,6 +59,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
}
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
h.setProgress(req.CancelToken, "Packaging spread kit", 78)
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
var subdir string
@@ -117,6 +123,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
zipBytes = zipSt.Size()
}
h.setProgress(req.CancelToken, "Saving to database", 99)
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, FileName: zipName, CreatedAt: time.Now(),
@@ -145,6 +152,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
}
func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
h.setProgress(req.CancelToken, "Building fusion deliverables", 78)
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
// Resolve payload display name (used for runner naming and ZIP title)
@@ -258,6 +266,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
zipBytes2 = zipSt2.Size()
}
h.setProgress(req.CancelToken, "Saving to database", 99)
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, FileName: zipName, CreatedAt: time.Now(),

View File

@@ -153,6 +153,12 @@ type BuildArtifactFile struct {
FilePath string `json:"file_path,omitempty"`
}
// BuildProgress is returned by GET /builder/progress/{token} while a forge is running.
type BuildProgress struct {
Stage string `json:"stage"`
Pct int `json:"pct"`
}
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
if len(arts) == 0 {
return nil
@@ -181,6 +187,11 @@ type Handler struct {
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
activeCancelsMu sync.Mutex
activeCancels map[string]context.CancelFunc
// Real-time build progress — maps cancel_token → current stage so the frontend
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
activeProgressMu sync.RWMutex
activeProgress map[string]BuildProgress
}
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
@@ -231,6 +242,46 @@ func (h *Handler) unregisterCancel(token string) {
h.activeCancelsMu.Unlock()
}
// setProgress records the current forge stage so the frontend can poll it.
func (h *Handler) setProgress(token, stage string, pct int) {
if token == "" {
return
}
h.activeProgressMu.Lock()
if h.activeProgress == nil {
h.activeProgress = make(map[string]BuildProgress)
}
h.activeProgress[token] = BuildProgress{Stage: stage, Pct: pct}
h.activeProgressMu.Unlock()
}
func (h *Handler) clearProgress(token string) {
if token == "" {
return
}
h.activeProgressMu.Lock()
delete(h.activeProgress, token)
h.activeProgressMu.Unlock()
}
// ServeProgress returns the current build stage for a running forge identified by its cancel token.
// The frontend polls this every second to drive a real progress bar instead of a client-side simulation.
func (h *Handler) ServeProgress(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.Error(w, "token required", http.StatusBadRequest)
return
}
h.activeProgressMu.RLock()
prog, ok := h.activeProgress[token]
h.activeProgressMu.RUnlock()
if !ok {
writeJSON(w, http.StatusNotFound, BuildProgress{Stage: "", Pct: 0})
return
}
writeJSON(w, http.StatusOK, prog)
}
type SignPolicy struct {
Enabled bool `json:"enabled"`
CertThumbprint string `json:"cert_thumbprint"`
@@ -346,6 +397,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancelFn = context.WithCancel(ctx)
h.registerCancel(req.CancelToken, cancelFn)
defer h.unregisterCancel(req.CancelToken)
defer h.clearProgress(req.CancelToken)
}
// FusionOutputName will be derived from the payload filename if not set
@@ -539,12 +591,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
}
h.setProgress(req.CancelToken, "Copying source files", 5)
if err := h.copyAgentSource(agentDir); err != nil {
cleanupBuild()
log.Printf("Failed to copy agent source: %v", err)
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
}
h.setProgress(req.CancelToken, "Configuring build", 14)
configDir := filepath.Join(agentDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
cleanupBuild()
@@ -553,18 +607,21 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
platforms := platformsForRequest(req)
p := platforms[0]
h.setProgress(req.CancelToken, "Compiling agent", 20)
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil {
cleanupBuild()
log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
workerName := filepath.Base(outputPath)
finalPath := outputPath
finalName := workerName
var fusionEnabled bool
h.setProgress(req.CancelToken, "Writing scripts", 76)
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
if err != nil {
cleanupBuild()
@@ -577,12 +634,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
if req.FusionPayloadKind == "" {
req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
}
h.setProgress(req.CancelToken, "Building fusion bundle", 80)
var err error
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
if err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
h.setProgress(req.CancelToken, "Fusion bundle ready", 88)
finalPath = fusionRes.LauncherPath
finalName = filepath.Base(finalPath)
fusionEnabled = true
@@ -658,6 +717,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
}
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
} else {
h.setProgress(req.CancelToken, "Publishing build", 91)
var err error
exportPath, err = h.publishRootExecutable(finalPath, finalName)
if err != nil {
@@ -680,6 +740,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
signed := false
if h.shouldSignBuild(req) {
h.setProgress(req.CancelToken, "Signing binary", 95)
if err := h.signExecutable(finalPath); err != nil {
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
}
@@ -692,6 +753,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
scrambled := false
fingerprint := ""
if shouldSigilScramble(req) {
h.setProgress(req.CancelToken, "Scrambling sigil", 97)
fp, err := ApplySigilScramble(finalPath, buildID)
if err != nil {
log.Printf("[Forge] sigil scramble: %v", err)
@@ -750,6 +812,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
PoolTLS: req.PoolTLS,
PoolPass: req.PoolPass,
}
h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(buildRecord); err != nil {
log.Printf("Failed to record build: %v", err)
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""

View File

@@ -125,7 +125,11 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
res := &PathForgeResult{}
ctx := r.Context()
err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || d.IsDir() {
return nil
}

View File

@@ -1,7 +1,9 @@
package builder
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -82,3 +84,136 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) {
}
}
}
// TestPathForgeContextCancel verifies that cancelling the request context stops
// the walk gracefully without hanging or panicking. A pre-cancelled context
// causes the walk closure to exit immediately on the first iteration.
func TestPathForgeContextCancel(t *testing.T) {
root := t.TempDir()
for i := 0; i < 5; i++ {
name := fmt.Sprintf("video%d.mkv", i)
if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
}
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancel so the walk exits at the first check
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) // must return promptly, not hang
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatalf("response decode: %v", err)
}
// With a pre-cancelled context the walk stops before placing any files.
if res.Placed != 0 {
t.Errorf("expected 0 placements with cancelled context, got %d", res.Placed)
}
t.Logf("context cancel: placed=%d total=%d errors=%d", res.Placed, res.Total, res.Errors)
}
// TestPathForgePartialPlacementErrorCount verifies that the Placed counter only
// reflects successfully placed files; a read-only directory causes placement
// failure for that subtree while other directories succeed.
func TestPathForgePartialPlacementErrorCount(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("read-only directory permission simulation is not reliable on Windows")
}
root := t.TempDir()
dir1 := filepath.Join(root, "good")
dir2 := filepath.Join(root, "locked")
if err := os.MkdirAll(dir1, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dir2, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir1, "clip.mkv"), []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir2, "film.mkv"), []byte("video"), 0644); err != nil {
t.Fatal(err)
}
// Make dir2 read-only so companion files cannot be written there.
if err := os.Chmod(dir2, 0555); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chmod(dir2, 0755) }()
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if res.Total != 2 {
t.Fatalf("expected 2 total media files, got %d", res.Total)
}
// dir1 succeeds; dir2 is read-only so it fails → Placed must not double-count.
if res.Placed < 1 {
t.Errorf("expected at least 1 placed (from dir1), got %d", res.Placed)
}
if res.Errors == 0 {
t.Errorf("expected at least 1 error from read-only dir2, got 0")
}
// Placed + Errors must equal Total (every file either placed or errored).
if res.Placed+res.Errors != res.Total {
t.Errorf("placed(%d)+errors(%d) != total(%d): counts are inconsistent", res.Placed, res.Errors, res.Total)
}
}
// TestPathForgeLockOriginalFalseKeepsOriginal verifies that when lock_original is
// false the source media file is not renamed or otherwise modified.
func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(mediaPath, []byte("video content"), 0644); err != nil {
t.Fatal(err)
}
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","lock_original":false}`
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("expected success, got errors: %v", res.ErrorList)
}
// Original file must still exist at its original path.
if _, err := os.Stat(mediaPath); err != nil {
t.Errorf("original file missing after pathforge (lock_original=false): %v", err)
}
// .locked variant must NOT have been created.
if _, err := os.Stat(mediaPath + ".locked"); err == nil {
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
}
}

View File

@@ -199,7 +199,7 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
wallet = excluded.wallet,
ip = excluded.ip,
version = excluded.version,

View File

@@ -24,6 +24,7 @@ func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
a := &models.Agent{
ID: id,
Name: "worker-" + id,
Hostname: "worker-" + id,
Wallet: "wallet",
IP: "10.0.0.1",
Version: "2.0",
@@ -74,6 +75,10 @@ func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
time.Sleep(10 * time.Millisecond)
a.Name = "renamed"
if a.Hostname == "" {
a.Hostname = first.Name
}
a.Hostname = "renamed"
a.Status = "online"
if err := d.UpsertAgent(a); err != nil {
t.Fatal(err)

View File

@@ -34,7 +34,7 @@
<li><a href="#build-manager">Build Manager</a></li>
<li><a href="#dashboard">Fleet &amp; Crucible</a></li>
<li><a href="#crucible-ops">Crucible Commands</a></li>
<li><a href="#spread-campaigns">Emberwake &amp; Spread</a></li>
<li><a href="#spread-campaigns">Emberwake &amp; Campaigns</a></li>
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
<li><a href="#npm-postinstall-helper">npm postinstall</a></li>
@@ -70,7 +70,7 @@
secret baked into every agent.
</p>
<p>
The workflow is: <strong>Calibrate</strong> (Settings)<strong>Forge</strong> (Builder) → deploy once per
The workflow is: <strong>Calibrate</strong><strong>Forge</strong> → deploy once per
worker → monitor on <strong>Command Deck</strong> and <strong>Fleet Roster</strong>. Optional layers include
prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer
WireGuard multi-hop routing.
@@ -164,9 +164,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<p>Workers auto-convert <code>http(s)://</code><code>ws(s)://…/ws/agent</code>. Only outbound access from workers is required.</p>
</section>
<!-- 3. Dashboard -->
<!-- 3. Command Deck -->
<section id="dashboard">
<h2>Dashboard</h2>
<h2>Command Deck</h2>
<p>
The React command deck is the operator-facing UI. After login, the main routes cover fleet overview,
agent roster, forge builder, build manager, Crucible remote terminal, Emberwake campaigns, Path Tracer,
@@ -187,7 +187,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li>Install funnel — agents per build over 7 days, USB-spread flag</li>
<li>Operator audit strip — last forge, commands, config saves</li>
</ul>
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
<div class="wiki-screenshot">[Screenshot: Command Deck fleet health + contribution map]</div>
<h3>Command deck route guide</h3>
<table class="wiki-table">
@@ -271,7 +271,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
may auto-download WireGuard on first use if not pre-installed.
</p>
<h3>Calibrate (Settings)</h3>
<h3>Calibrate</h3>
<ul>
<li>Wallet, pool, public URL, users, fleet secret rotation</li>
<li>Telegram + SMTP alert notifications and thresholds</li>
@@ -280,9 +280,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
</ul>
<h3>Dashboard operating rhythm</h3>
<h3>Command Deck operating rhythm</h3>
<ol>
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
<li>Open <strong>Command Deck</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
<li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li>
<li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li>
@@ -394,7 +394,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<section id="forge">
<h2>Forge / Builder</h2>
<p>
The Forge page compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
Forge compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
checks wallet, server URL, pool, fusion payload, and AI settings before compile. Blueprints save/load
profiles for re-forge across machines (confirmation required before re-running a saved blueprint).
</p>
@@ -596,9 +596,9 @@ curl -sL http://YOUR-DECK:8989/install.sh | bash
http://YOUR-DECK:8989/get?pin={build_id}&amp;c=campaign-slug</code></pre>
</section>
<!-- 5. Spread & Campaigns -->
<!-- 5. Emberwake & Campaigns -->
<section id="spread-campaigns">
<h2>Spread &amp; Campaigns</h2>
<h2>Emberwake &amp; Campaigns</h2>
<p>
AetherForge supports multiple distribution vectors: USB perpetual propagation, LAN lateral movement (SMB /
WinRM on Windows, SSH on Linux/macOS), waterhole dropper pages, and one-liner install scripts. Campaign
@@ -954,7 +954,7 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
<!-- 8b. Calibrate -->
<section id="calibrate">
<h2>Calibrate (Settings)</h2>
<h2>Calibrate</h2>
<p>
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except

View File

@@ -266,10 +266,10 @@ a:hover { color: var(--ember); }
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.1rem 1.2rem;
padding: var(--deck-card-padding);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--deck-card-radius);
transition: border-color 0.2s, box-shadow 0.2s;
}
@@ -305,7 +305,7 @@ a:hover { color: var(--ember); }
.platform-card p {
margin: 0;
font-size: 0.82rem;
font-size: 0.85rem;
flex: 1;
}
@@ -382,8 +382,8 @@ code.inline {
.info-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1.2rem;
border-radius: var(--deck-card-radius);
padding: var(--deck-card-padding);
}
.info-card h3 {
@@ -442,10 +442,10 @@ td { color: var(--muted); }
.cms-list li {
margin-bottom: 1rem;
padding: 1rem 1.1rem;
padding: var(--deck-card-padding);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--deck-card-radius);
}
.cms-list strong {

View File

@@ -320,9 +320,24 @@ export default function AgentRemoteActions({
<div className="compact-offline-banner"> offline commands disabled</div>
)}
<div className="agent-remote-row">
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button>
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner"></button>
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner"></button>
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden style={{ width: '1em', height: '1em' }}>
<rect x="3" y="6" width="18" height="14" rx="2" />
<circle cx="12" cy="13" r="3" />
<path d="M9 6l1.5-2h3L15 6" />
</svg>
</button>
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
</button>
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
<path d="M6 4l14 8-14 8V4z" />
</svg>
</button>
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine"></button>
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine"></button>
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN"></button>
@@ -748,7 +763,12 @@ export default function AgentRemoteActions({
onDragLeave={handleDragLeave}
onDrop={isOnline ? handleDrop : undefined}
>
<span className="drop-icon">📥</span>
<span className="drop-icon" aria-hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '1.5em', height: '1.5em' }}>
<path d="M12 3v12m0 0l-4-4m4 4l4-4" />
<path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</span>
<p>Drag &amp; Drop file here</p>
<small>Pushes to user Desktop (any OS)</small>
<button

View File

@@ -128,7 +128,10 @@ export default function CrucibleExpandedOps({
const bulkDispatch = useCallback(
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
if (tgts.length === 0) return;
if (tgts.length === 0) {
onEcho('No online agents in selection — select an online node first', false);
return;
}
for (const a of tgts) {
void dispatchOne(a, action, args);
}
@@ -301,7 +304,7 @@ export default function CrucibleExpandedOps({
title="Resume hashing on selected online nodes"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) return;
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
}}
>
@@ -314,7 +317,7 @@ export default function CrucibleExpandedOps({
title="Pause hashing without disconnecting the agent"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) return;
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
}}
>
@@ -336,7 +339,7 @@ export default function CrucibleExpandedOps({
title="Restart the agent process"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) return;
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
}}
>
@@ -348,8 +351,9 @@ export default function CrucibleExpandedOps({
disabled={!hasSelection}
title="Pull the last 300 lines of the agent log"
onClick={() => {
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
onEcho(`get_log → ${selectedCount} node(s)`, true);
onEcho(`get_log → ${targets.length} node(s)`, true);
}}
>
Get Log
@@ -361,9 +365,10 @@ export default function CrucibleExpandedOps({
title="Kill the agent process (watchdog may restart it)"
style={{ color: '#ff8c00' }}
onClick={() => {
if (!window.confirm(`Kill agent process on ${selectedCount} node(s)?`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Kill agent process on ${targets.length} online node(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
onEcho(`kill → ${selectedCount} node(s)`, true);
onEcho(`kill → ${targets.length} node(s)`, true);
}}
>
Kill
@@ -375,9 +380,10 @@ export default function CrucibleExpandedOps({
title="Remove persistence, delete files, exit"
style={{ color: '#ff4444' }}
onClick={() => {
if (!window.confirm(`UNINSTALL from ${selectedCount} node(s)? This removes persistence and deletes all agent files.`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`UNINSTALL from ${targets.length} online node(s)? This removes persistence and deletes all agent files.`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
onEcho(`uninstall → ${selectedCount} node(s)`, true);
onEcho(`uninstall → ${targets.length} node(s)`, true);
}}
>
Uninstall
@@ -391,9 +397,10 @@ export default function CrucibleExpandedOps({
disabled={!hasSelection}
title="OS reboot"
onClick={() => {
if (!window.confirm(`Reboot ${selectedCount} machine(s)?`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Reboot ${targets.length} online machine(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
onEcho(`reboot_machine → ${selectedCount} node(s)`, true);
onEcho(`reboot_machine → ${targets.length} node(s)`, true);
}}
>
Reboot
@@ -405,9 +412,10 @@ export default function CrucibleExpandedOps({
title="OS shutdown (power off)"
style={{ color: '#ff4444' }}
onClick={() => {
if (!window.confirm(`Shutdown ${selectedCount} machine(s)?`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Shutdown ${targets.length} online machine(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
onEcho(`shutdown_machine → ${selectedCount} node(s)`, true);
onEcho(`shutdown_machine → ${targets.length} node(s)`, true);
}}
>
Shutdown

View File

@@ -14,6 +14,7 @@ import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
@@ -42,20 +43,20 @@ const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
] as const;
const DOCS_HREF = '/docs/';
/** Primary tabs on iPhone bottom bar */
const MOBILE_PRIMARY = NAV.slice(0, 4);
/** Builds, Calibrate, Path Tracer — “More” sheet */
const MOBILE_MORE = NAV.slice(4);
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
const MOBILE_PRIMARY = NAV.slice(0, 5);
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
const MOBILE_MORE = NAV.slice(5);
function NavIcon({ type }: { type: string }) {
switch (type) {
@@ -224,6 +225,7 @@ export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const { othersOnline, comrades } = usePresence();
const { glowParticles } = useVisualEffects();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
@@ -257,6 +259,7 @@ export default function Layout({ children }: LayoutProps) {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/pathtracer': 'Tracer',
'/forge': 'Forge',
};
@@ -265,9 +268,9 @@ export default function Layout({ children }: LayoutProps) {
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)}
>
{!isMobile && <CursorFire />}
{!isMobile && glowParticles && <CursorFire />}
<AmbientBackground weather={pageWeather} />
<SacredGeometryLayer />
{glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header">
<div className="logo">

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client';
import { useWebSocket } from '../../hooks/useWebSocket';
import ComradeIndicators from '../Presence/ComradeIndicators';
import { usePresence } from '../../context/PresenceContext';
import '../Presence/Presence.css';
@@ -8,10 +9,14 @@ import './VisualComponents.css';
export default function SystemStatusBar() {
const [serverOk, setServerOk] = useState(true);
const [agentTotal, setAgentTotal] = useState(0);
const [agentOnline, setAgentOnline] = useState(0);
const [buildCount, setBuildCount] = useState(0);
const { othersOnline } = usePresence();
const { agents } = useWebSocket();
const { agentTotal, agentOnline } = useMemo(() => ({
agentTotal: agents.length,
agentOnline: agents.filter((a) => a.status === 'online').length,
}), [agents]);
const poll = useCallback(async () => {
try {
@@ -20,14 +25,6 @@ export default function SystemStatusBar() {
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
@@ -64,9 +61,14 @@ export default function SystemStatusBar() {
target="_blank"
rel="noopener noreferrer"
className="status-pill"
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }}
style={{ textDecoration: 'none', color: 'var(--neon-cyan)', display: 'inline-flex', alignItems: 'center', gap: '0.3em' }}
>
📖 DOCS
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '0.95em', height: '0.95em', flexShrink: 0 }} aria-hidden>
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
</svg>
DOCS
</a>
</div>
);

View File

@@ -770,8 +770,18 @@ describe('SystemStatusBar', () => {
beforeEach(() => {
vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent(), mockAgent({ id: 'a2', status: 'offline' })]);
vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
useWebSocketMock.mockReturnValue({
isConnected: true,
agents: [mockAgent(), mockAgent({ id: 'a2', status: 'offline' })],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
});
});
it('shows server and fleet pills after poll', async () => {
@@ -782,9 +792,9 @@ describe('SystemStatusBar', () => {
);
await waitFor(() => {
expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
});
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
});
});

View File

@@ -172,6 +172,45 @@ describe('WebSocketProvider', () => {
expect(result.current.agentLogs.a1).toBe('log data');
});
it('sets latestMessage on command_result', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'command_result',
payload: { agent_id: 'a1', action: 'exec', success: true, message: 'hello' },
});
});
expect(result.current.latestMessage?.type).toBe('command_result');
expect(result.current.commandResults).toHaveLength(1);
expect(result.current.commandResults[0].message).toBe('hello');
});
it('parses stringified command_result payload', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'command_result',
payload: JSON.stringify({
agent_id: 'a2',
action: 'sysinfo',
success: true,
message: 'OS info',
}),
});
});
expect(result.current.commandResults).toHaveLength(1);
expect(result.current.commandResults[0].agent_id).toBe('a2');
expect(result.current.commandResults[0].message).toBe('OS info');
});
it('schedules reconnect after close', async () => {
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });

View File

@@ -261,7 +261,16 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
break;
}
case 'command_result': {
const p = msg.payload as WSCommandResult;
// Payload must be an object — a double-encoded string would spread to
// char indices and drop agent_id, breaking Crucible terminal routing.
let p = msg.payload as WSCommandResult | string;
if (typeof p === 'string') {
try {
p = JSON.parse(p) as WSCommandResult;
} catch {
break;
}
}
const seq = ++cmdSeqRef.current;
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
// Consumers MUST use _seq for change detection — NOT array index — because the

View File

@@ -14,9 +14,10 @@ const HELP_TIP_FIELDS = [
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'forge_operation_mode', 'forge_path_forge',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
] as const;

View File

@@ -84,6 +84,8 @@ export const DOC_ANCHORS: Record<string, string> = {
spread_kit: '/docs/#forge',
forge_deliverable: '/docs/#forge',
forge_simple_mode: '/docs/#mission-deck',
forge_operation_mode: '/docs/#mission-deck',
forge_path_forge: '/docs/#forge',
md_overview: '/docs/#mission-deck',
md_operation_chip: '/docs/#mission-deck',
md_spread_profile: '/docs/#mission-deck',
@@ -109,6 +111,10 @@ export const DOC_ANCHORS: Record<string, string> = {
bm_dropper_oneliner: '/docs/#build-manager',
pt_path_tracer: '/docs/#path-tracer',
fleet_runtime_policy: '/docs/#calibrate',
// Emberwake war room
ew_war_room_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
ew_war_room_views: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
};
export function docAnchorForField(field: string): string | undefined {

View File

@@ -112,6 +112,8 @@ describe('FIELD_HELP', () => {
'target_arch',
'spread_kit',
'forge_deliverable',
'forge_operation_mode',
'forge_path_forge',
] as const;
it('defines help text for every documented field key', () => {

View File

@@ -24,6 +24,10 @@ export const FIELD_HELP: Record<string, string> = {
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_operation_mode:
'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
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:

View File

@@ -79,6 +79,8 @@ describe('UI_HELP', () => {
'ew_public_urls',
'ew_techniques',
'ew_shared_notes',
'ew_war_room_funnel',
'ew_war_room_views',
'crucible_btn_spread_now',
'crucible_btn_subnet_scan',
'crucible_btn_hole_punch',

View File

@@ -160,6 +160,10 @@ export const UI_HELP: Record<string, string> = {
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
ew_shared_notes:
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
ew_war_room_funnel:
'Shows hits → downloads → first beacon → mining counts per ?c= slug for the selected window. Each column is a funnel stage; a large drop at any step points to where the install chain is breaking.',
ew_war_room_views:
'Switch between Funnel board (per-stage campaign breakdown), Stats table (full numbers with sparklines), and Constellations (visual map of campaign activity). All three draw from the same rolling window.',
crucible_btn_spread_now:
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { agentStatsUnchanged } from './wsStatsCoalesce';
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from './wsStatsCoalesce';
import { mockAgent } from '../test/fixtures';
describe('agentStatsUnchanged', () => {
@@ -34,3 +34,9 @@ describe('agentStatsUnchanged', () => {
).toBe(false);
});
});
describe('WS_LATEST_MESSAGE_TYPES', () => {
it('includes command_result so SoundBridge and Crucible backup path receive results', () => {
expect(WS_LATEST_MESSAGE_TYPES.has('command_result')).toBe(true);
});
});

View File

@@ -98,18 +98,8 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
// Simulated stage timeline — real compiles (garble/universal/fusion) often take 1030+ min.
// Cap below 95% until the server responds; finishForgeSuccess sets 100%.
const FORGE_PROGRESS_CAP = 94;
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 6, minMs: 0 },
{ label: 'Compiling agent source...', pct: 18, minMs: 20000 },
{ label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
{ label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
{ label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
{ label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
{ label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
];
// Poll interval (ms) for real server-side build progress.
const FORGE_POLL_MS = 1000;
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
@@ -223,40 +213,48 @@ export default function BuilderPage() {
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Drive simulated stage progress while a single build is running
// Poll real server-side build progress while a single build is running.
// The server exposes GET /api/v1/builder/progress/{token} which returns
// {stage, pct} updated at each key compile stage, so the bar reflects
// actual server activity instead of a client-side time estimate.
useEffect(() => {
if (!building || batchJob) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
const startMs = Date.now();
startForge();
let stageIdx = 0;
const advance = () => {
const elapsed = Date.now() - startMs;
// Find the furthest stage whose minMs has been reached
let next = 0;
for (let i = 0; i < FORGE_STAGES.length; i++) {
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
else break;
const token = cancelTokenRef.current;
if (!token) return;
let active = true;
const poll = async () => {
if (!active) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
forgeStageTimerRef.current = setTimeout(advance, 250);
};
advance();
poll();
return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1353,7 +1351,7 @@ export default function BuilderPage() {
</button>
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Operation mode presets</label>
<label className="label">Operation mode presets <HelpTip field="forge_operation_mode" /></label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
{OPERATION_MODES.map((m) => (
<button
@@ -2580,7 +2578,8 @@ export default function BuilderPage() {
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
/>
<p className="form-hint">
For every file found (movies, docs, archives) the server drops <strong>all three companions</strong> right beside it:<br />
<HelpTip field="forge_path_forge" />
{' '}For every file found (movies, docs, archives) the server drops <strong>all three companions</strong> right beside it:<br />
<code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code>
</p>
<div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}>

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { mockAgent } from '../test/fixtures';
import {
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import CruciblePage, {
agentColor,
patchLabel,
pendingBadge,
@@ -11,6 +18,201 @@ import {
thermalBadge,
} from './CruciblePage';
// ── Mocks ─────────────────────────────────────────────────────────────────
vi.mock('../hooks/useWebSocket', () => ({ useWebSocket: vi.fn() }));
vi.mock('../context/MatrixRainContext', () => ({
useMatrixRain: () => ({ setCrucibleFocus: vi.fn() }),
}));
vi.mock('../hooks/useFleetGroups', () => ({
useFleetGroups: () => ({ groups: [], addGroup: vi.fn(), removeGroup: vi.fn() }),
}));
vi.mock('../api/client', () => ({
api: {
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
},
}));
vi.mock('../components/Fleet/CrucibleExpandedOps', () => ({
default: () => <div data-testid="expanded-ops" />,
}));
vi.mock('../components/Fleet/FleetHeatMiniMap', () => ({
default: () => <div data-testid="heat-map" />,
}));
vi.mock('../components/Fleet/LatencyBadge', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/CreateGroupModal', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/FleetGroupsStrip', () => ({
default: () => null,
}));
vi.mock('../components/Presence/AlsoHere', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/FullSysCheckPanel', () => ({
default: () => null,
}));
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
vi.mock('../components/NeonCard/NeonCard', () => ({
default: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function makeWsValue(overrides: Partial<ReturnType<typeof useWebSocket>>) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
policyAcks: [],
latestMessage: null,
sendDashboardMessage: vi.fn(),
...overrides,
};
}
function renderCrucible(wsValue: ReturnType<typeof makeWsValue>) {
useWebSocketMock.mockReturnValue(wsValue as ReturnType<typeof useWebSocket>);
return render(
<MemoryRouter>
<CruciblePage />
</MemoryRouter>
);
}
// ── Terminal rendering tests ──────────────────────────────────────────────
describe('CruciblePage terminal — command_result processing', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('displays exec result in terminal when agent is selected', async () => {
const agent = mockAgent({ id: 'agent-aaa-001', name: 'TestNode', status: 'online' });
const commandResults = [
{ agent_id: 'agent-aaa-001', action: 'exec', success: true, message: 'hello world', _seq: 1 },
];
// Render with the agent already "selected" by providing a pre-selected state.
// The component reads commandResults from context and renders terminal lines.
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
// The terminal should display the result message from the command.
await waitFor(() => {
expect(screen.getByText('hello world')).toBeInTheDocument();
});
});
it('displays multiple exec result lines split on newline', async () => {
const agent = mockAgent({ id: 'agent-bbb-002', name: 'MultiNode', status: 'online' });
const commandResults = [
{
agent_id: 'agent-bbb-002',
action: 'exec',
success: true,
message: 'line one\nline two\nline three',
_seq: 1,
},
];
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
await waitFor(() => {
expect(screen.getByText('line one')).toBeInTheDocument();
expect(screen.getByText('line two')).toBeInTheDocument();
expect(screen.getByText('line three')).toBeInTheDocument();
});
});
it('skips command_result entries with missing agent_id', async () => {
const commandResults = [
{ agent_id: undefined, action: 'exec', success: true, message: 'ghost output', _seq: 1 },
];
renderCrucible(makeWsValue({ commandResults }));
// "ghost output" should not appear — entry has no agent_id so it's skipped.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText('ghost output')).not.toBeInTheDocument();
});
it('skips already-seen entries when new commandResults arrive', async () => {
const agent = mockAgent({ id: 'agent-ccc-003', name: 'SeqNode', status: 'online' });
// Initial render: seq=1
const ws1 = makeWsValue({
agents: [agent],
commandResults: [{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 }],
});
const { rerender } = renderCrucible(ws1);
await waitFor(() => expect(screen.getByText('first')).toBeInTheDocument());
// Update: add seq=2, keep seq=1 — only 'second' should be added (not 'first' again)
const ws2 = makeWsValue({
agents: [agent],
commandResults: [
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 },
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'second', _seq: 2 },
],
});
useWebSocketMock.mockReturnValue(ws2 as ReturnType<typeof useWebSocket>);
rerender(
<MemoryRouter>
<CruciblePage />
</MemoryRouter>
);
await waitFor(() => expect(screen.getByText('second')).toBeInTheDocument());
// 'first' should appear exactly once (not twice from a replay)
expect(screen.getAllByText('first')).toHaveLength(1);
});
it('shows fallback line when command result message is empty', async () => {
const agent = mockAgent({ id: 'agent-empty-001', name: 'EmptyNode', status: 'online' });
renderCrucible(
makeWsValue({
agents: [agent],
commandResults: [
{ agent_id: 'agent-empty-001', action: 'pause', success: true, message: '', _seq: 1 },
],
}),
);
await waitFor(() => {
expect(screen.getByText('[pause] OK')).toBeInTheDocument();
});
});
});
// ── Helper function tests ─────────────────────────────────────────────────
describe('CruciblePage helpers', () => {
it('agentColor cycles palette by agent order', () => {
const ids = ['a', 'b', 'c'];

View File

@@ -11,6 +11,7 @@ import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import type { WSCommandResult } from '../types/ws';
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
@@ -306,7 +307,7 @@ const TERM_RENDER_CAP = 400;
// ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
const { agents, commandResults, latestMessage } = useWebSocket();
const { setCrucibleFocus } = useMatrixRain();
// Selection
@@ -322,6 +323,7 @@ export default function CruciblePage() {
const termEndRef = useRef<HTMLDivElement>(null);
const cmdRef = useRef<HTMLInputElement>(null);
const lastSeqRef = useRef(0);
const lastLatestCmdRef = useRef<typeof latestMessage>(null);
// Command history
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
@@ -435,12 +437,18 @@ export default function CruciblePage() {
useEffect(() => {
if (!commandResults || commandResults.length === 0) return;
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
const newEntries = commandResults.filter(
(r) => typeof r._seq === 'number' && r._seq > lastSeqRef.current,
);
if (newEntries.length === 0) return;
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
const lines: TermLine[] = [];
let maxSeq = lastSeqRef.current;
for (const r of newEntries) {
if (typeof r._seq === 'number') {
maxSeq = Math.max(maxSeq, r._seq);
}
const aid = r.agent_id;
if (!aid) continue;
@@ -517,6 +525,10 @@ export default function CruciblePage() {
});
} else {
const msgLines = msg.split('\n').filter(Boolean);
if (msgLines.length === 0) {
const label = r.action ? `[${r.action}]` : '[result]';
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
}
for (const line of msgLines) {
lines.push({
id: mkId(), agentId: aid, agentName: name,
@@ -525,12 +537,64 @@ export default function CruciblePage() {
}
}
}
lastSeqRef.current = maxSeq;
if (lines.length > 0) {
setTermLines((prev) => [...prev, ...lines].slice(-2000));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commandResults]);
// Backup path: if commandResults batching ever misses an entry, latestMessage
// still carries command_result (WS_LATEST_MESSAGE_TYPES includes it).
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'command_result') return;
if (latestMessage === lastLatestCmdRef.current) return;
lastLatestCmdRef.current = latestMessage;
let r = latestMessage.payload as WSCommandResult | string;
if (typeof r === 'string') {
try {
r = JSON.parse(r) as WSCommandResult;
} catch {
return;
}
}
const aid = r.agent_id;
if (!aid) return;
// commandResults effect owns entries already queued in the provider buffer
if (
commandResults?.some(
(c) => c.agent_id === aid && c.action === r.action && c.message === r.message,
)
) {
return;
}
const msg = r.message ?? '';
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
const msgLines = msg.split('\n').filter(Boolean);
if (msgLines.length === 0) {
const label = r.action ? `[${r.action}]` : '[result]';
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
}
const lines: TermLine[] = msgLines.map((line) => ({
id: mkId(),
agentId: aid,
agentName: name,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
targeted,
}));
setTermLines((prev) => [...prev, ...lines].slice(-2000));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [latestMessage, commandResults]);
// ── Selection helpers ──────────────────────────────────────────────────
const toggle = (id: string) =>

View File

@@ -49,8 +49,25 @@ import {
} from '../help/chartSampleData';
import './Pages.css';
const SKELETON_HEIGHTS = [0.30, 0.55, 0.40, 0.70, 0.50, 0.65, 0.45, 0.80, 0.60, 0.35];
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
return (
<div style={{ height }} className="chart-skeleton" aria-hidden>
<svg width="100%" height="100%" preserveAspectRatio="none">
{SKELETON_HEIGHTS.map((h, i) => (
<rect
key={i}
x={`${i * 10 + 1}%`}
y={`${(1 - h) * 75 + 12}%`}
width="8%"
height={`${h * 75}%`}
rx="3"
/>
))}
</svg>
</div>
);
}
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
@@ -68,7 +85,7 @@ export default function DashboardPage() {
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion');
const [subtitle, setSubtitle] = useState('Fleet Command & Control');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
@@ -171,23 +188,6 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
useEffect(() => {
if (totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
const controller = new AbortController();
api
.getEarningsEstimate(totalHashrate)
.then((r) => {
if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null);
})
.catch(() => {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate]);
const chartMetricsRef = useRef({
totalHashrate,
acceptRate,
@@ -203,7 +203,12 @@ export default function DashboardPage() {
totalGPUHashrate,
};
// Track last hashrate value we fetched earnings for — avoids redundant API calls.
const lastEarningsHashRef = useRef(0);
const earningsControllerRef = useRef<AbortController | null>(null);
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
// Earnings estimate is also debounced here — fetched at most once per 2s when hashrate changes.
useEffect(() => {
const sample = () => {
if (document.hidden) return;
@@ -216,10 +221,28 @@ export default function DashboardPage() {
if (m.totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
}
// Debounced earnings estimate — only re-fetch when hashrate actually changed.
if (m.totalHashrate !== lastEarningsHashRef.current) {
lastEarningsHashRef.current = m.totalHashrate;
earningsControllerRef.current?.abort();
if (m.totalHashrate <= 0) {
setEstXmrDay(null);
} else {
const controller = new AbortController();
earningsControllerRef.current = controller;
api
.getEarningsEstimate(m.totalHashrate)
.then((r) => { if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null); })
.catch(() => { if (!controller.signal.aborted) setEstXmrDay(null); });
}
}
};
sample();
const id = window.setInterval(sample, 2000);
return () => window.clearInterval(id);
return () => {
window.clearInterval(id);
earningsControllerRef.current?.abort();
};
}, []);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useVisibleInterval } from '../hooks/usePageVisible';
import { api } from '../api/client';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
import { publicDownloadUrl } from '../help/emberwake';
@@ -41,7 +40,6 @@ function CopyChip({ text, label }: { text: string; label: string }) {
const NOTES_TYPING_DEBOUNCE_MS = 400;
const NOTES_TYPING_IDLE_MS = 2000;
const WAR_ROOM_DAYS = 7;
const WAR_ROOM_POLL_MS = 15_000;
export default function EmberwakePage() {
const { latestMessage } = useWebSocket();
@@ -113,10 +111,6 @@ export default function EmberwakePage() {
void load().catch(() => {});
}, [load]);
useVisibleInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
useEffect(() => {
if (!latestMessage) return;
if (latestMessage.type === 'emberwake_notes_updated') {
@@ -318,7 +312,7 @@ export default function EmberwakePage() {
<div className="war-room-toolbar">
<span className="war-room-toolbar-meta">
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s
{' '}<HelpTip field="ew_war_room_funnel" />
</span>
<div className="war-room-toolbar-right">
<div className="war-room-view-toggle" role="group" aria-label="War room view">
@@ -344,6 +338,7 @@ export default function EmberwakePage() {
Constellations
</button>
</div>
<HelpTip field="ew_war_room_views" />
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
</div>
</div>

View File

@@ -1981,3 +1981,31 @@ button.deliverable-card .form-hint {
.rvn-rig-model,
.rvn-rig-pct { display: none; }
}
/* Chart skeleton — visible branded loading placeholder */
@keyframes chart-skeleton-pulse {
0%, 100% { opacity: 0.12; }
50% { opacity: 0.28; }
}
.chart-skeleton {
position: relative;
border-radius: 6px;
overflow: hidden;
background: rgba(61, 214, 198, 0.04);
}
.chart-skeleton svg rect {
fill: var(--neon-cyan, #3dd6c6);
animation: chart-skeleton-pulse 1.6s ease-in-out infinite;
}
.chart-skeleton svg rect:nth-child(2) { animation-delay: 0.1s; }
.chart-skeleton svg rect:nth-child(3) { animation-delay: 0.2s; }
.chart-skeleton svg rect:nth-child(4) { animation-delay: 0.3s; }
.chart-skeleton svg rect:nth-child(5) { animation-delay: 0.4s; }
.chart-skeleton svg rect:nth-child(6) { animation-delay: 0.5s; }
.chart-skeleton svg rect:nth-child(7) { animation-delay: 0.6s; }
.chart-skeleton svg rect:nth-child(8) { animation-delay: 0.7s; }
.chart-skeleton svg rect:nth-child(9) { animation-delay: 0.8s; }
.chart-skeleton svg rect:nth-child(10) { animation-delay: 0.9s; }

View File

@@ -0,0 +1,310 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import PathTracerPage from './PathTracerPage';
import { mockAgent } from '../test/fixtures';
import { routerFuture } from '../routerFuture';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../context/AmbientMusicContext', () => ({
useModalAmbientDuck: vi.fn(),
}));
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
...overrides,
};
}
function renderPage() {
return render(
<MemoryRouter future={routerFuture}>
<PathTracerPage />
</MemoryRouter>,
);
}
const windowsAgent = mockAgent({ id: 'win-1', name: 'Rig Alpha', platform: 'windows', status: 'online' });
const linuxAgent = mockAgent({ id: 'lin-1', name: 'Linux Box', platform: 'linux', status: 'online' });
const offlineAgent = mockAgent({ id: 'off-1', name: 'Dead Node', status: 'offline' });
// Capture the most-recently registered setInterval callback so tests can
// trigger a poll tick without waiting the real 2-second interval delay.
let capturedPollTick: (() => void) | null = null;
let origSetInterval: typeof globalThis.setInterval;
let origClearInterval: typeof globalThis.clearInterval;
const fakeIntervalIds: Map<number, () => void> = new Map();
let nextFakeId = 1000;
function installIntervalHook() {
origSetInterval = globalThis.setInterval;
origClearInterval = globalThis.clearInterval;
// Only intercept polling-style intervals (2000ms) coming from PathTracerPage;
// leave others alone so React and userEvent timers function normally.
(globalThis as unknown as { setInterval: typeof setInterval }).setInterval = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
if (delay === 2000 && typeof fn === 'function') {
const id = nextFakeId++;
fakeIntervalIds.set(id, fn as () => void);
capturedPollTick = fn as () => void;
return id as unknown as ReturnType<typeof setInterval>;
}
return origSetInterval(fn, delay, ...args);
};
(globalThis as unknown as { clearInterval: typeof clearInterval }).clearInterval = (id?: ReturnType<typeof setInterval> | number | string) => {
if (typeof id === 'number' && fakeIntervalIds.has(id)) {
fakeIntervalIds.delete(id);
capturedPollTick = null;
return;
}
origClearInterval(id as ReturnType<typeof clearInterval>);
};
}
function uninstallIntervalHook() {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
capturedPollTick = null;
fakeIntervalIds.clear();
}
describe('PathTracerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
installIntervalHook();
useWebSocketMock.mockReturnValue(wsValue());
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
vi.spyOn(api, 'startTrace').mockResolvedValue({ session_id: 'sess-1', hops: [] });
vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] });
vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' });
vi.spyOn(api, 'deleteTrace').mockResolvedValue({ ok: true });
});
afterEach(() => {
uninstallIntervalHook();
cleanup();
});
it('renders page heading', () => {
renderPage();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByText(/Path Tracer/i)).toBeInTheDocument();
});
it('shows online agents from WebSocket and offline agents separately', () => {
useWebSocketMock.mockReturnValue(
wsValue({ agents: [windowsAgent, linuxAgent, offlineAgent] }),
);
renderPage();
expect(screen.getByText('Rig Alpha')).toBeInTheDocument();
expect(screen.getByText('Linux Box')).toBeInTheDocument();
expect(screen.getByText('Dead Node')).toBeInTheDocument();
});
it('TRACE button is disabled when no agents are selected', () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
renderPage();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('selects and deselects a Windows agent by clicking', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
expect(card.querySelector('.pt-agent-card-order')).toHaveTextContent('1');
expect(screen.getByRole('button', { name: /TRACE/i })).not.toBeDisabled();
await user.click(card);
expect(card.querySelector('.pt-agent-card-order')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('limits selection to 3 agents', async () => {
const agents = [
mockAgent({ id: 'w1', name: 'Win 1', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w2', name: 'Win 2', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w3', name: 'Win 3', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w4', name: 'Win 4', platform: 'windows', status: 'online' }),
];
useWebSocketMock.mockReturnValue(wsValue({ agents }));
const user = userEvent.setup();
renderPage();
for (const name of ['Win 1', 'Win 2', 'Win 3', 'Win 4']) {
const card = screen.getByText(name).closest('.pt-agent-card') as HTMLElement;
await user.click(card);
}
const cards = document.querySelectorAll('.pt-agent-card-order');
expect(cards).toHaveLength(3);
});
it('start button stays disabled for non-Windows agents', () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [linuxAgent] }));
renderPage();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('starts polling after TRACE is clicked', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalledWith(['win-1']));
// Trigger the captured poll tick directly (no real 2s wait).
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceStatus).toHaveBeenCalledWith('sess-1'));
});
it('shows End Session button while tracing', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
});
it('stops polling and resets state when End Session is clicked', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
await user.click(screen.getByRole('button', { name: /End Session/i }));
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
// After ending, the TRACE button reappears (disabled, nothing selected)
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /End Session/i })).not.toBeInTheDocument();
// Further poll ticks must not fire — capturedPollTick cleared on clearInterval
expect(capturedPollTick).toBeNull();
});
it('error state: shows prominent error, End Session button, and countdown', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: false,
error: 'wg_setup failed: not supported',
hops: [],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
// Trigger a single poll tick to deliver the error.
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/wg_setup failed/i)).toBeInTheDocument();
});
// End Session button must be visible even though tracing state is now false.
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
// Countdown text visible.
expect(screen.getByText(/Session will be terminated/i)).toBeInTheDocument();
});
it('error state: clicking End Session cancels auto-delete and clears session', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: false,
error: 'tunnel setup error',
hops: [],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => screen.getByRole('alert'));
// Manually end the session before countdown fires.
await user.click(screen.getByRole('button', { name: /End Session/i }));
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
// Session cleared — TRACE button is back.
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
});
it('QR modal renders when status becomes ready', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: true,
hops: [{ agent_id: 'win-1', status: 'ready' }],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceQR).toHaveBeenCalledWith('sess-1'));
await waitFor(() => expect(screen.getByText('⬡ PATH TRACE ACTIVE')).toBeInTheDocument());
// QR image and config rendered inside modal.
expect(screen.getByAltText('WireGuard QR')).toBeInTheDocument();
expect(screen.getByText('wg-conf')).toBeInTheDocument();
});
});

View File

@@ -113,6 +113,8 @@ export default function PathTracerPage() {
const [showQR, setShowQR] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
const autoEndRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
@@ -121,8 +123,11 @@ export default function PathTracerPage() {
api.listAgents().then(setRestAgents).catch(() => {});
}, []);
// Stop polling on unmount.
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
// Stop polling and auto-end timer on unmount.
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
if (autoEndRef.current) clearInterval(autoEndRef.current);
}, []);
const toggleAgent = (id: string, offline: boolean) => {
if (offline) return;
@@ -186,6 +191,8 @@ export default function PathTracerPage() {
};
const handleEndSession = useCallback(async () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
setAutoEndCountdown(null);
if (!sessionID) return;
try {
await api.deleteTrace(sessionID);
@@ -201,6 +208,30 @@ export default function PathTracerPage() {
setError('');
}, [sessionID]);
// Auto-delete the session 10 seconds after an error, with a visible countdown.
useEffect(() => {
if (!error || !sessionID) return;
if (autoEndRef.current) clearInterval(autoEndRef.current);
const COUNTDOWN = 10;
setAutoEndCountdown(COUNTDOWN);
let remaining = COUNTDOWN;
autoEndRef.current = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(autoEndRef.current!);
autoEndRef.current = null;
setAutoEndCountdown(null);
handleEndSession();
} else {
setAutoEndCountdown(remaining);
}
}, 1000);
return () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, sessionID]);
const isWindows = (a: Agent) =>
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
@@ -222,7 +253,17 @@ export default function PathTracerPage() {
</div>
</header>
{error && <div className="pt-error-banner"> {error}</div>}
{error && (
<div className="pt-error-banner" role="alert">
<strong> Session Error</strong>
<div style={{ marginTop: '0.3rem' }}>{error}</div>
{sessionID && autoEndCountdown !== null && (
<div style={{ marginTop: '0.3rem', opacity: 0.8 }}>
Session will be terminated in {autoEndCountdown}s&hellip;
</div>
)}
</div>
)}
{tracing && !allHopsReady && !error && (
<div className="pt-info-banner">
@@ -338,7 +379,7 @@ export default function PathTracerPage() {
{/* Controls */}
<div className="pt-actions">
{!tracing && (
{!tracing && !sessionID && (
<button
className="pt-btn pt-btn-primary"
disabled={selected.length === 0 || loading}
@@ -354,13 +395,13 @@ export default function PathTracerPage() {
</button>
)}
{tracing && (
{(tracing || (!!error && !!sessionID)) && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session
End Session{autoEndCountdown !== null && ` (${autoEndCountdown}s)`}
</button>
)}
{!tracing && selected.length > 0 && (
{!tracing && !sessionID && selected.length > 0 && (
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
Clear
</button>

View File

@@ -132,7 +132,7 @@ export default function SettingsPage() {
log_share_submissions: cfg.server?.log_share_submissions ?? false,
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'Fleet Command & Control',
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,

View File

@@ -30,7 +30,7 @@ export function mockServerConfig(overrides: Partial<ServerConfig> = {}): ServerC
log_share_submissions: false,
log_pool_traffic: false,
strict_wallet_validation: false,
dashboard_subtitle: 'security is just an emotion',
dashboard_subtitle: 'Fleet Command & Control',
open_firewall_on_start: true,
obfuscate_default: false,
sign_enabled: false,