fix(web/scripts): group remediation cycle phases under one parent card (Intune-cycle UX)

Confirmed via real non-compliant cycle data from session 8810cf81 / policy 99e1274b:
detection (non-compliant) + remediation (script crashed exit 1) + post-detection (still
non-compliant) — three separate sibling cards for the same Remediation policy was
visually fragmenting and obscured that they all belong to one cycle.

New grouping:

- groupScriptItems() folds phases for the same (policyId, scriptType=remediation) into
  a single ScriptCard with phases[] sorted detection → remediation → post-detection.
  Platform scripts and detect-only / running remediations stay as single-phase cards.

- deriveRemediationHeader() computes the cycle-level outcome label + state with priority:
  Running → Failed (e.g. remediation script crash) → Non-compliant after remediation →
  Remediated successfully → Non-compliant (detect-only) → Compliant (detect-only).

- ScriptCardView wraps multi-phase cycles in a parent header (icon + "Remediation" +
  shortId + cycle badge + System/Target/Status meta + collapsible chevron) with the
  individual phase rows nested inside on a lighter inner background. Single-phase
  cards reuse the existing ScriptItemRow layout 1:1 (no parent wrapper) so the
  detect-only majority case stays unchanged.

- Header counter "succeeded / non-compliant / failed" now counts CARDS not phases, so
  a remediated cycle reads as "1 succeeded" rather than "1 succeeded + 2 non-compliant"
  from its detection / post-detection en route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-12 11:09:37 +02:00
