Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Single-host branch runner complements fallback chain under Fleet AI Control: persona ghost forks, onion_miner_log hops, server exhaustion splice from graft mining genome, Crucible depth badge, and hospice retire after max cycles.
1629 lines
68 KiB
TypeScript
1629 lines
68 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 FleetToolbar from '../components/Fleet/FleetToolbar';
|
||
import CrucibleAgentMeta from '../components/Fleet/CrucibleAgentMeta';
|
||
import {
|
||
DEFAULT_FLEET_FILTERS,
|
||
filterFleetAgents,
|
||
formatHashrate,
|
||
type FleetFilterState,
|
||
} from '../help/fleetFilters';
|
||
import { useFleetBulkActions } from '../hooks/useFleetBulkActions';
|
||
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||
import { useMatrixRain } from '../context/MatrixRainContext';
|
||
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
|
||
import type { WSCommandResult } from '../types/ws';
|
||
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||
import AccessDepthPanel from '../components/Fleet/AccessDepthPanel';
|
||
import LotlAttemptsList from '../components/Fleet/LotlAttemptsList';
|
||
import LotlTierBadge from '../components/Fleet/LotlTierBadge';
|
||
import RiskBadge from '../components/Fleet/RiskBadge';
|
||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||
import { parseTierReport } from '../types/lotl';
|
||
import { parseAccessDepthDiagnostics, type AccessDepthDiagnostics } from '../help/accessDepth';
|
||
import { platformIcon } from '../help/platform';
|
||
import AlsoHere from '../components/Presence/AlsoHere';
|
||
import { HelpTip } from '../components/HelpTip';
|
||
import '../components/Fleet/FullSysCheckPanel.css';
|
||
import '../components/Fleet/FleetToolbar.css';
|
||
import { TERM_RENDER_CAP, visibleTerminalLines } from '../help/terminalRenderCap';
|
||
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;
|
||
}
|
||
|
||
interface RichMiningDiagnostics {
|
||
type: 'mining_diagnostics';
|
||
generated_at?: string;
|
||
active_method?: string;
|
||
execution_mode?: string;
|
||
likely_blockers: string[];
|
||
av_recommendation?: string;
|
||
lotl_tier?: string;
|
||
lotl_attempts?: import('../types/lotl').TierAttempt[];
|
||
mining_hashrate?: number;
|
||
}
|
||
|
||
type RichTermData =
|
||
| RichListenPorts
|
||
| RichPatchStatus
|
||
| RichPostureSummary
|
||
| RichScreenshot
|
||
| RichFullSysCheck
|
||
| RichMiningDiagnostics;
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
const AGENT_COLORS = [
|
||
'#00e8f5', '#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] ?? '#00e8f5';
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** Contingency onion depth — Fleet AI Control single-host branch tree. */
|
||
export function contingencyDepthBadge(agent: Agent): { label: string; cls: string } | null {
|
||
const depth = agent.contingency_depth ?? 0;
|
||
if (depth <= 0) return null;
|
||
return { label: `ONION ${depth}`, cls: depth >= 8 ? 'cn-contingency-deep' : 'cn-contingency' };
|
||
}
|
||
|
||
// ── 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');
|
||
}
|
||
|
||
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`;
|
||
|
||
/** Roster page size — avoids rendering 500+ node cards at once. */
|
||
const ROSTER_PAGE_SIZE = 80;
|
||
|
||
// ── Component ──────────────────────────────────────────────────────────────
|
||
|
||
export default function CruciblePage() {
|
||
const { agents, commandResults, latestMessage } = useWebSocket();
|
||
const { setCrucibleFocus } = useMatrixRain();
|
||
|
||
// Selection
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
|
||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||
const { bulkBusy, handleBulkAction } = useFleetBulkActions({
|
||
agents,
|
||
selectedIds,
|
||
commandResults,
|
||
});
|
||
|
||
// Terminal
|
||
const [termLines, setTermLines] = useState<TermLine[]>([]);
|
||
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);
|
||
const lastLatestCmdRef = useRef<typeof latestMessage>(null);
|
||
|
||
// Command history
|
||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||
const [histIdx, setHistIdx] = useState(-1);
|
||
|
||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||
const [activeTab, setActiveTab] = useState<'ops' | 'recon' | 'files' | 'spread' | 'tunnels'>('ops');
|
||
const [rosterPage, setRosterPage] = useState(0);
|
||
|
||
// SSH / posture overrides (from on-demand probes)
|
||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||
const [accessDepthByAgent, setAccessDepthByAgent] = useState<Record<string, AccessDepthDiagnostics>>({});
|
||
|
||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||
|
||
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
|
||
if (a.status === 'online' && b.status !== 'online') return -1;
|
||
if (a.status !== 'online' && b.status === 'online') return 1;
|
||
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||
if (tb !== ta) return tb - ta;
|
||
return a.name.localeCompare(b.name);
|
||
}), [agents]);
|
||
|
||
const filteredAgents = useMemo(
|
||
() => filterFleetAgents(sortedAgents, filters),
|
||
[sortedAgents, filters],
|
||
);
|
||
|
||
const rosterPageCount = Math.max(1, Math.ceil(filteredAgents.length / ROSTER_PAGE_SIZE));
|
||
const rosterPageSafe = Math.min(rosterPage, rosterPageCount - 1);
|
||
const rosterSlice = useMemo(() => {
|
||
const start = rosterPageSafe * ROSTER_PAGE_SIZE;
|
||
return filteredAgents.slice(start, start + ROSTER_PAGE_SIZE);
|
||
}, [filteredAgents, rosterPageSafe]);
|
||
|
||
useEffect(() => {
|
||
setRosterPage(0);
|
||
}, [filters]);
|
||
const selectedAgents = useMemo(
|
||
() => agents.filter((a) => selectedIds.has(a.id)),
|
||
[agents, selectedIds]
|
||
);
|
||
|
||
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 { visible: visibleTermLines, truncated: termTruncated, hidden: termHidden } = useMemo(
|
||
() => visibleTerminalLines(termLines),
|
||
[termLines],
|
||
);
|
||
|
||
const dispatchTunnelCommand = useCallback(
|
||
async (action: string, args?: Record<string, unknown>) => {
|
||
if (!singleSelectedAgent) return;
|
||
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(),
|
||
},
|
||
]);
|
||
},
|
||
[singleSelectedAgent]
|
||
);
|
||
|
||
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(() => {
|
||
const id = requestAnimationFrame(() => {
|
||
termEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
|
||
});
|
||
return () => cancelAnimationFrame(id);
|
||
}, [termLines.length]);
|
||
|
||
// ── Process incoming command_result messages ───────────────────────────
|
||
|
||
useEffect(() => {
|
||
if (!commandResults || commandResults.length === 0) return;
|
||
const newEntries = commandResults.filter(
|
||
(r) => typeof r._seq === 'number' && r._seq > lastSeqRef.current,
|
||
);
|
||
if (newEntries.length === 0) return;
|
||
|
||
const lines: TermLine[] = [];
|
||
let maxSeq = lastSeqRef.current;
|
||
|
||
for (const r of newEntries) {
|
||
if (typeof r._seq === 'number') {
|
||
maxSeq = Math.max(maxSeq, r._seq);
|
||
}
|
||
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) {
|
||
const cleanB64 = sanitizeScreenshotBase64(msg);
|
||
if (cleanB64) {
|
||
richData = { type: 'screenshot', b64: cleanB64 };
|
||
}
|
||
}
|
||
|
||
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 if (r.action === 'mining_diagnostics') {
|
||
const blockers = parsed.likely_blockers ?? parsed.blockers;
|
||
const tierFields = parseTierReport(parsed as Record<string, unknown>);
|
||
const depthDiag = parseAccessDepthDiagnostics(parsed as Record<string, unknown>);
|
||
setAccessDepthByAgent((prev) => ({ ...prev, [aid]: depthDiag }));
|
||
if (Array.isArray(blockers) || tierFields.lotl_attempts.length > 0) {
|
||
richData = {
|
||
type: 'mining_diagnostics',
|
||
generated_at: parsed.generated_at,
|
||
active_method: parsed.active_method,
|
||
execution_mode: parsed.execution_mode,
|
||
likely_blockers: Array.isArray(blockers)
|
||
? blockers.filter((b: unknown) => typeof b === 'string')
|
||
: [],
|
||
av_recommendation: parsed.av_recommendation,
|
||
lotl_tier: tierFields.lotl_tier,
|
||
lotl_attempts: tierFields.lotl_attempts,
|
||
mining_hashrate: tierFields.mining_hashrate,
|
||
};
|
||
}
|
||
} else {
|
||
// Generic JSON with posture fields (legacy path)
|
||
if (typeof parsed.posture_score === 'number') {
|
||
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);
|
||
if (msgLines.length === 0) {
|
||
const label = r.action ? `[${r.action}]` : '[result]';
|
||
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
|
||
}
|
||
for (const line of msgLines) {
|
||
lines.push({
|
||
id: mkId(), agentId: aid, agentName: name,
|
||
isCmd: false, text: line, ts: new Date(), success: r.success, targeted,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
lastSeqRef.current = maxSeq;
|
||
if (lines.length > 0) {
|
||
setTermLines((prev) => [...prev, ...lines].slice(-2000));
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [commandResults]);
|
||
|
||
// Backup path: if commandResults batching ever misses an entry, latestMessage
|
||
// still carries command_result (WS_LATEST_MESSAGE_TYPES includes it).
|
||
useEffect(() => {
|
||
if (!latestMessage || latestMessage.type !== 'command_result') return;
|
||
if (latestMessage === lastLatestCmdRef.current) return;
|
||
lastLatestCmdRef.current = latestMessage;
|
||
|
||
let r = latestMessage.payload as WSCommandResult | string;
|
||
if (typeof r === 'string') {
|
||
try {
|
||
r = JSON.parse(r) as WSCommandResult;
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
const aid = r.agent_id;
|
||
if (!aid) return;
|
||
|
||
// commandResults effect owns entries already queued in the provider buffer
|
||
if (
|
||
commandResults?.some(
|
||
(c) => c.agent_id === aid && c.action === r.action && c.message === r.message,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const msg = r.message ?? '';
|
||
const agent = agents.find((a) => a.id === aid);
|
||
const name = agent?.name ?? aid.slice(0, 8);
|
||
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
|
||
const msgLines = msg.split('\n').filter(Boolean);
|
||
if (msgLines.length === 0) {
|
||
const label = r.action ? `[${r.action}]` : '[result]';
|
||
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
|
||
}
|
||
const lines: TermLine[] = msgLines.map((line) => ({
|
||
id: mkId(),
|
||
agentId: aid,
|
||
agentName: name,
|
||
isCmd: false,
|
||
text: line,
|
||
ts: new Date(),
|
||
success: r.success,
|
||
targeted,
|
||
}));
|
||
setTermLines((prev) => [...prev, ...lines].slice(-2000));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [latestMessage, 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,
|
||
},
|
||
]);
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// 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 RichMiningDiagnosticsBlock = ({ d }: { d: RichMiningDiagnostics }) => (
|
||
<div className="rich-block rich-mining-diag">
|
||
<div className="rich-header">
|
||
<span className="rich-label">MINING DIAGNOSTICS</span>
|
||
{d.lotl_tier && <LotlTierBadge tier={d.lotl_tier} attempts={d.lotl_attempts} variant="inline" />}
|
||
{d.active_method && <span className="rich-tag">{d.active_method}</span>}
|
||
</div>
|
||
{d.execution_mode && (
|
||
<div className="rich-kv-row">
|
||
<span className="rich-key">Execution</span>
|
||
<span className="rich-val">{d.execution_mode}</span>
|
||
</div>
|
||
)}
|
||
{(d.lotl_attempts?.length ?? 0) > 0 && (
|
||
<LotlAttemptsList
|
||
attempts={d.lotl_attempts ?? []}
|
||
activeTier={d.lotl_tier}
|
||
miningHashrate={d.mining_hashrate}
|
||
/>
|
||
)}
|
||
<div className="rich-sub-label">LIKELY BLOCKERS</div>
|
||
{d.likely_blockers.length === 0 ? (
|
||
<div className="rich-empty">No blockers detected</div>
|
||
) : (
|
||
<ul className="rich-blocker-list">
|
||
{d.likely_blockers.map((b, i) => (
|
||
<li key={i} className="rich-blocker-item">{b}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
|
||
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
|
||
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
|
||
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
|
||
if (d.type === 'mining_diagnostics') return <RichMiningDiagnosticsBlock d={d} />;
|
||
if (d.type === 'full_sys_check') {
|
||
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
|
||
}
|
||
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" />
|
||
|
||
{agents.length > 0 && (
|
||
<FleetToolbar
|
||
agents={agents}
|
||
filters={filters}
|
||
onChange={setFilters}
|
||
selectedCount={selectedIds.size}
|
||
selectedAgents={selectedAgents}
|
||
filteredCount={filteredAgents.length}
|
||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||
onBulkAction={handleBulkAction}
|
||
onCreateGroup={() => setShowGroupModal(true)}
|
||
bulkBusy={bulkBusy}
|
||
/>
|
||
)}
|
||
|
||
<FleetGroupsStrip
|
||
groups={groups}
|
||
liveAgentIds={onlineAgentIds}
|
||
selectedCount={selectedIds.size}
|
||
onSelectGroup={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}
|
||
>
|
||
<div className="crucible-section-title font-tech" style={{ marginBottom: '0.5rem' }}>
|
||
<span className="section-ornament">◆</span> HEAT MAP <HelpTip field="crucible_heat_map" />
|
||
</div>
|
||
<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 <HelpTip field="crucible_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>
|
||
) : filteredAgents.length === 0 ? (
|
||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes match filters.</p>
|
||
) : (
|
||
<>
|
||
{filteredAgents.length > ROSTER_PAGE_SIZE && (
|
||
<div className="crucible-roster-pager" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', fontSize: '0.8rem' }}>
|
||
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe <= 0} onClick={() => setRosterPage((p) => Math.max(0, p - 1))}>← Prev</button>
|
||
<span className="font-tech">
|
||
{rosterPageSafe * ROSTER_PAGE_SIZE + 1}–{Math.min((rosterPageSafe + 1) * ROSTER_PAGE_SIZE, filteredAgents.length)} of {filteredAgents.length}
|
||
</span>
|
||
<button type="button" className="btn btn-outline btn-sm" disabled={rosterPageSafe >= rosterPageCount - 1} onClick={() => setRosterPage((p) => Math.min(rosterPageCount - 1, p + 1))}>Next →</button>
|
||
</div>
|
||
)}
|
||
<div className="crucible-roster">
|
||
{rosterSlice.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>
|
||
{(a.tags?.length ?? 0) > 0 && (
|
||
<div className="cn-tags" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.2rem' }}>
|
||
{a.tags!.map((t) => (
|
||
<span key={t} className="agent-tag-chip" style={{ fontSize: '0.65rem' }}>{t}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{a.notes?.trim() && (
|
||
<div className="form-hint" style={{ fontSize: '0.68rem', marginTop: '0.15rem', opacity: 0.75 }}>
|
||
{a.notes.trim().slice(0, 60)}{a.notes.length > 60 ? '…' : ''}
|
||
</div>
|
||
)}
|
||
<div className="cn-ip font-tech">{a.ip || '—'}</div>
|
||
<div className="cn-stats">
|
||
<span>{a.cpu_cores}c</span>
|
||
<span>{a.gpu_miner_active && (a.gpu_hashrate_15m ?? 0) > 0 ? `GPU: ${formatHashrate(a.gpu_hashrate_15m ?? 0)}` : formatHashrate(a.hashrate_15m)}</span>
|
||
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
|
||
</div>
|
||
<div className="cn-badges">
|
||
<LotlTierBadge tier={a.lotl_tier} attempts={a.lotl_attempts} />
|
||
{(() => { const cb = contingencyDepthBadge(a); return cb && (
|
||
<div className={`cn-contingency ${cb.cls}`} title="Onion contingency tree depth (Fleet AI Control)">
|
||
{cb.label}
|
||
</div>
|
||
); })()}
|
||
<RiskBadge findings={a.vuln_findings} />
|
||
<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>
|
||
|
||
{focusedAgent && (
|
||
<CrucibleAgentMeta agent={focusedAgent} />
|
||
)}
|
||
|
||
{/* ── 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 <HelpTip field="crucible_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>
|
||
<LotlTierBadge tier={focusedAgent.lotl_tier} attempts={focusedAgent.lotl_attempts} variant="inline" />
|
||
<RiskBadge findings={focusedAgent.vuln_findings} variant="inline" />
|
||
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
|
||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
|
||
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
|
||
{focusedAgent.status !== 'online' && (
|
||
<span style={{ color: 'var(--error-color)', 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>
|
||
)}
|
||
|
||
{focusedAgent && (
|
||
<AccessDepthPanel
|
||
agent={focusedAgent}
|
||
diagnostics={accessDepthByAgent[focusedAgent.id]}
|
||
/>
|
||
)}
|
||
|
||
{/* ── 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 <HelpTip field="crucible_groups" />
|
||
</div>
|
||
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
|
||
Named color subsets — click a chip to select all members for bulk commands.
|
||
</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>
|
||
|
||
{/* Tab selectors */}
|
||
<div className="crucible-tabs font-tech" style={{
|
||
display: 'flex',
|
||
gap: '0.35rem',
|
||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||
marginBottom: '0.75rem',
|
||
paddingBottom: '0.4rem',
|
||
flexWrap: 'wrap',
|
||
}}>
|
||
{(
|
||
[
|
||
{ id: 'ops', label: 'OPERATIONS' },
|
||
{ id: 'recon', label: 'INTEL & RECON' },
|
||
{ id: 'files', label: 'FILE OPS' },
|
||
{ id: 'spread', label: 'LATERAL / SPREAD' },
|
||
{ id: 'tunnels', label: 'TUNNELS' },
|
||
] as const
|
||
).map((t) => {
|
||
const tabHelp: Record<typeof t.id, string> = {
|
||
ops: 'crucible_tab_ops',
|
||
recon: 'crucible_tab_recon',
|
||
files: 'crucible_tab_files',
|
||
spread: 'crucible_tab_spread',
|
||
tunnels: 'crucible_tab_tunnels',
|
||
};
|
||
return (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
className={`crucible-tab-btn ${activeTab === t.id ? 'active' : ''}`}
|
||
onClick={() => setActiveTab(t.id)}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: activeTab === t.id ? 'var(--neon-amber)' : 'var(--text-muted)',
|
||
cursor: 'pointer',
|
||
fontSize: '0.72rem',
|
||
fontWeight: 600,
|
||
padding: '0.25rem 0.55rem',
|
||
borderBottom: activeTab === t.id ? '2px solid var(--neon-amber)' : '2px solid transparent',
|
||
transition: 'all 0.15s',
|
||
letterSpacing: '0.08em',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '0.15rem',
|
||
}}
|
||
>
|
||
{t.label}
|
||
<HelpTip field={tabHelp[t.id]} />
|
||
</button>
|
||
);})}
|
||
</div>
|
||
|
||
<div className="crucible-ops-panel">
|
||
<CrucibleExpandedOps
|
||
activeTab={activeTab}
|
||
selectedAgents={selectedAgents}
|
||
selectedCount={selectedIds.size}
|
||
singleSelectedAgent={singleSelectedAgent}
|
||
allAgents={agents}
|
||
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,
|
||
},
|
||
]);
|
||
}}
|
||
onScanSelected={() => scanSelected()}
|
||
onProbePosture={() => probePosture(selectedIds.size > 0 ? selectedAgents.filter(online) : agents.filter(online))}
|
||
onProbeSSH={() => probeSSH()}
|
||
onWakeSSH={() => wakeSSH()}
|
||
onShellDispatch={(command) => {
|
||
const cmd = command === 'ipconfig /all'
|
||
? (shellType === 'powershell' ? 'ipconfig /all' : 'ip addr')
|
||
: command;
|
||
dispatch(cmd, shellType);
|
||
}}
|
||
browseAgent={browseAgent}
|
||
encryptTargets={encryptTargets}
|
||
fmCommandResults={fmCommandResults}
|
||
tunnelStatusMsg={tunnelStatusMsg}
|
||
onDispatchTunnel={dispatchTunnelCommand}
|
||
/>
|
||
|
||
{/* ── 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" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
|
||
<span className="section-ornament">◆</span> TERMINAL <HelpTip field="crucible_master_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>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||
<div className="crucible-shell-tabs">
|
||
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
className={`crucible-shell-tab ${shellType === s ? 'active' : ''}`}
|
||
onClick={() => setShellType(s)}
|
||
>
|
||
{s === 'powershell' ? 'PS' : s === 'exec' ? 'CMD' : 'SH'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button type="button" className="crucible-btn-muted" onClick={() => setTermLines([])}>CLEAR</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="crucible-terminal">
|
||
{termLines.length === 0 && (
|
||
<div className="crucible-term-empty">
|
||
Select nodes · type a command · press Enter
|
||
</div>
|
||
)}
|
||
{termTruncated && (
|
||
<div className="crucible-term-truncated" style={{ opacity: 0.55, fontSize: '0.72rem', padding: '0.2rem 0' }}>
|
||
… {termHidden} older lines hidden (CLEAR to reset)
|
||
</div>
|
||
)}
|
||
{visibleTermLines.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>
|
||
|
||
<CreateGroupModal
|
||
open={showGroupModal}
|
||
agentCount={selectedIds.size}
|
||
onClose={() => setShowGroupModal(false)}
|
||
onCreate={(name, color) => {
|
||
addGroup(name, color, [...selectedIds]);
|
||
setShowGroupModal(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|