Improve fleet control, Crucible ops, and multi-machine identity.
Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
This commit is contained in:
@@ -3,6 +3,7 @@ import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import type { Agent, AgentService } from '../types';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
||||
import { formatHashrate } from '../help/fleetFilters';
|
||||
import './CruciblePage.css';
|
||||
|
||||
@@ -18,6 +19,8 @@ interface TermLine {
|
||||
text: string;
|
||||
ts: Date;
|
||||
success?: boolean;
|
||||
// Whether this agent was in the active selection when the command was dispatched
|
||||
targeted?: boolean;
|
||||
// Structured data for rich terminal renderers
|
||||
richData?: RichTermData;
|
||||
}
|
||||
@@ -314,6 +317,15 @@ export default function CruciblePage() {
|
||||
|
||||
const online = (a: Agent) => a.status === 'online';
|
||||
|
||||
// Prune selectedIds when agents are removed (e.g. after roster delete).
|
||||
useEffect(() => {
|
||||
const liveIds = new Set(agents.map((a) => a.id));
|
||||
setSelectedIds((prev) => {
|
||||
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
|
||||
return pruned.size === prev.size ? prev : pruned;
|
||||
});
|
||||
}, [agents]);
|
||||
|
||||
// ── Auto-scroll terminal ───────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
@@ -377,23 +389,22 @@ export default function CruciblePage() {
|
||||
} catch { /* malformed JSON — fall through to plain text */ }
|
||||
}
|
||||
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
|
||||
if (richData) {
|
||||
// Single rich-rendered line (table/block replaces raw JSON)
|
||||
lines.push({
|
||||
id: mkId(), agentId: aid, agentName: name,
|
||||
isCmd: false, text: '', ts: new Date(),
|
||||
success: r.success, richData,
|
||||
success: r.success, richData, targeted,
|
||||
});
|
||||
} else {
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
lines.push({
|
||||
id: mkId(), agentId: aid, agentName: name,
|
||||
isCmd: false, text: line, ts: new Date(), success: r.success,
|
||||
isCmd: false, text: line, ts: new Date(), success: r.success, targeted,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -503,19 +514,16 @@ export default function CruciblePage() {
|
||||
|
||||
const probePosture = (targets?: Agent[]) => {
|
||||
const tgts = targets ?? selectedAgents.filter(online);
|
||||
if (tgts.length === 0) { alert('No online agents selected.'); return; }
|
||||
Promise.all(
|
||||
tgts.map((a) =>
|
||||
api.sendAgentCommand(a.id, 'posture').catch((err) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId: a.id,
|
||||
agentName: a.name,
|
||||
isCmd: false,
|
||||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||||
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(),
|
||||
success: false,
|
||||
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
|
||||
},
|
||||
]);
|
||||
})
|
||||
@@ -523,10 +531,13 @@ export default function CruciblePage() {
|
||||
);
|
||||
};
|
||||
|
||||
// Fires posture + listen_ports + patch_status in parallel for all selected online nodes.
|
||||
// Fires posture + listen_ports + patch_status in parallel.
|
||||
// When agents are selected, targets only selection. Otherwise targets all online.
|
||||
const scanSelected = (targets?: Agent[]) => {
|
||||
const tgts = targets ?? selectedAgents.filter(online);
|
||||
if (tgts.length === 0) return;
|
||||
const tgts = targets ?? (selectedAgents.filter(online).length > 0
|
||||
? selectedAgents.filter(online)
|
||||
: agents.filter(online));
|
||||
if (tgts.length === 0) { alert('No online agents available.'); return; }
|
||||
const cmds = ['posture', 'listen_ports', 'patch_status'] as const;
|
||||
for (const a of tgts) {
|
||||
for (const cmd of cmds) {
|
||||
@@ -536,7 +547,7 @@ export default function CruciblePage() {
|
||||
{
|
||||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||||
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(), success: false,
|
||||
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -544,6 +555,9 @@ export default function CruciblePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Focused agent — when exactly one is selected show its details prominently.
|
||||
const focusedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') { sendCmd(); return; }
|
||||
if (e.key === 'ArrowUp') {
|
||||
@@ -773,6 +787,7 @@ export default function CruciblePage() {
|
||||
<div className="cn-stats">
|
||||
<span>{a.cpu_cores}c</span>
|
||||
<span>{formatHashrate(a.hashrate_15m)}</span>
|
||||
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
|
||||
</div>
|
||||
<div className="cn-badges">
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
@@ -866,6 +881,40 @@ export default function CruciblePage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Focused machine banner ──────────────────────────────────────── */}
|
||||
{focusedAgent && (
|
||||
<div className="crucible-focus-bar" style={{
|
||||
display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap',
|
||||
background: 'rgba(0,245,255,0.06)', border: '1px solid rgba(0,245,255,0.25)',
|
||||
borderRadius: '8px', padding: '0.65rem 1rem', marginBottom: '1rem',
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
<span style={{ color: 'var(--neon-cyan)', fontFamily: 'monospace', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.1em' }}>
|
||||
▶ ACTIVE TARGET
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', color: agentColor(focusedAgent.id, allIds), fontWeight: 600 }}>
|
||||
{platformIcon(focusedAgent.platform)} {focusedAgent.name}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
|
||||
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
|
||||
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
|
||||
{focusedAgent.status !== 'online' && (
|
||||
<span style={{ color: '#ff6666', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
⚠ offline — commands will fail until it reconnects
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="button crucible-btn-muted"
|
||||
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Deselect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Groups & Actions ────────────────────────────────────────────── */}
|
||||
<div className="crucible-row">
|
||||
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
|
||||
@@ -903,36 +952,40 @@ export default function CruciblePage() {
|
||||
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> OPERATIONS
|
||||
{selectedIds.size > 0 && (
|
||||
<span style={{ marginLeft: '0.75rem', color: 'var(--neon-cyan)', fontSize: '0.75rem', fontWeight: 400 }}>
|
||||
→ {selectedIds.size === 1 ? selectedAgents[0]?.name ?? '1 node' : `${selectedIds.size} nodes`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="crucible-ops">
|
||||
|
||||
{/* ── Posture ──────────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Posture</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => probePosture()}
|
||||
title="Probe posture (AV, firewall, SSH, patch) on selected nodes"
|
||||
>
|
||||
Probe Posture
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-wake"
|
||||
disabled={agents.filter(online).length === 0}
|
||||
onClick={() => probePosture(agents.filter(online))}
|
||||
title="Probe ALL online nodes at once"
|
||||
>
|
||||
⚡ Fleet Posture Scan
|
||||
</button>
|
||||
<span className="cop-label">Posture & Recon</span>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-scan"
|
||||
disabled={selectedIds.size === 0}
|
||||
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
|
||||
onClick={() => scanSelected()}
|
||||
title="Fire posture + listen_ports + patch_status in parallel on selected nodes"
|
||||
title={selectedIds.size > 0
|
||||
? `Deep scan ${selectedIds.size} selected node(s): posture + ports + patch`
|
||||
: 'Deep scan ALL online nodes: posture + ports + patch'}
|
||||
>
|
||||
⬡ Scan All Selected
|
||||
{selectedIds.size > 0
|
||||
? `⬡ Deep Scan (${selectedIds.size} selected)`
|
||||
: `⬡ Deep Scan Fleet (${agents.filter(online).length} online)`}
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
|
||||
onClick={() => probePosture(selectedIds.size > 0 ? selectedAgents.filter(online) : agents.filter(online))}
|
||||
title="Posture only (AV, firewall, SSH state)"
|
||||
>
|
||||
Posture Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── SSH ──────────────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">SSH</span>
|
||||
<button
|
||||
@@ -953,6 +1006,7 @@ export default function CruciblePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Mining ───────────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Mining</span>
|
||||
<button
|
||||
@@ -960,44 +1014,36 @@ export default function CruciblePage() {
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
|
||||
>
|
||||
Resume All
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
|
||||
>
|
||||
Pause All
|
||||
Pause
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Recon</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => dispatch('whoami', shellType)}
|
||||
title="Run whoami on selected"
|
||||
>
|
||||
whoami
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => dispatch(shellType === 'powershell' ? 'Get-ComputerInfo | Select CsName,WindowsVersion,OsArchitecture' : 'uname -a', shellType)}
|
||||
>
|
||||
sysinfo
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)}
|
||||
title="Network adapter info"
|
||||
>
|
||||
ipconfig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Shell type ───────────────────────────────── */}
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Shell</span>
|
||||
<span className="cop-label">Shell Mode</span>
|
||||
<div className="crucible-shell-tabs">
|
||||
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
|
||||
<button
|
||||
@@ -1011,6 +1057,7 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Selection chips ──────────────────────────── */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="crucible-sel-chips">
|
||||
{selectedAgents.map((a) => {
|
||||
@@ -1057,10 +1104,12 @@ export default function CruciblePage() {
|
||||
)}
|
||||
{termLines.map((line) => {
|
||||
const color = agentColor(line.agentId, allIds);
|
||||
const dim = line.targeted === false;
|
||||
return (
|
||||
<div
|
||||
key={line.id}
|
||||
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''} ${line.richData ? 'rich-line' : ''}`}
|
||||
style={dim ? { opacity: 0.45 } : undefined}
|
||||
>
|
||||
<span className="ctl-agent" style={{ color }}>
|
||||
{line.agentName.slice(0, 12).padEnd(12)}
|
||||
|
||||
Reference in New Issue
Block a user