- {/* Metadata */}
+ {/* Metadata — slimmer when nested (parent header already shows policy id / context / target / status). */}
-
Policy ID: {intuneUrl ? (
- {item.policyId}
- ) : (
- {item.policyId}
- )}
- {item.runContext &&
Context: {item.runContext}}
- {item.targetType != null &&
Target: {item.targetType === 2 ? "Device" : "User"}}
+ {!nested && (
+
Policy ID: {intuneUrl ? (
+ {item.policyId}
+ ) : (
+ {item.policyId}
+ )}
+ )}
+ {!nested && item.runContext &&
Context: {item.runContext}}
+ {!nested && item.targetType != null &&
Target: {item.targetType === 2 ? "Device" : "User"}}
{item.exitCode != null &&
Exit Code: {item.exitCode}}
{item.result &&
Result: {item.result}}
{item.complianceResult &&
Compliance: {item.complianceResult === "True" ? "Compliant" : "Non-compliant"}}
- {remediationStatusLabel &&
Status: {remediationStatusLabel}}
+ {!nested && remediationStatusLabel &&
Status: {remediationStatusLabel}}
{item.errorCode != null && item.errorCode !== 0 && (
Error Code: {item.errorCode}
)}
@@ -285,7 +434,6 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
)}
- {/* stdout */}
{hasStdout && (
stdout
@@ -295,12 +443,10 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
)}
- {/* stdout hidden hint */}
{showScriptOutput === false && item.stdout && item.stdout.trim().length > 0 && (
stdout hidden by admin setting
)}
- {/* stderr */}
{hasStderr && (
stderr
@@ -310,7 +456,6 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
)}
- {/* No output */}
{!hasOutput && !(showScriptOutput === false && item.stdout && item.stdout.trim().length > 0) && (
No script output captured
)}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts
index 6a94c427..52b1ef7e 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts
@@ -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 => ({
+ 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);
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts
index 58082821..361c0b1a 100644
--- a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts
+++ b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts
@@ -43,6 +43,38 @@ export function scriptItemKey(item: Pick): 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 = {
+ "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();
+ 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;
+}