feat: listen_ports + patch_status commands, POSTURE/PATCH/PORTS chips, Scan All Selected button

This commit is contained in:
AetherForge
2026-05-30 23:31:52 -07:00
parent d005d5d07c
commit 159747877c
17 changed files with 421 additions and 41 deletions

View File

@@ -47,14 +47,19 @@ function sshBadge(agent: Agent) {
function postureBadge(score?: number) {
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
return { label: `P:${score}`, cls: 'posture-bad' };
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' };
}
function patchLabel(days?: number) {
if (days === undefined) return null;
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
}
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' };
}
function postureTooltip(agent: Agent): string {
@@ -66,6 +71,7 @@ function postureTooltip(agent: Agent): string {
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
@@ -448,6 +454,27 @@ export default function CruciblePage() {
);
};
// Fires posture + listen_ports + patch_status in parallel for all selected online nodes.
const scanSelected = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) 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,
},
]);
});
}
}
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
@@ -476,7 +503,11 @@ export default function CruciblePage() {
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) };
return {
...postureBadge(score),
patch: patchLabel(patchDays),
ports: portsBadge(a.listen_port_count),
};
};
// ── Render ─────────────────────────────────────────────────────────────
@@ -554,11 +585,19 @@ export default function CruciblePage() {
{posture.patch && (
<div
className={`cn-patch ${posture.patch.cls}`}
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
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}
@@ -672,9 +711,9 @@ export default function CruciblePage() {
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
title="Probe posture (AV, firewall, SSH, patch) on selected nodes"
>
Probe Selected
Probe Posture
</button>
<button
className="button crucible-op-btn crucible-op-wake"
@@ -682,7 +721,15 @@ export default function CruciblePage() {
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Probe All
Fleet Posture Scan
</button>
<button
className="button crucible-op-btn crucible-op-scan"
disabled={selectedIds.size === 0}
onClick={() => scanSelected()}
title="Fire posture + listen_ports + patch_status in parallel on selected nodes"
>
Scan All Selected
</button>
</div>