From 127d5c117356a293d5b857cd00db7adaf89ac459 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 14 Oct 2026 02:30:29 +0200 Subject: [PATCH] Diagnosis: the page answers what happened, for every status with a verdict (D-356, D-357) The diagnosis page is rebuilt on the server headline instead of rule confidence: a verdict card per status and cause (culprit tiles, ESP phases, what an unfinished enrollment still waits for), the circumstances around it, the steps of the finding that explains it, where the time went, all open findings grouped by role, team notes and the deduplicated errors and warnings. It reuses the session page's hook set, refetches the headline on live activity and opens for Failed, Stalled, AwaitingUser, Incomplete and Succeeded with open findings, also from read-only and cross-tenant views. Rule text: lib/ruleText.ts prunes a finding's explanation and steps like the notifications do (shared cases in tests/fixtures/rule-text-display, also run by RuleTemplateInterpolator.InterpolateForNotification), then parses the markdown and fills values into text nodes only. The session page's analysis section, the rule card and the dry run render through it; components/findings/EvidenceChips replaces the two evidence-link copies and covers correlation pairs. Vocabularies: DiagnosisPrerequisites names the engine's completion prerequisites (manifest diagnosis.prerequisites, parity fact against DecisionEngine.CompletionPrerequisites); the page's texts for every diagnosis vocabulary are exhaustive over the manifest types. Fixed on the way: useSessionSignalR held the first render's callbacks, so ruleResultsReady was lost on an already connected hub; a deep link to an event dropped its hash before the event loaded; the global analyze rule update answered 500 for an invalid presentation (now 400); the rule editor kept the presentation block on create and in JSON mode. Co-Authored-By: Claude Opus 5.5 --- .../DiagnosisHeadlineBuilderTests.cs | 21 +- .../GlobalAnalyzeRuleUpdateTests.cs | 37 + .../RuleTemplateInterpolatorTests.cs | 26 +- .../SharedManifestParityTests.cs | 1 + .../Functions/Rules/GlobalRulesFunction.cs | 21 +- .../Models/DiagnosisApiModels.cs | 22 + .../__tests__/analyzeRuleFormLogic.test.ts | 25 +- .../components/AnalyzeRuleCard.tsx | 16 +- .../components/RuleDryRunPanel.tsx | 31 +- .../app/analyze-rules/ruleDryRunLogic.ts | 20 + .../app/analyze-rules/types.ts | 10 + .../diagnosis/components/ContextSection.tsx | 47 + .../diagnosis/components/DiagnosisBadges.tsx | 60 - .../diagnosis/components/FindingsSection.tsx | 201 ++++ .../app/diagnosis/components/NotesSection.tsx | 29 + .../diagnosis/components/ProblemsSection.tsx | 66 ++ .../app/diagnosis/components/StepsSection.tsx | 46 + .../app/diagnosis/components/TimeSection.tsx | 163 +++ .../app/diagnosis/components/VerdictCard.tsx | 298 +++++ .../diagnosis/hooks/useSessionDiagnosis.ts | 58 + .../lib/__tests__/eventLists.test.ts | 46 + .../lib/__tests__/findingGroups.test.ts | 74 ++ .../app/diagnosis/lib/__tests__/steps.test.ts | 98 ++ .../diagnosis/lib/__tests__/timeLanes.test.ts | 86 ++ .../lib/__tests__/verdictText.test.ts | 84 ++ .../app/diagnosis/lib/contextItems.ts | 81 ++ .../app/diagnosis/lib/diagnosisFormat.ts | 100 ++ .../app/diagnosis/lib/eventLists.ts | 113 ++ .../app/diagnosis/lib/findingGroups.ts | 96 ++ .../app/diagnosis/lib/steps.ts | 75 ++ .../app/diagnosis/lib/timeLanes.ts | 140 +++ .../app/diagnosis/lib/verdictText.ts | 201 ++++ .../app/diagnosis/lib/vocabulary.ts | 14 + .../app/diagnosis/page.tsx | 1052 ++++------------- .../components/AnalysisResultsSection.tsx | 90 +- .../components/SessionAnnotationsBody.tsx | 142 +++ .../components/SessionAnnotationsCard.tsx | 269 +---- .../__tests__/sessionHintsLogic.test.ts | 17 + .../sessions/components/sessionHintsLogic.ts | 18 + .../app/sessions/hooks/useSessionAnalysis.ts | 9 +- .../sessions/hooks/useSessionAnnotations.ts | 131 ++ .../app/sessions/hooks/useSessionSignalR.ts | 32 +- .../hooks/useSessionTimeAttribution.ts | 50 + .../app/sessions/page.tsx | 96 +- .../components/findings/EvidenceChips.tsx | 56 + .../components/findings/PresentationView.tsx | 78 ++ .../components/findings/RuleText.tsx | 91 ++ .../lib/__tests__/apiPathEncoding.test.ts | 1 + .../lib/__tests__/evidenceChips.test.ts | 42 + .../lib/__tests__/ruleText.test.ts | 125 ++ src/Web/autopilot-monitor-web/lib/api.ts | 3 + .../lib/evidenceChips.ts | 47 + .../generated/shared-manifests.generated.ts | 6 + .../lib/generated/shared-manifests.json | 6 + src/Web/autopilot-monitor-web/lib/routes.ts | 4 +- src/Web/autopilot-monitor-web/lib/ruleText.ts | 272 +++++ .../autopilot-monitor-web/test/factories.ts | 24 +- tests/fixtures/rule-text-display/README.md | 18 + tests/fixtures/rule-text-display/cases.json | 55 + tools/quality/jscpd-baseline.json | 1 - 60 files changed, 3862 insertions(+), 1279 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/GlobalAnalyzeRuleUpdateTests.cs create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx delete mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/DiagnosisBadges.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/NotesSection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/ProblemsSection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/StepsSection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/TimeSection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/components/VerdictCard.tsx create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/hooks/useSessionDiagnosis.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/eventLists.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/findingGroups.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/steps.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/timeLanes.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/verdictText.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/contextItems.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/diagnosisFormat.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/eventLists.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/findingGroups.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/steps.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/timeLanes.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/verdictText.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/vocabulary.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsBody.tsx create mode 100644 src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionAnnotations.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionTimeAttribution.ts create mode 100644 src/Web/autopilot-monitor-web/components/findings/EvidenceChips.tsx create mode 100644 src/Web/autopilot-monitor-web/components/findings/PresentationView.tsx create mode 100644 src/Web/autopilot-monitor-web/components/findings/RuleText.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/evidenceChips.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/ruleText.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/evidenceChips.ts create mode 100644 src/Web/autopilot-monitor-web/lib/ruleText.ts create mode 100644 tests/fixtures/rule-text-display/README.md create mode 100644 tests/fixtures/rule-text-display/cases.json diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosisHeadlineBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosisHeadlineBuilderTests.cs index 44907066..9634605a 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosisHeadlineBuilderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosisHeadlineBuilderTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.Functions.Services.Diagnosis; using AutopilotMonitor.Shared.Models; @@ -473,7 +475,7 @@ public class DiagnosisHeadlineBuilderTests public void The_agent_timeout_is_the_agent_giving_up_and_lists_what_it_waited_for() { var events = new Timeline() - .Add("completion_waiting", new() { ["missingPrerequisites"] = "desktop_arrived,hello_resolved" }) + .Add("completion_waiting", new() { ["missingPrerequisites"] = "desktop_arrival,hello_resolution" }) .Add("app_tracking_summary", new() { ["installingNames"] = new List { "Contoso Notes" }, @@ -487,12 +489,25 @@ public class DiagnosisHeadlineBuilderTests Assert.Equal(DiagnosisCauses.AgentGaveUp, h.Cause); Assert.Null(h.FailedStep); Assert.Collection(h.Waiting!, - w => { Assert.Equal(DiagnosisWaitKinds.Prerequisite, w.Kind); Assert.Equal("desktop_arrived", w.Name); }, - w => Assert.Equal("hello_resolved", w.Name), + w => { Assert.Equal(DiagnosisWaitKinds.Prerequisite, w.Kind); Assert.Equal(DiagnosisPrerequisites.DesktopArrival, w.Name); }, + w => Assert.Equal(DiagnosisPrerequisites.HelloResolution, w.Name), w => { Assert.Equal(DiagnosisWaitKinds.App, w.Kind); Assert.Equal("Contoso Notes", w.Name); Assert.Equal("installing", w.State); }, w => { Assert.Equal("Contoso Agent", w.Name); Assert.Equal("pending", w.State); }); } + [Fact] + public void The_prerequisite_names_are_the_decision_engines_literals() + { + // The engine's literal class is internal to DecisionCore; the portal's wait labels key on this list. + var literals = typeof(DecisionEngine).GetNestedType("CompletionPrerequisites", BindingFlags.NonPublic)! + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral) + .Select(f => (string)f.GetRawConstantValue()!) + .OrderBy(s => s, StringComparer.Ordinal); + + Assert.Equal(literals, DiagnosisPrerequisites.All.OrderBy(s => s, StringComparer.Ordinal)); + } + [Fact] public void A_stalled_session_is_dated_by_its_stall_and_lists_stuck_providers() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GlobalAnalyzeRuleUpdateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GlobalAnalyzeRuleUpdateTests.cs new file mode 100644 index 00000000..cfdd666d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GlobalAnalyzeRuleUpdateTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using AutopilotMonitor.Functions.Functions.Rules; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The cross-tenant rule update answers like the tenant route: a rule the service rejects is a 400 +/// with the reason, never a 500 through the global exception middleware. +/// +public class GlobalAnalyzeRuleUpdateTests +{ + private const string TenantId = "00000000-0000-4000-8000-000000000002"; + + [Fact] + public async Task An_invalid_presentation_is_a_bad_request_and_nothing_is_stored() + { + var repo = new Mock(); + var function = new GlobalRulesFunction( + NullLogger.Instance, + new GatherRuleService(repo.Object, NullLogger.Instance), + new AnalyzeRuleService(repo.Object, NullLogger.Instance)); + // A custom rule as the editor sends it (isBuiltIn defaults to true on the model). + const string body = "{\"ruleId\":\"ANALYZE-CUSTOM-901\",\"isBuiltIn\":false,\"title\":\"t\",\"severity\":\"high\",\"category\":\"apps\"," + + "\"explanation\":\"e\",\"conditions\":[],\"presentation\":{\"role\":\"bogus\"}}"; + var (req, _) = EndpointHarness.Request(TenantId, jsonBody: body, queryString: $"?tenantId={TenantId}"); + + var response = await function.UpdateAnalyzeRule(req, "ANALYZE-CUSTOM-901"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs index bd93d986..5fd0218b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleTemplateInterpolatorTests.cs @@ -24,13 +24,15 @@ public class RuleTemplateInterpolatorTests // ── Shared cases (tests/fixtures/rule-template-interpolation) ───────── - private static readonly Lazy Fixture = new(() => + private static readonly Lazy Fixture = new(() => LoadFixture("rule-template-interpolation")); + + private static JObject LoadFixture(string folder) { - var path = Path.Combine(FindRepoRoot(), "tests", "fixtures", "rule-template-interpolation", "cases.json"); + var path = Path.Combine(FindRepoRoot(), "tests", "fixtures", folder, "cases.json"); // No date parsing: a timestamp-looking string must stay the string the renderers see. return JsonConvert.DeserializeObject( File.ReadAllText(path), new JsonSerializerSettings { DateParseHandling = DateParseHandling.None })!; - }); + } public static IEnumerable CaseNames() => Fixture.Value["cases"]!.Select(c => new object[] { c.Value("name")! }); @@ -157,6 +159,24 @@ public class RuleTemplateInterpolatorTests Assert.True(offenders.Count == 0, "Placeholder reached a notification:\n" + string.Join("\n", offenders)); } + // ── Shared display cases (tests/fixtures/rule-text-display) ────────── + // The portal prunes a finding's text the same way before it renders the markdown. + + private static readonly Lazy DisplayFixture = new(() => LoadFixture("rule-text-display")); + + public static IEnumerable DisplayCaseNames() + => DisplayFixture.Value["cases"]!.Select(c => new object[] { c.Value("name")! }); + + [Theory] + [MemberData(nameof(DisplayCaseNames))] + public void InterpolateForNotification_prunes_like_the_shared_display_case(string name) + { + var testCase = DisplayFixture.Value["cases"]!.Single(c => c.Value("name") == name); + Assert.Equal( + testCase.Value("expected")!, + RuleTemplateInterpolator.InterpolateForNotification(testCase.Value("text"), AsDictionaries(testCase["matchedConditions"] as JObject))); + } + // ── End to end: engine evidence → alert ─────────────────────────────── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index aabbce2a..494a481e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -158,6 +158,7 @@ public sealed class SharedManifestParityTests ["culpritKinds"] = DiagnosisCulpritKinds.All, ["culpritEvidence"] = DiagnosisCulpritEvidence.All, ["waitKinds"] = DiagnosisWaitKinds.All, + ["prerequisites"] = DiagnosisPrerequisites.All, ["caveats"] = DiagnosisCaveats.All, }, ["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GlobalRulesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GlobalRulesFunction.cs index 59340eeb..ce3752f6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GlobalRulesFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GlobalRulesFunction.cs @@ -170,10 +170,23 @@ namespace AutopilotMonitor.Functions.Functions.Rules _logger.LogInformation("Global admin updating analyze rule {RuleId} for tenant {TenantId}", ruleId, tenantId); - var success = await _analyzeRuleService.UpdateRuleAsync(tenantId, rule); - var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError); - await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = success, Message = success ? "Rule updated" : "Failed to update rule" }); - return response; + // Same answers as the tenant route: the service rejects an invalid rule (presentation, + // evaluateOn, related docs) with ArgumentException and a stale write with InvalidOperationException. + try + { + var success = await _analyzeRuleService.UpdateRuleAsync(tenantId, rule); + var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError); + await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = success, Message = success ? "Rule updated" : "Failed to update rule" }); + return response; + } + catch (InvalidOperationException ex) + { + return await req.ConflictAsync(ex.Message); + } + catch (ArgumentException ex) + { + return await req.BadRequestAsync(ex.Message); + } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DiagnosisApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/DiagnosisApiModels.cs index 1cc23cd6..160351ab 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/DiagnosisApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/DiagnosisApiModels.cs @@ -369,6 +369,28 @@ namespace AutopilotMonitor.Shared.Models public static readonly IReadOnlyList All = new[] { Prerequisite, App, PolicyProvider }; } + /// + /// Names of a wait item: the completion prerequisites + /// the decision engine lists in completion_waiting.missingPrerequisites. + /// + public static class DiagnosisPrerequisites + { + /// The account setup phase of the ESP has not reported its end. + public const string AccountSetupProvisioningComplete = "account_setup_provisioning_complete"; + + /// Windows Hello has not been set up or skipped. + public const string HelloResolution = "hello_resolution"; + + /// The user has not reached the desktop. + public const string DesktopArrival = "desktop_arrival"; + + /// RealmJoin has not finished. + public const string RealmJoinResolution = "realmjoin_resolution"; + + /// Every prerequisite, the engine's order. + public static readonly IReadOnlyList All = new[] { AccountSetupProvisioningComplete, HelloResolution, DesktopArrival, RealmJoinResolution }; + } + /// Values of . public static class DiagnosisCaveats { diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts index 630200f7..a4655f07 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { EMPTY_FORM, formToEvaluateOn, jsonToForm } from "../types"; +import { EMPTY_FORM, formRuleBody, formToEvaluateOn, jsonToForm, ruleToForm, type AnalyzeRule } from "../types"; /** * The JSON editor serializes the FORM object (eval* checkbox fields), but what users @@ -95,3 +95,26 @@ describe("jsonToForm", () => { expect(formToEvaluateOn(jsonToForm(baseExport))).toBeUndefined(); }); }); + +describe("presentation in the editor", () => { + const presentation = { + role: "cause", + summary: "{{failed.appName}} failed", + subject: { kind: "app", key: "{{failed.appId}}", name: "{{failed.appName}}" }, + facts: [{ label: "Exit code", value: "{{failed.exitCode}}", format: "code" }], + }; + + it("survives a rule-shaped and a form-shaped JSON paste into the body a save sends", () => { + const fromExport = jsonToForm({ ruleId: "ANALYZE-CUSTOM-102", title: "t", conditions: [], presentation }); + expect(formRuleBody(fromExport).presentation).toEqual(presentation); + const fromForm = jsonToForm({ ...EMPTY_FORM, presentation }); + expect(formRuleBody(fromForm).presentation).toEqual(presentation); + }); + + it("is carried from a stored rule and removed when the JSON drops it", () => { + const stored = { ruleId: "ANALYZE-CUSTOM-103", title: "t", conditions: [], confidenceFactors: [], remediation: [], relatedDocs: [], presentation } as unknown as AnalyzeRule; + expect(ruleToForm(stored).presentation).toEqual(presentation); + expect(formRuleBody(jsonToForm({ ...ruleToForm(stored), presentation: null })).presentation).toBeNull(); + expect(formRuleBody(EMPTY_FORM).presentation).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx index 15044a81..bfbf9a31 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -7,7 +7,9 @@ import AnalyzeRuleFormFields from "./AnalyzeRuleFormFields"; import { RuleDryRunPanel } from "./RuleDryRunPanel"; import { editorDraft } from "../ruleDryRunLogic"; import { stripInternalFields } from "@/lib/rulePageHelpers"; -import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; +import { parseInline, parseRuleText } from "@/lib/ruleText"; +import { RuleInlines, RuleText } from "@/components/findings/RuleText"; +import { PresentationTemplateView } from "@/components/findings/PresentationView"; import { safeHttpUrl } from "@/lib/safeDocUrl"; import { SPARKLINE_BARS, sparklineBars } from "@/lib/ruleSparkline"; @@ -430,10 +432,18 @@ export default function AnalyzeRuleCard({ + {rule.presentation && ( +
+

How a finding shows

+ +
+ )} + {rule.explanation && (

Explanation

-

{formatInlineMarkdown(rule.explanation)}

+ {/* The template as written: tokens are filled in per session. */} +
)} @@ -445,7 +455,7 @@ export default function AnalyzeRuleCard({
{rem.title}
    - {rem.steps.map((step, sIdx) => (
  1. {step}
  2. ))} + {rem.steps.map((step, sIdx) => (
  3. ))}
))} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx index 3d0aee72..8042954b 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx @@ -5,16 +5,17 @@ import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; import { trackEvent } from "@/lib/appInsights"; -import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; import { isGuid } from "@/lib/inputValidation"; -import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; +import { ruleTextBlocks, type RuleBlock } from "@/lib/ruleText"; +import { RuleText } from "@/components/findings/RuleText"; +import { PresentationPreview } from "@/components/findings/PresentationView"; import type { AnalyzeRule as AnalyzeRuleWire, DryRunAnalyzeRuleRequest, DryRunAnalyzeRuleResponse, RuleDryRun, } from "@/lib/generated/wire-types.generated"; -import { confidenceSummary, evidenceText, eventCountText, verdictLabel, type DryRunDraft } from "../ruleDryRunLogic"; +import { confidenceSummary, evidenceText, eventCountText, presentationPreview, verdictLabel, type DryRunDraft } from "../ruleDryRunLogic"; interface RuleDryRunPanelProps { /** Reads the draft at click time from whichever mode the editor is in (form or JSON). */ @@ -25,7 +26,8 @@ interface RuleDryRunPanelProps { interface Outcome { result: RuleDryRun; - explanation: string; + explanation: RuleBlock[]; + presentation: ReturnType; } /** @@ -61,10 +63,8 @@ export function RuleDryRunPanel({ getDraft, idPrefix }: RuleDryRunPanelProps) { body: jsonBody({ sessionId: id, rule: draft.rule as unknown as AnalyzeRuleWire }), }); trackEvent("rule_dryrun_run", { verdict: data.result.verdict }); - const explanation = typeof draft.rule.explanation === "string" - ? interpolateRuleTemplate(draft.rule.explanation, data.result.matchedConditions) - : ""; - setOutcome({ result: data.result, explanation }); + const explanation = typeof draft.rule.explanation === "string" ? ruleTextBlocks(draft.rule.explanation, data.result.matchedConditions) : []; + setOutcome({ result: data.result, explanation, presentation: presentationPreview(draft.rule, data.result.matchedConditions) }); } catch (e) { setOutcome(null); setError(apiErrorText(e, "The test could not run.")); @@ -107,7 +107,7 @@ export function RuleDryRunPanel({ getDraft, idPrefix }: RuleDryRunPanelProps) { } function DryRunResult({ outcome }: { outcome: Outcome }) { - const { result, explanation } = outcome; + const { result, explanation, presentation } = outcome; const fired = result.verdict === "fired"; return (
@@ -158,10 +158,19 @@ function DryRunResult({ outcome }: { outcome: Outcome }) { })} - {fired && explanation && ( + {fired && presentation && ( +
+

How the finding shows

+
+ +
+
+ )} + + {fired && explanation.length > 0 && (

Explanation preview

-

{formatInlineMarkdown(explanation)}

+
)}
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/ruleDryRunLogic.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/ruleDryRunLogic.ts index d7e20d46..b6ec6dbd 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/ruleDryRunLogic.ts +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/ruleDryRunLogic.ts @@ -1,4 +1,6 @@ import type { RuleDryRun, RuleDryRunCondition } from "@/lib/generated/wire-types.generated"; +import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; +import { resolvePresentation, type PresentationTemplate, type ResolvedPresentation } from "@/lib/rulePresentation"; import { formToDryRunRule, jsonToForm, type AnalyzeRule, type PastedAnalyzeJson, type RuleForm } from "./types"; /** The editor's current draft, or why it cannot be tested yet (JSON that does not parse). */ @@ -22,6 +24,24 @@ export function editorDraft(jsonMode: boolean, jsonText: string, form: RuleForm, return { rule: { ...(stored ?? {}), ...formToDryRunRule(source) } }; } +/** + * The draft's presentation as one dry-run fills it in, and the labels of the facts this session did not + * record (a fact sharing its label with a filled alternative counts as filled). Null without a block. + */ +export function presentationPreview( + rule: Record, + matchedConditions: Record | null | undefined +): { resolved: ResolvedPresentation; notRecorded: string[] } | null { + const template = rule.presentation as PresentationTemplate | null | undefined; + const explanation = typeof rule.explanation === "string" ? rule.explanation : null; + const resolved = resolvePresentation(template, matchedConditions, explanation, interpolateRuleTemplate); + if (!resolved) return null; + const notRecorded = (template?.facts ?? []) + .filter((f) => f?.label && !resolved.facts.some((r) => r.label === f.label)) + .map((f) => f.label); + return { resolved, notRecorded: [...new Set(notRecorded)] }; +} + /** Plain words for the dry-run verdicts (RuleDryRunVerdict on the backend); an unknown value shows as is. */ const VERDICT_LABELS: Record = { fired: "Would fire", diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts index 560575e2..83fec734 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts @@ -1,3 +1,5 @@ +import type { RulePresentation } from "@/lib/generated/wire-types.generated"; + export interface RelatedDoc { title: string; url: string; @@ -99,6 +101,8 @@ export interface AnalyzeRule { * Interim triggers: "whiteglove_sealed" and "on_event:". */ evaluateOn?: string[] | null; + /** How the portal and the MCP show a finding of this rule (role, summary, subject, facts) — raw templates. */ + presentation?: RulePresentation | null; createdAt: string; updatedAt: string; } @@ -127,6 +131,8 @@ export interface RuleForm { tags: string[]; /** Effective "fire → mark session failed" carried as the new rule's default on import. */ markSessionAsFailedDefault: boolean; + /** Carried through create/save so JSON mode can add, change or remove it (no form UI yet). */ + presentation: RulePresentation | null; } export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const; @@ -216,6 +222,7 @@ export const EMPTY_FORM: RuleForm = { evalOnEventTypes: "", tags: [], markSessionAsFailedDefault: false, + presentation: null, }; export function ruleToForm(rule: AnalyzeRule): RuleForm { @@ -245,6 +252,7 @@ export function ruleToForm(rule: AnalyzeRule): RuleForm { // Fold the tenant override into the effective value: an exported rule that marks // sessions failed must keep doing so when re-created from its JSON. markSessionAsFailedDefault: rule.markSessionAsFailed ?? rule.markSessionAsFailedDefault ?? false, + presentation: rule.presentation ?? null, }; } @@ -322,6 +330,8 @@ export function formRuleBody(form: RuleForm) { confidenceFactors: form.confidenceFactors.filter(f => f.signal.trim()), remediation: form.remediation.filter(r => r.title.trim()), relatedDocs: form.relatedDocs.filter(d => d.title.trim() && d.url.trim()), + // null stores no block: a JSON edit that removes it removes it from the rule too. + presentation: form.presentation ?? null, }; } diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx new file mode 100644 index 00000000..f0f4cff3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { ContextIcon, ContextItem } from "../lib/contextItems"; + +const ICONS: Record = { + power: "M21 10.5h.375c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125H21M3.75 18h15A2.25 2.25 0 0021 15.75v-6a2.25 2.25 0 00-2.25-2.25h-15A2.25 2.25 0 001.5 9.75v6A2.25 2.25 0 003.75 18z", + update: "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99", + clock: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z", + memory: "M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z", + restart: "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182", + eye: "M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88", + info: "M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z", +}; + +/** Circumstances around the outcome: context findings and the limits of the observation. */ +export default function ContextSection({ items, outcome, children }: { items: ContextItem[]; outcome: string; children?: ReactNode }) { + if (items.length === 0 && !children) return null; + return ( +
+
+

+ {outcome === "failed" ? "Around the failure" : "Around this enrollment"} +

+

Observed on the device. Context, not proof of cause.

+
+ {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + + + +
    +

    {item.title}

    + {item.detail &&

    {item.detail}

    } +
    +
  • + ))} +
+ )} + {children} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/DiagnosisBadges.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/DiagnosisBadges.tsx deleted file mode 100644 index 35786907..00000000 --- a/src/Web/autopilot-monitor-web/app/diagnosis/components/DiagnosisBadges.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -export function ConfidenceBadge({ - score, - compact = false, -}: { - score: number; - compact?: boolean; -}) { - const color = - score >= 80 - ? "text-red-600 bg-red-50 border-red-200" - : score >= 60 - ? "text-orange-600 bg-orange-50 border-orange-200" - : score >= 40 - ? "text-yellow-600 bg-yellow-50 border-yellow-200" - : "text-blue-600 bg-blue-50 border-blue-200"; - - if (compact) { - return ( - - {score}% - - ); - } - - return ( -
- {score}% - CONFIDENCE -
- ); -} - -export function SeverityBadge({ - severity, - compact = false, -}: { - severity: string; - compact?: boolean; -}) { - const colors: Record = { - critical: "bg-red-100 text-red-800 border-red-200", - high: "bg-orange-100 text-orange-800 border-orange-200", - warning: "bg-yellow-100 text-yellow-800 border-yellow-200", - info: "bg-blue-100 text-blue-800 border-blue-200", - }; - - const c = colors[severity] || colors.info; - - return ( - - {compact ? severity.charAt(0).toUpperCase() : severity.toUpperCase()} - - ); -} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx new file mode 100644 index 00000000..0f0cc474 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState } from "react"; +import type { Route } from "next"; +import { RuleInlines, RuleText } from "@/components/findings/RuleText"; +import { EvidenceChips } from "@/components/findings/EvidenceChips"; +import { ruleTextBlocks } from "@/lib/ruleText"; +import { safeHttpUrl } from "@/lib/safeDocUrl"; +import { trackEvent } from "@/lib/appInsights"; +import type { FindingGroups, FindingView, PrimaryTone } from "../lib/findingGroups"; +import { formatFact, shortTime } from "../lib/diagnosisFormat"; +import { remediationGroups } from "../lib/steps"; + +const SEVERITY: Record = { + critical: "border-red-200 bg-red-100 text-red-800", + high: "border-orange-200 bg-orange-100 text-orange-800", + warning: "border-yellow-200 bg-yellow-100 text-yellow-800", + info: "border-blue-200 bg-blue-100 text-blue-800", +}; + +const PRIMARY_TONE: Record = { + red: "text-red-700", + orange: "text-orange-700", + amber: "text-amber-800", +}; + +const CONFIDENCE_WIDTH: Record = { + certain: "w-full", + "very likely": "w-11/12", + likely: "w-3/4", + possible: "w-1/2", + weak: "w-1/4", +}; + +/** Every open finding of the session, grouped by what it means for the outcome. */ +export default function FindingsSection({ + groups, + analysisNote, + eventHref, +}: { + groups: FindingGroups; + /** Shown instead of rows while there are none (analysis running, no rule fired, read failed). */ + analysisNote: string | null; + eventHref: (eventId: string) => Route; +}) { + const sections: { label: string; tone: string; items: FindingView[] }[] = [ + { label: groups.primary.label, tone: PRIMARY_TONE[groups.primary.tone], items: groups.primary.items }, + { label: "The outcome", tone: "text-gray-700", items: groups.outcome }, + { label: "Context", tone: "text-gray-600", items: groups.context }, + { label: "Other findings", tone: "text-gray-600", items: groups.other }, + ].filter((s) => s.items.length > 0); + const count = sections.reduce((n, s) => n + s.items.length, 0); + + return ( +
+
+

+ Findings · {count} +

+
+ {count === 0 &&

{analysisNote ?? "No analyze rule fired for this session."}

} + {sections.map((section) => ( +
+

+ {section.label} · {section.items.length} +

+
+ {section.items.map((f) => ( + + ))} +
+
+ ))} +
+ ); +} + +function FindingRow({ finding, eventHref }: { finding: FindingView; eventHref: (eventId: string) => Route }) { + const [open, setOpen] = useState(false); + const { result } = finding; + const panelId = `finding-${result.ruleId}`; + const time = shortTime(result.firstDetectedAt ?? result.detectedAt); + + const toggle = () => { + if (!open) trackEvent("diagnosis_finding_expanded", { ruleId: result.ruleId, role: finding.role || "none" }); + setOpen(!open); + }; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} + +function FindingDetail({ finding, eventHref }: { finding: FindingView; eventHref: (eventId: string) => Route }) { + const { result, presentation } = finding; + const facts = presentation?.facts ?? []; + const fixes = remediationGroups(result); + const docs = (result.relatedDocs ?? []).map((d) => ({ title: d.title, href: safeHttpUrl(d.url) })).filter((d) => d.href); + + return ( +
+
+ {facts.length > 0 && ( +
+ {facts.map((fact) => { + const shown = formatFact(fact.value, fact.format); + return ( + + ); + })} +
+ )} + +
+
+
+

Evidence

+ +
+ {fixes.length > 0 && ( +
+

Fix

+
+ {fixes.map((group, i) => ( +
+ +
    + {group.steps.map((step, j) => ( +
  • + +
  • + ))} +
+
+ ))} +
+
+ )} + {docs.length > 0 && ( + + )} +
+
+ ); +} + +function FactRow({ label, text, mono, hint }: { label: string; text: string; mono: boolean; hint?: string }) { + return ( + <> +
{label}
+
+ {text} + {hint && {hint}} +
+ + ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/NotesSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/NotesSection.tsx new file mode 100644 index 00000000..6098cb34 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/NotesSection.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useSessionAnnotations } from "@/app/sessions/hooks/useSessionAnnotations"; +import SessionAnnotationsBody from "@/app/sessions/components/SessionAnnotationsBody"; +import type { AnnotationUser } from "@/app/sessions/components/sessionAnnotationLogic"; + +/** The team's verdict and notes on this session — the same lanes as the session page's annotations. */ +export default function NotesSection({ + sessionId, + tenantId, + user, + getAccessToken, +}: { + sessionId: string; + tenantId?: string; + user: AnnotationUser | null | undefined; + getAccessToken: (forceRefresh?: boolean) => Promise; +}) { + const state = useSessionAnnotations({ sessionId, effectiveTenantId: tenantId, user, getAccessToken }); + return ( +
+

+ Notes from your team +

+

Confirmed root causes and corrections feed rule quality.

+ +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/ProblemsSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/ProblemsSection.tsx new file mode 100644 index 00000000..7b5b872f --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/ProblemsSection.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import type { Route } from "next"; +import type { ProblemEvent } from "../lib/eventLists"; +import { timeOfDay } from "../lib/diagnosisFormat"; + +type Filter = "all" | "error" | "warning"; + +/** Errors and warnings of the session, collapsed; identical repeats are listed once. */ +export default function ProblemsSection({ events, eventHref }: { events: ProblemEvent[]; eventHref: (eventId: string) => Route }) { + const [filter, setFilter] = useState("all"); + if (events.length === 0) return null; + + const isError = (e: ProblemEvent) => e.severity === "Error" || e.severity === "Critical"; + const errors = events.filter(isError).length; + const warnings = events.length - errors; + const shown = events.filter((e) => (filter === "all" ? true : filter === "error" ? isError(e) : !isError(e))); + const options: { key: Filter; label: string }[] = [ + { key: "all", label: `All ${events.length}` }, + { key: "error", label: `Errors ${errors}` }, + { key: "warning", label: `Warnings ${warnings}` }, + ]; + + return ( +
+ + Errors and warnings + + {errors} {errors === 1 ? "error" : "errors"} · {warnings} {warnings === 1 ? "warning" : "warnings"} + + +
+
+
+ {options.map((o) => ( + + ))} +
+
+
    + {shown.map((e) => ( +
  • + {timeOfDay(e.timestamp)} + + {e.source} + + {e.message} + {e.count > 1 && {e.count}× merged} + +
  • + ))} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/StepsSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/StepsSection.tsx new file mode 100644 index 00000000..26907064 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/StepsSection.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { RuleInlines, RuleText } from "@/components/findings/RuleText"; +import type { StepsView } from "../lib/steps"; + +/** What to do: the steps of the finding that explains the outcome, filled in with this session's values. */ +export default function StepsSection({ steps, onCopy, copied }: { steps: StepsView; onCopy: () => void; copied: boolean }) { + // Steps are numbered across the groups. + const offsets = steps.groups.map((_, i) => steps.groups.slice(0, i).reduce((sum, g) => sum + g.steps.length, 0)); + return ( +
+
+

+ What to do +

+ +
+
+ {steps.groups.map((group, i) => ( +
+ +
    + {group.steps.map((step, j) => ( +
  1. + {offsets[i] + j + 1} +

    + +

    +
  2. + ))} +
+
+ ))} +
+

+ From the finding “{steps.source.result.ruleTitle}”, filled in with this session’s values. +

+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/TimeSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/TimeSection.tsx new file mode 100644 index 00000000..091b192a --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/TimeSection.tsx @@ -0,0 +1,163 @@ +"use client"; + +import Link from "next/link"; +import type { Route } from "next"; +import type { BarTone, LaneMarker, TimeLanes } from "../lib/timeLanes"; +import type { KeyEvent, KeyEventTone } from "../lib/eventLists"; +import { timeOfDay } from "../lib/diagnosisFormat"; + +// Literal class strings per tone; vivid fills read in both themes. +const BAR: Record = { + done: "border border-green-200 bg-green-100 text-green-800", + failed: "border border-red-200 bg-red-100 text-red-800", + signin: "border border-sky-200 bg-sky-100 text-sky-900", + asleep: "bg-gray-200 text-gray-600", + restart: "border border-amber-200 bg-amber-100 text-amber-900", + update: "border border-blue-200 bg-blue-100 text-blue-800", + app: "bg-green-500", + appFailed: "bg-red-500", +}; + +// The label and its line shift alike, so neither leaves the chart at an edge. +const MARKER_ALIGN: Record = { + start: "", + center: "-translate-x-1/2", + end: "-translate-x-full", +}; + +const DOT: Record = { + failed: "bg-red-500", + warning: "bg-amber-500", + done: "bg-green-500", + neutral: "bg-gray-400", +}; + +/** Where the time went (lanes from the time breakdown) and the key events of the session. */ +export default function TimeSection({ + lanes, + keyEvents, + keyEventTotal, + eventHref, + timelineHref, +}: { + lanes: TimeLanes | null; + keyEvents: KeyEvent[]; + keyEventTotal: number; + eventHref: (eventId: string) => Route; + timelineHref: Route; +}) { + if (!lanes && keyEvents.length === 0) return null; + // One label row per failure line, so labels never overlap, however narrow the chart. + const labelRows = `${Math.max(1, lanes?.markers.length ?? 0) * 1.25}rem`; + return ( +
+

+ {lanes ? "Where the time went" : "What happened"} +

+ + {lanes && ( +
+
+
+
+ {lanes.markers.map((m, i) => ( + + {m.label} + + ))} +
+ {lanes.lanes.map((lane) => ( + + ))} +
+
+ {lanes.ticks.map((t, i) => ( + + {t.label} + + ))} +
+
+ + )} + + {keyEvents.length > 0 && ( +
+ + Key events + + {keyEvents.length} of {keyEventTotal} + + + Open the full timeline + + +
    + {keyEvents.map((e, i) => ( +
  1. + +
    + + {i < keyEvents.length - 1 && } +
    +
    + + {e.title} + + {e.detail &&

    {e.detail}

    } +
    +
  2. + ))} +
+
+ )} +
+ ); +} + +function LaneRow({ lane }: { lane: TimeLanes["lanes"][number] }) { + return ( + <> +
+ {lane.label} +
+
+ {lane.bars.map((bar, i) => ( +
+ {bar.label && ( + <> + {/* A full-height first item: a label too wide for the bar wraps onto a hidden second line instead of showing cut off. */} + + {bar.label} + + )} +
+ ))} +
+ + ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/VerdictCard.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/VerdictCard.tsx new file mode 100644 index 00000000..b34d6566 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/VerdictCard.tsx @@ -0,0 +1,298 @@ +"use client"; + +import type { ReactNode } from "react"; +import Link from "next/link"; +import type { Route } from "next"; +import type { DiagnosisCulprit, DiagnosisEspCategory, DiagnosisHeadline, DiagnosisWaitItem } from "@/lib/generated/wire-types.generated"; +import type { VerdictText, VerdictTone } from "../lib/verdictText"; +import { formatFact, phaseName, shortTime, stepName, timeOfDay, waitName } from "../lib/diagnosisFormat"; +import { vocabularyEntry, type CulpritEvidence } from "../lib/vocabulary"; +import { humanizeKey } from "@/lib/evidenceChips"; + +// Literal class strings per tone (Tailwind must see them whole; dark mode comes from the global overrides). +const BAND: Record = { + red: { box: "bg-red-50 border-red-200", text: "text-red-800", icon: "text-red-600" }, + orange: { box: "bg-orange-50 border-orange-200", text: "text-orange-800", icon: "text-orange-600" }, + sky: { box: "bg-sky-50 border-sky-200", text: "text-sky-900", icon: "text-sky-900" }, + slate: { box: "bg-slate-50 border-slate-200", text: "text-slate-800", icon: "text-slate-600" }, + green: { box: "bg-green-50 border-green-200", text: "text-green-800", icon: "text-green-700" }, + amber: { box: "bg-amber-50 border-amber-200", text: "text-amber-900", icon: "text-amber-700" }, + blue: { box: "bg-blue-50 border-blue-200", text: "text-blue-800", icon: "text-blue-700" }, + gray: { box: "bg-gray-50 border-gray-200", text: "text-gray-800", icon: "text-gray-500" }, +}; + +const EVIDENCE_LABELS: Record = { + install_failed: "Install error", + presumed_failed: "Installing when the ESP failed", + presumed_stuck: "Still installing when the ESP gave up", + in_flight: "Still in progress", + never_started: "Never started", + not_completed: "Not completed", + named_by_esp: "Named by the ESP", + appx_log: "AppX deployment log", + provider_stalled: "Policy provider did not finish", +}; + +const PILL = "rounded-full border border-gray-300 bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600"; + +const CLOCK = "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"; + +// Columns per tile count, so every row is full and no gap shows the grid's background. +const TILE_COLUMNS: Record = { + 1: "", + 2: "sm:grid-cols-2", + 3: "sm:grid-cols-3", + 4: "sm:grid-cols-2 lg:grid-cols-4", + 5: "lg:grid-cols-5", +}; + +export default function VerdictCard({ + headline, + verdict, + basedOn, + timelineHref, + supersededHref, + onCopySummary, + copied, +}: { + headline: DiagnosisHeadline; + verdict: VerdictText; + /** Findings behind the verdict (all open findings); 0 hides the link. */ + basedOn: number; + timelineHref: Route | null; + supersededHref: Route | null; + onCopySummary: () => void; + copied: boolean; +}) { + const band = BAND[verdict.tone]; + const meta = [ + timeOfDay(headline.failedStep?.at ?? headline.at), + headline.failedStep?.errorCode ? `ESP error ${headline.failedStep.errorCode}` : null, + ].filter(Boolean); + + return ( +
+
+

+ + {verdict.title} +

+ {meta.length > 0 &&

{meta.join(" · ")}

} +
+ +
+

{verdict.heading}

+

+ {verdict.summary} +

+ {verdict.details.length > 0 &&

{verdict.details.join(" ")}

} + + {headline.culprits && headline.culprits.length > 0 && } + {headline.waiting && headline.waiting.length > 0 && } + {headline.esp && headline.esp.length > 0 && } + +
+ {timelineHref && ( + + Show in the timeline + + )} + {supersededHref && ( + + Open the newer enrollment + + )} + + {basedOn > 0 && ( + + {basedOn === 1 ? "1 finding" : `${basedOn} findings`} below + + + )} +
+
+
+ ); +} + +function Culprits({ culprits }: { culprits: DiagnosisCulprit[] }) { + const [first, ...rest] = culprits; + const tiles = [ + +
{first.name}
+
+ {[first.appType, first.targeted, first.intent === "Install" ? "Required" : first.intent, first.scope].filter(Boolean).map((p) => ( + + {p} + + ))} +
+
{vocabularyEntry(EVIDENCE_LABELS, first.evidence) ?? humanizeKey(first.evidence)}
+
, + (first.errorCode || first.exitCode) && , + first.installStarts != null && ( + +
{first.installStarts}
+ {first.lastStartedAt &&
Last start {shortTime(first.lastStartedAt)}
} +
+ ), + (first.detectionResult || first.state) && ( + +
{humanizeKey(first.detectionResult ?? first.state ?? "")}
+ {first.failedAt &&
Failed {shortTime(first.failedAt)}
} +
+ ), + first.stalledForMinutes != null && ( + +
{Math.round(first.stalledForMinutes)} min
+
+ ), + ].filter(Boolean); + return ( +
+
+ {tiles} +
+ {rest.length > 0 && ( +
    + {rest.map((c) => ( +
  • + {c.name} + · {vocabularyEntry(EVIDENCE_LABELS, c.evidence) ?? humanizeKey(c.evidence)} + {c.errorCode && · {c.errorCode}} +
  • + ))} +
+ )} +
+ ); +} + +function Tile({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} + +function ErrorTile({ code, label }: { code: string; label: string }) { + const fact = formatFact(code, "errorCode"); + return ( + +
{fact.text}
+
{fact.hint ?? "Not in our error catalog"}
+
+ ); +} + +function Waiting({ items }: { items: DiagnosisWaitItem[] }) { + return ( +
+

Still waiting for

+
    + {items.map((item) => ( +
  • + {waitName(item)} + {item.state && · {humanizeKey(item.state).toLowerCase()}} +
  • + ))} +
+
+ ); +} + +function EspPhases({ phases, headline }: { phases: DiagnosisEspCategory[]; headline: DiagnosisHeadline }) { + const config = headline.espConfig; + const profile = [ + config?.continueAnyway === false ? "no “continue anyway”" : config?.continueAnyway ? "“continue anyway” allowed" : null, + config?.tryAgain ? "retry allowed" : null, + config?.timeoutMinutes ? `timeout ${config.timeoutMinutes} min` : null, + config?.blockingAppCount ? `${config.blockingAppCount} blocking ${config.blockingAppCount === 1 ? "app" : "apps"}` : null, + ].filter(Boolean); + return ( +
+
+

Enrollment Status Page

+ {profile.length > 0 &&

{profile.join(" · ")}

} +
+
    + {phases.map((phase) => { + const failed = phase.state === "failed"; + return ( +
  1. +

    + + {phaseName(phase.category)} +

    + {phase.subcategories.length > 0 && ( +
      + {phase.subcategories.map((s) => ( +
    • + + {stepName(s.name)} + {s.errorCode && · {s.errorCode}} +
    • + ))} +
    + )} +
  2. + ); + })} +
+
+ ); +} + +function PhaseIcon({ state }: { state: string }) { + if (state === "succeeded") return ✓; + if (state === "failed") return ✕; + return ( + + + + ); +} + +function StepMark({ state }: { state: string }) { + const s = state.toLowerCase(); + if (s === "succeeded") return ; + if (s === "failed") return ; + if (s === "notrequired") return ; + return ; +} + +function BandIcon({ outcome, className }: { outcome: string; className: string }) { + const path = + outcome === "failed" + ? "M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + : outcome === "succeeded" + ? "M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + : outcome === "awaiting_user" || outcome === "pending" + ? "M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" + : CLOCK; + return ( + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/hooks/useSessionDiagnosis.ts b/src/Web/autopilot-monitor-web/app/diagnosis/hooks/useSessionDiagnosis.ts new file mode 100644 index 00000000..7be30391 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/hooks/useSessionDiagnosis.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { fetchJson } from "@/lib/apiClient"; +import { isGuid } from "@/lib/inputValidation"; +import { createBurstScheduler, type BurstScheduler } from "@/lib/burstScheduler"; +import { useLatest } from "@/hooks/useLatest"; +import type { DiagnosisHeadline, GetSessionDiagnosisResponse } from "@/lib/generated/wire-types.generated"; + +export type DiagnosisLoadState = "loading" | "ready" | "error"; + +/** + * The session's verdict (GET sessions/{id}/diagnosis). A live session pushes with every agent batch; + * `scheduleRefetch` coalesces those into one read after each burst. A failed refresh keeps the last + * verdict — only a failed first read is an error. + */ +export function useSessionDiagnosis( + sessionId: string, + tenantId: string | null, + getAccessToken: (forceRefresh?: boolean) => Promise +) { + const [headline, setHeadline] = useState(null); + const [state, setState] = useState("loading"); + const getToken = useLatest(getAccessToken); + const scheduler = useRef(null); + + const fetchHeadline = useCallback(async () => { + if (!sessionId || !tenantId || !isGuid(tenantId)) return; + try { + const json = await fetchJson(api.sessions.diagnosis(sessionId, tenantId), (force?: boolean) => + getToken.current(force) + ); + setHeadline(json.headline); + setState("ready"); + } catch { + setState((prev) => (prev === "ready" ? prev : "error")); + } + }, [sessionId, tenantId, getToken]); + + const fetchLatest = useLatest(fetchHeadline); + + useEffect(() => { + const run = async () => { + await fetchHeadline(); + }; + void run(); + }, [fetchHeadline]); + + useEffect(() => () => scheduler.current?.cancel(), []); + + const scheduleRefetch = useCallback(() => { + scheduler.current ??= createBurstScheduler(() => void fetchLatest.current(), { trailingMs: 1500, maxWaitMs: 10_000 }); + scheduler.current.trigger(); + }, [fetchLatest]); + + return { headline, state, refetch: fetchHeadline, scheduleRefetch }; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/eventLists.test.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/eventLists.test.ts new file mode 100644 index 00000000..40611095 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/eventLists.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { makeEvent } from "@/test/factories"; +import { keyEvents, problemEvents } from "../eventLists"; + +const culprits = [{ kind: "app", evidence: "install_failed", key: "00000000-0000-4000-8000-0000000000a1", name: "Contoso Notes" }]; + +describe("keyEvents", () => { + const events = [ + makeEvent({ eventId: "1", sequence: 1, eventType: "esp_phase_changed", message: "ESP phase: DeviceSetup" }), + makeEvent({ eventId: "2", sequence: 2, eventType: "app_install_started", message: "Contoso Portal install started", data: { appName: "Contoso Portal" } }), + makeEvent({ eventId: "3", sequence: 3, eventType: "app_install_started", message: "Contoso Notes install started", data: { appId: "00000000-0000-4000-8000-0000000000A1" } }), + makeEvent({ eventId: "4", sequence: 4, eventType: "performance_snapshot", severity: "Info" }), + makeEvent({ eventId: "5", sequence: 5, eventType: "app_install_failed", severity: "Error", message: "Contoso Notes failed", data: { appName: "Contoso Notes", errorCode: "0x80d03805" } }), + makeEvent({ eventId: "6", sequence: 6, eventType: "app_install_failed", severity: "Error", message: "Contoso Notes failed", data: { appName: "Contoso Notes", errorCode: "0x80d03805" } }), + makeEvent({ eventId: "7", sequence: 7, eventType: "enrollment_failed", severity: "Error", message: "enrollment_failed: esp_terminal_failure" }), + ]; + + it("keeps milestones, the culprit's attempts and errors, once each", () => { + const { items, total } = keyEvents(events, culprits); + expect(items.map((e) => e.eventId)).toEqual(["1", "3", "5", "7"]); + expect(total).toBe(4); + expect(items[2]).toMatchObject({ title: "Contoso Notes failed", detail: "0x80d03805", tone: "failed" }); + }); + + it("keeps the first two and the end of a long story", () => { + const many = Array.from({ length: 20 }, (_, i) => makeEvent({ eventId: `e${i}`, sequence: i, eventType: "esp_phase_changed", message: `phase ${i}`, timestamp: `2026-10-13T14:${String(i).padStart(2, "0")}:00Z` })); + const { items, total } = keyEvents(many, [], 6); + expect(total).toBe(20); + expect(items.map((e) => e.eventId)).toEqual(["e0", "e1", "e16", "e17", "e18", "e19"]); + }); +}); + +describe("problemEvents", () => { + it("lists errors and warnings in order and merges an event that arrived twice", () => { + const rows = problemEvents([ + makeEvent({ eventId: "a", sequence: 1, severity: "Warning", eventType: "wu_scan_failed", message: "Windows Update scan failed", source: "Windows Update" }), + makeEvent({ eventId: "b", sequence: 2, severity: "Error", eventType: "app_install_failed", message: "Contoso Notes failed", timestamp: "2026-10-13T14:19:46.100Z" }), + makeEvent({ eventId: "c", sequence: 3, severity: "Error", eventType: "app_install_failed", message: "Contoso Notes failed", timestamp: "2026-10-13T14:19:46.900Z" }), + makeEvent({ eventId: "d", sequence: 4, severity: "Info", eventType: "x" }), + ]); + expect(rows.map((r) => [r.eventId, r.count])).toEqual([ + ["a", 1], + ["b", 2], + ]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/findingGroups.test.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/findingGroups.test.ts new file mode 100644 index 00000000..edcf4021 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/findingGroups.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { makeRuleResult } from "@/test/factories"; +import { confidenceWord, groupFindings } from "../findingGroups"; +import { contextItems } from "../contextItems"; + +const app = { kind: "app", key: "00000000-0000-4000-8000-0000000000a1", name: "{{failed.appName}}" }; + +const results = [ + makeRuleResult({ ruleId: "ANALYZE-ENRL-001", severity: "critical", confidenceScore: 100, presentation: { role: "outcome", summary: "Enrollment failed" } }), + makeRuleResult({ ruleId: "ANALYZE-APP-008", severity: "warning", confidenceScore: 70, presentation: { role: "cause", summary: "Slow install" } }), + makeRuleResult({ + ruleId: "ANALYZE-APP-006", + severity: "high", + confidenceScore: 75, + presentation: { role: "cause", summary: "{{failed.appName}} was started {{retry.count}} times", subject: app, facts: [{ label: "Install starts", value: "{{retry.count}}", format: "number" }] }, + matchedConditions: { failed: { appName: "Contoso Notes" }, retry: { count: 4 } }, + }), + makeRuleResult({ + ruleId: "ANALYZE-DEV-012", + severity: "info", + presentation: { role: "context", summary: "The device went to sleep during setup", subject: { kind: "device", key: "power", name: "Power" }, facts: [{ label: "Asleep for", value: "{{s.value}}", format: "duration" }] }, + matchedConditions: { s: { field: "durationSeconds", value: "274" } }, + }), + makeRuleResult({ ruleId: "ANALYZE-ID-002", severity: "high", presentation: { role: "advisory" } }), + makeRuleResult({ ruleId: "ANALYZE-CUSTOM-001", severity: "critical" }), + makeRuleResult({ ruleId: "ANALYZE-OLD-001", resolvedAt: "2026-10-13T15:00:00Z", presentation: { role: "cause" } }), +]; + +describe("groupFindings", () => { + const culprits = [{ kind: "app", evidence: "install_failed", key: "00000000-0000-4000-8000-0000000000a1", name: "Contoso Notes" }]; + const groups = groupFindings(results, "failed", culprits); + + it("sorts open findings by role; resolved ones are gone", () => { + expect(groups.outcome.map((f) => f.result.ruleId)).toEqual(["ANALYZE-ENRL-001"]); + expect(groups.context.map((f) => f.result.ruleId)).toEqual(["ANALYZE-DEV-012"]); + // Advisory and rules without a presentation (tenant custom rules) land under "other". + expect(groups.other.map((f) => f.result.ruleId)).toEqual(["ANALYZE-CUSTOM-001", "ANALYZE-ID-002"]); + }); + + it("labels the primary group for the status and puts the culprit's findings first", () => { + expect(groups.primary.label).toBe("Caused the failure"); + expect(groups.primary.items.map((f) => f.result.ruleId)).toEqual(["ANALYZE-APP-006", "ANALYZE-APP-008"]); + expect(groups.primary.items[0].summary).toBe("Contoso Notes was started 4 times"); + expect(groups.primary.items[0].about).toBe("Contoso Notes"); + expect(groups.primary.tone).toBe("red"); + expect(groupFindings(results, "stalled", []).primary).toMatchObject({ label: "Likely holding it up", tone: "orange" }); + expect(groupFindings(results, "succeeded", []).primary).toMatchObject({ label: "Worth a look", tone: "amber" }); + }); + + it("names what a finding is about", () => { + expect(groups.outcome[0].about).toBe("This enrollment"); + expect(groups.other[0].about).toBe("Apps"); + }); +}); + +describe("contextItems", () => { + it("turns context findings and caveats into circumstances", () => { + const groups = groupFindings(results, "failed", []); + expect(contextItems(groups.context, ["agent_started_late", "unknown_caveat"])).toEqual([ + { key: "ANALYZE-DEV-012", icon: "power", title: "The device went to sleep during setup", detail: "Asleep for 4m 34s", ruleId: "ANALYZE-DEV-012" }, + { key: "caveat:agent_started_late", icon: "eye", title: "Start not observed", detail: "The agent started late, so the first part of the enrollment was not watched." }, + ]); + }); +}); + +describe("confidenceWord", () => { + it.each([ + [100, "certain"], + [90, "very likely"], + [70, "likely"], + [55, "possible"], + [30, "weak"], + ])("%d → %s", (score, word) => expect(confidenceWord(score)).toBe(word)); +}); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/steps.test.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/steps.test.ts new file mode 100644 index 00000000..53ce2765 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/steps.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { makeRuleResult } from "@/test/factories"; +import type { DiagnosisHeadline } from "@/lib/generated/wire-types.generated"; +import { groupFindings } from "../findingGroups"; +import { copySummary, stepsFrom } from "../steps"; +import { verdictText } from "../verdictText"; +import { formatFact, stepPath, waitName } from "../diagnosisFormat"; + +const outcome = makeRuleResult({ + ruleId: "ANALYZE-ENRL-001", + presentation: { role: "outcome" }, + remediation: [{ title: "Find the cause", steps: ["Open the finding that names the cause"] }], +}); +const cause = makeRuleResult({ + ruleId: "ANALYZE-APP-002", + presentation: { role: "cause" }, + matchedConditions: { failed: { field: "appName", value: "Contoso Notes" } }, + remediation: [ + { title: "Check {{appName}} in Intune", steps: ["Open Apps › {{appName}}", "Look up `{{errorCode}}`"] }, + { title: "Only unrecorded", steps: ["Retry {{exitCode}}"] }, + ], +}); + +describe("stepsFrom", () => { + it("takes the cause finding's remediation, filled in, without steps it cannot fill", () => { + const steps = stepsFrom(groupFindings([outcome, cause], "failed", []))!; + expect(steps.source.result.ruleId).toBe("ANALYZE-APP-002"); + expect(steps.groups).toHaveLength(1); + expect(steps.groups[0].steps).toHaveLength(1); + }); + + it("falls back to the outcome finding, and is null without remediation", () => { + expect(stepsFrom(groupFindings([outcome], "failed", []))!.source.result.ruleId).toBe("ANALYZE-ENRL-001"); + expect(stepsFrom(groupFindings([makeRuleResult({ presentation: { role: "cause" } })], "failed", []))).toBeNull(); + }); + + it("passes over a finding whose steps all name unrecorded values", () => { + const unfillable = makeRuleResult({ + ruleId: "ANALYZE-APP-006", + presentation: { role: "cause" }, + remediation: [{ title: "Retry", steps: ["Retry {{exitCode}}"] }], + }); + expect(stepsFrom(groupFindings([unfillable, outcome], "failed", []))!.source.result.ruleId).toBe("ANALYZE-ENRL-001"); + }); +}); + +describe("copySummary", () => { + it("writes the verdict, the step, the culprit and the steps as plain text", () => { + const headline: DiagnosisHeadline = { + outcome: "failed", + cause: "esp_app_failure", + verdictPath: "agent:failed", + failedStep: { category: "AccountSetup", subcategory: "Apps", errorCode: "0x80d03805", at: "2026-10-13T14:44:24Z" }, + culprits: [{ kind: "app", evidence: "install_failed", name: "Contoso Notes", errorCode: "0x80d03805", installStarts: 4 }], + }; + const steps = stepsFrom(groupFindings([cause], "failed", [])); + const text = copySummary({ verdict: verdictText(headline), headline, device: "DESKTOP-CONTOSO1", steps, url: "https://portal.example/diagnosis?id=1" }); + expect(text).toBe( + [ + "Enrollment failed in Account setup › Apps — DESKTOP-CONTOSO1", + "Contoso Notes could not be installed, so the ESP ended the enrollment.", + "It failed with 0x80d03805 after 4 install starts.", + "ESP step: Account setup › Apps (0x80d03805)", + "App: Contoso Notes — error 0x80d03805, 4 install starts", + "", + "What to do:", + "Check Contoso Notes in Intune", + "1. Open Apps › Contoso Notes", + "", + "https://portal.example/diagnosis?id=1", + ].join("\n") + ); + }); +}); + +describe("diagnosisFormat", () => { + it("names ESP phases and steps", () => { + expect(stepPath("AccountSetup", "WaitingForAadRegistration")).toBe("Account setup › Entra registration"); + expect(stepPath(null, "SecurityPolicies")).toBe("Security policies"); + expect(stepPath("SomeFuturePhase", null)).toBe("Some future phase"); + expect(stepPath(null, null)).toBeNull(); + }); + + it("formats facts by their declared format", () => { + expect(formatFact("274", "duration")).toEqual({ text: "4m 34s", mono: false }); + expect(formatFact("n/a", "duration")).toEqual({ text: "n/a", mono: false }); + expect(formatFact("0x80d03805", "code")).toEqual({ text: "0x80d03805", mono: true }); + expect(formatFact("plain", "text")).toEqual({ text: "plain", mono: false }); + expect(formatFact("0x80070005", "errorCode").mono).toBe(true); + }); + + it("names what an enrollment still waits for", () => { + expect(waitName({ kind: "prerequisite", name: "desktop_arrival" })).toBe("Desktop to appear"); + expect(waitName({ kind: "prerequisite", name: "account_setup_provisioning_complete" })).toBe("Account setup to finish"); + expect(waitName({ kind: "prerequisite", name: "some_future_gate" })).toBe("Some future gate"); + expect(waitName({ kind: "app", name: "Contoso Notes" })).toBe("Contoso Notes"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/timeLanes.test.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/timeLanes.test.ts new file mode 100644 index 00000000..10e6e5d9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/timeLanes.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import type { SessionTimeBreakdown } from "@/lib/generated/wire-types.generated"; +import { timeLanes } from "../timeLanes"; + +const T = (min: number) => new Date(Date.UTC(2026, 9, 13, 13, 0) + min * 60_000).toISOString(); +const span = (from: number, to: number) => ({ startUtc: T(from), endUtc: T(to), seconds: (to - from) * 60 }); + +const breakdown = { + segments: [ + { segmentKey: "device_prep", ...span(0, 10) }, + { segmentKey: "esp_apps", ...span(10, 30) }, + { segmentKey: "identity_hello", ...span(30, 40) }, + { segmentKey: "user_esp", ...span(40, 100) }, + ], + blockingApps: [ + { appId: "a", appName: "Contoso Portal", ...span(12, 15) }, + { appId: "b", appName: "Contoso Notes", ...span(50, 60) }, + { appId: "b", appName: "Contoso Notes", ...span(70, 80) }, + ], + sleepSpans: [{ kind: "modern_standby", segmentKey: "user_esp", ...span(55, 58) }], + rebootSpans: [{ segmentKey: "user_esp", ...span(85, 90) }], + osUpdates: [], +} as unknown as SessionTimeBreakdown; + +describe("timeLanes", () => { + const lanes = timeLanes(breakdown, { + failedCategory: "AccountSetup", + failure: { at: T(100), label: "ESP failed" }, + culprits: [{ kind: "app", evidence: "install_failed", key: "B", name: "Contoso Notes", failedAt: T(80) }], + })!; + + it("draws the ESP phases with the failed one marked, then sign-in, the apps and the device", () => { + expect(lanes.lanes.map((l) => l.key)).toEqual(["esp", "sign-in", "app:a", "app:b", "device"]); + const esp = lanes.lanes[0].bars; + expect(esp.map((b) => [b.label, b.tone, Math.round(b.left), Math.round(b.width)])).toEqual([ + ["Device preparation", "done", 0, 10], + ["Device setup", "done", 10, 20], + ["Account setup", "failed", 40, 60], + ]); + }); + + it("emphasizes the culprit app (matched by id, case-insensitively) with all its attempts", () => { + const notes = lanes.lanes.find((l) => l.key === "app:b")!; + expect(notes.emphasis).toBe(true); + expect(notes.bars.map((b) => b.tone)).toEqual(["appFailed", "appFailed"]); + expect(lanes.lanes.find((l) => l.key === "app:a")!.emphasis).toBe(false); + }); + + it("puts sleep and restarts on the device lane and marks the failures", () => { + expect(lanes.lanes.at(-1)!.bars.map((b) => [b.tone, b.label])).toEqual([ + ["asleep", "Asleep"], + ["restart", "Restart"], + ]); + expect(lanes.markers.map((m) => Math.round(m.left))).toEqual([80, 100]); + expect(lanes.markers[1].label.startsWith("ESP failed ")).toBe(true); + expect(lanes.ticks.map((t) => [t.left, t.minor])).toEqual([ + [0, false], + [25, true], + [50, false], + [75, true], + [100, false], + ]); + }); + + it("keeps marker labels inside the chart, in time order", () => { + expect(lanes.markers.map((m) => m.align)).toEqual(["center", "end"]); + const early = timeLanes(breakdown, { + failure: { at: T(3), label: "Failed" }, + culprits: [{ kind: "app", evidence: "install_failed", name: "Contoso Notes", failedAt: T(60) }], + })!; + expect(early.markers.map((m) => [m.label.split(" ")[0], m.align])).toEqual([ + ["Failed", "start"], + ["App", "center"], + ]); + }); + + it("keeps the culprit when it caps the app lanes", () => { + const capped = timeLanes(breakdown, { culprits: [{ kind: "app", evidence: "install_failed", name: "Contoso Notes" }], maxApps: 1 })!; + expect(capped.lanes.filter((l) => l.key.startsWith("app:")).map((l) => l.label)).toEqual(["Contoso Notes"]); + }); + + it("is null without a breakdown or a window", () => { + expect(timeLanes(null, {})).toBeNull(); + expect(timeLanes({ ...breakdown, segments: [] } as SessionTimeBreakdown, {})).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/verdictText.test.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/verdictText.test.ts new file mode 100644 index 00000000..61aaec5f --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/verdictText.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import type { DiagnosisCulprit, DiagnosisHeadline } from "@/lib/generated/wire-types.generated"; +import { verdictText } from "../verdictText"; + +const headline = (over: Partial): DiagnosisHeadline => Object.assign({ outcome: "failed", cause: "unknown", verdictPath: "agent:failed" }, over); + +const appFailure = headline({ + cause: "esp_app_failure", + failedStep: { category: "AccountSetup", subcategory: "Apps", errorCode: "0x80d03805", at: "2026-10-13T14:44:24Z" }, + culprits: [{ kind: "app", evidence: "install_failed", name: "Contoso Notes", targeted: "User", errorCode: "0x80d03805", installStarts: 4 }], + espConfig: { continueAnyway: false, timeoutMinutes: 40 }, +}); + +describe("verdictText", () => { + it("names the failed step, the app and why the ESP stopped", () => { + expect(verdictText(appFailure)).toEqual({ + tone: "red", + title: "Enrollment failed in Account setup › Apps", + heading: "Why it failed", + summary: "Contoso Notes could not be installed, so the ESP ended the enrollment.", + details: [ + "The app is required for the user.", + "It failed with 0x80d03805 after 4 install starts.", + "This ESP does not let users continue after an install error.", + ], + }); + }); + + it("counts several failed apps and tells a detection failure from an install error", () => { + const two = verdictText( + headline({ + cause: "esp_app_failure", + culprits: [ + { kind: "app", evidence: "install_failed", name: "Contoso Notes", detectionResult: "NotDetected" }, + { kind: "app", evidence: "presumed_failed", name: "Contoso VPN" }, + ], + }) + ); + expect(two.summary).toBe("2 required apps failed, so the ESP ended the enrollment."); + expect(two.details).toContain("It finished installing, but its detection rule did not find the app."); + }); + + it("says an app never started when the ESP timed out on it", () => { + const v = verdictText(headline({ cause: "esp_app_timeout", culprits: [{ kind: "app", evidence: "never_started", name: "Contoso Agent" }], espConfig: { timeoutMinutes: 60 } })); + expect(v.summary).toBe("The ESP stopped while Contoso Agent had not started installing."); + expect(v.details).toEqual(["The ESP time limit is 60 minutes."]); + }); + + it.each([ + [{ outcome: "incomplete", cause: "agent_gave_up", verdictPath: "maxlife:r6" }, "slate", "Enrollment incomplete", "The agent reached its maximum lifetime before the enrollment finished."], + [{ outcome: "incomplete", cause: "agent_gave_up", verdictPath: "stopwait:r5_incomplete" }, "slate", "Enrollment incomplete", "The agent stopped waiting for the user to sign in."], + [{ outcome: "incomplete", cause: "sweep_timeout", verdictPath: "sweep:r6" }, "slate", "Enrollment incomplete", "No completion arrived before the timeout, so the platform closed the session."], + [{ outcome: "stalled", cause: "agent_silent", verdictPath: "sweep:stalled" }, "orange", "Enrollment stalled", "The agent stopped sending data."], + [{ outcome: "awaiting_user", cause: "awaiting_user", verdictPath: "ingest:wg_awaiting" }, "sky", "Waiting for the user", "Pre-provisioning finished; the device is waiting for its user."], + [{ outcome: "awaiting_user", cause: "awaiting_user", verdictPath: "sweep:r5_awaiting" }, "sky", "Waiting for the user", "Device setup finished; the enrollment is waiting for the user."], + [{ outcome: "succeeded", cause: "reconciled", verdictPath: "sweep:r4" }, "green", "Enrollment succeeded", "The platform declared this session complete without a completion report."], + [{ outcome: "succeeded", cause: "soft_failure", verdictPath: "agent:complete_soft" }, "amber", "Enrollment succeeded after an ESP error", "The ESP failed, the user continued anyway, and setup completed."], + [{ outcome: "failed", cause: "rule_ko", verdictPath: "rule:ANALYZE-APP-013", ruleId: "ANALYZE-APP-013" }, "red", "Enrollment failed", "The analyze rule ANALYZE-APP-013 marked this session as failed."], + [{ outcome: "failed", cause: "admin_marked", verdictPath: "manual:failed" }, "red", "Enrollment failed", "An administrator marked this session as failed."], + [{ outcome: "incomplete", cause: "superseded", verdictPath: "register:superseded" }, "slate", "Enrollment incomplete", "A newer enrollment of this device replaced this session."], + [{ outcome: "in_progress", cause: "none", verdictPath: "register:new" }, "blue", "Enrollment in progress", "The enrollment is still running."], + ] as const)("%j", (over, tone, title, summary) => { + const v = verdictText(headline(over)); + expect(v.tone).toBe(tone); + expect(v.title).toBe(title); + expect(v.summary).toBe(summary); + }); + + it("says what happened to the app of a soft failure", () => { + const soft = (culprit: DiagnosisCulprit) => verdictText(headline({ outcome: "succeeded", cause: "soft_failure", culprits: [culprit] })).details; + expect(soft({ kind: "app", evidence: "install_failed", name: "Contoso Notes", errorCode: "0x80070652" })).toEqual(["Contoso Notes failed with 0x80070652."]); + expect(soft({ kind: "app", evidence: "not_completed", name: "Contoso Notes" })).toEqual(["Contoso Notes had not finished when the ESP failed."]); + }); + + it("falls back to the reason text when the cause is unknown", () => { + expect(verdictText(headline({ reasonText: "Session timed out after 5 hours" })).summary).toBe("Session timed out after 5 hours"); + expect(verdictText(headline({})).summary).toBe("The outcome could not be attributed."); + }); + + it("explains an agent-level failure by its reason", () => { + const v = verdictText(headline({ cause: "agent_failure", failedStep: { reason: "oobe_update_not_finished", at: "2026-10-13T14:00:00Z" } })); + expect(v.summary).toBe("The Windows update at the end of OOBE did not finish."); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/contextItems.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/contextItems.ts new file mode 100644 index 00000000..83dcd922 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/contextItems.ts @@ -0,0 +1,81 @@ +import type { FindingView } from "./findingGroups"; +import { formatFact } from "./diagnosisFormat"; +import { vocabularyEntry, type Caveat } from "./vocabulary"; + +export type ContextIcon = "power" | "update" | "clock" | "memory" | "restart" | "eye" | "info"; + +/** One circumstance around the outcome: a context finding, or a limit of the observation. */ +export interface ContextItem { + key: string; + icon: ContextIcon; + title: string; + detail: string | null; + /** The finding behind it, when there is one. */ + ruleId?: string; +} + +const CAVEATS: Record = { + agent_started_late: { + icon: "eye", + title: "Start not observed", + detail: "The agent started late, so the first part of the enrollment was not watched.", + }, + agent_crashed: { + icon: "eye", + title: "An agent run crashed", + detail: "Events of that run may be missing.", + }, + verdict_derived: { + icon: "info", + title: "Older session", + detail: "This session predates the recorded verdict path; the verdict was read from its stored reason.", + }, + blocking_list_truncated: { + icon: "info", + title: "ESP app list capped", + detail: "The ESP lists at most 50 apps per type, so an app missing from it may still have blocked.", + }, +}; + +/** + * "Around the outcome": the context findings (sleep, forced restarts, battery, updates — the rules + * that detect them carry their summary and facts) followed by the observation caveats of the verdict. + */ +export function contextItems(context: FindingView[], caveats: string[] | null | undefined): ContextItem[] { + const items: ContextItem[] = context.map((f) => ({ + key: f.result.ruleId, + icon: iconFor(f), + title: f.summary ?? f.result.ruleTitle, + detail: factLine(f), + ruleId: f.result.ruleId, + })); + for (const caveat of caveats ?? []) { + const known = vocabularyEntry(CAVEATS, caveat); + if (known) items.push({ key: `caveat:${caveat}`, ...known }); + } + return items; +} + +/** "Label value · Label value" of a finding's facts, values in their formats. */ +export function factLine(f: FindingView): string | null { + const facts = f.presentation?.facts ?? []; + if (facts.length === 0) return null; + return facts.map((fact) => `${fact.label} ${formatFact(fact.value, fact.format).text}`).join(" · "); +} + +function iconFor(f: FindingView): ContextIcon { + const subject = f.presentation?.subject; + switch (subject?.key) { + case "power": + return "power"; + case "windows-update": + case "oobe-update": + case "os": + return "update"; + case "clock": + return "clock"; + case "memory": + return "memory"; + } + return subject?.kind === "policy" || subject?.kind === "app" ? "restart" : "info"; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/diagnosisFormat.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/diagnosisFormat.ts new file mode 100644 index 00000000..dc3747d2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/diagnosisFormat.ts @@ -0,0 +1,100 @@ +import { formatDuration } from "@/lib/formatting"; +import { getErrorCodeDescription } from "@/lib/errorCodeMap"; +import { humanizeKey } from "@/lib/evidenceChips"; +import { vocabularyEntry, type Prerequisite } from "./vocabulary"; + +/** ESP phases and steps as the portal names them; anything else reads as its humanized key. */ +const PHASE_NAMES: Record = { + DevicePreparation: "Device preparation", + DeviceSetup: "Device setup", + AccountSetup: "Account setup", +}; + +const STEP_NAMES: Record = { + Apps: "Apps", + Certificates: "Certificates", + SecurityPolicies: "Security policies", + NetworkConnections: "Network connections", + WaitingForAadRegistration: "Entra registration", + TpmAttestation: "TPM attestation", + Aadj: "Entra join", + MdmEnrollment: "MDM enrollment", + EspProviderInstallation: "ESP provider", +}; + +/** What the engine still waits for, read after "Still waiting for". */ +const PREREQUISITES: Record = { + account_setup_provisioning_complete: "Account setup to finish", + hello_resolution: "Windows Hello to finish", + desktop_arrival: "Desktop to appear", + realmjoin_resolution: "RealmJoin to finish", +}; + +/** The name of a wait item: apps and providers by name, prerequisites in words. */ +export function waitName(item: { kind: string; name: string }): string { + if (item.kind !== "prerequisite") return item.name; + return vocabularyEntry(PREREQUISITES, item.name) ?? humanizeKey(item.name); +} + +export function phaseName(category: string | null | undefined): string | null { + if (!category) return null; + return PHASE_NAMES[category] ?? humanizeKey(category); +} + +export function stepName(subcategory: string | null | undefined): string | null { + if (!subcategory) return null; + return STEP_NAMES[subcategory] ?? humanizeKey(subcategory); +} + +/** "Account setup › Apps", "Account setup", "Apps" or null. */ +export function stepPath(category: string | null | undefined, subcategory: string | null | undefined): string | null { + const parts = [phaseName(category), stepName(subcategory)].filter((p): p is string => !!p); + return parts.length > 0 ? parts.join(" › ") : null; +} + +export interface FactDisplay { + text: string; + mono: boolean; + /** Secondary line: the catalog meaning of an error code, the full date of a time. */ + hint?: string; +} + +/** A fact value in its declared format (rule presentation formats: text, code, errorCode, duration, number, timestamp). */ +export function formatFact(value: string, format: string): FactDisplay { + switch (format) { + case "errorCode": { + const meaning = getErrorCodeDescription(value); + return { text: value, mono: true, hint: meaning ?? undefined }; + } + case "duration": { + const seconds = Number(value); + return Number.isFinite(seconds) && seconds >= 0 ? { text: formatDuration(seconds), mono: false } : { text: value, mono: false }; + } + case "timestamp": { + const ms = Date.parse(value); + return Number.isFinite(ms) + ? { text: new Date(ms).toLocaleTimeString(), mono: false, hint: new Date(ms).toLocaleString() } + : { text: value, mono: false }; + } + case "number": { + const n = Number(value); + return { text: Number.isFinite(n) ? n.toLocaleString() : value, mono: false }; + } + case "code": + return { text: value, mono: true }; + default: + return { text: value, mono: false }; + } +} + +/** Local time of day for an ISO timestamp; empty when absent or unparsable. */ +export function timeOfDay(iso: string | null | undefined): string { + const ms = iso ? Date.parse(iso) : NaN; + return Number.isFinite(ms) ? new Date(ms).toLocaleTimeString() : ""; +} + +/** Short local time ("14:19", "2:19 PM") for dense views. */ +export function shortTime(iso: string | null | undefined): string { + const ms = iso ? Date.parse(iso) : NaN; + return Number.isFinite(ms) ? new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : ""; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/eventLists.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/eventLists.ts new file mode 100644 index 00000000..de430737 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/eventLists.ts @@ -0,0 +1,113 @@ +import type { EnrollmentEvent } from "@/types"; +import type { DiagnosisCulprit } from "@/lib/generated/wire-types.generated"; +import { humanizeKey } from "@/lib/evidenceChips"; + +export type KeyEventTone = "failed" | "warning" | "done" | "neutral"; + +export interface KeyEvent { + eventId: string; + timestamp: string; + title: string; + detail: string | null; + tone: KeyEventTone; +} + +/** Milestones and verdict events; app events only for the headline's culprits. */ +const KEY_TYPES = new Set([ + "phase_transition", + "esp_phase_changed", + "esp_failure_settle_started", + "esp_failure_advisory", + "esp_failure_recovered", + "enrollment_failed", + "enrollment_complete", + "whiteglove_complete", + "desktop_arrived", + "system_reboot_detected", + "hello_provisioning_completed", +]); + +const APP_TYPES = new Set(["app_install_started", "app_install_failed", "app_install_completed"]); +const DONE_TYPES = new Set(["enrollment_complete", "whiteglove_complete", "hello_provisioning_completed", "desktop_arrived"]); + +/** + * The story of the session in a dozen events: milestones, the culprit apps' attempts and every error, + * in order. A longer story keeps its first two events and its end — where the verdict was decided. + */ +export function keyEvents(events: EnrollmentEvent[], culprits: DiagnosisCulprit[] | null | undefined, max = 12): { items: KeyEvent[]; total: number } { + const culpritKeys = new Set( + (culprits ?? []).filter((c) => c.kind === "app").flatMap((c) => [c.key, c.name]).filter((k): k is string => !!k).map((k) => k.toLowerCase()) + ); + const isCulpritEvent = (e: EnrollmentEvent) => { + const appId = String(e.data?.appId ?? "").toLowerCase(); + const appName = String(e.data?.appName ?? "").toLowerCase(); + return (appId !== "" && culpritKeys.has(appId)) || (appName !== "" && culpritKeys.has(appName)); + }; + + const { unique: picked } = dedupe( + events.filter( + (e) => + KEY_TYPES.has(e.eventType) || + (APP_TYPES.has(e.eventType) && isCulpritEvent(e)) || + e.severity === "Error" || + e.severity === "Critical" + ) + ); + const items = picked.length > max ? [...picked.slice(0, 2), ...picked.slice(picked.length - (max - 2))] : picked; + return { + total: picked.length, + items: items.map((e) => ({ + eventId: e.eventId, + timestamp: e.timestamp, + title: e.message && e.message.length <= 140 ? e.message : humanizeKey(e.eventType), + detail: typeof e.data?.errorCode === "string" && e.data.errorCode !== "" ? e.data.errorCode : null, + tone: + e.severity === "Error" || e.severity === "Critical" + ? "failed" + : e.severity === "Warning" + ? "warning" + : DONE_TYPES.has(e.eventType) + ? "done" + : "neutral", + })), + }; +} + +export interface ProblemEvent { + eventId: string; + timestamp: string; + severity: string; + source: string; + message: string; + /** How many identical events this row stands for. */ + count: number; +} + +/** Errors and warnings in order; the same event arriving twice at the same second is listed once. */ +export function problemEvents(events: EnrollmentEvent[]): ProblemEvent[] { + const { unique, counts } = dedupe(events.filter((e) => e.severity === "Error" || e.severity === "Critical" || e.severity === "Warning")); + return unique.map((e) => ({ + eventId: e.eventId, + timestamp: e.timestamp, + severity: e.severity, + source: e.source, + message: e.message || humanizeKey(e.eventType), + count: counts.get(identity(e)) ?? 1, + })); +} + +function identity(e: EnrollmentEvent): string { + return `${e.eventType}|${e.message}|${(e.timestamp ?? "").slice(0, 19)}`; +} + +function dedupe(events: EnrollmentEvent[]): { unique: EnrollmentEvent[]; counts: Map } { + const counts = new Map(); + const unique: EnrollmentEvent[] = []; + for (const e of events) { + const id = identity(e); + const seen = counts.get(id); + counts.set(id, (seen ?? 0) + 1); + if (seen === undefined) unique.push(e); + } + return { unique, counts }; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/findingGroups.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/findingGroups.ts new file mode 100644 index 00000000..59a401c4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/findingGroups.ts @@ -0,0 +1,96 @@ +import type { RuleResult } from "@/types"; +import type { DiagnosisCulprit } from "@/lib/generated/wire-types.generated"; +import { resolvePresentation, type ResolvedPresentation } from "@/lib/rulePresentation"; +import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; +import { humanizeKey } from "@/lib/evidenceChips"; + +/** One open finding as the page shows it. */ +export interface FindingView { + result: RuleResult; + presentation: ResolvedPresentation | null; + /** outcome | cause | context | advisory; empty for a rule without a presentation (tenant custom rules). */ + role: string; + /** One line under the title: the presentation's summary, null when the rule has none. */ + summary: string | null; + /** What it is about: the subject, else "This enrollment" for an outcome, else the rule category. */ + about: string; + /** The confidence score in words. */ + confidence: string; +} + +export interface FindingGroups { + /** Findings that restate the outcome — the verdict counts them ("based on N findings"). */ + outcome: FindingView[]; + /** The findings that explain the outcome, labelled and toned for the status. */ + primary: { label: string; tone: PrimaryTone; items: FindingView[] }; + /** Circumstances (sleep, restarts, battery, updates). */ + context: FindingView[]; + /** Hygiene and security, and rules without a presentation. */ + other: FindingView[]; +} + +const SEVERITY_RANK: Record = { critical: 0, high: 1, warning: 2, info: 3 }; + +export function confidenceWord(score: number): string { + if (score >= 95) return "certain"; + if (score >= 85) return "very likely"; + if (score >= 70) return "likely"; + if (score >= 50) return "possible"; + return "weak"; +} + +/** Red for a failure, amber for a finding on a finished enrollment, orange for what holds one up. */ +export type PrimaryTone = "red" | "orange" | "amber"; + +function primaryGroup(outcome: string): { label: string; tone: PrimaryTone } { + switch (outcome) { + case "failed": + return { label: "Caused the failure", tone: "red" }; + case "succeeded": + return { label: "Worth a look", tone: "amber" }; + default: + return { label: "Likely holding it up", tone: "orange" }; + } +} + +export function findingView(result: RuleResult): FindingView { + const presentation = resolvePresentation(result.presentation, result.matchedConditions, result.explanation, interpolateRuleTemplate); + const role = presentation?.role ?? ""; + return { + result, + presentation, + role, + summary: presentation?.summary ?? null, + about: presentation?.subject?.name ?? (role === "outcome" ? "This enrollment" : humanizeKey(result.category)), + confidence: confidenceWord(result.confidenceScore), + }; +} + +/** + * Open findings by role. Within a group a finding about one of the headline's culprits comes first + * (the app the verdict names), then by severity, then by confidence. + */ +export function groupFindings(results: RuleResult[], outcome: string, culprits: DiagnosisCulprit[] | undefined): FindingGroups { + const aboutCulprit = (f: FindingView) => (culprits ?? []).some((c) => isAboutCulprit(f, c)); + const order = (a: FindingView, b: FindingView) => + Number(aboutCulprit(b)) - Number(aboutCulprit(a)) || + (SEVERITY_RANK[a.result.severity] ?? 4) - (SEVERITY_RANK[b.result.severity] ?? 4) || + b.result.confidenceScore - a.result.confidenceScore; + + const open = results.filter((r) => !r.resolvedAt).map(findingView); + const ofRole = (role: string) => open.filter((f) => f.role === role).sort(order); + return { + outcome: ofRole("outcome"), + primary: { ...primaryGroup(outcome), items: ofRole("cause") }, + context: ofRole("context"), + other: open.filter((f) => f.role !== "outcome" && f.role !== "cause" && f.role !== "context").sort(order), + }; +} + +/** Whether a finding is about the given culprit (its subject names the culprit). */ +export function isAboutCulprit(finding: FindingView, culprit: DiagnosisCulprit | undefined): boolean { + const subject = finding.presentation?.subject; + if (!subject || !culprit) return false; + const keys = [culprit.key, culprit.name].filter((k): k is string => !!k).map((k) => k.toLowerCase()); + return keys.includes(subject.key.toLowerCase()) || keys.includes(subject.name.toLowerCase()); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/steps.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/steps.ts new file mode 100644 index 00000000..d9f199a7 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/steps.ts @@ -0,0 +1,75 @@ +import { inlinePlain, ruleStepInlines, ruleTextBlocks, ruleTextPlain, type RuleBlock, type RuleInline } from "@/lib/ruleText"; +import type { DiagnosisHeadline } from "@/lib/generated/wire-types.generated"; +import type { RuleResult } from "@/types"; +import type { FindingGroups, FindingView } from "./findingGroups"; +import type { VerdictText } from "./verdictText"; +import { stepPath } from "./diagnosisFormat"; + +export interface StepGroup { + title: RuleBlock[]; + steps: RuleInline[][]; +} + +export interface StepsView { + /** The finding whose remediation these are. */ + source: FindingView; + groups: StepGroup[]; +} + +/** + * A finding's remediation filled in with its session's values. A step naming a value the session did + * not record is dropped; a group left without steps goes with it. + */ +export function remediationGroups(result: RuleResult): StepGroup[] { + return (result.remediation ?? []) + .map((group) => ({ + title: ruleTextBlocks(group.title, result.matchedConditions), + steps: group.steps.map((step) => ruleStepInlines(step, result.matchedConditions)).filter((s): s is RuleInline[] => s !== null), + })) + .filter((g) => g.steps.length > 0); +} + +/** + * What to do: the remediation of the strongest finding that explains the outcome (or, without one, + * of the outcome finding) that keeps a step once filled in. + */ +export function stepsFrom(groups: FindingGroups): StepsView | null { + for (const source of [...groups.primary.items, ...groups.outcome]) { + const stepGroups = remediationGroups(source.result); + if (stepGroups.length > 0) return { source, groups: stepGroups }; + } + return null; +} + +/** The verdict as plain text for a ticket or a chat. */ +export function copySummary(args: { verdict: VerdictText; headline: DiagnosisHeadline; device?: string | null; steps: StepsView | null; url?: string }): string { + const { verdict, headline, steps } = args; + const lines: string[] = [verdict.title + (args.device ? ` — ${args.device}` : ""), verdict.summary, ...verdict.details]; + + const step = stepPath(headline.failedStep?.category, headline.failedStep?.subcategory); + if (step) lines.push(`ESP step: ${step}${headline.failedStep?.errorCode ? ` (${headline.failedStep.errorCode})` : ""}`); + for (const culprit of headline.culprits ?? []) { + const facts = [ + culprit.errorCode && `error ${culprit.errorCode}`, + culprit.installStarts && culprit.installStarts > 1 && `${culprit.installStarts} install starts`, + culprit.detectionResult === "NotDetected" && "not detected", + ].filter(Boolean); + lines.push(`${culprit.kind === "app" ? "App" : culprit.kind === "appx_package" ? "Package" : "Policy provider"}: ${culprit.name}${facts.length ? ` — ${facts.join(", ")}` : ""}`); + } + + if (steps) lines.push("", "What to do:", stepsPlain(steps)); + if (args.url) lines.push("", args.url); + return lines.join("\n"); +} + +/** The steps as plain text, numbered across the groups. */ +export function stepsPlain(steps: StepsView): string { + const lines: string[] = []; + let n = 0; + for (const group of steps.groups) { + const title = ruleTextPlain(group.title); + if (title) lines.push(title); + for (const s of group.steps) lines.push(`${++n}. ${inlinePlain(s)}`); + } + return lines.join("\n"); +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/timeLanes.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/timeLanes.ts new file mode 100644 index 00000000..fb457a82 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/timeLanes.ts @@ -0,0 +1,140 @@ +import type { DiagnosisCulprit, SessionTimeBreakdown } from "@/lib/generated/wire-types.generated"; +import { formatDuration } from "@/lib/formatting"; +import { shortTime } from "./diagnosisFormat"; + +export type BarTone = "done" | "failed" | "signin" | "asleep" | "restart" | "update" | "app" | "appFailed"; + +export interface LaneBar { + /** Position and width in percent of the observed window. */ + left: number; + width: number; + tone: BarTone; + label?: string; + title: string; +} + +export interface Lane { + key: string; + label: string; + /** The lane of a culprit app. */ + emphasis: boolean; + bars: LaneBar[]; +} + +export interface LaneMarker { + left: number; + label: string; + /** Where the label sits against its line, so a label at an edge stays inside the chart. */ + align: "start" | "center" | "end"; +} + +export interface TimeLanes { + lanes: Lane[]; + /** `minor` ticks are left out on narrow screens. */ + ticks: { left: number; label: string; minor: boolean }[]; + /** The failure lines in time order. */ + markers: LaneMarker[]; +} + +const PHASES: Record = { + device_prep: { label: "Device preparation", category: "DevicePreparation" }, + esp_apps: { label: "Device setup", category: "DeviceSetup" }, + user_esp: { label: "Account setup", category: "AccountSetup" }, +}; + +const SIGN_IN: Record = { + identity_hello: "Sign-in and Hello", + awaiting_sign_in: "Waiting for sign-in", +}; + +/** + * "Where the time went" from the pre-computed breakdown: one lane for the ESP phases, one for sign-in, + * one per ESP-blocking app (culprits first), and one for the device (sleep, restarts, updates). Null + * without a breakdown or without a measurable window. + */ +export function timeLanes( + breakdown: SessionTimeBreakdown | null | undefined, + opts: { + failedCategory?: string | null; + /** The failure line: when, and what it is called ("ESP failed", "Failed"). */ + failure?: { at: string; label: string } | null; + culprits?: DiagnosisCulprit[] | null; + maxApps?: number; + } +): TimeLanes | null { + if (!breakdown || breakdown.segments.length === 0) return null; + const spans = [ + ...breakdown.segments, + ...breakdown.blockingApps, + ...breakdown.sleepSpans, + ...breakdown.rebootSpans, + ...breakdown.osUpdates, + ]; + const start = Math.min(...spans.map((s) => Date.parse(s.startUtc))); + const end = Math.max(...spans.map((s) => Date.parse(s.endUtc))); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; + + const pct = (iso: string) => Math.min(100, Math.max(0, ((Date.parse(iso) - start) / (end - start)) * 100)); + const bar = (s: { startUtc: string; endUtc: string; seconds: number }, tone: BarTone, name: string, label?: string): LaneBar => { + const left = pct(s.startUtc); + const width = Math.max(0.5, pct(s.endUtc) - left); + return { left: Math.min(left, 100 - width), width, tone, label, title: `${name} · ${formatDuration(s.seconds)}` }; + }; + + const lanes: Lane[] = []; + const phaseBars = breakdown.segments + .filter((s) => PHASES[s.segmentKey]) + .map((s) => { + const phase = PHASES[s.segmentKey]; + return bar(s, phase.category === opts.failedCategory ? "failed" : "done", phase.label, phase.label); + }); + if (phaseBars.length > 0) lanes.push({ key: "esp", label: "ESP", emphasis: false, bars: phaseBars }); + + const signInBars = breakdown.segments.filter((s) => SIGN_IN[s.segmentKey]).map((s) => bar(s, "signin", SIGN_IN[s.segmentKey], formatDuration(s.seconds))); + if (signInBars.length > 0) lanes.push({ key: "sign-in", label: "Sign-in and Hello", emphasis: false, bars: signInBars }); + + const isCulprit = (appId: string, appName: string) => + (opts.culprits ?? []).some((c) => c.kind === "app" && ((c.key && c.key.toLowerCase() === appId.toLowerCase()) || c.name === appName)); + const byApp = new Map(); + for (const interval of breakdown.blockingApps) { + const key = interval.appId || interval.appName; + const entry = byApp.get(key) ?? { name: interval.appName, culprit: isCulprit(interval.appId, interval.appName), first: Date.parse(interval.startUtc), intervals: [] }; + entry.intervals.push(interval); + entry.first = Math.min(entry.first, Date.parse(interval.startUtc)); + byApp.set(key, entry); + } + [...byApp.entries()] + .sort(([, a], [, b]) => Number(b.culprit) - Number(a.culprit) || a.first - b.first) + .slice(0, opts.maxApps ?? 6) + .sort(([, a], [, b]) => a.first - b.first) + .forEach(([key, app]) => + lanes.push({ + key: `app:${key}`, + label: app.name, + emphasis: app.culprit, + bars: app.intervals.map((i) => bar(i, app.culprit ? "appFailed" : "app", app.name)), + }) + ); + + const deviceBars = [ + ...breakdown.sleepSpans.map((s) => bar(s, "asleep", "Asleep", "Asleep")), + ...breakdown.rebootSpans.map((s) => bar(s, "restart", "Restart", "Restart")), + ...breakdown.osUpdates.map((s) => bar(s, "update", s.kbs.length > 0 ? `Windows update ${s.kbs.join(", ")}` : "Windows update", "Update")), + ]; + if (deviceBars.length > 0) lanes.push({ key: "device", label: "Device", emphasis: false, bars: deviceBars }); + + const ticks = [0, 1, 2, 3, 4].map((i) => { + const ms = start + ((end - start) * i) / 4; + return { left: i * 25, label: shortTime(new Date(ms).toISOString()), minor: i % 2 === 1 }; + }); + + const lines: { left: number; label: string }[] = []; + const appFailedAt = (opts.culprits ?? []).find((c) => c.kind === "app" && c.failedAt)?.failedAt; + if (appFailedAt) lines.push({ left: pct(appFailedAt), label: `App failed ${shortTime(appFailedAt)}` }); + if (opts.failure) lines.push({ left: pct(opts.failure.at), label: `${opts.failure.label} ${shortTime(opts.failure.at)}` }); + const markers: LaneMarker[] = lines + .sort((a, b) => a.left - b.left) + .map((line) => ({ ...line, align: line.left < 15 ? "start" : line.left > 85 ? "end" : "center" })); + + return { lanes, ticks, markers }; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/verdictText.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/verdictText.ts new file mode 100644 index 00000000..95f96838 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/verdictText.ts @@ -0,0 +1,201 @@ +import type { DiagnosisCulprit, DiagnosisHeadline } from "@/lib/generated/wire-types.generated"; +import { stepName, stepPath } from "./diagnosisFormat"; +import { vocabularyEntry, type Outcome } from "./vocabulary"; + +/** Status-band tone; the same families as the session status badge. */ +export type VerdictTone = "red" | "orange" | "sky" | "slate" | "green" | "amber" | "blue" | "gray"; + +export interface VerdictText { + tone: VerdictTone; + /** The status band, e.g. "Enrollment failed in Account setup › Apps". */ + title: string; + /** The label above the answer, e.g. "Why it failed". */ + heading: string; + /** The answer in one sentence. */ + summary: string; + /** Supporting sentences. */ + details: string[]; +} + +const TONES: Record = { + failed: "red", + stalled: "orange", + awaiting_user: "sky", + incomplete: "slate", + succeeded: "green", + in_progress: "blue", + pending: "amber", + unknown: "gray", +}; + +const HEADINGS: Record = { + failed: "Why it failed", + stalled: "Why it is stuck", + awaiting_user: "What it is waiting for", + incomplete: "Why it did not finish", + succeeded: "How it ended", + in_progress: "Where it stands", + pending: "Where it stands", + unknown: "What we know", +}; + +const AGENT_REASONS: Record = { + oobe_update_not_finished: "The Windows update at the end of OOBE did not finish.", + esp_exit_without_completion_evidence: "The ESP closed, but no completion signal followed.", + esp_recovered_without_completion_evidence: "The ESP recovered from an error, but setup never reported completion.", + effect_infrastructure_failure: "The agent hit an internal error and stopped.", +}; + +/** The verdict as the page states it, from the headline alone. */ +export function verdictText(h: DiagnosisHeadline): VerdictText { + const step = stepPath(h.failedStep?.category, h.failedStep?.subcategory); + return { + tone: h.cause === "soft_failure" ? "amber" : (vocabularyEntry(TONES, h.outcome) ?? "gray"), + title: bandTitle(h, step), + heading: vocabularyEntry(HEADINGS, h.outcome) ?? HEADINGS.unknown, + ...answer(h, step), + }; +} + +function bandTitle(h: DiagnosisHeadline, step: string | null): string { + switch (h.outcome) { + case "failed": + return `${h.cause === "whiteglove_failure" ? "Pre-provisioning failed" : "Enrollment failed"}${step ? ` in ${step}` : ""}`; + case "stalled": + return "Enrollment stalled"; + case "awaiting_user": + return "Waiting for the user"; + case "incomplete": + return "Enrollment incomplete"; + case "succeeded": + return h.cause === "soft_failure" ? "Enrollment succeeded after an ESP error" : "Enrollment succeeded"; + case "in_progress": + return "Enrollment in progress"; + case "pending": + return "Pre-provisioned, waiting for the user"; + default: + return "Outcome unknown"; + } +} + +function answer(h: DiagnosisHeadline, step: string | null): { summary: string; details: string[] } { + const apps = (h.culprits ?? []).filter((c) => c.kind === "app"); + const failedApps = apps.filter((c) => c.evidence === "install_failed" || c.evidence === "presumed_failed"); + const first = failedApps[0] ?? apps[0]; + const code = h.failedStep?.errorCode; + const origin = h.verdictPath.split(":")[0]; + + switch (h.cause) { + case "esp_app_failure": + return { + summary: + failedApps.length > 1 + ? `${failedApps.length} required apps failed, so the ESP ended the enrollment.` + : `${first?.name ?? "A required app"} could not be installed, so the ESP ended the enrollment.`, + details: [...appSentences(first, code), ...continueSentence(h)], + }; + case "esp_app_timeout": + return { + summary: + apps.length > 1 + ? `The ESP stopped while ${apps.length} required apps had not finished.` + : first?.evidence === "never_started" + ? `The ESP stopped while ${first.name} had not started installing.` + : `The ESP stopped while ${first?.name ?? "a required app"} was still installing.`, + details: [ + ...(h.espConfig?.timeoutMinutes ? [`The ESP time limit is ${h.espConfig.timeoutMinutes} minutes.`] : []), + ...continueSentence(h), + ], + }; + case "esp_step_failure": { + const name = stepName(h.failedStep?.subcategory) ?? h.failedStep?.failureType ?? "An ESP step"; + return { + summary: `${name === "An ESP step" ? name : `The ESP step ${name}`} failed${code ? ` with ${code}` : ""}.`, + details: (h.culprits ?? []).map((c) => + c.kind === "appx_package" ? `The AppX log points at ${c.name}.` : `The policy provider ${c.name} did not finish.` + ), + }; + } + case "whiteglove_failure": + return { + summary: `Pre-provisioning failed${step ? ` at ${step}` : ""}${code ? ` with ${code}` : ""}.`, + details: first ? [`${first.name} did not install in the technician phase.`] : [], + }; + case "agent_failure": { + const reason = h.failedStep?.reason; + return { summary: (reason && AGENT_REASONS[reason]) ?? `The agent reported a failure: ${reason ?? h.reasonText ?? "no reason given"}.`, details: [] }; + } + case "rule_ko": + return { + summary: h.ruleId ? `The analyze rule ${h.ruleId} marked this session as failed.` : "An analyze rule marked this session as failed.", + details: h.reasonText ? [h.reasonText] : [], + }; + case "admin_marked": + return { summary: `An administrator marked this session as ${h.outcome === "succeeded" ? "succeeded" : "failed"}.`, details: [] }; + case "agent_gave_up": + return { + summary: + origin === "maxlife" + ? "The agent reached its maximum lifetime before the enrollment finished." + : origin === "stopwait" + ? "The agent stopped waiting for the user to sign in." + : h.verdictPath.endsWith(":r3") + ? "The agent's emergency break ended the session." + : "The agent stopped before the enrollment finished.", + details: h.reasonText ? [h.reasonText] : [], + }; + case "sweep_timeout": + return { summary: "No completion arrived before the timeout, so the platform closed the session.", details: h.reasonText ? [h.reasonText] : [] }; + case "superseded": + return { summary: "A newer enrollment of this device replaced this session.", details: [] }; + case "agent_silent": + return { summary: "The agent stopped sending data.", details: h.reasonText ? [h.reasonText] : [] }; + case "no_progress": + return { summary: "The agent reported no progress for an hour.", details: h.reasonText ? [h.reasonText] : [] }; + case "awaiting_user": + return { + summary: + h.verdictPath.includes("wg_awaiting") || h.verdictPath.includes("r1b") + ? "Pre-provisioning finished; the device is waiting for its user." + : "Device setup finished; the enrollment is waiting for the user.", + details: [], + }; + case "whiteglove_sealed": + return { summary: "Pre-provisioning finished and the device was sealed for its user.", details: [] }; + case "reconciled": + return { summary: "The platform declared this session complete without a completion report.", details: h.reasonText ? [h.reasonText] : [] }; + case "soft_failure": + return { + summary: `The ESP failed${step ? ` in ${step}` : ""}, the user continued anyway, and setup completed.`, + details: first ? [appOutcome(first, code, first.name) ?? `${first.name} had not finished when the ESP failed.`] : [], + }; + case "completed": + return { summary: "The enrollment completed.", details: [] }; + case "none": + return { summary: "The enrollment is still running.", details: [] }; + default: + return { summary: h.reasonText ?? "The outcome could not be attributed.", details: [] }; + } +} + +function appSentences(app: DiagnosisCulprit | undefined, stepCode: string | null | undefined): string[] { + if (!app) return []; + const target = app.targeted?.toLowerCase(); + const outcome = appOutcome(app, stepCode, "It"); + return [...(target === "user" || target === "device" ? [`The app is required for the ${target}.`] : []), ...(outcome ? [outcome] : [])]; +} + +/** What happened to the app, with `subject` naming it; null when its evidence tells no more than its name. */ +function appOutcome(app: DiagnosisCulprit, stepCode: string | null | undefined, subject: string): string | null { + const starts = app.installStarts && app.installStarts > 1 ? ` after ${app.installStarts} install starts` : ""; + if (app.evidence === "presumed_failed") return `${subject} was still installing when the ESP failed${stepCode ? ` with ${stepCode}` : ""}.`; + if (app.errorCode) return `${subject} failed with ${app.errorCode}${starts}.`; + if (app.detectionResult === "NotDetected") return `${subject} finished installing${starts}, but its detection rule did not find the app.`; + return null; +} + +function continueSentence(h: DiagnosisHeadline): string[] { + if (h.espConfig?.continueAnyway === false) return ["This ESP does not let users continue after an install error."]; + if (h.espConfig?.continueAnyway === true) return ["This ESP lets users continue anyway."]; + return []; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/vocabulary.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/vocabulary.ts new file mode 100644 index 00000000..3ea77fc3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/vocabulary.ts @@ -0,0 +1,14 @@ +import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated"; + +// The headline's vocabularies as the backend writes them (shared manifest). Text maps keyed by them +// are exhaustive, so a value the backend adds fails this build until it has a text. +type Vocabulary = typeof SHARED_MANIFEST.diagnosis; +export type Outcome = Vocabulary["outcomes"][number]; +export type CulpritEvidence = Vocabulary["culpritEvidence"][number]; +export type Caveat = Vocabulary["caveats"][number]; +export type Prerequisite = Vocabulary["prerequisites"][number]; + +/** The entry for a wire value; undefined for a value newer than this build (the backend deploys first). */ +export function vocabularyEntry(map: Record, value: string | null | undefined): V | undefined { + return value != null && Object.prototype.hasOwnProperty.call(map, value) ? map[value as K] : undefined; +} diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index f14a1e2d..ac35109a 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -1,865 +1,291 @@ "use client"; -import { sessionUrl } from "@/lib/routes"; -import { Suspense, useCallback, useEffect, useState, useRef } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useSignalR, useSignalRResync } from "../../contexts/SignalRContext"; -import { useTenant } from "../../contexts/TenantContext"; -import { useAuth } from "../../contexts/AuthContext"; -import { useNotifications } from "../../contexts/NotificationContext"; -import { ProtectedRoute } from "../../components/ProtectedRoute"; -import { api } from "@/lib/api"; -import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; -import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; -import { ConfidenceBadge, SeverityBadge } from "./components/DiagnosisBadges"; -import { Session, EnrollmentEvent, RuleResult } from "@/types"; -import { useAdminMode } from "@/hooks/useAdminMode"; -import { isGuid } from "@/lib/inputValidation"; -import { safeHttpUrl } from "@/lib/safeDocUrl"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { ProtectedRoute } from "@/components/ProtectedRoute"; +import { SessionStatusBadge } from "@/components/SessionStatusBadge"; import { DocsLink } from "@/components/DocsLink"; import { InvestigateWithAiHint } from "@/components/InvestigateWithAiHint"; +import { CardSkeleton } from "@/components/skeletons/PageSkeleton"; +import { useSignalR } from "@/contexts/SignalRContext"; +import { useTenant } from "@/contexts/TenantContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { useNotifications } from "@/contexts/NotificationContext"; +import { useAdminMode } from "@/hooks/useAdminMode"; +import { useCopy } from "@/hooks/useCopy"; +import { trackEvent } from "@/lib/appInsights"; import { DOCS_PATHS } from "@/lib/docsPaths"; -import type { GetRuleResultsResponse, GetSessionEventsResponse, GetSessionResponse } from "@/lib/generated/wire-types.generated"; -import { fetchJson } from "@/lib/apiClient"; -import { notifyApiError } from "@/contexts/NotificationContext"; +import { asGuidOrUndefined, isGuid } from "@/lib/inputValidation"; +import { isTerminalStatus } from "@/lib/sessionStatus"; +import { diagnosisUrl, sessionUrl } from "@/lib/routes"; +import { hasTenantReadScope } from "@/lib/tenantScope"; +import { useSessionDetail } from "../sessions/hooks/useSessionDetail"; +import { useSessionAnalysis } from "../sessions/hooks/useSessionAnalysis"; +import { useSessionEvents } from "../sessions/hooks/useSessionEvents"; +import { useSessionSignalR } from "../sessions/hooks/useSessionSignalR"; +import { useSessionTimeAttribution } from "../sessions/hooks/useSessionTimeAttribution"; +import { useSessionDiagnosis } from "./hooks/useSessionDiagnosis"; +import VerdictCard from "./components/VerdictCard"; +import ContextSection from "./components/ContextSection"; +import StepsSection from "./components/StepsSection"; +import TimeSection from "./components/TimeSection"; +import FindingsSection from "./components/FindingsSection"; +import NotesSection from "./components/NotesSection"; +import ProblemsSection from "./components/ProblemsSection"; +import { verdictText } from "./lib/verdictText"; +import { groupFindings } from "./lib/findingGroups"; +import { contextItems } from "./lib/contextItems"; +import { copySummary, stepsFrom, stepsPlain } from "./lib/steps"; +import { timeLanes } from "./lib/timeLanes"; +import { keyEvents, problemEvents } from "./lib/eventLists"; + +/** How long after the end a session without findings may still be in its queued analysis. */ +const ANALYSIS_GRACE_MS = 5 * 60_000; export default function DiagnosisPage() { - // useSearchParams() in DiagnosisContent requires a Suspense boundary for static prerender. + // useSearchParams() requires a Suspense boundary for the static prerender. return ( - + ); } -function DiagnosisContent() { +/** + * Identity from the query (`?id=&tenantId=`). The content is keyed by the session: the session hooks + * keep their state across an id change, and the verdict links to other sessions (superseded). + */ +function DiagnosisRoute() { const searchParams = useSearchParams(); - const router = useRouter(); - // Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders. - const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string; - - const [session, setSession] = useState(null); - const [sessionTenantId, setSessionTenantId] = useState(null); - const [events, setEvents] = useState([]); - const [analysisResults, setAnalysisResults] = useState([]); - // Any stored finding, resolved ones included: only a session without any may say "no rule flagged it". - const [hasAnyRuleResult, setHasAnyRuleResult] = useState(false); - const [loading, setLoading] = useState(true); - const [expandedResult, setExpandedResult] = useState(null); - const [showEvidence, setShowEvidence] = useState(null); - const [copiedId, setCopiedId] = useState(null); - - const hasInitialFetch = useRef(false); - const lastFetchedSessionId = useRef(null); - const sessionIdRef = useRef(sessionId); - // Eager tenant hydration lets the session + analysis fetches run in parallel, - // so the loading gate must wait for BOTH — otherwise a fast analysis response - // (or a slow/failed session fetch) would clear loading while `session` is - // still null and the page would flash a wrong IN PROGRESS / empty state. - const sessionFetchDone = useRef(false); - const analysisFetchDone = useRef(false); - - const finishLoadingWhenSettled = useCallback(() => { - if (sessionFetchDone.current && analysisFetchDone.current) { - setLoading(false); - } - }, []); + // Percent-decoded deep-link values: only GUIDs may reach the API builders. + const raw = searchParams.get("id") ?? ""; + const sessionId = isGuid(raw) ? raw.trim() : ""; + const tenantIdOverride = asGuidOrUndefined(searchParams.get("tenantId")); + return ; +} +function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedSessionId: string; tenantIdOverride?: string }) { + const { getAccessToken, user } = useAuth(); + // A member without a tenant role sees ProtectedRoute's notice; the empty id keeps the hooks inert. + const sessionId = !user || hasTenantReadScope(user) ? requestedSessionId : ""; + const { globalAdminMode } = useAdminMode(); const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); const { tenantId } = useTenant(); - const { getAccessToken, user } = useAuth(); const { addNotification } = useNotifications(); - - const { globalAdminMode } = useAdminMode(); + const { copied, copy } = useCopy(); - // Declared ahead of the effects that call them (react-hooks/immutability: - // a later declaration would freeze the effect's view of the binding). - const fetchSessionDetails = useCallback(async () => { - try { - // Fetch the single session directly instead of pulling the entire session - // list and .find()-ing client-side. The backend resolves the tenant via - // ResolveSessionTenantIdAsync for global admins when tenantId is omitted. - const knownTenantId = sessionTenantId || (!globalAdminMode ? tenantId : null); - const endpoint = - knownTenantId && isGuid(knownTenantId) - ? api.sessions.get(sessionId, knownTenantId) - : api.sessions.get(sessionId); - const data = await fetchJson(endpoint, getAccessToken); - const found = data.session; - if (found) { - setSession(found); - setSessionTenantId(found.tenantId); - } - } catch (error) { - console.error("Failed to fetch session details:", error); - notifyApiError(addNotification, 'Backend Error', error, 'diagnosis-fetch-error', 'Unable to load session details.'); - } finally { - sessionFetchDone.current = true; - finishLoadingWhenSettled(); - } - }, [sessionId, sessionTenantId, tenantId, globalAdminMode, getAccessToken, finishLoadingWhenSettled, addNotification]); - - // After every connection gap the session object is re-read; events and analysis come through - // the join catch-up below. - useSignalRResync(() => { - if (sessionId) void fetchSessionDetails(); + const detail = useSessionDetail({ sessionId, tenantId, globalAdminMode, tenantIdOverride, user, getAccessToken, addNotification }); + const analysis = useSessionAnalysis(sessionId, detail.sessionTenantId, getAccessToken, { withVulnerabilityReport: false }); + const eventsApi = useSessionEvents({ + sessionId, + sessionTenantId: detail.sessionTenantId, + sessionStatus: detail.session?.status ?? null, + resolveEffectiveTenantId: detail.resolveEffectiveTenantId, + sessionRef: detail.sessionRef, + fetchSessionDetails: detail.fetchSessionDetails, + setLoading: detail.setLoading, + isConnected, + getAccessToken, + addNotification, + }); + const diagnosis = useSessionDiagnosis(sessionId, detail.sessionTenantId, getAccessToken); + useSessionSignalR({ + sessionId, + sessionTenantId: detail.sessionTenantId, + tenantId, + sessionTenantIdFromSession: detail.session?.tenantId, + globalAdminMode, + sessionIdRef: detail.sessionIdRef, + sessionRef: detail.sessionRef, + resolveEffectiveTenantId: detail.resolveEffectiveTenantId, + signalR: { on, off, isConnected, joinGroup, leaveGroup }, + scheduleFetchEvents: eventsApi.scheduleFetchEvents, + setSession: detail.setSession, + setSessionTenantId: detail.setSessionTenantId, + fetchSessionDetails: detail.fetchSessionDetails, + fetchAnalysisResults: analysis.fetchAnalysisResults, + fetchVulnerabilityReport: analysis.fetchVulnerabilityReport, + onActivity: diagnosis.scheduleRefetch, }); - const fetchEvents = useCallback(async () => { - const effectiveTenantId = sessionTenantId || tenantId; - try { - const data = await fetchJson(api.sessions.events(sessionId, effectiveTenantId), getAccessToken); - // No `fields` projection on this read, so every event is complete. - setEvents((data.events || []) as EnrollmentEvent[]); - } catch (error) { - console.error("Failed to fetch events:", error); - notifyApiError(addNotification, 'Backend Error', error, 'diagnosis-events-error', 'Unable to load session events.'); - } - }, [sessionId, sessionTenantId, tenantId, getAccessToken, addNotification]); + const session = detail.session; + const breakdown = useSessionTimeAttribution(sessionId, session?.status, detail.sessionTenantId || tenantIdOverride, getAccessToken); + const headline = diagnosis.headline; - const fetchAnalysisResults = useCallback(async () => { - const effectiveTenantId = sessionTenantId || tenantId; - try { - const data = await fetchJson(api.sessions.analysis(sessionId, effectiveTenantId), getAccessToken); - { - if (data.results) { - setHasAnyRuleResult(data.results.length > 0); - // Resolved findings (no longer detected by a later evaluation) are audit-only — - // the diagnosis flow must not present them as the probable cause. - setAnalysisResults( - data.results - .filter((r: RuleResult) => !r.resolvedAt) - .sort( - (a: RuleResult, b: RuleResult) => - b.confidenceScore - a.confidenceScore - ) - ); - } - } - } catch (error) { - console.error("Failed to fetch analysis results:", error); - } finally { - analysisFetchDone.current = true; - finishLoadingWhenSettled(); - } - }, [sessionId, sessionTenantId, tenantId, getAccessToken, finishLoadingWhenSettled]); + // Links keep the tenant of a cross-tenant view (Global Admin, MSP drill-in). + const viewedTenantId = (session?.tenantId ?? detail.sessionTenantId ?? tenantIdOverride ?? "").toLowerCase(); + const crossTenant = !!viewedTenantId && !!tenantId && viewedTenantId !== tenantId.toLowerCase(); + const linkTenant = crossTenant ? viewedTenantId : undefined; + const eventHref = (eventId: string) => sessionUrl(sessionId, { tenantId: linkTenant, hash: `event-${eventId}` }); + const results = analysis.analysisResults; + const groups = useMemo(() => groupFindings(results, headline?.outcome ?? "unknown", headline?.culprits), [results, headline]); + const verdict = useMemo(() => (headline ? verdictText(headline) : null), [headline]); + const steps = useMemo(() => stepsFrom(groups), [groups]); + const context = useMemo(() => contextItems(groups.context, headline?.caveats), [groups, headline]); + const lanes = useMemo( + () => + timeLanes(breakdown, { + failedCategory: headline?.failedStep?.category, + failure: headline?.failedStep ? { at: headline.failedStep.at, label: headline.failedStep.category ? "ESP failed" : "Failed" } : null, + culprits: headline?.culprits, + }), + [breakdown, headline] + ); + const key = useMemo(() => keyEvents(eventsApi.events, headline?.culprits), [eventsApi.events, headline]); + const problems = useMemo(() => problemEvents(eventsApi.events), [eventsApi.events]); + + // The end-of-enrollment analysis runs from a queue, and a run without findings pushes nothing: + // for a few minutes after the end "no findings" may still mean "not analyzed yet". + const completedAt = session?.completedAt; + const [analysisMayRun, setAnalysisMayRun] = useState(false); useEffect(() => { - if (!sessionId) return; - if (!globalAdminMode && !tenantId) return; // wait for real tenant ID - const run = async () => { - sessionIdRef.current = sessionId; - if (lastFetchedSessionId.current !== sessionId) { - hasInitialFetch.current = false; - lastFetchedSessionId.current = sessionId; - sessionFetchDone.current = false; - analysisFetchDone.current = false; - setSessionTenantId(null); - setLoading(true); - } - if (hasInitialFetch.current) return; - hasInitialFetch.current = true; - - // Performance: eager-set sessionTenantId from TenantContext when known, so - // the events/analysis effect fires in parallel with fetchSessionDetails - // instead of waiting for its roundtrip — eliminates the fetch waterfall. - // Global Admins in all-tenant view fall through with null (backend resolves - // the tenant from the session itself). - if (!globalAdminMode && isGuid(tenantId)) { - setSessionTenantId(tenantId); - } - - await fetchSessionDetails(); - }; - void run(); - }, [sessionId, tenantId, globalAdminMode, fetchSessionDetails]); - - useEffect(() => { - if (sessionTenantId && sessionId) { - const run = async () => { - await Promise.all([fetchEvents(), fetchAnalysisResults()]); - }; - void run(); - } - }, [sessionTenantId, sessionId, fetchEvents, fetchAnalysisResults]); - - // SignalR groups - subscribe-then-fetch pattern - useEffect(() => { - const effectiveTenantId = sessionTenantId || tenantId; - if (!sessionId || !isConnected || !effectiveTenantId) return; - const group = `session-${effectiveTenantId}-${sessionId}`; - // Cleared by the cleanup: a run superseded while its join was in flight (typically because - // sessionTenantId resolved) must not fetch on behalf of the run that replaced it. - let current = true; - const joinAndCatchUp = async () => { - await joinGroup(group); - if (!current) return; - // Re-fetch after group join to catch any missed during join - void Promise.all([fetchEvents(), fetchAnalysisResults()]); - }; - void joinAndCatchUp(); - // Every run that joined leaves in its own cleanup: group membership is reference-counted in - // the SignalR layer, and a leave that arrives before the join resolved is settled there. + if (!completedAt) return; + const left = Date.parse(completedAt) + ANALYSIS_GRACE_MS - Date.now(); + const start = setTimeout(() => setAnalysisMayRun(left > 0), 0); + const end = left > 0 ? setTimeout(() => setAnalysisMayRun(false), left) : undefined; return () => { - current = false; - leaveGroup(group); + clearTimeout(start); + if (end) clearTimeout(end); }; - }, [sessionId, isConnected, sessionTenantId, tenantId, joinGroup, leaveGroup, fetchEvents, fetchAnalysisResults]); + }, [completedAt]); - // Real-time analysis updates + const reported = useRef(false); useEffect(() => { - // eventStream carries { sessionId, tenantId, newEventCount, newRuleResults } — the full - // session object was removed from the payload long ago (see useSessionSignalR), so only - // the rule-result delta is consumed here. - const handleEventStream = (data: { - sessionId: string; - newRuleResults?: RuleResult[]; - }) => { - if (data.sessionId !== sessionIdRef.current) return; - if (data.newRuleResults?.length) { - setAnalysisResults((prev) => { - const existingIds = new Set(prev.map((r) => r.ruleId)); - const newResults = data.newRuleResults!.filter( - (r) => !existingIds.has(r.ruleId) - ); - return [...prev, ...newResults].sort( - (a, b) => b.confidenceScore - a.confidenceScore - ); - }); - } - }; - on("eventStream", handleEventStream); - return () => { - off("eventStream", handleEventStream); - }; - }, [on, off]); + if (!headline || reported.current) return; + reported.current = true; + trackEvent("diagnosis_opened", { status: session?.status ?? "", outcome: headline.outcome, cause: headline.cause }); + }, [headline, session?.status]); - const copyRemediation = (result: RuleResult) => { - const text = result.remediation - .map((r) => { - const title = interpolateRuleTemplate(r.title, result.matchedConditions); - const steps = r.steps - .map((s) => ` - ${interpolateRuleTemplate(s, result.matchedConditions)}`) - .join("\n"); - return `${title}\n${steps}`; - }) - .join("\n\n"); - navigator.clipboard.writeText(text).then(() => { - setCopiedId(result.ruleId); - setTimeout(() => setCopiedId(null), 2000); - }); + useEffect(() => { + const label = session?.deviceName || session?.serialNumber; + document.title = label ? `Diagnosis · ${label} | Autopilot Monitor` : "Diagnosis | Autopilot Monitor"; + return () => { + document.title = "Autopilot Monitor"; + }; + }, [session?.deviceName, session?.serialNumber]); + + const openCount = groups.outcome.length + groups.primary.items.length + groups.context.length + groups.other.length; + const analysisNote = !analysis.analysisLoaded + ? "Loading findings…" + : openCount > 0 + ? null + : !isTerminalStatus(session?.status) + ? "Findings appear here while the enrollment runs." + : analysisMayRun + ? "The analysis is still running; findings appear here when it finishes." + : "No analyze rule fired for this session."; + + const timelineEventId = headline?.failedStep?.eventId ?? headline?.culprits?.find((c) => c.eventId)?.eventId; + const device = session?.deviceName || session?.serialNumber || null; + + const onCopySummary = () => { + if (!headline || !verdict) return; + trackEvent("diagnosis_action", { action: "copy_summary" }); + void copy(copySummary({ verdict, headline, device, steps, url: window.location.href }), "summary"); + }; + const onCopySteps = () => { + if (!steps) return; + trackEvent("diagnosis_action", { action: "copy_steps" }); + void copy(stepsPlain(steps), "steps"); }; - // Derive error/warning events for evidence - const errorEvents = events.filter( - (e) => e.severity === "Error" || e.severity === "Critical" - ); - const warningEvents = events.filter((e) => e.severity === "Warning"); - - // Keep this early-return wrapped in : on a fresh direct - // navigation (new tab, bookmark, shared link) the auth cache is empty, so the - // tenant-gated fetch never runs and `loading` stays true forever. Without the - // gate here we'd hang on "Loading diagnosis..." and never trigger the MSAL - // login redirect. See the matching note in app/sessions/[sessionId]/page.tsx. - if (loading) { - return ( - -
-
Loading diagnosis...
-
-
- ); - } - - const primaryResult = analysisResults[0] || null; - // Global Admin / MSP drill-in into another tenant's session: the copied prompt names the tenant, and - // the actions that stay in the viewer's own tenant (dry-run as Tenant Admin, Report Session) drop out. - const crossTenant = !!sessionTenantId && !!tenantId && sessionTenantId.toLowerCase() !== tenantId.toLowerCase(); - const otherResults = analysisResults.slice(1); - const primaryDocUrl = safeHttpUrl(primaryResult?.relatedDocs?.[0]?.url); - - const statusLabel = - session?.status === "Failed" - ? "FAILED" - : session?.status === "Succeeded" - ? "SUCCEEDED" - : "IN PROGRESS"; - - const statusColor = - session?.status === "Failed" - ? "text-red-600" - : session?.status === "Succeeded" - ? "text-green-600" - : "text-blue-600"; - return ( - +
- {/* Header */}
-
-
-
- -
- - - -
-

- Diagnosis -

-

- Session{" "} - - {sessionId.split("-")[0]?.toUpperCase()} - {" "} - - {statusLabel} - - {session && ( - - | {session.deviceName || session.serialNumber} |{" "} - {session.manufacturer} {session.model} - - )} -

-
-
-
-
- - -
+
+
+ {sessionId && ( + + ← Session details + + )} +

+ Diagnosis + {session && ( + + )} +

+ {session && ( +

+ {[device, [session.manufacturer, session.model].filter(Boolean).join(" ")].filter(Boolean).join(" · ")} +

+ )}
+
-
- {analysisResults.length === 0 ? ( -
- - - -

- No Diagnosis Available -

- {session?.status === "Failed" && !hasAnyRuleResult ? ( -
- -
- ) : ( -

- {session?.status === "InProgress" - ? "Analysis results will appear here as they are generated during the enrollment." - : session?.status === "Succeeded" - ? "This session completed successfully. No issues were detected." - : "No analysis rules matched for this session. Check the event timeline for more details."} -

- )} - {errorEvents.length > 0 && ( -
-

- Errors found in events ({errorEvents.length}): -

-
- {errorEvents.slice(0, 5).map((e) => ( -
- {e.message} -
- ))} -
-
- )} -
+
+ {!requestedSessionId ? ( +

This link does not name a valid session.

) : ( -
- {/* Primary Suspect */} - {primaryResult && ( -
-
-
-
- - {primaryResult.severity === "critical" - ? "\uD83C\uDFAF" - : "\uD83D\uDD0D"} - -
-
- Primary Suspect -
-
-
-
- - -
-
- -

- {primaryResult.ruleTitle} -

- -

- {interpolateRuleTemplate(primaryResult.explanation, primaryResult.matchedConditions)} -

- - {/* Evidence Summary */} - {primaryResult.matchedConditions && - Object.keys(primaryResult.matchedConditions).length > - 0 && ( -
-

- Evidence Found: -

-
- {Object.entries( - primaryResult.matchedConditions - ).map(([key, value]) => ( -
- - • - - - {key}:{" "} - {typeof value === "object" - ? JSON.stringify(value) - : String(value)} - -
- ))} -
-
- )} - - {/* Quick Fix Box */} - {primaryResult.remediation && - primaryResult.remediation.length > 0 && ( -
-
-

- Quick Fix -

- -
- {primaryResult.remediation.map((rem, i) => ( -
-

- {interpolateRuleTemplate(rem.title, primaryResult.matchedConditions)} -

-
    - {rem.steps.map((step, j) => ( -
  • - - • - - {interpolateRuleTemplate(step, primaryResult.matchedConditions)} -
  • - ))} -
-
- ))} -
- )} - - {/* Action Buttons */} -
- {primaryDocUrl && ( - - - - - Learn More - - )} - -
- - {/* Evidence Detail */} - {showEvidence === primaryResult.ruleId && ( -
- -
-
-                            {JSON.stringify(
-                              primaryResult.matchedConditions,
-                              null,
-                              2
-                            )}
-                          
-
-
- )} -
+ <> + {diagnosis.state === "loading" && ( +
+
)} - - {/* Other Possibilities */} - {otherResults.length > 0 && ( -
-
-

- Other Possibilities -

-
-
- {otherResults.map((result) => ( -
-
- setExpandedResult( - expandedResult === result.ruleId - ? null - : result.ruleId - ) - } - > -
- -
-
- {result.ruleTitle} -
-
- {result.category} -
-
-
-
- - - {expandedResult === result.ruleId - ? "\u25BC" - : "\u25B6"} - -
-
- - {expandedResult === result.ruleId && ( -
-

- {formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))} -

- - {result.remediation?.length > 0 && ( -
-
-

- Remediation -

- -
- {result.remediation.map((rem, i) => ( -
-

- {interpolateRuleTemplate(rem.title, result.matchedConditions)} -

-
    - {rem.steps.map((step, j) => ( -
  • {interpolateRuleTemplate(step, result.matchedConditions)}
  • - ))} -
-
- ))} -
- )} - - {result.matchedConditions && - Object.keys(result.matchedConditions).length > - 0 && ( -
- - {showEvidence === result.ruleId && ( -
- -
-
-                                          {JSON.stringify(
-                                            result.matchedConditions,
-                                            null,
-                                            2
-                                          )}
-                                        
-
-
- )} -
- )} - - {result.relatedDocs?.length > 0 && ( -
- {result.relatedDocs.map((doc, i) => { - const href = safeHttpUrl(doc.url); - return href ? ( - - {doc.title} - - ) : ( - {doc.title} - ); - })} -
- )} -
- )} -
- ))} -
+ {diagnosis.state === "error" && ( +
+ The verdict could not be loaded. +
)} - - {/* Error Events Summary */} - {errorEvents.length > 0 && ( -
-

- Error Events ({errorEvents.length}) -

-
- {errorEvents.map((e) => ( -
- - ✖ - -
-
- - {new Date(e.timestamp).toLocaleTimeString()} - - - {e.source} - -
-

{e.message}

-
-
- ))} -
-
+ {headline && verdict && ( + )} - {/* Warning Events Summary */} - {warningEvents.length > 0 && ( -
-

- Warnings ({warningEvents.length}) -

-
- {warningEvents.map((e) => ( -
- - ⚠ - -
-
- - {new Date(e.timestamp).toLocaleTimeString()} - -
-

{e.message}

-
-
- ))} -
-
+ + {steps && } + + + + {session?.status === "Failed" && analysis.analysisLoaded && results.length === 0 && !analysisMayRun && ( + )} -
+ + {sessionId && } + + )}
); } - -function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record; sessionId: string }) { - const eventLinks: { signal: string; eventId: string; eventType?: string }[] = []; - const seenEventIds = new Set(); - - for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) { - if (signal.startsWith("factor_")) continue; - // Evidence values are rule-engine JSON; only object-shaped entries carry event links. - const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null; - if (evidence && typeof evidence === "object" && evidence.eventId) { - // Multiple matched conditions can extract different fields from the - // same event — one chip per distinct event, not per condition. - if (seenEventIds.has(evidence.eventId)) continue; - seenEventIds.add(evidence.eventId); - eventLinks.push({ signal, eventId: evidence.eventId, eventType: evidence.eventType }); - } - } - - if (eventLinks.length === 0) return null; - - return ( -
- {eventLinks.map(({ signal, eventId, eventType }) => ( - - - - - {eventType || signal} - - ))} -
- ); -} - diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx index 1c37f8ab..501d6f1c 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx @@ -4,8 +4,9 @@ import { useState, useEffect, type ReactNode } from "react"; import Link from "next/link"; import type { Route } from "next"; import { RuleResult } from "@/types"; -import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; -import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; +import { ruleStepInlines, ruleTextBlocks } from "@/lib/ruleText"; +import { RuleInlines, RuleText } from "@/components/findings/RuleText"; +import { EvidenceChips } from "@/components/findings/EvidenceChips"; import { api } from "@/lib/api"; import { dashboardUrl } from "@/lib/routes"; import { recentWindowStartIso, sumRecentFires } from "@/lib/ruleRecentFires"; @@ -360,27 +361,29 @@ function AnalysisResultCard({ {result.explanation && (

Explanation

-

- {formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))} -

+
)} {result.remediation && result.remediation.length > 0 && (

Remediation

- {result.remediation.map((rem, i) => ( -
-

- {interpolateRuleTemplate(rem.title, result.matchedConditions)} -

-
    - {rem.steps.map((step, j) => ( -
  • {interpolateRuleTemplate(step, result.matchedConditions)}
  • - ))} -
-
- ))} + {result.remediation.map((rem, i) => { + // A step that names a value this session did not record is dropped, like a list item. + const steps = rem.steps.map((step) => ruleStepInlines(step, result.matchedConditions)).filter((s) => s !== null); + return ( +
+ +
    + {steps.map((step, j) => ( +
  • + +
  • + ))} +
+
+ ); + })}
)} @@ -411,7 +414,7 @@ function AnalysisResultCard({ {result.matchedConditions && Object.keys(result.matchedConditions).length > 0 && (

Evidence

- +
{JSON.stringify(result.matchedConditions, null, 2)}
@@ -423,49 +426,10 @@ function AnalysisResultCard({ ); } -function scrollToEvent(eventId: string) { - const el = document.getElementById(`event-${eventId}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - el.classList.add("ring-2", "ring-amber-400", "ring-offset-1"); - setTimeout(() => el.classList.remove("ring-2", "ring-amber-400", "ring-offset-1"), 3000); - } -} - -function EvidenceEventLinks({ matchedConditions }: { matchedConditions: Record }) { - const eventLinks: { signal: string; eventId: string; eventType?: string }[] = []; - const seenEventIds = new Set(); - - for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) { - if (signal.startsWith("factor_")) continue; - // Evidence values are rule-engine JSON; only object-shaped entries carry event links. - const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null; - if (evidence && typeof evidence === "object" && evidence.eventId) { - // Multiple matched conditions can extract different fields from the - // same event — one chip per distinct event, not per condition. - if (seenEventIds.has(evidence.eventId)) continue; - seenEventIds.add(evidence.eventId); - eventLinks.push({ signal, eventId: evidence.eventId, eventType: evidence.eventType }); - } - } - - if (eventLinks.length === 0) return null; - - return ( -
- {eventLinks.map(({ signal, eventId, eventType }) => ( - - ))} -
- ); +/** + * The page's #event- handler expands the event's phase, unhides a filtered severity and waits for a + * later page of events — a direct DOM lookup here missed every row that was not on screen yet. + */ +function openEventInTimeline(eventId: string) { + window.location.hash = `event-${encodeURIComponent(eventId)}`; } diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsBody.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsBody.tsx new file mode 100644 index 00000000..fe175acd --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsBody.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + ANNOTATION_MAX_NOTE_LENGTH, + ANNOTATION_VERDICTS, + buildPutBody, + hasContent, + LANE_LABELS, + VERDICT_DESCRIPTIONS, + VERDICT_LABELS, + VERDICT_PILL_CLASSES, + type AnnotationVerdict, +} from "./sessionAnnotationLogic"; +import type { SessionAnnotationsState } from "../hooks/useSessionAnnotations"; + +/** + * The annotation lanes of a session (verdict + note per lane, editable where the server allows): + * the body of the session page's annotations card and of the diagnosis page's team notes. + */ +export default function SessionAnnotationsBody({ state }: { state: SessionAnnotationsState }) { + const { lanes, annotations, writableLanes, loaded, laneEdit, setLaneEdit, handleSave } = state; + + return ( +
+ {lanes.map((lane) => { + const annotation = annotations[lane]; + const writable = writableLanes.has(lane); + const edit = laneEdit(lane); + + if (!writable && !hasContent(annotation)) { + return null; + } + + return ( +
+
+ {LANE_LABELS[lane]} + {lane === "globaladmin" && ( + + never visible to the tenant + + )} + {annotation?.verdict != null && !writable && ( + + {VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict} + + )} + {annotation?.authorDisplayName && ( + + {annotation.authorDisplayName} + {annotation.updatedAtUtc ? ` · ${new Date(annotation.updatedAtUtc).toLocaleString()}` : ""} + + )} +
+ + {writable ? ( + <> +
+ {ANNOTATION_VERDICTS.map((verdict) => { + const selected = edit.verdict === verdict; + return ( + + ); + })} +
+