diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/[sessionId]/page.tsx index 8c82ec03..d3f9d7f9 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/[sessionId]/page.tsx @@ -9,6 +9,7 @@ import { useNotifications } from "../../../contexts/NotificationContext"; import { ProtectedRoute } from "../../../components/ProtectedRoute"; import { api } from "@/lib/api"; import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; +import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { ConfidenceBadge, SeverityBadge } from "./components/DiagnosisBadges"; import { Session, EnrollmentEvent, RuleResult } from "@/types"; @@ -190,7 +191,13 @@ export default function DiagnosisPage() { const copyRemediation = (result: RuleResult) => { const text = result.remediation - .map((r) => `${r.title}\n${r.steps.map((s) => ` - ${s}`).join("\n")}`) + .map((r) => { + const title = interpolateRuleTemplate(r.title, result.matchedConditions); + const steps = r.steps + .map((s) => ` - ${interpolateRuleTemplate(s, result.matchedConditions)}`) + .join("\n"); + return `${title}\n${steps}`; + }) .join("\n\n"); navigator.clipboard.writeText(text).then(() => { setCopiedId(result.ruleId); @@ -379,7 +386,7 @@ export default function DiagnosisPage() {

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

{/* Evidence Summary */} @@ -435,7 +442,7 @@ export default function DiagnosisPage() { {primaryResult.remediation.map((rem, i) => (

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

@@ -581,7 +588,7 @@ export default function DiagnosisPage() { {expandedResult === result.ruleId && (

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

{result.remediation?.length > 0 && ( @@ -605,11 +612,11 @@ export default function DiagnosisPage() { {result.remediation.map((rem, i) => (

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

    {rem.steps.map((step, j) => ( -
  • {step}
  • +
  • {interpolateRuleTemplate(step, result.matchedConditions)}
  • ))}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx index c2c27704..30001afe 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { RuleResult } from "@/types"; import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; +import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate"; import { api } from "@/lib/api"; import { authenticatedFetch } from "@/lib/authenticatedFetch"; import { useAuth } from "@/contexts/AuthContext"; @@ -230,7 +231,9 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?: {result.explanation && (

Explanation

-

{formatInlineMarkdown(result.explanation)}

+

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

)} @@ -239,10 +242,12 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:

Remediation

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

{rem.title}

+

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

    {rem.steps.map((step, j) => ( -
  • {step}
  • +
  • {interpolateRuleTemplate(step, result.matchedConditions)}
  • ))}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts new file mode 100644 index 00000000..ef96ca07 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/interpolateRuleTemplate.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { interpolateRuleTemplate } from "../interpolateRuleTemplate"; + +describe("interpolateRuleTemplate", () => { + const matched = { + ca2023_not_updated: { + eventId: "abc", + sequence: 23, + timestamp: "2026-04-28T18:24:16Z", + field: "uefiCA2023Status", + value: "unknown", + }, + secureboot_enabled: { + eventId: "abc", + field: "uefiSecureBootEnabled", + value: "True", + }, + }; + + it("replaces {{field}} tokens from matchedConditions", () => { + const out = interpolateRuleTemplate( + "Status: `{{uefiCA2023Status}}`", + matched + ); + expect(out).toBe("Status: `unknown`"); + }); + + it("replaces multiple distinct tokens in one string", () => { + const out = interpolateRuleTemplate( + "SB={{uefiSecureBootEnabled}} CA={{uefiCA2023Status}}", + matched + ); + expect(out).toBe("SB=True CA=unknown"); + }); + + it("falls back to signal-name lookup when field is unknown", () => { + const out = interpolateRuleTemplate("{{ca2023_not_updated}}", matched); + expect(out).toBe("unknown"); + }); + + it("tolerates whitespace inside braces", () => { + const out = interpolateRuleTemplate("[{{ uefiCA2023Status }}]", matched); + expect(out).toBe("[unknown]"); + }); + + it("leaves unknown tokens untouched so authors notice the typo", () => { + const out = interpolateRuleTemplate("{{notARealField}}", matched); + expect(out).toBe("{{notARealField}}"); + }); + + it("returns input unchanged when matchedConditions is null/undefined", () => { + expect(interpolateRuleTemplate("hello {{x}}", null)).toBe("hello {{x}}"); + expect(interpolateRuleTemplate("hello {{x}}", undefined)).toBe( + "hello {{x}}" + ); + }); + + it("handles non-string scalar values (numbers, booleans)", () => { + const out = interpolateRuleTemplate("seq={{n}} ok={{b}}", { + n: { field: "n", value: 42 }, + b: { field: "b", value: false }, + }); + expect(out).toBe("seq=42 ok=false"); + }); + + it("returns empty string for null/undefined input", () => { + expect(interpolateRuleTemplate(null, matched)).toBe(""); + expect(interpolateRuleTemplate(undefined, matched)).toBe(""); + }); + + it("does not blow up on text with no placeholders", () => { + expect(interpolateRuleTemplate("plain text", matched)).toBe("plain text"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts b/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts new file mode 100644 index 00000000..05fa6914 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/interpolateRuleTemplate.ts @@ -0,0 +1,54 @@ +/** + * Replaces `{{token}}` placeholders in a rule's explanation/remediation text + * with values pulled from the rule's `matchedConditions` evidence map. + * + * Resolution order for `{{token}}`: + * 1. The first matchedConditions entry whose `field` equals `token` + * (e.g. {{uefiCA2023Status}} -> entry where field === "uefiCA2023Status"). + * 2. A matchedConditions entry whose key (signal name) equals `token` + * (e.g. {{ca2023_not_updated}} -> that signal's value). + * 3. Unresolved tokens are left untouched so authors notice the typo. + * + * Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated. + */ +export function interpolateRuleTemplate( + text: string | null | undefined, + matchedConditions: Record | null | undefined +): string { + if (!text) return text ?? ""; + if (!matchedConditions || typeof matchedConditions !== "object") return text; + + const byField = new Map(); + const bySignal = new Map(); + + for (const [signal, evidence] of Object.entries(matchedConditions)) { + if (!evidence || typeof evidence !== "object") { + if (evidence != null) bySignal.set(signal, formatValue(evidence)); + continue; + } + const e = evidence as Record; + if (typeof e.field === "string" && "value" in e) { + byField.set(e.field, formatValue(e.value)); + } + if ("value" in e) { + bySignal.set(signal, formatValue(e.value)); + } + } + + return text.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (raw, token: string) => { + if (byField.has(token)) return byField.get(token)!; + if (bySignal.has(token)) return bySignal.get(token)!; + return raw; + }); +} + +function formatValue(v: unknown): string { + if (v == null) return ""; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + try { + return JSON.stringify(v); + } catch { + return String(v); + } +}