mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix(agent/v2,web/scripts): UX polish + dedupe duplicates + stderr-as-failure rule
UX fallout from session e2929c97 debrief:
- Dedupe: same (policyId, scriptType, scriptPart) re-emissions collapse into one row
keeping the most-complete entry via dataCompleteness() score. Real-world Autopilot
pattern is IME re-emitting the same script across ESP-phase transitions, sometimes
with degraded data on the second emission. Trade-off vs. Codex finding 3 (preserving
distinct runs of scheduled scripts in long sessions) — out of scope for this UI.
- Labels: use Intune Admin Center terminology — all health-script rows now read
"Remediation" with phase shown via separate badge ("detection" / "remediation" /
"post-detection"). The customer-facing word for these policies in Intune is
"Remediation", not "Health Detection".
- Flicker fix: React key included firstSeenIndex which was reassigned on every
reducer run, causing ScriptItemRow to remount on upstream prop changes (live
polling / SignalR) and collapse expanded detail panels. New scriptItemKey() uses
only identity-shaped fields (policyId, scriptType, scriptPart). firstSeenIndex
field removed from ScriptItem entirely.
- Animation polish: removed animate-pulse from Running container — only the inline
spinner SVG conveys motion now. Container is neutral gray. With multiple concurrent
Running cards the full-box pulse made the page feel unstable.
- stderr-as-failure rule (consistent across script types): stderr present →
script_failed regardless of exit code or compliance verdict. Eliminates the
inconsistency where a platform script with stderr showed red but a remediation
detection with stderr (and exit 0 + compliant) showed green for the same data
shape. Implemented in BOTH the Agent adapter and the Web reducer.
Agent V2 only — V1 phasing out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -745,23 +745,25 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
|
||||
|
||||
// Outcome classification — phase-aware. The Microsoft IME Health-Script (Proactive
|
||||
// Remediation) lifecycle treats the detection scripts as compliance reporters:
|
||||
// - detection exit 0 = compliant
|
||||
// - detection exit non-zero = non-compliant (script ran perfectly, just reporting state)
|
||||
// - remediation exit 0 = remediation succeeded
|
||||
// - remediation exit non-zero = remediation script failed (real script crash)
|
||||
// - post-detection same as detection (exit non-zero = remediation didn't take, NOT a crash)
|
||||
// So a non-compliant detection that gets remediated must NOT count as a script failure
|
||||
// in metrics or render as red in the timeline. Only the remediation phase + platform
|
||||
// scripts route non-zero exit to script_failed. The cycle-level outcome is conveyed
|
||||
// separately via RemediationStatus (2=Remediated, 3=RemediationFailed) on each event.
|
||||
// Outcome classification — three rules in priority order:
|
||||
// 1. stderr present → script_failed. Per user UX preference (debrief 2026-05-11):
|
||||
// consistent failure semantics across script types — any time a script writes
|
||||
// to stderr the user wants visibility, even if exit was 0 and IME reported
|
||||
// compliant. Eliminates the confusing case where a platform script with stderr
|
||||
// shows red but a remediation detection with stderr shows green.
|
||||
// 2. Phase-aware exit handling: detection / post-detection use exit code as a
|
||||
// compliance verdict (not a crash signal), so non-zero exit alone is NOT
|
||||
// failure for those phases. Only remediation phase + platform scripts route
|
||||
// non-zero exit to script_failed.
|
||||
// 3. Defensive: explicit Result=="Failed" → script_failed (V1 parity).
|
||||
var isHealthScript = IsRemediation(script.ScriptType);
|
||||
var isHealthComplianceReport = isHealthScript
|
||||
&& (string.Equals(script.ScriptPart, "detection", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(script.ScriptPart, "post-detection", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var isFailure = string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase)
|
||||
var hasStderr = !string.IsNullOrWhiteSpace(script.Stderr);
|
||||
var isFailure = hasStderr
|
||||
|| string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase)
|
||||
|| (!isHealthComplianceReport
|
||||
&& script.ExitCode.HasValue && script.ExitCode.Value != 0);
|
||||
var severity = isFailure ? EventSeverity.Error : EventSeverity.Info;
|
||||
|
||||
@@ -1 +1 @@
|
||||
684
|
||||
687
|
||||
|
||||
@@ -1 +1 @@
|
||||
1300
|
||||
1301
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { compareVersions } from "@/utils/bootstrapVersion";
|
||||
import {
|
||||
buildScriptItemLabel,
|
||||
getPhaseBadge,
|
||||
isDetectOnlyRow,
|
||||
isNonCompliantReport,
|
||||
mapRemediationStatus,
|
||||
reduceScriptEvents,
|
||||
scriptItemKey,
|
||||
STALE_RUNNING_THRESHOLD_SECONDS,
|
||||
type ScriptInputEvent,
|
||||
type ScriptItem,
|
||||
@@ -49,7 +51,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
|
||||
<h2 className="text-lg font-semibold text-gray-900">Script Executions</h2>
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
{runningCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium animate-pulse">
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 font-medium">
|
||||
{runningCount} running
|
||||
</span>
|
||||
)}
|
||||
@@ -89,7 +91,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
|
||||
<div className="space-y-3 mt-4">
|
||||
{scripts.map((item) => (
|
||||
<ScriptItemRow
|
||||
key={`${item.policyId}-${item.scriptPart ?? "_running"}-${item.firstSeenIndex}`}
|
||||
key={scriptItemKey(item)}
|
||||
item={item}
|
||||
showScriptOutput={showScriptOutput}
|
||||
latestBootstrapVersion={latestBootstrapVersion}
|
||||
@@ -130,7 +132,10 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
|
||||
const containerClass = item.state === "Failed"
|
||||
? "bg-red-50 border border-red-200"
|
||||
: item.state === "Running"
|
||||
? `bg-blue-50 border border-blue-200 ${isStale ? "" : "animate-pulse"}`
|
||||
// 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";
|
||||
@@ -160,7 +165,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
{item.state === "Running" ? (
|
||||
<svg className={`w-4 h-4 text-blue-500 flex-shrink-0 ${isStale ? "" : "animate-spin"}`} fill="none" viewBox="0 0 24 24">
|
||||
<svg className={`w-4 h-4 text-gray-500 flex-shrink-0 ${isStale ? "" : "animate-spin"}`} 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>
|
||||
@@ -184,13 +189,18 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
|
||||
) : (
|
||||
<span className="text-xs font-mono text-gray-500">{shortId}…</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
item.scriptType === "remediation"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
}`}>
|
||||
{item.scriptType}
|
||||
</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)}
|
||||
</span>
|
||||
)}
|
||||
{item.scriptType !== "remediation" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
|
||||
{item.scriptType}
|
||||
</span>
|
||||
)}
|
||||
{isDetectOnly && (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600"
|
||||
@@ -222,7 +232,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||
<span className={`font-medium ${
|
||||
item.state === "Failed" ? "text-red-600"
|
||||
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-blue-600")
|
||||
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-gray-600")
|
||||
: isNonCompliant ? "text-amber-700"
|
||||
: "text-green-600"
|
||||
}`}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildScriptItemLabel,
|
||||
getPhaseBadge,
|
||||
isDetectOnlyRow,
|
||||
isNonCompliantReport,
|
||||
mapRemediationStatus,
|
||||
reduceScriptEvents,
|
||||
scriptItemKey,
|
||||
toNumber,
|
||||
type ScriptInputEvent,
|
||||
type ScriptItem,
|
||||
@@ -62,24 +64,17 @@ describe("buildScriptItemLabel", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("Health Script (running) for live remediation placeholder", () => {
|
||||
expect(buildScriptItemLabel(base({ state: "Running", scriptPart: undefined }))).toBe("Health Script (running)");
|
||||
it("uses Intune terminology: Remediation for all health-script phases", () => {
|
||||
// The Intune Admin Center calls these policies "Remediations"; the phase (detection /
|
||||
// remediation / post-detection) is conveyed via a separate badge, not the title.
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 4 }))).toBe("Remediation");
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 2 }))).toBe("Remediation");
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "remediation", remediationStatus: 2 }))).toBe("Remediation");
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "post-detection", remediationStatus: 2 }))).toBe("Remediation");
|
||||
});
|
||||
|
||||
it("Health Detection for detect-only remediation phase", () => {
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 4 }))).toBe("Health Detection");
|
||||
});
|
||||
|
||||
it("Health Detection for pre-detection phase of full cycle (no special label)", () => {
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 2 }))).toBe("Health Detection");
|
||||
});
|
||||
|
||||
it("Remediation Run for the actual remediation phase", () => {
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "remediation", remediationStatus: 2 }))).toBe("Remediation Run");
|
||||
});
|
||||
|
||||
it("Detection (post) for post-detection verification phase", () => {
|
||||
expect(buildScriptItemLabel(base({ scriptPart: "post-detection", remediationStatus: 2 }))).toBe("Detection (post)");
|
||||
it("Remediation (running) for live placeholder", () => {
|
||||
expect(buildScriptItemLabel(base({ state: "Running", scriptPart: undefined }))).toBe("Remediation (running)");
|
||||
});
|
||||
|
||||
it("Platform Script for completed platform scripts", () => {
|
||||
@@ -91,6 +86,38 @@ describe("buildScriptItemLabel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPhaseBadge", () => {
|
||||
it("returns the scriptPart for completed remediation rows", () => {
|
||||
expect(getPhaseBadge({ scriptType: "remediation", scriptPart: "detection", state: "Success" })).toBe("detection");
|
||||
expect(getPhaseBadge({ scriptType: "remediation", scriptPart: "remediation", state: "Success" })).toBe("remediation");
|
||||
expect(getPhaseBadge({ scriptType: "remediation", scriptPart: "post-detection", state: "Failed" })).toBe("post-detection");
|
||||
});
|
||||
it("returns null for platform scripts (no phase concept)", () => {
|
||||
expect(getPhaseBadge({ scriptType: "platform", scriptPart: undefined, state: "Success" })).toBeNull();
|
||||
});
|
||||
it("returns null for Running placeholders (no phase known yet)", () => {
|
||||
expect(getPhaseBadge({ scriptType: "remediation", scriptPart: undefined, state: "Running" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scriptItemKey", () => {
|
||||
it("is stable across re-renders for the same identity (no timestamp / index in key)", () => {
|
||||
// Critical for preserving showDetails state across upstream prop changes — the React
|
||||
// key MUST NOT include any index or timestamp that shifts on reducer re-runs.
|
||||
const a = scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" });
|
||||
const b = scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
it("differs by phase so multi-phase rows get separate React identities", () => {
|
||||
expect(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" }))
|
||||
.not.toBe(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "remediation", state: "Success" }));
|
||||
});
|
||||
it("uses a separate slot for Running placeholders so they don't collide with finals", () => {
|
||||
expect(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: undefined, state: "Running" }))
|
||||
.not.toBe(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDetectOnlyRow", () => {
|
||||
const base = (overrides: Partial<ScriptItem> = {}): Pick<ScriptItem, "scriptType" | "scriptPart" | "remediationStatus" | "state"> => ({
|
||||
scriptType: "remediation",
|
||||
@@ -189,26 +216,48 @@ describe("reduceScriptEvents", () => {
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT collapse repeated runs of the same policy with different timestamps (Codex finding 3)", () => {
|
||||
// A health script policy on a daily schedule will run multiple times in a long-lived
|
||||
// session. Each run has a distinct timestamp from IME and must surface as its own row,
|
||||
// otherwise the second run silently disappears from the UI.
|
||||
it("collapses repeated emissions of the same (policyId, scriptType, scriptPart) keeping the most-complete entry", () => {
|
||||
// Trade-off vs. Codex finding 3: in an Autopilot enrollment session IME often re-emits
|
||||
// the same script across ESP-phase transitions (DeviceSetup→AccountSetup), and the
|
||||
// second emission frequently has degraded data (lost exit code etc) due to agent state
|
||||
// being cleared after the first emit. The user found this confusing — same script
|
||||
// appearing twice with different data quality. We now collapse and keep the entry with
|
||||
// the most populated fields (exitCode, stdout, etc). For long-running scheduled-script
|
||||
// monitoring outside Autopilot, this would lose distinct runs — out of scope for this UI.
|
||||
const items = reduceScriptEvents([
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0, complianceResult: "True" },
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0, complianceResult: "True", stdout: "first run details" },
|
||||
}),
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 86400,
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 1, complianceResult: "False", remediationStatus: 4 },
|
||||
// Second emission has fewer populated fields → must lose the dedupe race.
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection" },
|
||||
}),
|
||||
]);
|
||||
expect(items).toHaveLength(2);
|
||||
// Both rows are state=Success (script ran fine each time); the second carries a
|
||||
// non-compliant compliance verdict surfaced via the amber non-compliant styling.
|
||||
expect(items.map(i => i.state)).toEqual(["Success", "Success"]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].exitCode).toBe(0);
|
||||
expect(items[0].complianceResult).toBe("True");
|
||||
expect(items[1].complianceResult).toBe("False");
|
||||
expect(items[0].stdout).toBe("first run details");
|
||||
});
|
||||
|
||||
it("collapses platform-script re-emissions across ESP phases (real-world Autopilot pattern)", () => {
|
||||
// Pattern observed in session e2929c97: same platform script ran in DeviceSetup
|
||||
// (with full data) and again in AccountSetup (degraded data, no exit code). Earlier
|
||||
// versions surfaced both rows side-by-side which was confusing.
|
||||
const items = reduceScriptEvents([
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: { policyId: "847da54e", scriptType: "platform", exitCode: 0, result: "Success", runContext: "System" },
|
||||
}),
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 360,
|
||||
data: { policyId: "847da54e", scriptType: "platform", result: "Success" },
|
||||
}),
|
||||
]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].exitCode).toBe(0);
|
||||
expect(items[0].runContext).toBe("System");
|
||||
});
|
||||
|
||||
it("two runs interleaved with started signals each show their own Running placeholder until matched", () => {
|
||||
@@ -416,6 +465,47 @@ describe("reduceScriptEvents", () => {
|
||||
]);
|
||||
expect(items[0].state).toBe("Failed");
|
||||
});
|
||||
|
||||
// ── stderr-as-failure rule (user UX preference, debrief 2026-05-11) ─────────────
|
||||
it("PLATFORM script with stderr present renders as Failed regardless of exit code", () => {
|
||||
const items = reduceScriptEvents([
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: { policyId: "p1", scriptType: "platform", exitCode: 0, result: "Success", stderr: "WARNING: deprecated path" },
|
||||
}),
|
||||
]);
|
||||
expect(items[0].state).toBe("Failed");
|
||||
});
|
||||
|
||||
it("HEALTH-SCRIPT detection with stderr present renders as Failed (consistency over compliance verdict)", () => {
|
||||
// Earlier behavior: stderr-on-detection was just amber non-compliant. User wanted
|
||||
// consistency: stderr present → Failed everywhere. This is the case from session
|
||||
// e2929c97 (Inventory script with Invoke-WebRequest DNS error).
|
||||
const items = reduceScriptEvents([
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: {
|
||||
policyId: "p1",
|
||||
scriptType: "remediation",
|
||||
scriptPart: "detection",
|
||||
exitCode: 0,
|
||||
complianceResult: "True",
|
||||
stderr: "Invoke-WebRequest : The remote name could not be resolved",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(items[0].state).toBe("Failed");
|
||||
});
|
||||
|
||||
it("whitespace-only stderr does not flip state to Failed", () => {
|
||||
const items = reduceScriptEvents([
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: { policyId: "p1", scriptType: "platform", exitCode: 0, result: "Success", stderr: " \n " },
|
||||
}),
|
||||
]);
|
||||
expect(items[0].state).toBe("Success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNonCompliantReport", () => {
|
||||
|
||||
@@ -28,13 +28,41 @@ export interface ScriptItem {
|
||||
stderr?: string;
|
||||
state: "Running" | "Success" | "Failed";
|
||||
timestamp: string;
|
||||
firstSeenIndex: number;
|
||||
bootstrapVersion?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable React key for a ScriptItem. Based ONLY on identity-shaped fields (policyId,
|
||||
* scriptType, scriptPart) so re-renders triggered by upstream prop changes (live event
|
||||
* polling, SignalR) keep the same component instance mounted — preserving showDetails
|
||||
* and other local state. Do NOT include timestamp or insertion index here, otherwise
|
||||
* any reducer re-run will remount and collapse expanded detail panels.
|
||||
*/
|
||||
export function scriptItemKey(item: Pick<ScriptItem, "policyId" | "scriptType" | "scriptPart" | "state">): string {
|
||||
const part = item.state === "Running" ? "_running" : (item.scriptPart ?? "_nopart");
|
||||
return `${item.policyId || "_noid"}-${item.scriptType}-${part}`;
|
||||
}
|
||||
|
||||
/** Threshold above which a Running placeholder is rendered as "stuck?" rather than animated. */
|
||||
export const STALE_RUNNING_THRESHOLD_SECONDS = 600;
|
||||
|
||||
/**
|
||||
* Score how complete a script item's data is (higher = better). Used by the reducer to
|
||||
* pick the best entry when re-emissions of the same script collapse into one row.
|
||||
* Counts the presence of fields that meaningfully describe the script's outcome.
|
||||
*/
|
||||
function dataCompleteness(item: ScriptItem): number {
|
||||
let score = 0;
|
||||
if (item.exitCode != null) score += 4; // exit code is the most important signal
|
||||
if (item.result) score += 2;
|
||||
if (item.complianceResult) score += 2;
|
||||
if (item.remediationStatus != null) score += 1;
|
||||
if (item.stdout && item.stdout.length > 0) score += 1;
|
||||
if (item.stderr && item.stderr.length > 0) score += 1;
|
||||
if (item.runContext) score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a wire value to a number. Backend serializes integer event-data fields as strings
|
||||
* (`Dictionary<string, string>` payload format), but newer in-process emitters could pass
|
||||
@@ -62,19 +90,30 @@ export function mapRemediationStatus(status?: number): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the headline label for a row. Five distinct shapes — see in-code comments. */
|
||||
/**
|
||||
* Pick the headline label for a row. We use Intune Admin Center terminology — proactive
|
||||
* remediation policies are labelled "Remediation" in the Intune UI, regardless of which
|
||||
* phase (detection / remediation / post-detection) of the cycle this row represents.
|
||||
* The phase is conveyed via the badge next to the title, not the title itself.
|
||||
*/
|
||||
export function buildScriptItemLabel(item: Pick<ScriptItem, "scriptType" | "scriptPart" | "state" | "remediationStatus">): string {
|
||||
if (item.scriptType === "remediation") {
|
||||
if (item.state === "Running") return "Health Script (running)";
|
||||
if (item.scriptPart === "remediation") return "Remediation Run";
|
||||
if (item.scriptPart === "post-detection") return "Detection (post)";
|
||||
// Detection phase — same label whether detect-only (RemediationStatus=4) or pre-detection
|
||||
// of a full cycle. The detect-only badge handles the visual distinction.
|
||||
return "Health Detection";
|
||||
return item.state === "Running" ? "Remediation (running)" : "Remediation";
|
||||
}
|
||||
return item.state === "Running" ? "Platform Script (running)" : "Platform Script";
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase badge text for remediation rows (e.g. "detection", "remediation", "post-detection").
|
||||
* Returns null when no phase badge should render (platform scripts, Running placeholders).
|
||||
*/
|
||||
export function getPhaseBadge(item: Pick<ScriptItem, "scriptType" | "scriptPart" | "state">): string | null {
|
||||
if (item.scriptType !== "remediation") return null;
|
||||
if (item.state === "Running") return null;
|
||||
if (!item.scriptPart) return null;
|
||||
return item.scriptPart;
|
||||
}
|
||||
|
||||
/** True when this row should display the "detect-only" badge. */
|
||||
export function isDetectOnlyRow(item: Pick<ScriptItem, "scriptType" | "scriptPart" | "remediationStatus" | "state">): boolean {
|
||||
return item.state !== "Running"
|
||||
@@ -114,15 +153,14 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
const items: ScriptItem[] = [];
|
||||
const seenFinal = new Set<string>();
|
||||
// Keyed by `${policyId}-${scriptType}-${timestamp}` so repeated runs of the same health
|
||||
// script policy in a long-lived session each get their own running placeholder slot.
|
||||
// We can't dedupe a "started"-only emission against a "completed" without a shared
|
||||
// run-id (IME doesn't expose one), so we fall back to nearest-timestamp matching: a
|
||||
// running placeholder vanishes if a final event with a timestamp >= the started
|
||||
// timestamp exists for the same (policyId, scriptType).
|
||||
const finalTimestampsByPolicy = new Map<string, number[]>();
|
||||
// Map of dedupe-key → ScriptItem. Same (policyId, scriptType, scriptPart) re-emissions
|
||||
// collapse into one row keeping the entry with the most-complete data. This is the
|
||||
// common case in IME logs where the same script appears multiple times across ESP-phase
|
||||
// transitions, sometimes with degraded data on the second emission (lost exit code etc).
|
||||
// For Autopilot enrollment sessions (time-bounded, < 1 h typical) this is the right
|
||||
// trade-off — long-running scheduled-script monitoring is out of scope for this UI.
|
||||
const finalsByKey = new Map<string, ScriptItem>();
|
||||
const policyIdsWithFinal = new Set<string>();
|
||||
|
||||
for (let idx = 0; idx < sorted.length; idx++) {
|
||||
const evt = sorted[idx];
|
||||
@@ -134,38 +172,35 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
|
||||
const scriptType = d.scriptType ?? d.script_type ?? "platform";
|
||||
const scriptPart = d.scriptPart ?? d.script_part;
|
||||
const dedupeId = policyId || `_noid_${idx}`;
|
||||
// Including the timestamp distinguishes repeated runs of the same policy in a
|
||||
// long-lived session (e.g. health scripts on a daily schedule). Re-fetched events
|
||||
// carry the same timestamp and still dedupe correctly.
|
||||
const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}-${evt.timestamp}`;
|
||||
if (seenFinal.has(key)) continue;
|
||||
seenFinal.add(key);
|
||||
if (policyId) {
|
||||
const policyKey = `${policyId}-${scriptType}`;
|
||||
const timestamps = finalTimestampsByPolicy.get(policyKey);
|
||||
const ts = new Date(evt.timestamp).getTime();
|
||||
if (timestamps) timestamps.push(ts);
|
||||
else finalTimestampsByPolicy.set(policyKey, [ts]);
|
||||
}
|
||||
const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}`;
|
||||
if (policyId) policyIdsWithFinal.add(`${policyId}-${scriptType}`);
|
||||
|
||||
const exitCode = toNumber(d.exitCode ?? d.exit_code);
|
||||
const remediationStatus = toNumber(d.remediationStatus ?? d.remediation_status);
|
||||
const targetType = toNumber(d.targetType ?? d.target_type);
|
||||
const errorCode = toNumber(d.errorCode ?? d.error_code);
|
||||
const stdout = typeof d.stdout === "string" ? d.stdout : undefined;
|
||||
const stderr = typeof d.stderr === "string" ? d.stderr : undefined;
|
||||
const hasStderr = !!stderr && stderr.trim().length > 0;
|
||||
|
||||
// State derivation — phase-aware so a non-compliant health-script detection (exit 1)
|
||||
// does NOT show as a red Failed card. Detection / post-detection scripts use the exit
|
||||
// code as a compliance verdict, not a crash signal: exit 0 = compliant, exit non-zero
|
||||
// = non-compliant (the script ran fine, it's reporting state). Only the remediation
|
||||
// phase + platform scripts treat non-zero exit as a real failure. The defensive check
|
||||
// also handles legacy emitters that send script_completed for actual failures.
|
||||
// State derivation — three rules in priority order:
|
||||
// 1. stderr present → Failed. Consistent across script types: any time a script
|
||||
// writes to stderr the user wants visibility, even if exit was 0. Per user
|
||||
// preference (debrief 2026-05-11): "exit 0 bedeutet zwar success aber wenn auf
|
||||
// stderr was gezeigt wird sollte es failed sein".
|
||||
// 2. Phase-aware exit handling: detection / post-detection use exit code as a
|
||||
// compliance verdict (not a crash signal), so non-zero exit alone is NOT
|
||||
// failure for those phases. Only remediation phase + platform scripts route
|
||||
// non-zero exit to Failed.
|
||||
// 3. Defensive: explicit script_failed eventType OR result === "Failed" → Failed.
|
||||
const isHealthComplianceReport = scriptType === "remediation"
|
||||
&& (scriptPart === "detection" || scriptPart === "post-detection");
|
||||
const isFailureSignal = evt.eventType === "script_failed"
|
||||
|| hasStderr
|
||||
|| (!isHealthComplianceReport && exitCode != null && exitCode !== 0)
|
||||
|| d.result === "Failed";
|
||||
|
||||
items.push({
|
||||
const candidate: ScriptItem = {
|
||||
policyId,
|
||||
scriptType,
|
||||
scriptPart,
|
||||
@@ -177,18 +212,44 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
|
||||
targetType,
|
||||
errorCode,
|
||||
errorDetails: d.errorDetails ?? d.error_details,
|
||||
stdout: d.stdout,
|
||||
stderr: d.stderr,
|
||||
stdout,
|
||||
stderr,
|
||||
state: isFailureSignal ? "Failed" : "Success",
|
||||
timestamp: evt.timestamp,
|
||||
firstSeenIndex: items.length,
|
||||
bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(d.stdout) : null,
|
||||
});
|
||||
bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(stdout) : null,
|
||||
};
|
||||
|
||||
const existing = finalsByKey.get(key);
|
||||
if (!existing || dataCompleteness(candidate) > dataCompleteness(existing)) {
|
||||
finalsByKey.set(key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const seenRunning = new Set<string>();
|
||||
for (let idx = 0; idx < sorted.length; idx++) {
|
||||
const evt = sorted[idx];
|
||||
// Assemble the ordered list of finals. Sort by the timestamp of the kept entry so
|
||||
// the timeline reflects actual chronology of the surviving events.
|
||||
const items: ScriptItem[] = Array.from(finalsByKey.values()).sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Suppression set for Running placeholders: track which (policyId, scriptType) pairs
|
||||
// already have a final, so a started signal that arrives after its own completion
|
||||
// doesn't surface a stale Running indicator.
|
||||
const finalTimestampsByPolicy = new Map<string, number[]>();
|
||||
for (const item of items) {
|
||||
if (!item.policyId) continue;
|
||||
const policyKey = `${item.policyId}-${item.scriptType}`;
|
||||
const ts = new Date(item.timestamp).getTime();
|
||||
const arr = finalTimestampsByPolicy.get(policyKey);
|
||||
if (arr) arr.push(ts);
|
||||
else finalTimestampsByPolicy.set(policyKey, [ts]);
|
||||
}
|
||||
|
||||
// Running placeholders: emit one per (policyId, scriptType) when a started signal
|
||||
// exists without any final at-or-after its timestamp. Collapsed by policyId+type so
|
||||
// a single row shows "running" rather than one per started signal — matches the
|
||||
// collapsed-final dedupe semantics above.
|
||||
const runningEmitted = new Set<string>();
|
||||
for (const evt of sorted) {
|
||||
if (evt.eventType !== "script_started") continue;
|
||||
const d = evt.data;
|
||||
if (!d) continue;
|
||||
@@ -197,28 +258,19 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
|
||||
const scriptType = d.scriptType ?? d.script_type ?? "platform";
|
||||
if (!policyId) continue;
|
||||
|
||||
// Suppress this placeholder if a final event for the same (policyId, scriptType)
|
||||
// exists with a timestamp at-or-after this started timestamp. That covers two
|
||||
// cases: (a) the normal flow where the final lands after the started event;
|
||||
// (b) a re-run scenario where an *earlier* started signal already has a matching
|
||||
// final, but a *later* started should still surface as Running until ITS final
|
||||
// arrives.
|
||||
const policyKey = `${policyId}-${scriptType}`;
|
||||
if (runningEmitted.has(policyKey)) continue;
|
||||
|
||||
const startedTs = new Date(evt.timestamp).getTime();
|
||||
const finals = finalTimestampsByPolicy.get(`${policyId}-${scriptType}`);
|
||||
const finals = finalTimestampsByPolicy.get(policyKey);
|
||||
if (finals && finals.some(ts => ts >= startedTs)) continue;
|
||||
|
||||
// Per-timestamp key so two started-only signals for the same policy in different
|
||||
// runs both render placeholders.
|
||||
const key = `${policyId}-${scriptType}-_running-${evt.timestamp}`;
|
||||
if (seenRunning.has(key)) continue;
|
||||
seenRunning.add(key);
|
||||
|
||||
runningEmitted.add(policyKey);
|
||||
items.push({
|
||||
policyId,
|
||||
scriptType,
|
||||
state: "Running",
|
||||
timestamp: evt.timestamp,
|
||||
firstSeenIndex: items.length,
|
||||
bootstrapVersion: null,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user