mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(web): interpolate {{var}} placeholders in analyze-rule explanations
Rule authors can now embed `{{fieldName}}` tokens in explanation,
remediation title, and remediation steps; the renderer substitutes them
with the corresponding evidence value from matchedConditions (resolved
first by event-data field name, then by signal name as fallback).
Unresolved tokens stay literal so typos surface visibly.
Wired into AnalysisResultsSection (session page) and the diagnosis page
hero/quick-fix/expandable cards plus the copyRemediation clipboard text.
ANALYZE-SEC-001 ("Secure Boot UEFI CA 2023 not deployed") now renders
"Current UEFI CA 2023 status: unknown" instead of the raw template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 leading-relaxed mb-6">
|
||||
{primaryResult.explanation}
|
||||
{interpolateRuleTemplate(primaryResult.explanation, primaryResult.matchedConditions)}
|
||||
</p>
|
||||
|
||||
{/* Evidence Summary */}
|
||||
@@ -435,7 +442,7 @@ export default function DiagnosisPage() {
|
||||
{primaryResult.remediation.map((rem, i) => (
|
||||
<div key={i} className="mb-3 last:mb-0">
|
||||
<p className="text-sm font-medium text-green-700 mb-1">
|
||||
{rem.title}
|
||||
{interpolateRuleTemplate(rem.title, primaryResult.matchedConditions)}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{rem.steps.map((step, j) => (
|
||||
@@ -446,7 +453,7 @@ export default function DiagnosisPage() {
|
||||
<span className="text-green-400 mt-0.5">
|
||||
•
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
<span>{interpolateRuleTemplate(step, primaryResult.matchedConditions)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -581,7 +588,7 @@ export default function DiagnosisPage() {
|
||||
{expandedResult === result.ruleId && (
|
||||
<div className="mt-4 pl-16 space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatInlineMarkdown(result.explanation)}
|
||||
{formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
|
||||
</p>
|
||||
|
||||
{result.remediation?.length > 0 && (
|
||||
@@ -605,11 +612,11 @@ export default function DiagnosisPage() {
|
||||
{result.remediation.map((rem, i) => (
|
||||
<div key={i} className="mb-2 last:mb-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
{rem.title}
|
||||
{interpolateRuleTemplate(rem.title, result.matchedConditions)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-gray-600 ml-2">
|
||||
{rem.steps.map((step, j) => (
|
||||
<li key={j}>{step}</li>
|
||||
<li key={j}>{interpolateRuleTemplate(step, result.matchedConditions)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+8
-3
@@ -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 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Explanation</h4>
|
||||
<p className="text-sm text-gray-600">{formatInlineMarkdown(result.explanation)}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -239,10 +242,12 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
|
||||
<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">{rem.title}</p>
|
||||
<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}>{step}</li>
|
||||
<li key={j}>{interpolateRuleTemplate(step, result.matchedConditions)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | null | undefined
|
||||
): string {
|
||||
if (!text) return text ?? "";
|
||||
if (!matchedConditions || typeof matchedConditions !== "object") return text;
|
||||
|
||||
const byField = new Map<string, string>();
|
||||
const bySignal = new Map<string, string>();
|
||||
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user