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
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user