mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user