From 7e67a7075618aedb78f234ddd1f69b02b82db230 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 12 May 2026 11:09:37 +0200 Subject: [PATCH] fix(web/scripts): group remediation cycle phases under one parent card (Intune-cycle UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../buildcounter.txt | 2 +- .../components/ScriptExecutions.tsx | 241 ++++++++++++++---- .../lib/__tests__/scriptExecutions.test.ts | 136 ++++++++++ .../lib/scriptExecutions.ts | 195 ++++++++++++++ 4 files changed, 525 insertions(+), 49 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index c6380677..2034434e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -692 +693 diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx index a8454450..062ef0cf 100644 --- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx +++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx @@ -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 (
@@ -61,7 +64,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots )} {nonCompliantCount > 0 && ( - + {nonCompliantCount} non-compliant )} @@ -89,10 +92,10 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots {expanded && (
- {scripts.map((item) => ( - ( + @@ -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 ( + + ); + } + + // 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 ( +
+ + + {phasesOpen && ( + // Nested phase rows. Slightly inset so the parent → child relationship is visual. +
+ {card.phases.map((phase) => ( + + ))} +
+ )} +
+ ); +} + +function CardIcon({ state, className }: { state: ScriptCard["headerState"]; className: string }) { + if (state === "Running") { + return ( + + + + + ); + } + if (state === "Failed") { + return ( + + + + ); + } + if (state === "NonCompliant") { + return ( + + + + ); + } + return ( + + + + ); +} + +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 ) : isNonCompliant ? ( - // Triangle warning icon — script ran fine but reported non-compliant state. @@ -184,13 +329,14 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite )} {label} - {intuneUrl ? ( - {shortId}… - ) : ( - {shortId}… + {/* shortId only on standalone rows — inside a cycle the parent header already shows it. */} + {!nested && ( + intuneUrl ? ( + {shortId}… + ) : ( + {shortId}… + ) )} - {/* Phase badge for remediation rows (detection / remediation / post-detection). - For platform scripts we keep showing the type tag. */} {item.scriptType === "remediation" && getPhaseBadge(item) && ( {getPhaseBadge(item)} @@ -225,7 +371,8 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite outdated )} - {item.runContext && ( + {/* runContext/Target only on standalone rows — inside a cycle the parent header carries them. */} + {!nested && item.runContext && ( {item.runContext} )}
@@ -260,19 +407,21 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite {showDetails && item.state !== "Running" && (
- {/* 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; +}