mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-22 19:55:35 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<object> { "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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IRuleRepository>();
|
||||
var function = new GlobalRulesFunction(
|
||||
NullLogger<GlobalRulesFunction>.Instance,
|
||||
new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance),
|
||||
new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.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<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,15 @@ public class RuleTemplateInterpolatorTests
|
||||
|
||||
// ── Shared cases (tests/fixtures/rule-template-interpolation) ─────────
|
||||
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
private static readonly Lazy<JObject> 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<JObject>(
|
||||
File.ReadAllText(path), new JsonSerializerSettings { DateParseHandling = DateParseHandling.None })!;
|
||||
});
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> CaseNames()
|
||||
=> Fixture.Value["cases"]!.Select(c => new object[] { c.Value<string>("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<JObject> DisplayFixture = new(() => LoadFixture("rule-text-display"));
|
||||
|
||||
public static IEnumerable<object[]> DisplayCaseNames()
|
||||
=> DisplayFixture.Value["cases"]!.Select(c => new object[] { c.Value<string>("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<string>("name") == name);
|
||||
Assert.Equal(
|
||||
testCase.Value<string>("expected")!,
|
||||
RuleTemplateInterpolator.InterpolateForNotification(testCase.Value<string>("text"), AsDictionaries(testCase["matchedConditions"] as JObject)));
|
||||
}
|
||||
|
||||
// ── End to end: engine evidence → alert ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -369,6 +369,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public static readonly IReadOnlyList<string> All = new[] { Prerequisite, App, PolicyProvider };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names of a <see cref="DiagnosisWaitKinds.Prerequisite"/> wait item: the completion prerequisites
|
||||
/// the decision engine lists in <c>completion_waiting.missingPrerequisites</c>.
|
||||
/// </summary>
|
||||
public static class DiagnosisPrerequisites
|
||||
{
|
||||
/// <summary>The account setup phase of the ESP has not reported its end.</summary>
|
||||
public const string AccountSetupProvisioningComplete = "account_setup_provisioning_complete";
|
||||
|
||||
/// <summary>Windows Hello has not been set up or skipped.</summary>
|
||||
public const string HelloResolution = "hello_resolution";
|
||||
|
||||
/// <summary>The user has not reached the desktop.</summary>
|
||||
public const string DesktopArrival = "desktop_arrival";
|
||||
|
||||
/// <summary>RealmJoin has not finished.</summary>
|
||||
public const string RealmJoinResolution = "realmjoin_resolution";
|
||||
|
||||
/// <summary>Every prerequisite, the engine's order.</summary>
|
||||
public static readonly IReadOnlyList<string> All = new[] { AccountSetupProvisioningComplete, HelloResolution, DesktopArrival, RealmJoinResolution };
|
||||
}
|
||||
|
||||
/// <summary>Values of <see cref="DiagnosisHeadline.Caveats"/>.</summary>
|
||||
public static class DiagnosisCaveats
|
||||
{
|
||||
|
||||
+24
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule.presentation && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-1">How a finding shows</h4>
|
||||
<PresentationTemplateView presentation={rule.presentation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.explanation && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-1">Explanation</h4>
|
||||
<p className="text-sm text-gray-600 leading-relaxed bg-blue-50 border border-blue-200 rounded-lg p-3">{formatInlineMarkdown(rule.explanation)}</p>
|
||||
{/* The template as written: tokens are filled in per session. */}
|
||||
<RuleText blocks={parseRuleText(rule.explanation)} className="space-y-2 text-sm text-gray-600 leading-relaxed bg-blue-50 border border-blue-200 rounded-lg p-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -445,7 +455,7 @@ export default function AnalyzeRuleCard({
|
||||
<div key={idx} className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h5 className="text-sm font-semibold text-green-800 mb-2">{rem.title}</h5>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{rem.steps.map((step, sIdx) => (<li key={sIdx} className="text-sm text-green-700">{step}</li>))}
|
||||
{rem.steps.map((step, sIdx) => (<li key={sIdx} className="text-sm text-green-700"><RuleInlines inlines={parseInline(step)} /></li>))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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<typeof presentationPreview>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,10 +63,8 @@ export function RuleDryRunPanel({ getDraft, idPrefix }: RuleDryRunPanelProps) {
|
||||
body: jsonBody<DryRunAnalyzeRuleRequest>({ 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 (
|
||||
<div className="mt-4">
|
||||
@@ -158,10 +158,19 @@ function DryRunResult({ outcome }: { outcome: Outcome }) {
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{fired && explanation && (
|
||||
{fired && presentation && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-gray-700">How the finding shows</p>
|
||||
<div className="mt-1">
|
||||
<PresentationPreview resolved={presentation.resolved} notRecorded={presentation.notRecorded} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fired && explanation.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-gray-700">Explanation preview</p>
|
||||
<p className="mt-1 text-sm text-gray-700 leading-relaxed">{formatInlineMarkdown(explanation)}</p>
|
||||
<RuleText blocks={explanation} className="mt-1 space-y-2 text-sm leading-relaxed text-gray-700" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
matchedConditions: Record<string, unknown> | 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<string, string> = {
|
||||
fired: "Would fire",
|
||||
|
||||
@@ -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:<eventType>".
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ContextIcon, ContextItem } from "../lib/contextItems";
|
||||
|
||||
const ICONS: Record<ContextIcon, string> = {
|
||||
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 (
|
||||
<section className="rounded-lg bg-white p-5 shadow sm:p-6" aria-labelledby="context-title">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 id="context-title" className="text-lg font-semibold text-gray-900">
|
||||
{outcome === "failed" ? "Around the failure" : "Around this enrollment"}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Observed on the device. Context, not proof of cause.</p>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.key} className="flex gap-3 rounded-lg border border-gray-200 p-3">
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={ICONS[item.icon]} />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{item.title}</p>
|
||||
{item.detail && <p className="mt-0.5 break-words text-xs text-gray-500">{item.detail}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border ${color}`}
|
||||
>
|
||||
{score}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center space-x-1 px-3 py-1 rounded-lg border ${color}`}>
|
||||
<span className="text-sm font-bold">{score}%</span>
|
||||
<span className="text-xs opacity-75">CONFIDENCE</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeverityBadge({
|
||||
severity,
|
||||
compact = false,
|
||||
}: {
|
||||
severity: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${c}`}
|
||||
>
|
||||
{compact ? severity.charAt(0).toUpperCase() : severity.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<PrimaryTone, string> = {
|
||||
red: "text-red-700",
|
||||
orange: "text-orange-700",
|
||||
amber: "text-amber-800",
|
||||
};
|
||||
|
||||
const CONFIDENCE_WIDTH: Record<string, string> = {
|
||||
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 (
|
||||
<section id="findings" className="scroll-mt-16 overflow-hidden rounded-lg bg-white shadow" aria-labelledby="findings-title">
|
||||
<div className="border-b border-gray-200 px-4 py-4 sm:px-5">
|
||||
<h2 id="findings-title" className="text-lg font-semibold text-gray-900">
|
||||
Findings <span className="font-normal text-gray-500">· {count}</span>
|
||||
</h2>
|
||||
</div>
|
||||
{count === 0 && <p className="px-4 py-4 text-sm text-gray-500 sm:px-5">{analysisNote ?? "No analyze rule fired for this session."}</p>}
|
||||
{sections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<p className={`border-b border-gray-200 px-4 py-2 text-xs font-semibold sm:px-5 ${section.tone}`}>
|
||||
{section.label} · {section.items.length}
|
||||
</p>
|
||||
<div className="divide-y divide-gray-200 border-b border-gray-200">
|
||||
{section.items.map((f) => (
|
||||
<FindingRow key={f.result.ruleId} finding={f} eventHref={eventHref} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={toggle}
|
||||
className="grid w-full grid-cols-[5.5rem_minmax(0,1fr)_1rem] items-center gap-x-4 gap-y-1 px-4 py-3 text-left hover:bg-gray-50 sm:px-5 md:grid-cols-[5.5rem_minmax(0,1fr)_11rem_7rem_4.5rem_1rem]"
|
||||
>
|
||||
<span>
|
||||
<span className={`rounded border px-1.5 py-0.5 text-[11px] font-semibold uppercase ${SEVERITY[result.severity] ?? SEVERITY.info}`}>{result.severity}</span>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-gray-900">{result.ruleTitle}</span>
|
||||
{finding.summary && <span className="block truncate text-xs text-gray-500">{finding.summary}</span>}
|
||||
<span className="mt-0.5 block text-xs text-gray-500 md:hidden">
|
||||
{finding.about} · {finding.confidence}
|
||||
{time && ` · ${time}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden truncate text-sm text-gray-700 md:block">{finding.about}</span>
|
||||
<span className="hidden items-center gap-1.5 text-xs text-gray-500 md:flex" title={`Confidence ${result.confidenceScore}`}>
|
||||
<span className="h-1.5 w-10 overflow-hidden rounded-full bg-gray-200">
|
||||
<span className={`block h-full bg-gray-500 ${CONFIDENCE_WIDTH[finding.confidence] ?? "w-1/4"}`} />
|
||||
</span>
|
||||
{finding.confidence}
|
||||
</span>
|
||||
<span className="hidden whitespace-nowrap text-right text-xs tabular-nums text-gray-500 md:block">{time}</span>
|
||||
<svg className={`h-4 w-4 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div id={panelId} className="px-4 pb-5 sm:px-5 md:pl-[8.5rem]">
|
||||
<FindingDetail finding={finding} eventHref={eventHref} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div className="min-w-0 space-y-4 lg:col-span-2">
|
||||
{facts.length > 0 && (
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-sm">
|
||||
{facts.map((fact) => {
|
||||
const shown = formatFact(fact.value, fact.format);
|
||||
return (
|
||||
<FactRow key={fact.label + fact.value} label={fact.label} text={shown.text} mono={shown.mono} hint={shown.hint} />
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
)}
|
||||
<RuleText blocks={ruleTextBlocks(result.explanation, result.matchedConditions)} className="space-y-2 text-sm text-gray-600" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-gray-500">Evidence</p>
|
||||
<EvidenceChips matchedConditions={result.matchedConditions} href={eventHref} className="mt-2 flex flex-wrap gap-2" />
|
||||
</div>
|
||||
{fixes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-gray-500">Fix</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{fixes.map((group, i) => (
|
||||
<div key={i}>
|
||||
<RuleText blocks={group.title} className="text-sm font-medium text-gray-900" />
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm text-gray-700">
|
||||
{group.steps.map((step, j) => (
|
||||
<li key={j}>
|
||||
<RuleInlines inlines={step} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{docs.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{docs.map((d) => (
|
||||
<li key={d.href}>
|
||||
<a href={d.href!} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-700 hover:underline">
|
||||
{d.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactRow({ label, text, mono, hint }: { label: string; text: string; mono: boolean; hint?: string }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-gray-500">{label}</dt>
|
||||
<dd className={`break-words text-gray-900 ${mono ? "font-mono" : ""}`} title={hint}>
|
||||
{text}
|
||||
{hint && <span className="ml-2 font-sans text-xs text-gray-500">{hint}</span>}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>;
|
||||
}) {
|
||||
const state = useSessionAnnotations({ sessionId, effectiveTenantId: tenantId, user, getAccessToken });
|
||||
return (
|
||||
<section className="rounded-lg bg-white p-5 shadow sm:p-6" aria-labelledby="notes-title">
|
||||
<h2 id="notes-title" className="text-lg font-semibold text-gray-900">
|
||||
Notes from your team
|
||||
</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-gray-500">Confirmed root causes and corrections feed rule quality.</p>
|
||||
<SessionAnnotationsBody state={state} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<Filter>("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 (
|
||||
<details className="rounded-lg bg-white shadow">
|
||||
<summary className="flex cursor-pointer flex-wrap items-center gap-x-2 gap-y-1 rounded-lg px-4 py-3 hover:bg-gray-50 sm:px-5">
|
||||
<span className="text-sm font-semibold text-gray-900">Errors and warnings</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{errors} {errors === 1 ? "error" : "errors"} · {warnings} {warnings === 1 ? "warning" : "warnings"}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="flex justify-end px-4 pt-3 sm:px-5">
|
||||
<div className="flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs" role="group" aria-label="Filter">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
aria-pressed={filter === o.key}
|
||||
onClick={() => setFilter(o.key)}
|
||||
className={`rounded px-2 py-0.5 ${filter === o.key ? "bg-white font-medium text-gray-900 shadow-sm" : "text-gray-700"}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100 text-sm">
|
||||
{shown.map((e) => (
|
||||
<li key={e.eventId} className="grid grid-cols-[4.75rem_1rem_minmax(0,1fr)] gap-x-3 px-4 py-2 sm:px-5 md:grid-cols-[4.75rem_1rem_10rem_minmax(0,1fr)]">
|
||||
<span className="text-xs tabular-nums text-gray-500">{timeOfDay(e.timestamp)}</span>
|
||||
<span className={`mt-1.5 h-2 w-2 rounded-full ${isError(e) ? "bg-red-500" : "bg-amber-500"}`} aria-label={e.severity} />
|
||||
<span className="hidden truncate text-xs text-gray-500 md:block">{e.source}</span>
|
||||
<Link href={eventHref(e.eventId)} className="min-w-0 break-words text-gray-700 hover:underline">
|
||||
{e.message}
|
||||
{e.count > 1 && <span className="ml-1 rounded bg-gray-100 px-1.5 text-[11px] text-gray-600">{e.count}× merged</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="rounded-lg bg-white p-5 shadow sm:p-6" aria-labelledby="steps-title">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="steps-title" className="text-lg font-semibold text-gray-900">
|
||||
What to do
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{copied ? "Copied" : "Copy steps"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-5">
|
||||
{steps.groups.map((group, i) => (
|
||||
<div key={i}>
|
||||
<RuleText blocks={group.title} className="text-sm font-semibold text-gray-900" />
|
||||
<ol className="mt-2 space-y-3">
|
||||
{group.steps.map((step, j) => (
|
||||
<li key={j} className="grid grid-cols-[1.75rem_minmax(0,1fr)] gap-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-gray-100 text-sm font-semibold text-gray-700">{offsets[i] + j + 1}</span>
|
||||
<p className="pt-1 text-sm text-gray-700">
|
||||
<RuleInlines inlines={step} />
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
From the finding “{steps.source.result.ruleTitle}”, filled in with this session’s values.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<BarTone, string> = {
|
||||
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<LaneMarker["align"], string> = {
|
||||
start: "",
|
||||
center: "-translate-x-1/2",
|
||||
end: "-translate-x-full",
|
||||
};
|
||||
|
||||
const DOT: Record<KeyEventTone, string> = {
|
||||
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 (
|
||||
<section className="rounded-lg bg-white p-5 shadow sm:p-6" aria-labelledby="time-title">
|
||||
<h2 id="time-title" className="text-lg font-semibold text-gray-900">
|
||||
{lanes ? "Where the time went" : "What happened"}
|
||||
</h2>
|
||||
|
||||
{lanes && (
|
||||
<div className="relative mt-4 pb-1">
|
||||
<div className="grid grid-cols-[6rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2 sm:grid-cols-[9.5rem_minmax(0,1fr)]">
|
||||
<div />
|
||||
<div className="relative text-[11px] tabular-nums" style={{ height: labelRows }}>
|
||||
{lanes.markers.map((m, i) => (
|
||||
<span
|
||||
key={m.label}
|
||||
className={`absolute whitespace-nowrap font-semibold text-red-700 ${MARKER_ALIGN[m.align]}`}
|
||||
style={{ left: `${m.left}%`, top: `${i * 1.25}rem` }}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{lanes.lanes.map((lane) => (
|
||||
<LaneRow key={lane.key} lane={lane} />
|
||||
))}
|
||||
<div />
|
||||
<div className="relative h-4 text-[10px] tabular-nums text-gray-500">
|
||||
{lanes.ticks.map((t, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`absolute whitespace-nowrap ${i === 0 ? "" : i === lanes.ticks.length - 1 ? "-translate-x-full" : "-translate-x-1/2"} ${t.minor ? "hidden sm:block" : ""}`}
|
||||
style={{ left: `${t.left}%` }}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-5 left-[6.75rem] right-0 sm:left-[10.25rem]"
|
||||
style={{ top: `calc(${labelRows} + 0.25rem)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{lanes.markers.map((m) => (
|
||||
<div key={m.label} className={`absolute inset-y-0 border-l-2 border-red-500 ${MARKER_ALIGN[m.align]}`} style={{ left: `${m.left}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyEvents.length > 0 && (
|
||||
<details className="mt-4 rounded-lg border border-gray-200" open={!lanes}>
|
||||
<summary className="flex cursor-pointer flex-wrap items-center gap-x-2 gap-y-1 rounded-lg px-4 py-3 text-sm hover:bg-gray-50">
|
||||
<span className="font-medium text-gray-900">Key events</span>
|
||||
<span className="text-gray-500">
|
||||
{keyEvents.length} of {keyEventTotal}
|
||||
</span>
|
||||
<Link href={timelineHref} className="ml-auto text-xs text-blue-700 hover:underline">
|
||||
Open the full timeline
|
||||
</Link>
|
||||
</summary>
|
||||
<ol className="border-t border-gray-200 px-4 pb-1 pt-4">
|
||||
{keyEvents.map((e, i) => (
|
||||
<li key={e.eventId} className="grid grid-cols-[4.75rem_1.25rem_minmax(0,1fr)] gap-x-3">
|
||||
<time className={`pt-0.5 text-right text-xs tabular-nums ${e.tone === "failed" ? "font-semibold text-red-700" : "text-gray-500"}`}>
|
||||
{timeOfDay(e.timestamp)}
|
||||
</time>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className={`mt-1.5 h-2.5 w-2.5 rounded-full ${DOT[e.tone]}`} />
|
||||
{i < keyEvents.length - 1 && <span className="w-px flex-1 bg-gray-200" />}
|
||||
</div>
|
||||
<div className="min-w-0 pb-4">
|
||||
<Link href={eventHref(e.eventId)} className={`text-sm hover:underline ${e.tone === "failed" ? "font-semibold text-red-700" : "text-gray-900"}`}>
|
||||
{e.title}
|
||||
</Link>
|
||||
{e.detail && <p className="font-mono text-xs text-gray-500">{e.detail}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LaneRow({ lane }: { lane: TimeLanes["lanes"][number] }) {
|
||||
return (
|
||||
<>
|
||||
<div className={`truncate text-xs ${lane.emphasis ? "font-semibold text-red-700" : "font-medium text-gray-700"}`} title={lane.label}>
|
||||
{lane.label}
|
||||
</div>
|
||||
<div className="relative h-7 rounded bg-gray-50">
|
||||
{lane.bars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={bar.title}
|
||||
className={`absolute inset-y-1 flex flex-wrap items-center overflow-hidden rounded-sm text-[11px] font-medium ${BAR[bar.tone]}`}
|
||||
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
|
||||
>
|
||||
{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. */}
|
||||
<span className="h-full w-0" />
|
||||
<span className="whitespace-nowrap px-1.5">{bar.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<VerdictTone, { box: string; text: string; icon: string }> = {
|
||||
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<CulpritEvidence, string> = {
|
||||
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<number, string> = {
|
||||
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 (
|
||||
<section className="overflow-hidden rounded-lg bg-white shadow" aria-labelledby="verdict-summary">
|
||||
<div className={`flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b px-5 py-3 sm:px-6 ${band.box}`}>
|
||||
<p className={`flex items-center gap-2 text-sm font-semibold ${band.text}`}>
|
||||
<BandIcon outcome={headline.outcome} className={`h-5 w-5 shrink-0 ${band.icon}`} />
|
||||
{verdict.title}
|
||||
</p>
|
||||
{meta.length > 0 && <p className={`text-xs tabular-nums ${band.text}`}>{meta.join(" · ")}</p>}
|
||||
</div>
|
||||
|
||||
<div className="p-5 sm:p-6">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{verdict.heading}</p>
|
||||
<h2 id="verdict-summary" className="mt-1 text-xl font-semibold text-gray-900 text-balance">
|
||||
{verdict.summary}
|
||||
</h2>
|
||||
{verdict.details.length > 0 && <p className="mt-2 max-w-3xl text-gray-600">{verdict.details.join(" ")}</p>}
|
||||
|
||||
{headline.culprits && headline.culprits.length > 0 && <Culprits culprits={headline.culprits} />}
|
||||
{headline.waiting && headline.waiting.length > 0 && <Waiting items={headline.waiting} />}
|
||||
{headline.esp && headline.esp.length > 0 && <EspPhases phases={headline.esp} headline={headline} />}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||
{timelineHref && (
|
||||
<Link
|
||||
href={timelineHref}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-3.5 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Show in the timeline
|
||||
</Link>
|
||||
)}
|
||||
{supersededHref && (
|
||||
<Link
|
||||
href={supersededHref}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Open the newer enrollment
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopySummary}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{copied ? "Copied" : "Copy summary"}
|
||||
</button>
|
||||
{basedOn > 0 && (
|
||||
<a href="#findings" className="ml-auto inline-flex items-center gap-1 text-sm text-blue-700 hover:underline">
|
||||
{basedOn === 1 ? "1 finding" : `${basedOn} findings`} below
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Culprits({ culprits }: { culprits: DiagnosisCulprit[] }) {
|
||||
const [first, ...rest] = culprits;
|
||||
const tiles = [
|
||||
<Tile key="subject" label={first.kind === "app" ? "App" : first.kind === "appx_package" ? "Store package" : "Policy provider"}>
|
||||
<dd className="mt-1 break-words text-sm font-semibold text-gray-900">{first.name}</dd>
|
||||
<dd className="mt-1.5 flex flex-wrap gap-1">
|
||||
{[first.appType, first.targeted, first.intent === "Install" ? "Required" : first.intent, first.scope].filter(Boolean).map((p) => (
|
||||
<span key={p} className={PILL}>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
<dd className="mt-1 text-xs text-gray-500">{vocabularyEntry(EVIDENCE_LABELS, first.evidence) ?? humanizeKey(first.evidence)}</dd>
|
||||
</Tile>,
|
||||
(first.errorCode || first.exitCode) && <ErrorTile key="error" code={first.errorCode ?? first.exitCode!} label={first.errorCode ? "Error" : "Exit code"} />,
|
||||
first.installStarts != null && (
|
||||
<Tile key="starts" label="Install starts">
|
||||
<dd className="mt-1 text-sm font-semibold tabular-nums text-gray-900">{first.installStarts}</dd>
|
||||
{first.lastStartedAt && <dd className="mt-1 text-xs tabular-nums text-gray-500">Last start {shortTime(first.lastStartedAt)}</dd>}
|
||||
</Tile>
|
||||
),
|
||||
(first.detectionResult || first.state) && (
|
||||
<Tile key="state" label={first.detectionResult ? "Detection" : "State"}>
|
||||
<dd className="mt-1 text-sm font-semibold text-gray-900">{humanizeKey(first.detectionResult ?? first.state ?? "")}</dd>
|
||||
{first.failedAt && <dd className="mt-1 text-xs tabular-nums text-gray-500">Failed {shortTime(first.failedAt)}</dd>}
|
||||
</Tile>
|
||||
),
|
||||
first.stalledForMinutes != null && (
|
||||
<Tile key="stuck" label="Stuck for">
|
||||
<dd className="mt-1 text-sm font-semibold tabular-nums text-gray-900">{Math.round(first.stalledForMinutes)} min</dd>
|
||||
</Tile>
|
||||
),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<dl className={`grid grid-cols-1 gap-px overflow-hidden rounded-lg border border-gray-200 bg-gray-200 ${TILE_COLUMNS[tiles.length] ?? TILE_COLUMNS[4]}`}>
|
||||
{tiles}
|
||||
</dl>
|
||||
{rest.length > 0 && (
|
||||
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
|
||||
{rest.map((c) => (
|
||||
<li key={`${c.kind}:${c.key ?? c.name}`}>
|
||||
<span className="font-medium text-gray-900">{c.name}</span>
|
||||
<span className="text-gray-500"> · {vocabularyEntry(EVIDENCE_LABELS, c.evidence) ?? humanizeKey(c.evidence)}</span>
|
||||
{c.errorCode && <span className="font-mono text-xs text-gray-500"> · {c.errorCode}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 bg-white p-3 sm:p-4">
|
||||
<dt className="text-xs text-gray-500">{label}</dt>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorTile({ code, label }: { code: string; label: string }) {
|
||||
const fact = formatFact(code, "errorCode");
|
||||
return (
|
||||
<Tile label={label}>
|
||||
<dd className="mt-1 break-all font-mono text-sm font-semibold text-gray-900">{fact.text}</dd>
|
||||
<dd className="mt-1 text-xs text-gray-500">{fact.hint ?? "Not in our error catalog"}</dd>
|
||||
</Tile>
|
||||
);
|
||||
}
|
||||
|
||||
function Waiting({ items }: { items: DiagnosisWaitItem[] }) {
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Still waiting for</h3>
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}:${item.name}`} className="inline-flex items-center gap-1 rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-gray-700">
|
||||
{waitName(item)}
|
||||
{item.state && <span className="text-gray-500">· {humanizeKey(item.state).toLowerCase()}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-6">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Enrollment Status Page</h3>
|
||||
{profile.length > 0 && <p className="text-xs text-gray-500">{profile.join(" · ")}</p>}
|
||||
</div>
|
||||
<ol className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{phases.map((phase) => {
|
||||
const failed = phase.state === "failed";
|
||||
return (
|
||||
<li key={phase.category} className={`rounded-lg border p-3 ${failed ? "border-red-200 bg-red-50" : "border-gray-200"}`}>
|
||||
<p className={`flex items-center gap-1.5 text-sm ${failed ? "font-semibold text-red-800" : "font-medium text-gray-900"}`}>
|
||||
<PhaseIcon state={phase.state} />
|
||||
{phaseName(phase.category)}
|
||||
</p>
|
||||
{phase.subcategories.length > 0 && (
|
||||
<ul className="mt-2 flex flex-wrap gap-1 text-[11px]">
|
||||
{phase.subcategories.map((s) => (
|
||||
<li
|
||||
key={s.name}
|
||||
title={s.statusText ?? undefined}
|
||||
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 ${
|
||||
s.state === "failed" ? "border-red-200 bg-red-100 font-semibold text-red-800" : "border-gray-200 bg-white text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<StepMark state={s.state} />
|
||||
{stepName(s.name)}
|
||||
{s.errorCode && <span className="font-mono"> · {s.errorCode}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseIcon({ state }: { state: string }) {
|
||||
if (state === "succeeded") return <span aria-label="Completed" className="text-green-700">✓</span>;
|
||||
if (state === "failed") return <span aria-label="Failed" className="text-red-600">✕</span>;
|
||||
return (
|
||||
<svg role="img" aria-label="In progress" className="h-4 w-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={CLOCK} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StepMark({ state }: { state: string }) {
|
||||
const s = state.toLowerCase();
|
||||
if (s === "succeeded") return <span aria-hidden="true" className="text-green-700">✓</span>;
|
||||
if (s === "failed") return <span aria-hidden="true">✕</span>;
|
||||
if (s === "notrequired") return <span aria-hidden="true" className="text-gray-400">–</span>;
|
||||
return <span aria-hidden="true" className="text-gray-400">•</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>
|
||||
) {
|
||||
const [headline, setHeadline] = useState<DiagnosisHeadline | null>(null);
|
||||
const [state, setState] = useState<DiagnosisLoadState>("loading");
|
||||
const getToken = useLatest(getAccessToken);
|
||||
const scheduler = useRef<BurstScheduler | null>(null);
|
||||
|
||||
const fetchHeadline = useCallback(async () => {
|
||||
if (!sessionId || !tenantId || !isGuid(tenantId)) return;
|
||||
try {
|
||||
const json = await fetchJson<GetSessionDiagnosisResponse>(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 };
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>): 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.");
|
||||
});
|
||||
});
|
||||
@@ -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<Caveat, { icon: ContextIcon; title: string; detail: string }> = {
|
||||
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";
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
DevicePreparation: "Device preparation",
|
||||
DeviceSetup: "Device setup",
|
||||
AccountSetup: "Account setup",
|
||||
};
|
||||
|
||||
const STEP_NAMES: Record<string, string> = {
|
||||
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<Prerequisite, string> = {
|
||||
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" }) : "";
|
||||
}
|
||||
@@ -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<string, number> } {
|
||||
const counts = new Map<string, number>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, number> = { 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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<string, { label: string; category: string }> = {
|
||||
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<string, string> = {
|
||||
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<string, { name: string; culprit: boolean; first: number; intervals: typeof breakdown.blockingApps }>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<Outcome, VerdictTone> = {
|
||||
failed: "red",
|
||||
stalled: "orange",
|
||||
awaiting_user: "sky",
|
||||
incomplete: "slate",
|
||||
succeeded: "green",
|
||||
in_progress: "blue",
|
||||
pending: "amber",
|
||||
unknown: "gray",
|
||||
};
|
||||
|
||||
const HEADINGS: Record<Outcome, string> = {
|
||||
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<string, string> = {
|
||||
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 [];
|
||||
}
|
||||
@@ -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<K extends string, V>(map: Record<K, V>, value: string | null | undefined): V | undefined {
|
||||
return value != null && Object.prototype.hasOwnProperty.call(map, value) ? map[value as K] : undefined;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Explanation</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
|
||||
</p>
|
||||
<RuleText blocks={ruleTextBlocks(result.explanation, result.matchedConditions)} className="space-y-2 text-sm text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.remediation && result.remediation.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Remediation</h4>
|
||||
{result.remediation.map((rem, i) => (
|
||||
<div key={i} className="mb-2">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{interpolateRuleTemplate(rem.title, result.matchedConditions)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{rem.steps.map((step, j) => (
|
||||
<li key={j}>{interpolateRuleTemplate(step, result.matchedConditions)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{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 (
|
||||
<div key={i} className="mb-2">
|
||||
<RuleText blocks={ruleTextBlocks(rem.title, result.matchedConditions)} className="text-sm font-medium text-gray-600" />
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 ml-2">
|
||||
{steps.map((step, j) => (
|
||||
<li key={j}>
|
||||
<RuleInlines inlines={step} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -411,7 +414,7 @@ function AnalysisResultCard({
|
||||
{result.matchedConditions && Object.keys(result.matchedConditions).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Evidence</h4>
|
||||
<EvidenceEventLinks matchedConditions={result.matchedConditions} />
|
||||
<EvidenceChips matchedConditions={result.matchedConditions} onSelect={openEventInTimeline} />
|
||||
<div className="p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
|
||||
<pre>{JSON.stringify(result.matchedConditions, null, 2)}</pre>
|
||||
</div>
|
||||
@@ -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<string, unknown> }) {
|
||||
const eventLinks: { signal: string; eventId: string; eventType?: string }[] = [];
|
||||
const seenEventIds = new Set<string>();
|
||||
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{eventLinks.map(({ signal, eventId, eventType }) => (
|
||||
<button
|
||||
key={signal}
|
||||
onClick={() => scrollToEvent(eventId)}
|
||||
className="inline-flex items-center space-x-1 px-2 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded transition-colors"
|
||||
title={`Jump to event ${eventId}`}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span>{eventType || signal}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
{lanes.map((lane) => {
|
||||
const annotation = annotations[lane];
|
||||
const writable = writableLanes.has(lane);
|
||||
const edit = laneEdit(lane);
|
||||
|
||||
if (!writable && !hasContent(annotation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={lane} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">{LANE_LABELS[lane]}</span>
|
||||
{lane === "globaladmin" && (
|
||||
<span
|
||||
className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-violet-50 text-violet-700"
|
||||
title="This lane is platform-internal: it is filtered out of every tenant response server-side."
|
||||
>
|
||||
never visible to the tenant
|
||||
</span>
|
||||
)}
|
||||
{annotation?.verdict != null && !writable && (
|
||||
<span
|
||||
title={VERDICT_DESCRIPTIONS[annotation.verdict as AnnotationVerdict]}
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[annotation.verdict] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict}
|
||||
</span>
|
||||
)}
|
||||
{annotation?.authorDisplayName && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{annotation.authorDisplayName}
|
||||
{annotation.updatedAtUtc ? ` · ${new Date(annotation.updatedAtUtc).toLocaleString()}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{writable ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-3" role="radiogroup" aria-label={`${LANE_LABELS[lane]} verdict`}>
|
||||
{ANNOTATION_VERDICTS.map((verdict) => {
|
||||
const selected = edit.verdict === verdict;
|
||||
return (
|
||||
<button
|
||||
key={verdict}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
title={VERDICT_DESCRIPTIONS[verdict]}
|
||||
onClick={() => setLaneEdit(lane, { verdict: selected ? null : verdict, saveResult: null })}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
selected
|
||||
? `${VERDICT_PILL_CLASSES[verdict]} border-transparent ring-1 ring-inset ring-gray-300`
|
||||
: "bg-white text-gray-600 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{VERDICT_LABELS[verdict]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
value={edit.note}
|
||||
onChange={(e) => setLaneEdit(lane, { note: e.target.value, saveResult: null })}
|
||||
rows={3}
|
||||
maxLength={ANNOTATION_MAX_NOTE_LENGTH}
|
||||
placeholder="Optional note — what actually happened, what the analysis missed…"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="text-sm min-h-[1.25rem]">
|
||||
{edit.saveResult === "saved" ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-600">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
) : edit.saveResult ? (
|
||||
<span className="text-red-600">{edit.saveResult}</span>
|
||||
) : edit.note.length > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{edit.note.length}/{ANNOTATION_MAX_NOTE_LENGTH}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave(lane)}
|
||||
disabled={edit.saving || (!hasContent(annotation) && buildPutBody(edit.verdict, edit.note).isClear)}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{edit.saving && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{edit.saving
|
||||
? "Saving…"
|
||||
: hasContent(annotation) && buildPutBody(edit.verdict, edit.note).isClear
|
||||
? "Clear"
|
||||
: "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
annotation?.note && <p className="text-sm text-gray-700 whitespace-pre-wrap">{annotation.note}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{loaded && lanes.every((lane) => !writableLanes.has(lane) && !hasContent(annotations[lane])) && (
|
||||
<p className="text-sm text-gray-400">No annotations for this session.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import {
|
||||
ANNOTATION_MAX_NOTE_LENGTH,
|
||||
ANNOTATION_VERDICTS,
|
||||
buildPutBody,
|
||||
hasContent,
|
||||
LANE_LABELS,
|
||||
VERDICT_DESCRIPTIONS,
|
||||
VERDICT_LABELS,
|
||||
VERDICT_PILL_CLASSES,
|
||||
visibleLanes,
|
||||
validateNote,
|
||||
writableLaneSet,
|
||||
type AnnotationLane,
|
||||
type AnnotationUser,
|
||||
type AnnotationVerdict,
|
||||
type SessionAnnotationDto,
|
||||
} from "./sessionAnnotationLogic";
|
||||
import type { UpsertSessionAnnotationRequest } from "@/lib/generated/wire-types.generated";
|
||||
import { useState } from "react";
|
||||
import { LANE_LABELS, VERDICT_LABELS, VERDICT_PILL_CLASSES, type AnnotationUser, type AnnotationVerdict } from "./sessionAnnotationLogic";
|
||||
import { useSessionAnnotations } from "../hooks/useSessionAnnotations";
|
||||
import SessionAnnotationsBody from "./SessionAnnotationsBody";
|
||||
|
||||
/**
|
||||
* Session annotations: per-lane human verdict + note about this enrollment's analysis
|
||||
@@ -32,22 +14,6 @@ import type { UpsertSessionAnnotationRequest } from "@/lib/generated/wire-types.
|
||||
* cannot drift. Read is fail-soft; an error shows the empty state rather than
|
||||
* breaking the page.
|
||||
*/
|
||||
|
||||
interface AnnotationsResponse {
|
||||
success: boolean;
|
||||
annotations?: SessionAnnotationDto[] | null;
|
||||
/** Lanes the caller may write, per the backend matrix. Absent on older backends → read-only. */
|
||||
writableLanes?: string[] | null;
|
||||
}
|
||||
|
||||
interface LaneEditState {
|
||||
verdict: string | null;
|
||||
note: string;
|
||||
saving: boolean;
|
||||
/** "saved" | error message | null. Typing clears it. */
|
||||
saveResult: string | null;
|
||||
}
|
||||
|
||||
export default function SessionAnnotationsCard({
|
||||
sessionId,
|
||||
effectiveTenantId,
|
||||
@@ -60,98 +26,13 @@ export default function SessionAnnotationsCard({
|
||||
user: AnnotationUser | null | undefined;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
}) {
|
||||
const [annotations, setAnnotations] = useState<Partial<Record<string, SessionAnnotationDto>>>({});
|
||||
const [writableLanes, setWritableLanes] = useState<Set<string>>(new Set());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [edit, setEdit] = useState<Partial<Record<string, LaneEditState>>>({});
|
||||
const state = useSessionAnnotations({ sessionId, effectiveTenantId, user, getAccessToken });
|
||||
const { annotations, annotatedLanes } = state;
|
||||
// Collapsed by default: the verdict comes at the END of a diagnosis — the card must
|
||||
// not take space away from the details/analysis work above it. The header summary
|
||||
// (count + verdict pills) still shows at a glance whether a verdict exists.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Effective Global-Admin view (demo mode forces it off): the internal lane follows the view, so
|
||||
// presenting the session page shows the same two lanes a tenant admin sees.
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
const lanes = visibleLanes(user, globalAdminMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const json = await fetchJson<AnnotationsResponse>(api.sessions.annotations(sessionId, effectiveTenantId), getAccessToken);
|
||||
if (cancelled) return;
|
||||
const byLane: Partial<Record<string, SessionAnnotationDto>> = {};
|
||||
for (const a of json.annotations ?? []) byLane[a.lane] = a;
|
||||
setAnnotations(byLane);
|
||||
setWritableLanes(writableLaneSet(json.writableLanes));
|
||||
const seeded: Partial<Record<string, LaneEditState>> = {};
|
||||
for (const [lane, a] of Object.entries(byLane)) {
|
||||
seeded[lane] = {
|
||||
verdict: a?.verdict ?? null,
|
||||
note: a?.note ?? "",
|
||||
saving: false,
|
||||
saveResult: null,
|
||||
};
|
||||
}
|
||||
setEdit(seeded);
|
||||
} catch {
|
||||
// fail-soft: card shows the empty state
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, effectiveTenantId]);
|
||||
|
||||
const laneEdit = (lane: AnnotationLane): LaneEditState =>
|
||||
edit[lane] ?? { verdict: null, note: "", saving: false, saveResult: null };
|
||||
|
||||
const setLaneEdit = useCallback((lane: AnnotationLane, patch: Partial<LaneEditState>) => {
|
||||
setEdit((prev) => ({
|
||||
...prev,
|
||||
[lane]: { ...(prev[lane] ?? { verdict: null, note: "", saving: false, saveResult: null }), ...patch },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (lane: AnnotationLane) => {
|
||||
const state = laneEdit(lane);
|
||||
const noteError = validateNote(state.note);
|
||||
if (noteError) {
|
||||
setLaneEdit(lane, { saveResult: noteError });
|
||||
return;
|
||||
}
|
||||
const { body, isClear } = buildPutBody(state.verdict, state.note);
|
||||
setLaneEdit(lane, { saving: true, saveResult: null });
|
||||
try {
|
||||
const json = await fetchJson<{ success: boolean; annotation?: SessionAnnotationDto }>(
|
||||
api.sessions.annotation(sessionId, lane, effectiveTenantId),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "PUT",
|
||||
body: jsonBody<UpsertSessionAnnotationRequest>(body),
|
||||
}
|
||||
);
|
||||
setAnnotations((prev) => {
|
||||
const next = { ...prev };
|
||||
if (isClear) delete next[lane];
|
||||
else if (json.annotation) next[lane] = json.annotation;
|
||||
return next;
|
||||
});
|
||||
setLaneEdit(lane, { saving: false, saveResult: "saved" });
|
||||
} catch (err) {
|
||||
setLaneEdit(lane, {
|
||||
saving: false,
|
||||
saveResult: apiErrorText(err, "Failed to save annotation"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const annotatedLanes = lanes.filter((lane) => hasContent(annotations[lane]));
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div
|
||||
@@ -191,137 +72,13 @@ export default function SessionAnnotationsCard({
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mt-2 mb-4">
|
||||
Human assessment of this enrollment's analysis — confirmed root causes and
|
||||
corrections feed rule quality.
|
||||
</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{lanes.map((lane) => {
|
||||
const annotation = annotations[lane];
|
||||
const writable = writableLanes.has(lane);
|
||||
const state = laneEdit(lane);
|
||||
|
||||
if (!writable && !hasContent(annotation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={lane} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">{LANE_LABELS[lane]}</span>
|
||||
{lane === "globaladmin" && (
|
||||
<span
|
||||
className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-violet-50 text-violet-700"
|
||||
title="This lane is platform-internal: it is filtered out of every tenant response server-side."
|
||||
>
|
||||
never visible to the tenant
|
||||
</span>
|
||||
)}
|
||||
{annotation?.verdict != null && !writable && (
|
||||
<span
|
||||
title={VERDICT_DESCRIPTIONS[annotation.verdict as AnnotationVerdict]}
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[annotation.verdict] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict}
|
||||
</span>
|
||||
)}
|
||||
{annotation?.authorDisplayName && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{annotation.authorDisplayName}
|
||||
{annotation.updatedAtUtc ? ` · ${new Date(annotation.updatedAtUtc).toLocaleString()}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{writable ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-3" role="radiogroup" aria-label={`${LANE_LABELS[lane]} verdict`}>
|
||||
{ANNOTATION_VERDICTS.map((verdict) => {
|
||||
const selected = state.verdict === verdict;
|
||||
return (
|
||||
<button
|
||||
key={verdict}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
title={VERDICT_DESCRIPTIONS[verdict]}
|
||||
onClick={() =>
|
||||
setLaneEdit(lane, { verdict: selected ? null : verdict, saveResult: null })
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
selected
|
||||
? `${VERDICT_PILL_CLASSES[verdict]} border-transparent ring-1 ring-inset ring-gray-300`
|
||||
: "bg-white text-gray-600 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{VERDICT_LABELS[verdict]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
value={state.note}
|
||||
onChange={(e) => setLaneEdit(lane, { note: e.target.value, saveResult: null })}
|
||||
rows={3}
|
||||
maxLength={ANNOTATION_MAX_NOTE_LENGTH}
|
||||
placeholder="Optional note — what actually happened, what the analysis missed…"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="text-sm min-h-[1.25rem]">
|
||||
{state.saveResult === "saved" ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-600">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
) : state.saveResult ? (
|
||||
<span className="text-red-600">{state.saveResult}</span>
|
||||
) : state.note.length > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{state.note.length}/{ANNOTATION_MAX_NOTE_LENGTH}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave(lane)}
|
||||
disabled={state.saving || (!hasContent(annotation) && buildPutBody(state.verdict, state.note).isClear)}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state.saving && (
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{state.saving
|
||||
? "Saving…"
|
||||
: hasContent(annotation) && buildPutBody(state.verdict, state.note).isClear
|
||||
? "Clear"
|
||||
: "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
annotation?.note && (
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{annotation.note}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{loaded &&
|
||||
lanes.every(
|
||||
(lane) => !writableLanes.has(lane) && !hasContent(annotations[lane])
|
||||
) && (
|
||||
<p className="text-sm text-gray-400">No annotations for this session.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mt-2 mb-4">
|
||||
Human assessment of this enrollment's analysis — confirmed root causes and
|
||||
corrections feed rule quality.
|
||||
</p>
|
||||
<SessionAnnotationsBody state={state} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+17
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
showDiagnosisButton,
|
||||
showDiagnosticsSetupHint,
|
||||
showInvestigateHint,
|
||||
type DiagnosticsSetupHintInput,
|
||||
@@ -49,3 +50,19 @@ describe("showInvestigateHint", () => {
|
||||
expect(showInvestigateHint({ ...investigate, ...patch })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showDiagnosisButton", () => {
|
||||
it.each([
|
||||
["Failed", 0, true],
|
||||
["Stalled", 0, true],
|
||||
["AwaitingUser", 0, true],
|
||||
["Incomplete", 0, true],
|
||||
["Succeeded", 0, false],
|
||||
["Succeeded", 2, true],
|
||||
["InProgress", 3, false],
|
||||
["Pending", 1, false],
|
||||
[undefined, 0, false],
|
||||
] as const)("%s with %d open findings → %s", (status, open, expected) => {
|
||||
expect(showDiagnosisButton(status, open)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,3 +47,21 @@ export interface InvestigateHintInput {
|
||||
export function showInvestigateHint(input: InvestigateHintInput): boolean {
|
||||
return input.status === "Failed" && input.analysisLoaded && !input.loadingAnalysis && input.findingCount === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Diagnosis button: every session that ended without success or is stuck, and a success only
|
||||
* while an analyze finding is open on it. Read-only viewers get it too — the page only reads.
|
||||
*/
|
||||
export function showDiagnosisButton(status: string | undefined, openFindingCount: number): boolean {
|
||||
switch (status) {
|
||||
case "Failed":
|
||||
case "Stalled":
|
||||
case "AwaitingUser":
|
||||
case "Incomplete":
|
||||
return true;
|
||||
case "Succeeded":
|
||||
return openFindingCount > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import { fetchJson } from "@/lib/apiClient";
|
||||
export function useSessionAnalysis(
|
||||
sessionId: string,
|
||||
sessionTenantId: string | null,
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
|
||||
/** withVulnerabilityReport false: a page without the report (diagnosis) never reads it, also not on pushes. */
|
||||
opts?: { withVulnerabilityReport?: boolean }
|
||||
) {
|
||||
const withVulnerabilityReport = opts?.withVulnerabilityReport ?? true;
|
||||
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
|
||||
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
|
||||
// True after the first successful read: before it (and after only failed reads) an empty list means
|
||||
@@ -47,7 +50,7 @@ export function useSessionAnalysis(
|
||||
}, [sessionId, sessionTenantId, getAccessToken]);
|
||||
|
||||
const fetchVulnerabilityReport = useCallback(async (rescan = false) => {
|
||||
if (!sessionTenantId || !isGuid(sessionTenantId)) return;
|
||||
if (!withVulnerabilityReport || !sessionTenantId || !isGuid(sessionTenantId)) return;
|
||||
try {
|
||||
const data = await fetchJson<GetVulnerabilityReportResponse>(
|
||||
api.sessions.vulnerabilityReport(sessionId, sessionTenantId, rescan || undefined),
|
||||
@@ -57,7 +60,7 @@ export function useSessionAnalysis(
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch vulnerability report:", error);
|
||||
}
|
||||
}, [sessionId, sessionTenantId, getAccessToken]);
|
||||
}, [withVulnerabilityReport, sessionId, sessionTenantId, getAccessToken]);
|
||||
|
||||
// Initial fetch on mount / when sessionTenantId resolves. Without these calls the
|
||||
// page-reload path shows empty placeholders ("No issues detected yet" / "No vulnerability
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { useLatest } from "@/hooks/useLatest";
|
||||
import {
|
||||
buildPutBody,
|
||||
hasContent,
|
||||
visibleLanes,
|
||||
validateNote,
|
||||
writableLaneSet,
|
||||
type AnnotationLane,
|
||||
type AnnotationUser,
|
||||
type SessionAnnotationDto,
|
||||
} from "../components/sessionAnnotationLogic";
|
||||
import type { UpsertSessionAnnotationRequest } from "@/lib/generated/wire-types.generated";
|
||||
|
||||
interface AnnotationsResponse {
|
||||
success: boolean;
|
||||
annotations?: SessionAnnotationDto[] | null;
|
||||
/** Lanes the caller may write, per the backend matrix. Absent on older backends → read-only. */
|
||||
writableLanes?: string[] | null;
|
||||
}
|
||||
|
||||
export interface LaneEditState {
|
||||
verdict: string | null;
|
||||
note: string;
|
||||
saving: boolean;
|
||||
/** "saved" | error message | null. Typing clears it. */
|
||||
saveResult: string | null;
|
||||
}
|
||||
|
||||
const EMPTY_EDIT: LaneEditState = { verdict: null, note: "", saving: false, saveResult: null };
|
||||
|
||||
/**
|
||||
* Session annotations: per-lane human verdict + note about this enrollment's analysis (Operator /
|
||||
* Tenant Admin / platform team). Which lanes are writable comes from the SERVER (`writableLanes` on
|
||||
* the GET — the same matrix the PUT re-gates with), so UI and enforcement cannot drift. The read is
|
||||
* fail-soft: an error leaves the empty state.
|
||||
*/
|
||||
export function useSessionAnnotations({
|
||||
sessionId,
|
||||
effectiveTenantId,
|
||||
user,
|
||||
getAccessToken,
|
||||
}: {
|
||||
sessionId: string;
|
||||
/** Tenant used for the API calls (resolved session tenant / GA override); undefined = own tenant. */
|
||||
effectiveTenantId?: string;
|
||||
user: AnnotationUser | null | undefined;
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
|
||||
}) {
|
||||
const [annotations, setAnnotations] = useState<Partial<Record<string, SessionAnnotationDto>>>({});
|
||||
const [writableLanes, setWritableLanes] = useState<Set<string>>(new Set());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [edit, setEdit] = useState<Partial<Record<string, LaneEditState>>>({});
|
||||
const getToken = useLatest(getAccessToken);
|
||||
|
||||
// Effective Global-Admin view (demo mode forces it off): the internal lane follows the view, so
|
||||
// presenting the session page shows the same two lanes a tenant admin sees.
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
const lanes = visibleLanes(user, globalAdminMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const json = await fetchJson<AnnotationsResponse>(api.sessions.annotations(sessionId, effectiveTenantId), (force?: boolean) => getToken.current(force));
|
||||
if (cancelled) return;
|
||||
const byLane: Partial<Record<string, SessionAnnotationDto>> = {};
|
||||
for (const a of json.annotations ?? []) byLane[a.lane] = a;
|
||||
setAnnotations(byLane);
|
||||
setWritableLanes(writableLaneSet(json.writableLanes));
|
||||
const seeded: Partial<Record<string, LaneEditState>> = {};
|
||||
for (const [lane, a] of Object.entries(byLane)) {
|
||||
seeded[lane] = { verdict: a?.verdict ?? null, note: a?.note ?? "", saving: false, saveResult: null };
|
||||
}
|
||||
setEdit(seeded);
|
||||
} catch {
|
||||
// fail-soft: the empty state
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, effectiveTenantId, getToken]);
|
||||
|
||||
const laneEdit = (lane: AnnotationLane): LaneEditState => edit[lane] ?? EMPTY_EDIT;
|
||||
|
||||
const setLaneEdit = useCallback((lane: AnnotationLane, patch: Partial<LaneEditState>) => {
|
||||
setEdit((prev) => ({ ...prev, [lane]: { ...(prev[lane] ?? EMPTY_EDIT), ...patch } }));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (lane: AnnotationLane) => {
|
||||
const state = laneEdit(lane);
|
||||
const noteError = validateNote(state.note);
|
||||
if (noteError) {
|
||||
setLaneEdit(lane, { saveResult: noteError });
|
||||
return;
|
||||
}
|
||||
const { body, isClear } = buildPutBody(state.verdict, state.note);
|
||||
setLaneEdit(lane, { saving: true, saveResult: null });
|
||||
try {
|
||||
const json = await fetchJson<{ success: boolean; annotation?: SessionAnnotationDto }>(
|
||||
api.sessions.annotation(sessionId, lane, effectiveTenantId),
|
||||
(force?: boolean) => getToken.current(force),
|
||||
{ method: "PUT", body: jsonBody<UpsertSessionAnnotationRequest>(body) }
|
||||
);
|
||||
setAnnotations((prev) => {
|
||||
const next = { ...prev };
|
||||
if (isClear) delete next[lane];
|
||||
else if (json.annotation) next[lane] = json.annotation;
|
||||
return next;
|
||||
});
|
||||
setLaneEdit(lane, { saving: false, saveResult: "saved" });
|
||||
} catch (err) {
|
||||
setLaneEdit(lane, { saving: false, saveResult: apiErrorText(err, "Failed to save annotation") });
|
||||
}
|
||||
};
|
||||
|
||||
const annotatedLanes = lanes.filter((lane) => hasContent(annotations[lane]));
|
||||
|
||||
return { lanes, annotations, writableLanes, loaded, laneEdit, setLaneEdit, handleSave, annotatedLanes };
|
||||
}
|
||||
|
||||
export type SessionAnnotationsState = ReturnType<typeof useSessionAnnotations>;
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Session, RuleResult } from "@/types";
|
||||
import { Session } from "@/types";
|
||||
import { useLatest } from "@/hooks/useLatest";
|
||||
import { isTerminalStatus } from "@/lib/sessionStatus";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
import { useSignalRResync } from "@/contexts/SignalRContext";
|
||||
@@ -33,6 +34,8 @@ interface UseSessionSignalRParams {
|
||||
fetchSessionDetails: () => Promise<void>;
|
||||
fetchAnalysisResults: (reanalyze?: boolean) => Promise<void>;
|
||||
fetchVulnerabilityReport: (rescan?: boolean) => Promise<void>;
|
||||
/** Called on every event or session push for this session (the diagnosis page refetches its verdict). */
|
||||
onActivity?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,10 @@ export function applySessionUpdate(prev: Session, update: Partial<Session>): Ses
|
||||
* - listens for eventStream, newevents, ruleResultsReady, vulnerabilityReportReady
|
||||
* - re-reads the session, analysis and vulnerability report after every connection gap
|
||||
* - cleans up groups + handlers on unmount / sessionId change
|
||||
*
|
||||
* The handlers are registered once per connection and call the fetch callbacks through refs:
|
||||
* those callbacks capture the session tenant, which is still unknown on the first render, and a
|
||||
* handler holding that render's copy would ignore every ruleResultsReady push.
|
||||
*/
|
||||
export function useSessionSignalR({
|
||||
sessionId,
|
||||
@@ -71,8 +78,13 @@ export function useSessionSignalR({
|
||||
fetchSessionDetails,
|
||||
fetchAnalysisResults,
|
||||
fetchVulnerabilityReport,
|
||||
onActivity,
|
||||
}: UseSessionSignalRParams): void {
|
||||
const { on, off, isConnected, joinGroup, leaveGroup } = signalR;
|
||||
const fetchAnalysis = useLatest(fetchAnalysisResults);
|
||||
const fetchVulnerability = useLatest(fetchVulnerabilityReport);
|
||||
const scheduleEvents = useLatest(scheduleFetchEvents);
|
||||
const activity = useLatest(onActivity);
|
||||
|
||||
// After every connection gap. The events come through the join catch-up below; the session
|
||||
// object (its status arrives as a newevents delta), the analysis and the vulnerability report
|
||||
@@ -125,7 +137,7 @@ export function useSessionSignalR({
|
||||
useEffect(() => {
|
||||
// Listen for event stream signal — backend sends a lightweight signal (no event payload).
|
||||
// Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps.
|
||||
const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; newRuleResults?: RuleResult[] }) => {
|
||||
const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number }) => {
|
||||
console.log('Event stream signal received via SignalR:', data);
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
|
||||
@@ -140,17 +152,13 @@ export function useSessionSignalR({
|
||||
// Fetch full events from storage (single source of truth); the scheduler coalesces bursts.
|
||||
// Session updates arrive via the "newevents" message (tenant group) — no session
|
||||
// object in this signal to keep payloads minimal.
|
||||
scheduleFetchEvents();
|
||||
scheduleEvents.current();
|
||||
}
|
||||
|
||||
if (data.tenantId) {
|
||||
setSessionTenantId(prev => prev || data.tenantId);
|
||||
}
|
||||
|
||||
// Rule results from SignalR (only on enrollment completion)
|
||||
if (data.newRuleResults && data.newRuleResults.length > 0) {
|
||||
fetchAnalysisResults();
|
||||
}
|
||||
activity.current?.();
|
||||
};
|
||||
|
||||
// Listen for session delta updates via the tenant group ("newevents").
|
||||
@@ -165,20 +173,21 @@ export function useSessionSignalR({
|
||||
if (data.tenantId) {
|
||||
setSessionTenantId(prev => prev || data.tenantId);
|
||||
}
|
||||
activity.current?.();
|
||||
};
|
||||
|
||||
// Listen for async rule engine results (pushed after background analysis completes)
|
||||
const handleRuleResultsReady = (data: { sessionId: string; tenantId: string; ruleResultCount: number }) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
console.info(`[SessionDetail] ruleResultsReady signal: ${data.ruleResultCount} results`);
|
||||
fetchAnalysisResults();
|
||||
void fetchAnalysis.current();
|
||||
};
|
||||
|
||||
// Listen for async vulnerability correlation results
|
||||
const handleVulnerabilityReportReady = (data: { sessionId: string; tenantId: string; overallRisk: string }) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
console.info(`[SessionDetail] vulnerabilityReportReady signal: risk=${data.overallRisk}`);
|
||||
fetchVulnerabilityReport();
|
||||
void fetchVulnerability.current();
|
||||
};
|
||||
|
||||
on('eventStream', handleEventStream);
|
||||
@@ -192,6 +201,5 @@ export function useSessionSignalR({
|
||||
off('ruleResultsReady', handleRuleResultsReady);
|
||||
off('vulnerabilityReportReady', handleVulnerabilityReportReady);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [on, off]);
|
||||
}, [on, off, sessionIdRef, sessionRef, setSession, setSessionTenantId, scheduleEvents, activity, fetchAnalysis, fetchVulnerability]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
import { useLatest } from "@/hooks/useLatest";
|
||||
import type { GetSessionTimeAttributionResponse } from "@/lib/generated/wire-types.generated";
|
||||
import type { SessionTimeBreakdownDto } from "../components/TimeAttributionLane";
|
||||
|
||||
/**
|
||||
* F1 time attribution: the pre-computed breakdown of a terminal session. The backend writes it
|
||||
* with the terminal status, so it exists once the page sees Succeeded or Failed. Null is normal
|
||||
* (session before the feature, Incomplete — no wall clock) and simply omits the time views; the
|
||||
* read is fail-soft, nothing else may depend on it.
|
||||
*/
|
||||
export function useSessionTimeAttribution(
|
||||
sessionId: string,
|
||||
status: string | null | undefined,
|
||||
tenantId: string | null | undefined,
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>
|
||||
): SessionTimeBreakdownDto | null {
|
||||
const [breakdown, setBreakdown] = useState<SessionTimeBreakdownDto | null>(null);
|
||||
const getToken = useLatest(getAccessToken);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
if (status !== "Succeeded" && status !== "Failed") {
|
||||
setBreakdown(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = await fetchJson<GetSessionTimeAttributionResponse>(
|
||||
api.sessions.timeAttribution(sessionId, tenantId || undefined),
|
||||
(force?: boolean) => getToken.current(force)
|
||||
);
|
||||
if (!cancelled) setBreakdown(json.breakdown ?? null);
|
||||
} catch {
|
||||
// fail-soft: no time views
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, status, tenantId, getToken]);
|
||||
|
||||
return breakdown;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { api } from "@/lib/api";
|
||||
import { isGuid } from "@/lib/inputValidation";
|
||||
import { isTerminalStatus } from "@/lib/sessionStatus";
|
||||
import { hasTenantReadScope } from "@/lib/tenantScope";
|
||||
import { ApiError, fetchBlob, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { ApiError, fetchBlob, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
|
||||
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
|
||||
import { useAutoScroll } from "./hooks/useAutoScroll";
|
||||
@@ -29,16 +29,17 @@ import { useSessionEvents } from "./hooks/useSessionEvents";
|
||||
import { useSessionSignalR } from "./hooks/useSessionSignalR";
|
||||
import { useSessionDerivedData } from "./hooks/useSessionDerivedData";
|
||||
import { useSessionTenantConfig } from "./hooks/useSessionTenantConfig";
|
||||
import { useSessionTimeAttribution } from "./hooks/useSessionTimeAttribution";
|
||||
|
||||
import SessionInfoCard from "./components/SessionInfoCard";
|
||||
import DeviceHistoryBanner from "./components/DeviceHistoryBanner";
|
||||
import PhaseTimeline from "./components/PhaseTimeline";
|
||||
import TimeAttributionLane, { SessionTimeBreakdownDto } from "./components/TimeAttributionLane";
|
||||
import TimeAttributionLane from "./components/TimeAttributionLane";
|
||||
import OsUpdateLiveHint from "./components/OsUpdateLiveHint";
|
||||
import EventTimeline from "./components/EventTimeline";
|
||||
import AnalysisResultsSection from "./components/AnalysisResultsSection";
|
||||
import { DiagnosticsSetupHint } from "./components/DiagnosticsSetupHint";
|
||||
import { showDiagnosticsSetupHint, showInvestigateHint } from "./components/sessionHintsLogic";
|
||||
import { showDiagnosisButton, showDiagnosticsSetupHint, showInvestigateHint } from "./components/sessionHintsLogic";
|
||||
import { InvestigateWithAiHint } from "@/components/InvestigateWithAiHint";
|
||||
import VulnerabilityReportSection from "./components/VulnerabilityReportSection";
|
||||
import IntegrityBypassSection from "./components/IntegrityBypassSection";
|
||||
@@ -58,7 +59,7 @@ import { fitsReportRequest } from "@/lib/reportAttachments";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { DocsLink } from "@/components/DocsLink";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type { GetSessionTimeAttributionResponse, SubmitSessionReportRequest } from "@/lib/generated/wire-types.generated";
|
||||
import type { SubmitSessionReportRequest } from "@/lib/generated/wire-types.generated";
|
||||
|
||||
export default function SessionDetailPage() {
|
||||
// useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender.
|
||||
@@ -152,31 +153,8 @@ function SessionDetailContent() {
|
||||
const { session, setSession, sessionTenantId, loading } = detail;
|
||||
const events = eventsApi.events;
|
||||
|
||||
// F1 time attribution: fetch the pre-computed breakdown once the session is terminal.
|
||||
// A null breakdown is normal (pre-feature session, Incomplete — no wall clock) and simply
|
||||
// omits the lane; the fetch is fail-soft (the phase timeline must never depend on it).
|
||||
const [timeBreakdown, setTimeBreakdown] = useState<SessionTimeBreakdownDto | null>(null);
|
||||
const sessionStatusForAttribution = session?.status;
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
if (sessionStatusForAttribution !== "Succeeded" && sessionStatusForAttribution !== "Failed") {
|
||||
setTimeBreakdown(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const effectiveTenantId = sessionTenantId || tenantIdOverride || undefined;
|
||||
const json = await fetchJson<GetSessionTimeAttributionResponse>(api.sessions.timeAttribution(sessionId, effectiveTenantId), getAccessToken);
|
||||
if (!cancelled) setTimeBreakdown(json.breakdown ?? null);
|
||||
} catch {
|
||||
// fail-soft: no lane
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, sessionStatusForAttribution, sessionTenantId]);
|
||||
// F1 time attribution (null for live, Incomplete and pre-feature sessions: no lane).
|
||||
const timeBreakdown = useSessionTimeAttribution(sessionId, session?.status, sessionTenantId || tenantIdOverride, getAccessToken);
|
||||
|
||||
// Cross-tenant read-only view: a delegated ("MSP") admin viewing a MANAGED tenant's session. The backend
|
||||
// permits the reads (MemberRead + ?tenantId=, rescued by the delegated scope) but rejects the mutations
|
||||
@@ -406,27 +384,56 @@ function SessionDetailContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle #event-{eventId} hash fragment (e.g. from diagnosis page links)
|
||||
// #event-{eventId}: diagnosis links, evidence chips, shared URLs. The target is kept until its
|
||||
// row is on screen — a later page of events, a filtered severity, a collapsed phase or timeline
|
||||
// never lose it; only a finished walk without the event gives up. Every state change runs in a
|
||||
// timer callback (not the effect body), and the inner delay lets React commit the expansion
|
||||
// before the DOM lookup.
|
||||
const [eventTarget, setEventTarget] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (loading || events.length === 0) return;
|
||||
const hash = window.location.hash;
|
||||
if (!hash.startsWith("#event-")) return;
|
||||
// Deferred so the expansion setState runs in a timer callback, not the effect body;
|
||||
// the inner delay then lets React commit the expanded phases before the DOM lookup.
|
||||
setTimeout(() => {
|
||||
// Expand all phases so the event element is in the DOM
|
||||
expandAll();
|
||||
const read = () => {
|
||||
const hash = window.location.hash;
|
||||
setEventTarget(hash.startsWith("#event-") ? decodeURIComponent(hash.slice("#event-".length)) : null);
|
||||
};
|
||||
const initial = setTimeout(read, 0);
|
||||
window.addEventListener("hashchange", read);
|
||||
return () => {
|
||||
clearTimeout(initial);
|
||||
window.removeEventListener("hashchange", read);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initialWalkComplete = eventsApi.initialWalkComplete;
|
||||
useEffect(() => {
|
||||
if (!eventTarget) return;
|
||||
const target = events.find((e) => e.eventId === eventTarget);
|
||||
if (!target && !initialWalkComplete) return; // a later page may still carry it
|
||||
const done = () => {
|
||||
setEventTarget(null);
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
if (!target) return done();
|
||||
const severity = target.severity;
|
||||
if (severity && severity !== "Trace" && !severityFilters.has(severity)) {
|
||||
// Runs again once the filter shows the event's severity.
|
||||
setSeverityFilters((prev) => new Set(prev).add(severity));
|
||||
return;
|
||||
}
|
||||
setTimelineExpanded(true);
|
||||
setCollapsedPhases(new Set());
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
const el = document.getElementById(`event-${eventTarget}`);
|
||||
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);
|
||||
}
|
||||
done();
|
||||
}, 100);
|
||||
}, 0);
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}, [loading, events.length]);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eventTarget, events, initialWalkComplete, severityFilters]);
|
||||
|
||||
const togglePhase = (phaseName: string) => {
|
||||
setCollapsedPhases(prev => {
|
||||
@@ -536,9 +543,12 @@ function SessionDetailContent() {
|
||||
Download Diagnostics
|
||||
</button>
|
||||
)}
|
||||
{session?.status === 'Failed' && !isReadOnlyView && (
|
||||
{showDiagnosisButton(session?.status, analysis.analysisResults.filter((r) => !r.resolvedAt).length) && (
|
||||
<button
|
||||
onClick={() => router.push(diagnosisUrl(sessionId))}
|
||||
onClick={() => {
|
||||
trackEvent("session_diagnosis_clicked", { status: session?.status ?? "" });
|
||||
router.push(diagnosisUrl(sessionId, { tenantId: isCrossTenantView ? viewedTenantId : undefined }));
|
||||
}}
|
||||
className="px-4 py-2 bg-amber-500 text-white rounded-md hover:bg-amber-600 transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { evidenceChips } from "@/lib/evidenceChips";
|
||||
|
||||
const CHIP =
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-100";
|
||||
|
||||
/**
|
||||
* The events behind a finding as chips. `href` links each chip (another page, e.g. the session
|
||||
* timeline); `onSelect` handles the click in place (the timeline on the same page).
|
||||
*/
|
||||
export function EvidenceChips({
|
||||
matchedConditions,
|
||||
href,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
matchedConditions: Record<string, unknown> | null | undefined;
|
||||
href?: (eventId: string) => Route;
|
||||
onSelect?: (eventId: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const chips = evidenceChips(matchedConditions);
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={className ?? "mb-2 flex flex-wrap gap-2"}>
|
||||
{chips.map((chip) => {
|
||||
const content = (
|
||||
<>
|
||||
<svg className="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
{chip.timestamp && <span className="tabular-nums text-amber-600 dark:text-amber-300">{formatChipTime(chip.timestamp)}</span>}
|
||||
<span className="truncate">{chip.label}</span>
|
||||
</>
|
||||
);
|
||||
const title = `Open the event in the timeline (${chip.eventId})`;
|
||||
return href ? (
|
||||
<Link key={chip.eventId} href={href(chip.eventId)} className={CHIP} title={title}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button key={chip.eventId} type="button" onClick={() => onSelect?.(chip.eventId)} className={CHIP} title={title}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatChipTime(iso: string): string {
|
||||
const ms = Date.parse(iso);
|
||||
return Number.isFinite(ms) ? new Date(ms).toLocaleTimeString() : iso;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { RulePresentation } from "@/lib/generated/wire-types.generated";
|
||||
import type { ResolvedPresentation } from "@/lib/rulePresentation";
|
||||
import { parseInline } from "@/lib/ruleText";
|
||||
import { RuleInlines } from "./RuleText";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
outcome: "Outcome",
|
||||
cause: "Cause",
|
||||
context: "Context",
|
||||
advisory: "Advisory",
|
||||
};
|
||||
|
||||
// Neutral outline pill (D-100): the role is a label, not a state.
|
||||
const PILL = "inline-flex rounded-full border border-gray-300 px-2 py-0.5 text-[11px] font-medium text-gray-600";
|
||||
|
||||
/** A rule's presentation block as written: the templates, tokens shown as tokens (rule card). */
|
||||
export function PresentationTemplateView({ presentation }: { presentation: RulePresentation }) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={PILL}>{ROLE_LABELS[presentation.role] ?? presentation.role}</span>
|
||||
{presentation.summary && (
|
||||
<span className="text-gray-800">
|
||||
<RuleInlines inlines={parseInline(presentation.summary)} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{presentation.subject && (
|
||||
<p className="text-xs text-gray-500">
|
||||
About a {presentation.subject.kind}:{" "}
|
||||
<RuleInlines inlines={parseInline(presentation.subject.name ?? presentation.subject.key)} />
|
||||
</p>
|
||||
)}
|
||||
{presentation.facts && presentation.facts.length > 0 && (
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
|
||||
{presentation.facts.map((fact, i) => (
|
||||
<div key={i} className="contents">
|
||||
<dt className="text-gray-500">{fact.label}</dt>
|
||||
<dd className="break-words text-gray-800">
|
||||
<RuleInlines inlines={parseInline(fact.value)} />
|
||||
{fact.format && fact.format !== "text" && <span className="ml-2 text-gray-400">{fact.format}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A presentation resolved against one session's evidence (dry-run preview). */
|
||||
export function PresentationPreview({ resolved, notRecorded }: { resolved: ResolvedPresentation; notRecorded: string[] }) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-white p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={PILL}>{ROLE_LABELS[resolved.role] ?? resolved.role}</span>
|
||||
{resolved.summary && <span className="font-medium text-gray-900">{resolved.summary}</span>}
|
||||
</div>
|
||||
{resolved.subject && (
|
||||
<p className="text-xs text-gray-500">
|
||||
About a {resolved.subject.kind}: <span className="text-gray-800">{resolved.subject.name}</span>
|
||||
{resolved.subject.name !== resolved.subject.key && <span className="ml-1 font-mono text-gray-400">{resolved.subject.key}</span>}
|
||||
</p>
|
||||
)}
|
||||
{resolved.facts.length > 0 && (
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
|
||||
{resolved.facts.map((fact, i) => (
|
||||
<div key={i} className="contents">
|
||||
<dt className="text-gray-500">{fact.label}</dt>
|
||||
<dd className={`break-words text-gray-900 ${fact.format === "code" || fact.format === "errorCode" ? "font-mono" : ""}`}>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{notRecorded.length > 0 && <p className="text-xs text-gray-500">Not recorded in this session: {notRecorded.join(", ")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Fragment } from "react";
|
||||
import type { RuleBlock, RuleInline } from "@/lib/ruleText";
|
||||
|
||||
/**
|
||||
* Renders a rule text parsed by `lib/ruleText` (paragraphs, lists, bold, emphasis, code). Values
|
||||
* arrive as text nodes, so nothing a device reported is ever interpreted as markup.
|
||||
*/
|
||||
export function RuleText({ blocks, className }: { blocks: RuleBlock[]; className?: string }) {
|
||||
if (blocks.length === 0) return null;
|
||||
return (
|
||||
<div className={className ?? "space-y-2 text-sm leading-relaxed text-gray-700"}>
|
||||
{blocks.map((block, i) => {
|
||||
if (block.kind === "paragraph") {
|
||||
return (
|
||||
<p key={i}>
|
||||
{block.lines.map((line, j) => (
|
||||
<Fragment key={j}>
|
||||
{j > 0 && <br />}
|
||||
<RuleInlines inlines={line} />
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = block.items.map((item, j) => (
|
||||
<li key={j}>
|
||||
<RuleInlines inlines={item} />
|
||||
</li>
|
||||
));
|
||||
return block.ordered ? (
|
||||
<ol key={i} className="list-decimal space-y-1 pl-5">
|
||||
{items}
|
||||
</ol>
|
||||
) : (
|
||||
<ul key={i} className="list-disc space-y-1 pl-5">
|
||||
{items}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleInlines({ inlines }: { inlines: RuleInline[] }) {
|
||||
return (
|
||||
<>
|
||||
{inlines.map((node, i) => (
|
||||
<RuleInlineNode key={i} node={node} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleInlineNode({ node }: { node: RuleInline }) {
|
||||
switch (node.kind) {
|
||||
case "text":
|
||||
return <>{node.text}</>;
|
||||
case "strong":
|
||||
return (
|
||||
<strong className="font-semibold text-gray-900">
|
||||
<RuleInlines inlines={node.children} />
|
||||
</strong>
|
||||
);
|
||||
case "em":
|
||||
return (
|
||||
<em>
|
||||
<RuleInlines inlines={node.children} />
|
||||
</em>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs text-gray-800 break-words">
|
||||
<RuleInlines inlines={node.children} />
|
||||
</code>
|
||||
);
|
||||
case "token":
|
||||
return (
|
||||
<code className="rounded border border-dashed border-gray-300 px-1 font-mono text-xs text-gray-500" title="Filled in from each session">
|
||||
{node.token}
|
||||
</code>
|
||||
);
|
||||
case "value":
|
||||
return node.recorded ? (
|
||||
<>{node.text}</>
|
||||
) : (
|
||||
<span className="text-gray-400" title="Not recorded for this session">
|
||||
{node.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ describe("api path ids are percent-encoded", () => {
|
||||
(id) => api.sessions.analysis(id),
|
||||
(id) => api.sessions.vulnerabilityReport(id),
|
||||
(id) => api.sessions.timeAttribution(id),
|
||||
(id) => api.sessions.diagnosis(id),
|
||||
(id) => api.sessions.markFailed(id),
|
||||
(id) => api.sessions.markSucceeded(id),
|
||||
(id) => api.sessions.queueAction(id),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { evidenceChips, humanizeKey } from "../evidenceChips";
|
||||
|
||||
describe("evidenceChips", () => {
|
||||
it("makes one chip per distinct event, labelled by event type or signal", () => {
|
||||
const chips = evidenceChips({
|
||||
enrollment_failed_reason: { eventId: "e1", eventType: "enrollment_failed", timestamp: "2026-10-13T14:44:24Z", field: "reason", value: "x" },
|
||||
esp_failure_subcategory: { eventId: "e1", field: "failedSubcategory", value: "Apps" },
|
||||
failed_app: { eventId: "e2", field: "appName", value: "Contoso Notes" },
|
||||
factor_failed_app: true,
|
||||
scalar: "text",
|
||||
});
|
||||
expect(chips).toEqual([
|
||||
{ signal: "enrollment_failed_reason", eventId: "e1", label: "Enrollment failed", timestamp: "2026-10-13T14:44:24Z" },
|
||||
{ signal: "failed_app", eventId: "e2", label: "Failed app", timestamp: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("links both events of a correlation", () => {
|
||||
const chips = evidenceChips({
|
||||
app_regression: { joinField: "appId", eventA_eventId: "a", eventA_eventType: "app_install_completed", eventB_eventId: "b", eventB_eventType: "app_install_failed" },
|
||||
});
|
||||
expect(chips.map((c) => [c.eventId, c.label])).toEqual([
|
||||
["a", "App install completed"],
|
||||
["b", "App install failed"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("is empty without evidence", () => {
|
||||
expect(evidenceChips(null)).toEqual([]);
|
||||
expect(evidenceChips({ list: [{ eventId: "x" }] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanizeKey", () => {
|
||||
it.each([
|
||||
["app_install_failed", "App install failed"],
|
||||
["failedSubcategory", "Failed subcategory"],
|
||||
["eventA_eventId", "Event a event id"],
|
||||
["", ""],
|
||||
])("%s → %s", (key, expected) => expect(humanizeKey(key)).toBe(expected));
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
NOT_RECORDED,
|
||||
parseInline,
|
||||
parseRuleText,
|
||||
pruneRuleText,
|
||||
ruleStepInlines,
|
||||
ruleTextBlocks,
|
||||
ruleTextPlain,
|
||||
tokenResolver,
|
||||
type RuleInline,
|
||||
} from "../ruleText";
|
||||
|
||||
interface DisplayCase {
|
||||
name: string;
|
||||
text: string;
|
||||
matchedConditions: Record<string, unknown> | null;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
/** tests/fixtures/rule-text-display at the repo root — the pruning notifications and the portal share. */
|
||||
function loadCases(): DisplayCase[] {
|
||||
let dir = __dirname;
|
||||
while (!fs.existsSync(path.join(dir, "AutopilotMonitor.sln"))) {
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) throw new Error("repository root (AutopilotMonitor.sln) not found");
|
||||
dir = parent;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, "tests", "fixtures", "rule-text-display", "cases.json"), "utf8")).cases;
|
||||
}
|
||||
|
||||
/** The pruned template with values filled in as text — the shape the notification produces. */
|
||||
function displayText(text: string, matchedConditions: Record<string, unknown> | null): string {
|
||||
const resolve = tokenResolver(matchedConditions);
|
||||
return pruneRuleText(text, resolve).replace(/\{\{\s*[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*\}\}/g, (t) => resolve(t) ?? NOT_RECORDED);
|
||||
}
|
||||
|
||||
describe("pruneRuleText — shared display cases (portal = notifications)", () => {
|
||||
const cases = loadCases();
|
||||
|
||||
it("has cases", () => expect(cases.length).toBeGreaterThanOrEqual(8));
|
||||
|
||||
it.each(cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
|
||||
expect(displayText(c.text, c.matchedConditions)).toBe(c.expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRuleText", () => {
|
||||
it("splits paragraphs on blank lines, keeps single newlines as lines, groups list items", () => {
|
||||
const blocks = parseRuleText("First line.\nSecond line.\n\n- one\n- two\n\n1. step\n2. step");
|
||||
expect(blocks.map((b) => b.kind)).toEqual(["paragraph", "list", "list"]);
|
||||
expect(blocks[0]).toMatchObject({ kind: "paragraph", lines: [[{ kind: "text", text: "First line." }], [{ kind: "text", text: "Second line." }]] });
|
||||
expect(blocks[1]).toMatchObject({ kind: "list", ordered: false, items: [[{ text: "one" }], [{ text: "two" }]] });
|
||||
expect(blocks[2]).toMatchObject({ kind: "list", ordered: true });
|
||||
});
|
||||
|
||||
it("parses bold, emphasis and code, with tokens inside bold and code", () => {
|
||||
expect(parseInline("**ESP error:** `{{errorCode}}` *now*")).toEqual([
|
||||
{ kind: "strong", children: [{ kind: "text", text: "ESP error:" }] },
|
||||
{ kind: "text", text: " " },
|
||||
{ kind: "code", children: [{ kind: "token", token: "{{errorCode}}" }] },
|
||||
{ kind: "text", text: " " },
|
||||
{ kind: "em", children: [{ kind: "text", text: "now" }] },
|
||||
]);
|
||||
expect(parseInline("**{{appName}}**")).toEqual([{ kind: "strong", children: [{ kind: "token", token: "{{appName}}" }] }]);
|
||||
});
|
||||
|
||||
it("leaves stray markers as text", () => {
|
||||
expect(parseInline("3 * 4 and ** alone and `open")).toEqual([{ kind: "text", text: "3 * 4 and ** alone and `open" }]);
|
||||
});
|
||||
|
||||
it("keeps markdown inside code literal", () => {
|
||||
expect(parseInline("`**not bold**`")).toEqual([{ kind: "code", children: [{ kind: "text", text: "**not bold**" }] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruleTextBlocks", () => {
|
||||
const evidence = { failed: { field: "appName", value: "**Contoso** `Notes`" }, retry: { count: 4 } };
|
||||
|
||||
it("puts a recorded value into a text node, never into the markdown", () => {
|
||||
const [paragraph] = ruleTextBlocks("**App:** {{appName}}", evidence);
|
||||
expect(paragraph).toEqual({
|
||||
kind: "paragraph",
|
||||
lines: [[{ kind: "strong", children: [{ kind: "text", text: "App:" }] }, { kind: "text", text: " " }, { kind: "value", text: "**Contoso** `Notes`", recorded: true }]],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a list item without its value and marks a missing value in running text", () => {
|
||||
const blocks = ruleTextBlocks("Starts: {{retry.count}}, threshold {{retry.threshold}}.\n\n- code {{errorCode}}\n- kept", evidence);
|
||||
expect(blocks).toHaveLength(2);
|
||||
const line = (blocks[0] as { lines: RuleInline[][] }).lines[0];
|
||||
expect(line).toContainEqual({ kind: "value", text: "4", recorded: true });
|
||||
expect(line).toContainEqual({ kind: "value", text: NOT_RECORDED, recorded: false });
|
||||
expect(blocks[1]).toMatchObject({ kind: "list", items: [[{ kind: "text", text: "kept" }]] });
|
||||
});
|
||||
|
||||
it("returns nothing for an empty text", () => {
|
||||
expect(ruleTextBlocks("", evidence)).toEqual([]);
|
||||
expect(ruleTextBlocks(null, evidence)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruleStepInlines", () => {
|
||||
it("fills a step whose values were recorded", () => {
|
||||
expect(ruleStepInlines("Retry after `{{retry.count}}` starts", { retry: { count: 4 } })).toEqual([
|
||||
{ kind: "text", text: "Retry after " },
|
||||
{ kind: "code", children: [{ kind: "value", text: "4", recorded: true }] },
|
||||
{ kind: "text", text: " starts" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a step that names a value this finding did not record", () => {
|
||||
expect(ruleStepInlines("Look up `{{errorCode}}`", {})).toBeNull();
|
||||
expect(ruleStepInlines(" ", {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ruleTextPlain", () => {
|
||||
it("drops the markers and keeps lists readable", () => {
|
||||
const blocks = ruleTextBlocks("**Failed:** `{{appName}}`\n\n- one\n- two", { s: { field: "appName", value: "Contoso Notes" } });
|
||||
expect(ruleTextPlain(blocks)).toBe("Failed: Contoso Notes\n\n- one\n- two");
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,9 @@ export const api = {
|
||||
// Incomplete sessions — the lane is simply omitted, nothing is re-derived client-side).
|
||||
timeAttribution: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/time-attribution${qs({ tenantId })}`,
|
||||
// The session's verdict (DiagnosisHeadline, D-357): outcome, cause, failed step, culprits.
|
||||
diagnosis: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/diagnosis${qs({ tenantId })}`,
|
||||
markFailed: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-failed${qs({ tenantId })}`,
|
||||
markSucceeded: (sessionId: string, tenantId?: string) =>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The events a finding's evidence points at, one chip per distinct event: a condition's own
|
||||
* `eventId`, and both events of a correlation (`eventA_eventId`, `eventB_eventId`). Confidence
|
||||
* factors (`factor_*`) and scalar entries carry no event.
|
||||
*/
|
||||
export interface EvidenceChip {
|
||||
/** The signal that recorded the event (first one wins when several point at the same event). */
|
||||
signal: string;
|
||||
eventId: string;
|
||||
/** Human label: the event type, else the signal name. */
|
||||
label: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export function evidenceChips(matchedConditions: Record<string, unknown> | null | undefined): EvidenceChip[] {
|
||||
const chips: EvidenceChip[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (signal: string, eventId: unknown, eventType: unknown, timestamp: unknown) => {
|
||||
if (typeof eventId !== "string" || eventId === "" || seen.has(eventId)) return;
|
||||
seen.add(eventId);
|
||||
chips.push({
|
||||
signal,
|
||||
eventId,
|
||||
label: humanizeKey(typeof eventType === "string" && eventType !== "" ? eventType : signal),
|
||||
timestamp: typeof timestamp === "string" && timestamp !== "" ? timestamp : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
for (const [signal, raw] of Object.entries(matchedConditions ?? {})) {
|
||||
if (signal.startsWith("factor_") || !raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
||||
const e = raw as Record<string, unknown>;
|
||||
add(signal, e.eventId, e.eventType, e.timestamp);
|
||||
add(signal, e.eventA_eventId, e.eventA_eventType, e.eventA_timestamp);
|
||||
add(signal, e.eventB_eventId, e.eventB_eventType, e.eventB_timestamp);
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
/** "app_install_failed" → "App install failed", "failedSubcategory" → "Failed subcategory". */
|
||||
export function humanizeKey(key: string): string {
|
||||
const words = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_\-.]+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
|
||||
}
|
||||
@@ -983,6 +983,12 @@ export const SHARED_MANIFEST = {
|
||||
"app",
|
||||
"policy_provider"
|
||||
],
|
||||
"prerequisites": [
|
||||
"account_setup_provisioning_complete",
|
||||
"hello_resolution",
|
||||
"desktop_arrival",
|
||||
"realmjoin_resolution"
|
||||
],
|
||||
"caveats": [
|
||||
"agent_started_late",
|
||||
"agent_crashed",
|
||||
|
||||
@@ -979,6 +979,12 @@
|
||||
"app",
|
||||
"policy_provider"
|
||||
],
|
||||
"prerequisites": [
|
||||
"account_setup_provisioning_complete",
|
||||
"hello_resolution",
|
||||
"desktop_arrival",
|
||||
"realmjoin_resolution"
|
||||
],
|
||||
"caveats": [
|
||||
"agent_started_late",
|
||||
"agent_crashed",
|
||||
|
||||
@@ -43,8 +43,8 @@ export function networkTimelineUrl(
|
||||
return withQuery("/sessions/network-timeline", { id: sessionId, tenantId: opts?.tenantId });
|
||||
}
|
||||
|
||||
export function diagnosisUrl(sessionId: string): Route {
|
||||
return withQuery("/diagnosis", { id: sessionId });
|
||||
export function diagnosisUrl(sessionId: string, opts?: { tenantId?: string }): Route {
|
||||
return withQuery("/diagnosis", { id: sessionId, tenantId: opts?.tenantId });
|
||||
}
|
||||
|
||||
/** `source` is the install channel; the Intune default ("ime") is left out of the URL. */
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { interpolateRuleTemplate } from "./interpolateRuleTemplate";
|
||||
|
||||
/**
|
||||
* Rule texts (explanation, remediation steps) for display. A stored finding keeps the rule's raw
|
||||
* template; this module prunes it, parses its markdown, and only then puts the recorded values into
|
||||
* text nodes — a value from the device is never read as markdown.
|
||||
*
|
||||
* The pruning is the one notifications get (`RuleTemplateInterpolator.InterpolateForNotification`),
|
||||
* pinned for both by `tests/fixtures/rule-text-display/cases.json`: a list item whose token was not
|
||||
* recorded is dropped, a token in running text that was not recorded reads "–", and a sentence that
|
||||
* still talks about literal placeholders is removed.
|
||||
*
|
||||
* Markdown is the subset the rules use: paragraphs (single newlines are line breaks), "- " or
|
||||
* "1. " list items, **bold**, *emphasis* and `code`. No links, no HTML.
|
||||
*/
|
||||
|
||||
/** What a token reads like when this finding did not record it. */
|
||||
export const NOT_RECORDED = "–";
|
||||
|
||||
export type RuleInline =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "strong"; children: RuleInline[] }
|
||||
| { kind: "em"; children: RuleInline[] }
|
||||
| { kind: "code"; children: RuleInline[] }
|
||||
/** A template token, as written (template view without evidence). */
|
||||
| { kind: "token"; token: string }
|
||||
/** A token's value for this finding; `recorded` false when it was not recorded. */
|
||||
| { kind: "value"; text: string; recorded: boolean };
|
||||
|
||||
export type RuleBlock =
|
||||
| { kind: "paragraph"; lines: RuleInline[][] }
|
||||
| { kind: "list"; ordered: boolean; items: RuleInline[][] };
|
||||
|
||||
/** The token's recorded value, or null when this finding did not record it. */
|
||||
export type TokenResolver = (token: string) => string | null;
|
||||
|
||||
const TOKEN = /\{\{\s*[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*\}\}/g;
|
||||
const TOKEN_AT_START = /^\{\{\s*[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*\}\}/;
|
||||
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
||||
const ORDERED_ITEM = /^\s*\d+[.)]\s+/;
|
||||
const SENTENCE_BREAK = /(?<=[.!?])\s+/;
|
||||
|
||||
/** Resolves tokens from a finding's evidence; an unresolved token comes back from the interpolation as written. */
|
||||
export function tokenResolver(matchedConditions: Record<string, unknown> | null | undefined): TokenResolver {
|
||||
return (token) => {
|
||||
const value = interpolateRuleTemplate(token, matchedConditions);
|
||||
return value === token ? null : value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops what a reader without the placeholders cannot use. Works on the template, so the result
|
||||
* still carries its tokens for the parser.
|
||||
*/
|
||||
export function pruneRuleText(text: string, resolve: TokenResolver): string {
|
||||
const kept: string[] = [];
|
||||
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
let unresolved = false;
|
||||
const probe = (s: string) =>
|
||||
s.replace(TOKEN, (token) => {
|
||||
const value = resolve(token);
|
||||
if (value !== null) return value;
|
||||
unresolved = true;
|
||||
return NOT_RECORDED;
|
||||
});
|
||||
const rendered = probe(line);
|
||||
if (unresolved && LIST_ITEM.test(line)) continue;
|
||||
|
||||
if (!rendered.includes("{{")) {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
// A sentence that still shows "{{" after filling talks about placeholders themselves.
|
||||
const sentences = line.split(SENTENCE_BREAK).filter((sentence) => !probe(sentence).includes("{{"));
|
||||
if (sentences.length > 0) kept.push(sentences.join(" "));
|
||||
}
|
||||
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
/** Paragraphs and lists of a rule text; tokens stay tokens. */
|
||||
export function parseRuleText(text: string): RuleBlock[] {
|
||||
const blocks: RuleBlock[] = [];
|
||||
let paragraph: RuleInline[][] | null = null;
|
||||
let list: Extract<RuleBlock, { kind: "list" }> | null = null;
|
||||
|
||||
for (const raw of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
if (raw.trim() === "") {
|
||||
paragraph = null;
|
||||
list = null;
|
||||
continue;
|
||||
}
|
||||
const item = LIST_ITEM.exec(raw);
|
||||
if (item) {
|
||||
paragraph = null;
|
||||
const ordered = ORDERED_ITEM.test(raw);
|
||||
if (!list || list.ordered !== ordered) {
|
||||
list = { kind: "list", ordered, items: [] };
|
||||
blocks.push(list);
|
||||
}
|
||||
list.items.push(parseInline(raw.slice(item[0].length).trim()));
|
||||
continue;
|
||||
}
|
||||
list = null;
|
||||
if (!paragraph) {
|
||||
paragraph = [];
|
||||
blocks.push({ kind: "paragraph", lines: paragraph });
|
||||
}
|
||||
paragraph.push(parseInline(raw.trim()));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Inline markdown of one line: **bold**, *emphasis*, `code` (tokens only inside) and tokens. */
|
||||
export function parseInline(s: string): RuleInline[] {
|
||||
const out: RuleInline[] = [];
|
||||
let text = "";
|
||||
const flush = () => {
|
||||
if (text) out.push({ kind: "text", text });
|
||||
text = "";
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
if (s.startsWith("{{", i)) {
|
||||
const token = TOKEN_AT_START.exec(s.slice(i));
|
||||
if (token) {
|
||||
flush();
|
||||
out.push({ kind: "token", token: token[0] });
|
||||
i += token[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (s[i] === "`") {
|
||||
const end = s.indexOf("`", i + 1);
|
||||
if (end > i + 1) {
|
||||
flush();
|
||||
out.push({ kind: "code", children: parseTokensOnly(s.slice(i + 1, end)) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (s.startsWith("**", i)) {
|
||||
const end = s.indexOf("**", i + 2);
|
||||
if (end > i + 2) {
|
||||
flush();
|
||||
out.push({ kind: "strong", children: parseInline(s.slice(i + 2, end)) });
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (s[i] === "*" && s[i + 1] !== undefined && s[i + 1] !== "*" && !/\s/.test(s[i + 1])) {
|
||||
const end = closingEmphasis(s, i + 1);
|
||||
if (end > 0) {
|
||||
flush();
|
||||
out.push({ kind: "em", children: parseInline(s.slice(i + 1, end)) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
text += s[i];
|
||||
i++;
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fills every token with its value (or the not-recorded mark). */
|
||||
export function fillRuleText(blocks: RuleBlock[], resolve: TokenResolver): RuleBlock[] {
|
||||
return blocks.map((block) =>
|
||||
block.kind === "paragraph"
|
||||
? { kind: "paragraph", lines: block.lines.map((line) => fillInlines(line, resolve)) }
|
||||
: { ...block, items: block.items.map((item) => fillInlines(item, resolve)) }
|
||||
);
|
||||
}
|
||||
|
||||
/** A finding's rule text, ready to render: pruned, parsed, values filled in. */
|
||||
export function ruleTextBlocks(
|
||||
text: string | null | undefined,
|
||||
matchedConditions: Record<string, unknown> | null | undefined
|
||||
): RuleBlock[] {
|
||||
if (!text) return [];
|
||||
const resolve = tokenResolver(matchedConditions);
|
||||
return fillRuleText(parseRuleText(pruneRuleText(text, resolve)), resolve);
|
||||
}
|
||||
|
||||
/**
|
||||
* One remediation step, filled in; null when it names a value this finding did not record —
|
||||
* a step is a list item, and a list item without its value is dropped.
|
||||
*/
|
||||
export function ruleStepInlines(
|
||||
step: string | null | undefined,
|
||||
matchedConditions: Record<string, unknown> | null | undefined
|
||||
): RuleInline[] | null {
|
||||
if (!step?.trim()) return null;
|
||||
const resolve = tokenResolver(matchedConditions);
|
||||
const tokens = step.match(TOKEN) ?? [];
|
||||
if (tokens.some((token) => resolve(token) === null)) return null;
|
||||
return fillInlines(parseInline(step.trim()), resolve);
|
||||
}
|
||||
|
||||
/** Plain text of parsed blocks (copy to clipboard): markers dropped, values in, lists as "- " lines. */
|
||||
export function ruleTextPlain(blocks: RuleBlock[]): string {
|
||||
return blocks
|
||||
.map((block) =>
|
||||
block.kind === "paragraph"
|
||||
? block.lines.map(inlinePlain).join("\n")
|
||||
: block.items.map((item, i) => `${block.ordered ? `${i + 1}.` : "-"} ${inlinePlain(item)}`).join("\n")
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function inlinePlain(inlines: RuleInline[]): string {
|
||||
return inlines
|
||||
.map((node) => {
|
||||
switch (node.kind) {
|
||||
case "text":
|
||||
return node.text;
|
||||
case "token":
|
||||
return node.token;
|
||||
case "value":
|
||||
return node.text;
|
||||
default:
|
||||
return inlinePlain(node.children);
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function parseTokensOnly(s: string): RuleInline[] {
|
||||
const out: RuleInline[] = [];
|
||||
let last = 0;
|
||||
for (const match of s.matchAll(TOKEN)) {
|
||||
const at = match.index ?? 0;
|
||||
if (at > last) out.push({ kind: "text", text: s.slice(last, at) });
|
||||
out.push({ kind: "token", token: match[0] });
|
||||
last = at + match[0].length;
|
||||
}
|
||||
if (last < s.length) out.push({ kind: "text", text: s.slice(last) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The closing single "*" of an emphasis, not part of "**" and not after a space; -1 when none. */
|
||||
function closingEmphasis(s: string, from: number): number {
|
||||
for (let j = from; j < s.length; j++) {
|
||||
if (s[j] !== "*") continue;
|
||||
if (s[j + 1] === "*") {
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (j > from && !/\s/.test(s[j - 1])) return j;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function fillInlines(inlines: RuleInline[], resolve: TokenResolver): RuleInline[] {
|
||||
return inlines.map((node): RuleInline => {
|
||||
switch (node.kind) {
|
||||
case "token": {
|
||||
const value = resolve(node.token);
|
||||
return value === null
|
||||
? { kind: "value", text: NOT_RECORDED, recorded: false }
|
||||
: { kind: "value", text: value, recorded: true };
|
||||
}
|
||||
case "strong":
|
||||
case "em":
|
||||
case "code":
|
||||
return { ...node, children: fillInlines(node.children, resolve) };
|
||||
default:
|
||||
return node;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* wire type AND still lets a test force `data: undefined` at runtime to probe
|
||||
* legacy-payload tolerance.
|
||||
*/
|
||||
import type { EnrollmentEvent, Session } from "@/types";
|
||||
import type { EnrollmentEvent, RuleResult, Session } from "@/types";
|
||||
|
||||
export function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return Object.assign(
|
||||
@@ -84,3 +84,25 @@ export function makeEvent(overrides: Partial<EnrollmentEvent> = {}): EnrollmentE
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
export function makeRuleResult(overrides: Partial<RuleResult> = {}): RuleResult {
|
||||
return Object.assign(
|
||||
{
|
||||
resultId: "result-1",
|
||||
sessionId: "session-1",
|
||||
tenantId: "tenant-1",
|
||||
ruleId: "ANALYZE-TEST-001",
|
||||
ruleTitle: "Test rule",
|
||||
severity: "warning",
|
||||
category: "apps",
|
||||
confidenceScore: 80,
|
||||
explanation: "",
|
||||
remediation: [],
|
||||
relatedDocs: [],
|
||||
matchedConditions: {} as Record<string, unknown>,
|
||||
detectedAt: "2026-10-13T14:45:00Z",
|
||||
isInterim: false,
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Rule text display — shared cases
|
||||
|
||||
A finding's explanation is the rule's raw template; the reader sees it either as a notification
|
||||
(no renderer) or in the portal (markdown renderer). Both prune it the same way before showing it:
|
||||
a list item whose `{{token}}` was not recorded is dropped, a token in running text that was not
|
||||
recorded shows as `–`, and a sentence that still talks about literal placeholders is removed.
|
||||
`cases.json` holds the expected text with the recorded values filled in.
|
||||
|
||||
Implementations that run every case:
|
||||
|
||||
- Backend — `RuleTemplateInterpolator.InterpolateForNotification`
|
||||
(test: `RuleTemplateInterpolatorTests.InterpolateForNotification_prunes_like_the_shared_display_case`)
|
||||
- Web — `pruneRuleText` in `src/Web/autopilot-monitor-web/lib/ruleText.ts`, which keeps the
|
||||
template for the markdown parser and fills values into text nodes only afterwards
|
||||
(test: `lib/__tests__/ruleText.test.ts`)
|
||||
|
||||
Token resolution itself is the one in `../rule-template-interpolation`. A change to the pruning
|
||||
goes into `cases.json` first.
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "a list item whose token was not recorded is dropped",
|
||||
"text": "Facts:\n\n- **Reason:** `{{reason}}`\n- **ESP error code:** `{{errorCode}}`",
|
||||
"matchedConditions": { "sig": { "field": "reason", "value": "esp_terminal_failure" } },
|
||||
"expected": "Facts:\n\n- **Reason:** `esp_terminal_failure`"
|
||||
},
|
||||
{
|
||||
"name": "a token in running text that was not recorded shows a dash",
|
||||
"text": "The sweep closed this session after `{{timeoutHours}}` hour(s) of inactivity.",
|
||||
"matchedConditions": {},
|
||||
"expected": "The sweep closed this session after `–` hour(s) of inactivity."
|
||||
},
|
||||
{
|
||||
"name": "a sentence about literal placeholders is removed, the rest of the line stays",
|
||||
"text": "Check the app. Any field still shown as `{{…}}` was not recorded.",
|
||||
"matchedConditions": {},
|
||||
"expected": "Check the app."
|
||||
},
|
||||
{
|
||||
"name": "blank runs left by a dropped item collapse and the text is trimmed",
|
||||
"text": "First.\n\n- item {{x}}\n\n\nLast.\n",
|
||||
"matchedConditions": {},
|
||||
"expected": "First.\n\nLast."
|
||||
},
|
||||
{
|
||||
"name": "numbered items are list items too",
|
||||
"text": "1. Open {{appName}} in Intune\n2. Retry the enrollment",
|
||||
"matchedConditions": {},
|
||||
"expected": "2. Retry the enrollment"
|
||||
},
|
||||
{
|
||||
"name": "recorded values fill list items and running text",
|
||||
"text": "{{appName}} failed.\n\n- Starts: {{retry.count}}\n- Code: `{{errorCode}}`",
|
||||
"matchedConditions": {
|
||||
"failed": { "field": "appName", "value": "Contoso Notes", "errorCode": "0x80070652" },
|
||||
"retry": { "count": 4 }
|
||||
},
|
||||
"expected": "Contoso Notes failed.\n\n- Starts: 4\n- Code: `0x80070652`"
|
||||
},
|
||||
{
|
||||
"name": "windows line endings are normalized",
|
||||
"text": "A.\r\nB {{x}}.",
|
||||
"matchedConditions": {},
|
||||
"expected": "A.\nB –."
|
||||
},
|
||||
{
|
||||
"name": "a text without tokens passes unchanged",
|
||||
"text": "**Bold** and `code`.\n\n- one\n- two",
|
||||
"matchedConditions": null,
|
||||
"expected": "**Bold** and `code`.\n\n- one\n- two"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -62,7 +62,6 @@
|
||||
"a8ea1228aa75b506": 1,
|
||||
"ab6861a9ea29854d": 1,
|
||||
"af270234f673d876": 1,
|
||||
"b18fb773c65d275d": 1,
|
||||
"b26235044348e7ba": 1,
|
||||
"b4fc75e3c24c9138": 1,
|
||||
"b62f5f7b96567483": 1,
|
||||
|
||||
Reference in New Issue
Block a user