parent 76284cfcb2
commit 7e67a70756
4 changed files with 525 additions and 49 deletions
@@ -1 +1 @@
692
693
@@ -5,12 +5,15 @@ import { compareVersions } from "@/utils/bootstrapVersion";
import {
buildScriptItemLabel,
getPhaseBadge,
groupScriptItems,
isDetectOnlyRow,
isNonCompliantReport,
mapRemediationStatus,
reduceScriptEvents,
scriptCardKey,
scriptItemKey,
STALE_RUNNING_THRESHOLD_SECONDS,
type ScriptCard,
type ScriptInputEvent,
type ScriptItem,
} from "@/lib/scriptExecutions";
@@ -22,21 +25,21 @@ interface ScriptExecutionsProps {
}
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion }: ScriptExecutionsProps) {
const scripts = useMemo(() => reduceScriptEvents(events), [events]);
const cards = useMemo(() => groupScriptItems(reduceScriptEvents(events)), [events]);
const [expanded, setExpanded] = useState(true);
if (scripts.length === 0) return null;
if (cards.length === 0) return null;
// Distinguish "script ran fine and was compliant" from "script ran fine but reported
// non-compliance" — the latter isn't a failure but deserves visibility (it's the
// "needs attention" middle ground for health-script detection / post-detection).
const nonCompliantCount = scripts.filter(isNonCompliantReport).length;
const successCount = scripts.filter(s => s.state === "Success").length - nonCompliantCount;
const failedCount = scripts.filter(s => s.state === "Failed").length;
const runningCount = scripts.filter(s => s.state === "Running").length;
const platformCount = scripts.filter(s => s.scriptType === "platform").length;
const remediationCount = scripts.filter(s => s.scriptType === "remediation").length;
// Header counters work on cards (one per policy), so a remediated cycle reads as
// "1 succeeded" rather than "1 succeeded + 2 non-compliant" when its detection /
// post-detection were non-compliant en route.
const runningCount = cards.filter(c => c.headerState === "Running").length;
const successCount = cards.filter(c => c.headerState === "Success").length;
const nonCompliantCount = cards.filter(c => c.headerState === "NonCompliant").length;
const failedCount = cards.filter(c => c.headerState === "Failed").length;
const platformCount = cards.filter(c => c.scriptType === "platform").length;
const remediationCount = cards.filter(c => c.scriptType === "remediation").length;
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
@@ -61,7 +64,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
</span>
)}
{nonCompliantCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium" title="Detection script ran successfully but reported a non-compliant state">
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium" title="Detection ran successfully but reported a non-compliant state">
{nonCompliantCount} non-compliant
</span>
)}
@@ -89,10 +92,10 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
{expanded && (
<div className="space-y-3 mt-4">
{scripts.map((item) => (
<ScriptItemRow
key={scriptItemKey(item)}
item={item}
{cards.map((card) => (
<ScriptCardView
key={scriptCardKey(card)}
card={card}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
/>
@@ -110,7 +113,148 @@ function getIntuneScriptUrl(policyId: string, scriptType: string): string | null
return `https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/ConfigureWMPolicyMenuBlade/~/overview/policyId/${policyId}/policyType/0`;
}
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null }) {
function cardContainerClass(state: ScriptCard["headerState"]): string {
switch (state) {
case "Failed": return "bg-red-50 border border-red-200";
case "Running": return "bg-gray-50 border border-gray-200";
case "NonCompliant": return "bg-amber-50 border border-amber-200";
default: return "bg-green-50 border border-green-200";
}
}
function cardIconColor(state: ScriptCard["headerState"]): string {
switch (state) {
case "Failed": return "text-red-500";
case "Running": return "text-gray-500";
case "NonCompliant": return "text-amber-500";
default: return "text-green-500";
}
}
function cardStatusTextColor(state: ScriptCard["headerState"]): string {
switch (state) {
case "Failed": return "text-red-600";
case "Running": return "text-gray-600";
case "NonCompliant": return "text-amber-700";
default: return "text-green-600";
}
}
/**
* Top-level renderer for one logical script card. Multi-phase remediation cycles render
* the parent header + nested phase rows under it; platform / single-phase remediation
* cards collapse the parent header into the single phase row (no nesting).
*/
function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion }: { card: ScriptCard; showScriptOutput?: boolean; latestBootstrapVersion?: string | null }) {
// Cycles default to expanded (the user usually wants to see what happened in each
// phase); single-phase cards have nothing nested so the toggle would be a no-op.
const [phasesOpen, setPhasesOpen] = useState(true);
if (!card.isCycle) {
// Single-phase / platform: keep the existing row layout, no parent wrapper.
return (
<ScriptItemRow
item={card.phases[0]}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
/>
);
}
// Multi-phase remediation cycle — render parent header + nested phase rows.
const containerClass = cardContainerClass(card.headerState);
const iconColor = cardIconColor(card.headerState);
const statusColor = cardStatusTextColor(card.headerState);
const shortId = card.policyId
? (card.policyId.length >= 8 ? card.policyId.substring(0, 8) : card.policyId)
: "unknown";
// Pull the most-relevant cycle metadata once for the header detail strip.
const meta = card.phases.find(p => p.runContext) ?? card.phases[0];
const remediationStatus = card.phases.map(p => p.remediationStatus).find(s => s != null);
const remediationStatusLabel = mapRemediationStatus(remediationStatus);
return (
<div className={`rounded-lg ${containerClass}`}>
<button
onClick={() => setPhasesOpen(!phasesOpen)}
className="flex items-center justify-between w-full text-left p-3 hover:bg-black/[.02]"
aria-expanded={phasesOpen}
>
<div className="flex items-center space-x-2 min-w-0">
<CardIcon state={card.headerState} className={iconColor} />
<span className="text-sm font-medium text-gray-900 truncate">Remediation</span>
<span className="text-xs font-mono text-gray-500">{shortId}…</span>
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700">
cycle · {card.phases.length} phases
</span>
{meta.runContext && (
<span className="text-xs text-gray-500">{meta.runContext}</span>
)}
{meta.targetType != null && (
<span className="text-xs text-gray-500">Target: {meta.targetType === 2 ? "Device" : "User"}</span>
)}
{remediationStatusLabel && (
<span className="text-xs text-gray-500">Status: {remediationStatusLabel}</span>
)}
</div>
<div className="flex items-center space-x-3 text-xs flex-shrink-0 ml-2">
<span className={`font-medium ${statusColor}`}>{card.headerLabel}</span>
<svg className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${phasesOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
{phasesOpen && (
// Nested phase rows. Slightly inset so the parent → child relationship is visual.
<div className="border-t border-black/5 px-3 py-2 space-y-2">
{card.phases.map((phase) => (
<ScriptItemRow
key={scriptItemKey(phase)}
item={phase}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
nested
/>
))}
</div>
)}
</div>
);
}
function CardIcon({ state, className }: { state: ScriptCard["headerState"]; className: string }) {
if (state === "Running") {
return (
<svg className={`w-4 h-4 flex-shrink-0 animate-spin ${className}`} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
);
}
if (state === "Failed") {
return (
<svg className={`w-4 h-4 flex-shrink-0 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
if (state === "NonCompliant") {
return (
<svg className={`w-4 h-4 flex-shrink-0 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
}
return (
<svg className={`w-4 h-4 flex-shrink-0 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; nested?: boolean }) {
const [showDetails, setShowDetails] = useState(false);
// Re-render every 5s while in Running state so elapsed-time updates live.
const [now, setNow] = useState(() => Date.now());
@@ -129,16 +273,18 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
const isDetectOnly = isDetectOnlyRow(item);
const isNonCompliant = isNonCompliantReport(item);
const containerClass = item.state === "Failed"
? "bg-red-50 border border-red-200"
: item.state === "Running"
// Neutral gray for the box itself — only the inline spinner conveys motion.
// The full-box pulse was visually distracting with multiple concurrent Running cards
// and caused a "flickering" feeling that made the page feel unstable.
? "bg-gray-50 border border-gray-200"
: isNonCompliant
? "bg-amber-50 border border-amber-200"
: "bg-green-50 border border-green-200";
// Standalone rows get their own coloured container; nested phase rows live inside an
// already-coloured parent card so they use a lighter, neutral inner background to
// avoid the noisy double-tinting effect.
const containerClass = nested
? "bg-white/60 border border-black/5"
: item.state === "Failed"
? "bg-red-50 border border-red-200"
: item.state === "Running"
? "bg-gray-50 border border-gray-200"
: isNonCompliant
? "bg-amber-50 border border-amber-200"
: "bg-green-50 border border-green-200";
const shortId = item.policyId
? (item.policyId.length >= 8 ? item.policyId.substring(0, 8) : item.policyId)
@@ -174,7 +320,6 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : isNonCompliant ? (
// Triangle warning icon — script ran fine but reported non-compliant state.
<svg className="w-4 h-4 text-amber-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
@@ -184,13 +329,14 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
</svg>
)}
<span className="text-sm font-medium text-gray-900 truncate">{label}</span>
{intuneUrl ? (
<a href={intuneUrl} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-blue-600 hover:text-blue-800 hover:underline" title="Open in Intune portal">{shortId}…</a>
) : (
<span className="text-xs font-mono text-gray-500">{shortId}…</span>
{/* shortId only on standalone rows — inside a cycle the parent header already shows it. */}
{!nested && (
intuneUrl ? (
<a href={intuneUrl} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-blue-600 hover:text-blue-800 hover:underline" title="Open in Intune portal">{shortId}…</a>
) : (
<span className="text-xs font-mono text-gray-500">{shortId}…</span>
)
)}
{/* Phase badge for remediation rows (detection / remediation / post-detection).
For platform scripts we keep showing the type tag. */}
{item.scriptType === "remediation" && getPhaseBadge(item) && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700">
{getPhaseBadge(item)}
@@ -225,7 +371,8 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
outdated
</span>
)}
{item.runContext && (
{/* runContext/Target only on standalone rows — inside a cycle the parent header carries them. */}
{!nested && item.runContext && (
<span className="text-xs text-gray-500">{item.runContext}</span>
)}
</div>
@@ -260,19 +407,21 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
{showDetails && item.state !== "Running" && (
<div className="mt-3 space-y-2">
{/* Metadata */}
{/* Metadata — slimmer when nested (parent header already shows policy id / context / target / status). */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
<span><span className="font-medium text-gray-700">Policy ID:</span> {intuneUrl ? (
<a href={intuneUrl} target="_blank" rel="noopener noreferrer" className="font-mono text-blue-600 hover:text-blue-800 hover:underline">{item.policyId}</a>
) : (
<span className="font-mono">{item.policyId}</span>
)}</span>
{item.runContext && <span><span className="font-medium text-gray-700">Context:</span> {item.runContext}</span>}
{item.targetType != null && <span><span className="font-medium text-gray-700">Target:</span> {item.targetType === 2 ? "Device" : "User"}</span>}
{!nested && (
<span><span className="font-medium text-gray-700">Policy ID:</span> {intuneUrl ? (
<a href={intuneUrl} target="_blank" rel="noopener noreferrer" className="font-mono text-blue-600 hover:text-blue-800 hover:underline">{item.policyId}</a>
) : (
<span className="font-mono">{item.policyId}</span>
)}</span>
)}
{!nested && item.runContext && <span><span className="font-medium text-gray-700">Context:</span> {item.runContext}</span>}
{!nested && item.targetType != null && <span><span className="font-medium text-gray-700">Target:</span> {item.targetType === 2 ? "Device" : "User"}</span>}
{item.exitCode != null && <span><span className="font-medium text-gray-700">Exit Code:</span> <span className="font-mono">{item.exitCode}</span></span>}
{item.result && <span><span className="font-medium text-gray-700">Result:</span> {item.result}</span>}
{item.complianceResult && <span><span className="font-medium text-gray-700">Compliance:</span> {item.complianceResult === "True" ? "Compliant" : "Non-compliant"}</span>}
{remediationStatusLabel && <span><span className="font-medium text-gray-700">Status:</span> {remediationStatusLabel}</span>}
{!nested && remediationStatusLabel && <span><span className="font-medium text-gray-700">Status:</span> {remediationStatusLabel}</span>}
{item.errorCode != null && item.errorCode !== 0 && (
<span><span className="font-medium text-gray-700">Error Code:</span> <span className="font-mono">{item.errorCode}</span></span>
)}
@@ -285,7 +434,6 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
</div>
)}
{/* stdout */}
{hasStdout && (
<div>
<div className="text-xs font-medium text-gray-500 mb-1">stdout</div>
@@ -295,12 +443,10 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
</div>
)}
{/* stdout hidden hint */}
{showScriptOutput === false && item.stdout && item.stdout.trim().length > 0 && (
<div className="text-xs text-gray-400 italic">stdout hidden by admin setting</div>
)}
{/* stderr */}
{hasStderr && (
<div>
<div className="text-xs font-medium text-red-500 mb-1">stderr</div>
@@ -310,7 +456,6 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
</div>
)}
{/* No output */}
{!hasOutput && !(showScriptOutput === false && item.stdout && item.stdout.trim().length > 0) && (
<div className="text-xs text-gray-400 italic">No script output captured</div>
)}
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
buildScriptItemLabel,
getPhaseBadge,
groupScriptItems,
isDetectOnlyRow,
isNonCompliantReport,
mapRemediationStatus,
@@ -573,3 +574,138 @@ describe("isNonCompliantReport", () => {
expect(isNonCompliantReport({ scriptType: "platform", scriptPart: undefined, complianceResult: undefined, state: "Success" })).toBe(false);
});
});
// ── Card grouping (multi-phase remediation cycles fold into one parent card) ────────
describe("groupScriptItems", () => {
// Helper to construct a ScriptItem inline.
const item = (overrides: Partial<ScriptItem>): ScriptItem => ({
policyId: "p1",
scriptType: "remediation",
state: "Success",
timestamp: ts(0),
...overrides,
});
it("returns empty for empty input", () => {
expect(groupScriptItems([])).toEqual([]);
});
it("platform scripts stay as single-phase cards (no nesting)", () => {
const cards = groupScriptItems([
item({ policyId: "plat1", scriptType: "platform", scriptPart: undefined, exitCode: 0, result: "Success" }),
]);
expect(cards).toHaveLength(1);
expect(cards[0].isCycle).toBe(false);
expect(cards[0].phases).toHaveLength(1);
expect(cards[0].headerState).toBe("Success");
});
it("detect-only remediation (single phase, RemediationStatus=4) → single-phase card with detect-only header", () => {
const cards = groupScriptItems([
item({ scriptPart: "detection", complianceResult: "True", exitCode: 0, remediationStatus: 4 }),
]);
expect(cards).toHaveLength(1);
expect(cards[0].isCycle).toBe(false);
expect(cards[0].headerState).toBe("Success");
});
it("3-phase remediation cycle for the same policy folds into ONE parent card (the user-reported regression)", () => {
// Real shape from session 8810cf81 / policy 99e1274b: detection non-compliant,
// remediation script crashed (exit 1), post-detection still non-compliant. The user
// wants this to read as ONE card with three phase rows under it, not three siblings.
const cards = groupScriptItems([
item({ scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 2, timestamp: ts(0) }),
item({ scriptPart: "remediation", state: "Failed", exitCode: 1, remediationStatus: 2, timestamp: ts(1) }),
item({ scriptPart: "post-detection", complianceResult: "False", exitCode: 1, remediationStatus: 2, timestamp: ts(2) }),
]);
expect(cards).toHaveLength(1);
expect(cards[0].isCycle).toBe(true);
expect(cards[0].phases).toHaveLength(3);
// The remediation phase failed → header reads as Failed.
expect(cards[0].headerState).toBe("Failed");
expect(cards[0].headerLabel).toBe("Remediation script failed");
// Phases are sorted detection → remediation → post-detection regardless of insertion order.
expect(cards[0].phases.map(p => p.scriptPart)).toEqual(["detection", "remediation", "post-detection"]);
});
it("phases sort detection→remediation→post-detection even when input arrives reversed", () => {
const cards = groupScriptItems([
item({ scriptPart: "post-detection", complianceResult: "True", exitCode: 0, remediationStatus: 2, timestamp: ts(2) }),
item({ scriptPart: "remediation", exitCode: 0, remediationStatus: 2, timestamp: ts(1) }),
item({ scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 2, timestamp: ts(0) }),
]);
expect(cards[0].phases.map(p => p.scriptPart)).toEqual(["detection", "remediation", "post-detection"]);
});
it("successful remediation cycle (post-detection now compliant) → header 'Remediated successfully' green", () => {
const cards = groupScriptItems([
item({ scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 2, timestamp: ts(0) }),
item({ scriptPart: "remediation", exitCode: 0, remediationStatus: 2, timestamp: ts(1) }),
item({ scriptPart: "post-detection", complianceResult: "True", exitCode: 0, remediationStatus: 2, timestamp: ts(2) }),
]);
expect(cards[0].headerState).toBe("Success");
expect(cards[0].headerLabel).toBe("Remediated successfully");
});
it("non-compliant cycle (remediation didn't fix) → header 'Non-compliant after remediation' amber", () => {
const cards = groupScriptItems([
item({ scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 3, timestamp: ts(0) }),
item({ scriptPart: "remediation", exitCode: 0, remediationStatus: 3, timestamp: ts(1) }),
item({ scriptPart: "post-detection", complianceResult: "False", exitCode: 1, remediationStatus: 3, timestamp: ts(2) }),
]);
expect(cards[0].headerState).toBe("NonCompliant");
expect(cards[0].headerLabel).toBe("Non-compliant after remediation");
});
it("detect-only non-compliant → header 'Non-compliant (detect-only)' amber, single phase", () => {
const cards = groupScriptItems([
item({ scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 4 }),
]);
expect(cards[0].isCycle).toBe(false);
expect(cards[0].headerState).toBe("NonCompliant");
expect(cards[0].headerLabel).toBe("Non-compliant (detect-only)");
});
it("detect-only compliant → header 'Compliant (detect-only)' green, single phase", () => {
const cards = groupScriptItems([
item({ scriptPart: "detection", complianceResult: "True", exitCode: 0, remediationStatus: 4 }),
]);
expect(cards[0].headerState).toBe("Success");
expect(cards[0].headerLabel).toBe("Compliant (detect-only)");
});
it("Running placeholder card surfaces with header state Running", () => {
const cards = groupScriptItems([
item({ scriptPart: undefined, state: "Running", complianceResult: undefined, exitCode: undefined }),
]);
expect(cards[0].headerState).toBe("Running");
expect(cards[0].headerLabel).toBe("Running");
});
it("multiple distinct policies produce multiple cards in chronological order", () => {
const cards = groupScriptItems([
item({ policyId: "p2", scriptPart: "detection", complianceResult: "True", exitCode: 0, remediationStatus: 4, timestamp: ts(10) }),
item({ policyId: "p1", scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 4, timestamp: ts(5) }),
]);
expect(cards).toHaveLength(2);
// Sorted by earliest-phase timestamp ascending.
expect(cards[0].policyId).toBe("p1");
expect(cards[1].policyId).toBe("p2");
});
it("does NOT collapse different policies together (sanity check)", () => {
const cards = groupScriptItems([
item({ policyId: "p1", scriptPart: "detection", complianceResult: "True", exitCode: 0, remediationStatus: 4 }),
item({ policyId: "p2", scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 4 }),
]);
expect(cards).toHaveLength(2);
});
it("does NOT collapse remediation and platform of same policyId together (different scriptType)", () => {
const cards = groupScriptItems([
item({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", complianceResult: "True", exitCode: 0, remediationStatus: 4 }),
item({ policyId: "p1", scriptType: "platform", scriptPart: undefined, exitCode: 0, result: "Success" }),
]);
expect(cards).toHaveLength(2);
});
});
@@ -43,6 +43,38 @@ export function scriptItemKey(item: Pick<ScriptItem, "policyId" | "scriptType" |
return `${item.policyId || "_noid"}-${item.scriptType}-${part}`;
}
/**
* A logical script card shown in the UI. Platform scripts and detect-only / running
* remediations render as a single-phase card; non-compliant remediation cycles group
* the 2-3 phases (detection / remediation / post-detection) under one parent so the
* cycle reads as one unit rather than three disconnected rows. The header carries the
* cycle-level outcome derived across all phases + RemediationStatus.
*/
export interface ScriptCard {
policyId: string;
scriptType: string; // "platform" | "remediation"
/**
* The phase items that make up this card, sorted in their natural cycle order
* (detection → remediation → post-detection). For platform / single-phase cards
* this contains exactly one item.
*/
phases: ScriptItem[];
/** Header outcome label, e.g. "Compliant", "Remediated successfully", "Remediation failed". */
headerLabel: string;
/** Overall card state — drives the container colour. */
headerState: "Running" | "Success" | "Failed" | "NonCompliant";
/** True when the card represents a multi-phase remediation cycle (header expandable). */
isCycle: boolean;
/** First-phase timestamp; used for sorting cards chronologically in the UI. */
timestamp: string;
}
/** Stable React key for a ScriptCard. */
export function scriptCardKey(card: Pick<ScriptCard, "policyId" | "scriptType" | "headerState">): string {
const idPart = card.headerState === "Running" ? "_running" : "_card";
return `${card.policyId || "_noid"}-${card.scriptType}-${idPart}`;
}
/** Threshold above which a Running placeholder is rendered as "stuck?" rather than animated. */
export const STALE_RUNNING_THRESHOLD_SECONDS = 600;
@@ -277,3 +309,166 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
return items;
}
// ─────────────────────────────────────────────────────────────────────────────
// Card grouping — folds multi-phase remediation cycles under a single header
// ─────────────────────────────────────────────────────────────────────────────
const PHASE_ORDER: Record<string, number> = {
"detection": 0,
"remediation": 1,
"post-detection": 2,
};
function phaseSortKey(part?: string): number {
if (!part) return 0;
return PHASE_ORDER[part] ?? 99;
}
/**
* Derive the human-readable header label + state for a remediation card based on its
* phases and the cycle-level RemediationStatus. The label communicates the cycle
* outcome at a glance; the state drives the card colour. Priority (highest wins):
* 1. Any phase Running → state=Running, label="Running"
* 2. Any phase Failed (real script crash, e.g. remediation phase exit != 0 or stderr)
* → state=Failed, label="Remediation script failed"
* 3. Has post-detection AND post-detection compliance=False
* → state=NonCompliant, label="Non-compliant after remediation"
* 4. Has remediation phase AND post-detection compliance=True
* → state=Success, label="Remediated successfully"
* 5. detection-only AND compliance=False (RemediationStatus=4)
* → state=NonCompliant, label="Non-compliant (detect-only)"
* 6. detection-only AND compliance=True
* → state=Success, label="Compliant"
* 7. Fallback
* → state mirrors first phase, label = generic
*/
function deriveRemediationHeader(
phases: ScriptItem[]
): { label: string; state: ScriptCard["headerState"] } {
if (phases.some(p => p.state === "Running")) {
return { label: "Running", state: "Running" };
}
const detection = phases.find(p => p.scriptPart === "detection");
const remediation = phases.find(p => p.scriptPart === "remediation");
const postDetection = phases.find(p => p.scriptPart === "post-detection");
const remediationStatus = phases
.map(p => p.remediationStatus)
.find(s => s != null);
// Real script crash (not a non-compliant compliance verdict). The reducer already
// routes detection / post-detection non-zero-exit to state=Success-with-amber, so
// any phase here in state=Failed means a genuine failure: stderr or exit on the
// remediation phase or platform-style failure.
const failedPhase = phases.find(p => p.state === "Failed");
if (failedPhase) {
if (failedPhase.scriptPart === "remediation") {
return { label: "Remediation script failed", state: "Failed" };
}
return { label: "Script error", state: "Failed" };
}
// Multi-phase cycle (a remediation actually ran). The cycle outcome lives in the
// post-detection compliance + RemediationStatus.
if (remediation || postDetection) {
if (postDetection?.complianceResult === "False") {
return { label: "Non-compliant after remediation", state: "NonCompliant" };
}
if (postDetection?.complianceResult === "True") {
return { label: "Remediated successfully", state: "Success" };
}
// Remediation ran but post-detection result not yet known — mid-flight or partial data.
return { label: "Remediation ran", state: "Success" };
}
// Detect-only path — only the detection phase ever ran (RemediationStatus=4
// = NoRemediation, OR no remediation script attached to the policy).
if (detection) {
const isDetectOnly = remediationStatus === 4
|| (remediation == null && postDetection == null);
if (detection.complianceResult === "False") {
return {
label: isDetectOnly ? "Non-compliant (detect-only)" : "Non-compliant",
state: "NonCompliant",
};
}
if (detection.complianceResult === "True") {
return {
label: isDetectOnly ? "Compliant (detect-only)" : "Compliant",
state: "Success",
};
}
}
// Fallback for unknown shapes.
return { label: "Health script ran", state: "Success" };
}
/**
* Group flat ScriptItems into card entries: remediation phases for the same policyId
* collapse into one parent card with a phases array; platform scripts and lone
* remediation entries (single phase) become single-phase cards. Cards are returned in
* chronological order based on the earliest phase timestamp.
*
* This is the structural change that lets the UI render
* [ Remediation 99e1274b ── Non-compliant after remediation ]
* ⟶ detection (pre)
* ⟶ remediation
* ⟶ detection (post)
* instead of three disconnected sibling rows.
*/
export function groupScriptItems(items: ScriptItem[]): ScriptCard[] {
if (items.length === 0) return [];
// Group by (policyId, scriptType). Empty policyId items get unique single-phase cards
// so they never collide with each other.
const byPolicy = new Map<string, ScriptItem[]>();
for (let idx = 0; idx < items.length; idx++) {
const item = items[idx];
const key = item.policyId
? `${item.policyId}-${item.scriptType}`
: `_noid_${idx}-${item.scriptType}`;
const arr = byPolicy.get(key);
if (arr) arr.push(item);
else byPolicy.set(key, [item]);
}
const cards: ScriptCard[] = [];
for (const phases of byPolicy.values()) {
// Sort phases inside each card: detection → remediation → post-detection
// (Running entries naturally sort first since they have no scriptPart).
phases.sort((a, b) => phaseSortKey(a.scriptPart) - phaseSortKey(b.scriptPart));
const first = phases[0];
const isCycle = first.scriptType === "remediation" && phases.length > 1;
let headerLabel: string;
let headerState: ScriptCard["headerState"];
if (first.scriptType === "remediation") {
const derived = deriveRemediationHeader(phases);
headerLabel = derived.label;
headerState = derived.state;
} else {
// Platform scripts: header mirrors the single phase.
headerState = first.state === "Running" ? "Running" : (first.state === "Failed" ? "Failed" : "Success");
headerLabel = first.state === "Running"
? "Running"
: (first.result ?? (first.state === "Failed" ? "Failed" : "Success"));
}
cards.push({
policyId: first.policyId,
scriptType: first.scriptType,
phases,
headerLabel,
headerState,
isCycle,
timestamp: first.timestamp,
});
}
// Sort cards by earliest-phase timestamp so the timeline order is preserved.
cards.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
return cards;
}