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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user