diff --git a/src/Web/autopilot-monitor-web/app/about/faq.ts b/src/Web/autopilot-monitor-web/app/about/faq.ts index 8e41550c..8dc5a87d 100644 --- a/src/Web/autopilot-monitor-web/app/about/faq.ts +++ b/src/Web/autopilot-monitor-web/app/about/faq.ts @@ -34,6 +34,11 @@ export const ABOUT_FAQ: AboutFaqItem[] = [ answer: "Yes. Dozens of built-in, community-maintained analyze rules run automatically on every session and report confidence-scored findings with remediation steps. You can write your own rules, add Intune Management Extension (IME) log patterns, and gather registry, file, or WMI data on any event. Regression detection alerts tenant admins when a rule starts firing more often than usual.", }, + { + question: "What if no analyze rule explains why my enrollment failed?", + answer: + "The evidence is still there. Every session keeps its timeline for your retention period, even after the device was reset, and with diagnostics upload set to On Failure Only, failed enrollments also keep their agent and IME logs. Connect your AI assistant through the MCP server and let it investigate the session: clients that work with files, such as Claude Code, can download the diagnostics package and search the raw IME logs, and the built-in debug-session prompt walks through these steps. Once the cause is found, the assistant can draft an analyze rule, and a Tenant Admin can dry-run it against real sessions, create it in the portal, and submit it to the community pool, where accepted rules ship to every tenant after review. If the product should detect a case on its own, Report Session sends it to the maintainers.", + }, { question: "How do I get alerted when an Autopilot enrollment fails?", answer: diff --git a/src/Web/autopilot-monitor-web/app/ai/page.tsx b/src/Web/autopilot-monitor-web/app/ai/page.tsx index 1f494dea..26ce9c8e 100644 --- a/src/Web/autopilot-monitor-web/app/ai/page.tsx +++ b/src/Web/autopilot-monitor-web/app/ai/page.tsx @@ -4,6 +4,7 @@ import { MarketingTracker } from "../../components/MarketingTracker"; import { AiHero } from "../../components/ai/AiHero"; import { StructuredFacts } from "../../components/ai/StructuredFacts"; import { QuestionsSection } from "../../components/ai/QuestionsSection"; +import { RuleLoopSection } from "../../components/ai/RuleLoopSection"; import { ArchitectureSection } from "../../components/ai/ArchitectureSection"; import { ConnectSection } from "../../components/ai/ConnectSection"; import { ClosingCta } from "../../components/ai/ClosingCta"; @@ -21,6 +22,7 @@ export default function AiPage() { + diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/ruleDryRunLogic.test.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/ruleDryRunLogic.test.ts new file mode 100644 index 00000000..2ee85279 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/ruleDryRunLogic.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import type { RuleDryRun, RuleDryRunCondition } from "@/lib/generated/wire-types.generated"; +import { confidenceSummary, editorDraft, evidenceText, eventCountText, verdictLabel } from "../ruleDryRunLogic"; +import { EMPTY_FORM, EMPTY_CONDITION, type AnalyzeRule, type RuleForm } from "../types"; + +const base: RuleDryRun = { + verdict: "fired", + eventCount: 233, + preconditions: [], + conditions: [], + confidenceFactors: [{ signal: "esp_hresult", condition: "exists", weight: 15, matched: true }], + baseConfidence: 70, + finalConfidence: 85, + confidenceThreshold: 70, + wouldMarkSessionAsFailed: false, +}; + +const condition = (patch: Partial): RuleDryRunCondition => ({ + signal: "unmapped_exit_code", + source: "event_data", + eventType: "app_install_failed", + required: true, + matched: true, + ...patch, +}); + +describe("verdictLabel", () => { + it.each([ + ["fired", "Would fire"], + ["required_condition_not_met", "A required condition did not match"], + ["below_confidence_threshold", "Below the confidence threshold"], + ["some_future_verdict", "some_future_verdict"], + ])("%s → %s", (verdict, label) => { + expect(verdictLabel(verdict)).toBe(label); + }); +}); + +describe("confidenceSummary", () => { + it("adds the matched factor weights to the base", () => { + expect(confidenceSummary(base)).toBe("85 (base 70 + 15, threshold 70)"); + }); + + it("leaves unmatched factors out", () => { + expect(confidenceSummary({ ...base, finalConfidence: 70, confidenceFactors: [{ ...base.confidenceFactors[0], matched: false }] })) + .toBe("70 (base 70, threshold 70)"); + }); + + it("says when the evaluation stopped before scoring", () => { + expect(confidenceSummary({ ...base, finalConfidence: undefined, confidenceFactors: [] })).toBe("not scored (threshold 70)"); + }); +}); + +describe("evidenceText", () => { + it("shows the matched value of an evidence dictionary", () => { + expect(evidenceText(condition({ evidence: { field: "message", value: "Admin did NOT set mapping for lpExitCode: 60001" } }))) + .toBe("Admin did NOT set mapping for lpExitCode: 60001"); + }); + + it("shows the evaluator's reason for a miss", () => { + expect(evidenceText(condition({ matched: false, evidence: "no matching data" }))).toBe("no matching data"); + }); + + it("falls back to compact JSON and caps long values", () => { + expect(evidenceText(condition({ evidence: { durationSeconds: 1200 } }))).toBe('{"durationSeconds":1200}'); + const long = evidenceText(condition({ evidence: "x".repeat(500) })); + expect(long).toHaveLength(240); + expect(long?.endsWith("…")).toBe(true); + }); + + it("is empty without evidence", () => { + expect(evidenceText(condition({ evidence: undefined }))).toBeNull(); + }); +}); + +describe("eventCountText", () => { + it.each([ + [0, "0 events"], + [1, "1 event"], + [3, "3 events"], + ])("%i → %s", (count, text) => { + expect(eventCountText(condition({ matchingEventCount: count }))).toBe(text); + }); + + it("is empty for a condition without an event type", () => { + expect(eventCountText(condition({ matchingEventCount: undefined }))).toBeNull(); + }); +}); + +describe("editorDraft", () => { + const form: RuleForm = { + ...EMPTY_FORM, + ruleId: " ANALYZE-CUSTOM-110 ", + title: " Unmapped installer exit code stopped the ESP ", + conditions: [ + { ...EMPTY_CONDITION, signal: "unmapped_exit_code", eventType: "app_install_failed" }, + { ...EMPTY_CONDITION, signal: "" }, + ], + }; + + it("form mode sends the body a save would store, trimmed and without half-filled rows", () => { + const draft = editorDraft(false, "", form); + if ("error" in draft) throw new Error(draft.error); + expect(draft.rule.ruleId).toBe("ANALYZE-CUSTOM-110"); + expect(draft.rule.title).toBe("Unmapped installer exit code stopped the ESP"); + expect((draft.rule.conditions as unknown[]).length).toBe(1); + }); + + it("an empty rule id still yields a testable draft", () => { + const draft = editorDraft(false, "", { ...form, ruleId: " " }); + expect("rule" in draft && draft.rule.ruleId).toBe("DRAFT"); + }); + + it("JSON mode reads the text, not the stale form", () => { + const draft = editorDraft(true, JSON.stringify({ ruleId: "X-1", title: "From JSON", conditions: [] }), form); + if ("error" in draft) throw new Error(draft.error); + expect(draft.rule.title).toBe("From JSON"); + }); + + it("JSON that does not parse is reported, never sent", () => { + const draft = editorDraft(true, "{ not json", form); + expect("error" in draft && draft.error.startsWith("Fix the JSON first:")).toBe(true); + }); + + it("an edit keeps the stored rule's other fields underneath the form's", () => { + const stored = { ruleId: "ANALYZE-CUSTOM-110", title: "Old title", version: "1.0.3", author: "Contoso IT" } as unknown as AnalyzeRule; + const draft = editorDraft(false, "", form, stored); + if ("error" in draft) throw new Error(draft.error); + expect(draft.rule.version).toBe("1.0.3"); + expect(draft.rule.author).toBe("Contoso IT"); + expect(draft.rule.title).toBe("Unmapped installer exit code stopped the ESP"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx index 1758e89f..15044a81 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { AnalyzeRule, RuleForm, PastedAnalyzeJson, getSeverityColor, getCategoryColor, interimTriggerLabels, jsonToForm } from "../types"; import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle"; import AnalyzeRuleFormFields from "./AnalyzeRuleFormFields"; +import { RuleDryRunPanel } from "./RuleDryRunPanel"; +import { editorDraft } from "../ruleDryRunLogic"; import { stripInternalFields } from "@/lib/rulePageHelpers"; import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; import { safeHttpUrl } from "@/lib/safeDocUrl"; @@ -666,6 +668,7 @@ export default function AnalyzeRuleCard({ > + editorDraft(jsonModeEdit, jsonText, editForm, rule)} />
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx new file mode 100644 index 00000000..3d0aee72 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/RuleDryRunPanel.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +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 type { + AnalyzeRule as AnalyzeRuleWire, + DryRunAnalyzeRuleRequest, + DryRunAnalyzeRuleResponse, + RuleDryRun, +} from "@/lib/generated/wire-types.generated"; +import { confidenceSummary, evidenceText, eventCountText, verdictLabel, type DryRunDraft } from "../ruleDryRunLogic"; + +interface RuleDryRunPanelProps { + /** Reads the draft at click time from whichever mode the editor is in (form or JSON). */ + getDraft: () => DryRunDraft; + /** Keeps the input id unique when several editors are open (create form, edit form of a rule). */ + idPrefix: string; +} + +interface Outcome { + result: RuleDryRun; + explanation: string; +} + +/** + * "Test against a session" in the analyze-rule editor: the backend's side-effect-free dry-run + * (POST rules/analyze/dryrun, the route test_analyze_rule uses) for the draft as it stands. Shown where + * rules can be edited, so to Tenant Admins and Global Admins — both may call the route. + */ +export function RuleDryRunPanel({ getDraft, idPrefix }: RuleDryRunPanelProps) { + const { getAccessToken } = useAuth(); + const [sessionId, setSessionId] = useState(""); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const [outcome, setOutcome] = useState(null); + const inputId = `${idPrefix}-dryrun-session`; + + const run = async () => { + const id = sessionId.trim(); + if (!isGuid(id)) { + setError("Enter a session ID. Copy it from the Session Info card of the session you want to test against."); + return; + } + const draft = getDraft(); + if ("error" in draft) { + setError(draft.error); + return; + } + setRunning(true); + setError(null); + try { + const data = await fetchJson(api.rules.analyzeDryRun(), getAccessToken, { + method: "POST", + // The draft is the editor's loose rule object; the backend validates it and answers 400 with the reasons. + body: jsonBody({ sessionId: id, rule: draft.rule as unknown as AnalyzeRuleWire }), + }); + trackEvent("rule_dryrun_run", { verdict: data.result.verdict }); + const explanation = typeof draft.rule.explanation === "string" + ? interpolateRuleTemplate(draft.rule.explanation, data.result.matchedConditions) + : ""; + setOutcome({ result: data.result, explanation }); + } catch (e) { + setOutcome(null); + setError(apiErrorText(e, "The test could not run.")); + } finally { + setRunning(false); + } + }; + + return ( +
+
+ + +
+

+ Replays the draft as it would be saved against the session's stored events. Nothing is saved. Test a session where + the rule should fire and one where it should stay silent. +

+ {error &&

{error}

} + {outcome && } +
+ ); +} + +function DryRunResult({ outcome }: { outcome: Outcome }) { + const { result, explanation } = outcome; + const fired = result.verdict === "fired"; + return ( +
+
+ + {verdictLabel(result.verdict)} + + Confidence {confidenceSummary(result)} + + {result.eventCount} events + {fired && ( + <> + + + {result.wouldMarkSessionAsFailed ? "Would mark the session as failed" : "Does not mark the session as failed"} + + + )} +
+ + {result.preconditions.length > 0 && ( +
    + {result.preconditions.map((p, i) => ( +
  • + Precondition {[p.eventType, p.dataField, p.operator, p.value].filter(Boolean).join(" ")} + {p.passed ? " — passed" : " — failed, the rule was skipped"} +
  • + ))} +
+ )} + +
    + {result.conditions.map((c) => { + const evidence = evidenceText(c); + return ( +
  • + + {c.matched ? "Matched:" : "Not matched:"} + {c.signal} + + {[c.eventType ?? c.source, c.required ? "required" : "optional", eventCountText(c)].filter(Boolean).join(" · ")} + + {evidence && {evidence}} +
  • + ); + })} +
+ + {fired && explanation && ( +
+

Explanation preview

+

{formatInlineMarkdown(explanation)}

+
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index c1610de2..d0a04649 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -18,7 +18,9 @@ import { isTemplateRule } from "@/lib/analyzeRuleTabs"; import { groupRulesByCategory, categoryLabel } from "@/lib/ruleGroups"; import { RuleCategoryGroups } from "@/components/rules/RuleCategoryGroups"; import { useCollapsedGroups } from "@/hooks/useCollapsedGroups"; -import { AnalyzeRule, RuleForm, PastedAnalyzeJson, EMPTY_FORM, EMPTY_CONDITION, ruleToForm, jsonToForm, formToEvaluateOn, validateOnEventTypes, getCategoryColor } from "./types"; +import { AnalyzeRule, RuleForm, PastedAnalyzeJson, EMPTY_FORM, EMPTY_CONDITION, ruleToForm, jsonToForm, formToEvaluateOn, formRuleBody, validateOnEventTypes, getCategoryColor } from "./types"; +import { RuleDryRunPanel } from "./components/RuleDryRunPanel"; +import { editorDraft } from "./ruleDryRunLogic"; import { BLOCKED_INTERIM_TRIGGER_EVENT_TYPES } from "@/lib/generated/guardrails.generated"; /** Pre-flight for the evaluation-trigger form fields; returns an error message or null. */ @@ -370,19 +372,7 @@ function AnalyzeRulesContent() { tags: form.tags ?? [], markSessionAsFailedDefault: form.markSessionAsFailedDefault ?? false, ruleId: form.ruleId.trim(), - title: form.title.trim(), - description: form.description.trim(), - severity: form.severity, - category: form.category, - trigger: form.trigger, - explanation: form.explanation.trim(), - baseConfidence: form.baseConfidence, - confidenceThreshold: form.confidenceThreshold, - preconditions: form.preconditions.filter(p => p.eventType.trim() && p.operator.trim()), - conditions: form.conditions.filter(c => c.signal.trim()), - 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()), + ...formRuleBody(form), }; const result = await mutate( @@ -429,19 +419,7 @@ function AnalyzeRulesContent() { evaluateOn: formToEvaluateOn(form) ?? null, // From the form (not ...rule) so tag changes pasted in JSON mode survive the save. tags: form.tags ?? rule.tags ?? [], - title: form.title.trim(), - description: form.description.trim(), - severity: form.severity, - category: form.category, - trigger: form.trigger, - explanation: form.explanation.trim(), - baseConfidence: form.baseConfidence, - confidenceThreshold: form.confidenceThreshold, - preconditions: form.preconditions.filter(p => p.eventType.trim() && p.operator.trim()), - conditions: form.conditions.filter(c => c.signal.trim()), - 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()), + ...formRuleBody(form), version: bumpVersion(rule.version), }; @@ -861,6 +839,7 @@ function AnalyzeRulesContent() { > r.ruleId)} /> + editorDraft(jsonModeCreate, jsonText, newRule)} />
)} @@ -673,6 +684,23 @@ function SessionDetailContent() { canReanalyze={!isReadOnlyView && ((user?.isTenantAdmin ?? false) || (user?.isGlobalAdmin ?? false) || user?.role === "Operator")} persistFailureRuleIds={persistFailureRuleIds} sessionTenantId={sessionTenantId || undefined} + emptyState={showInvestigateHint({ + status: session?.status, + analysisLoaded, + loadingAnalysis, + findingCount: analysisResults.length, + }) ? ( + + ) : undefined} />
)} diff --git a/src/Web/autopilot-monitor-web/components/InvestigateWithAiHint.tsx b/src/Web/autopilot-monitor-web/components/InvestigateWithAiHint.tsx new file mode 100644 index 00000000..63096d62 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/InvestigateWithAiHint.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { useCopy } from "@/hooks/useCopy"; +import { trackEvent } from "@/lib/appInsights"; +import { buildInvestigationPrompt } from "@/lib/investigationPrompt"; +import { route } from "@/lib/routes"; + +interface InvestigateWithAiHintProps { + sessionId: string; + /** The session's tenant when it is not the viewer's own (Global Admin / MSP drill-in). */ + tenantId?: string; + /** auth/me hasMcpAccess: the platform policy and the tenant's MCP switch allow this person in. */ + mcpAccess: boolean; + /** Tenant Admin or platform role — their MCP catalog lists test_analyze_rule. */ + canDryRun: boolean; + /** Report Session is an Admin / Operator action; Viewers are not pointed at a button they lack. */ + canReport: boolean; + /** Where the hint sits, for the copy telemetry. */ + source: "analysis" | "diagnosis"; +} + +const CODE = "font-mono text-xs bg-white border border-gray-200 px-1.5 py-0.5 rounded break-all"; +const COPY_LINK = "text-xs font-medium text-sky-700 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-200 underline"; + +/** + * The empty state of a failed session that no analyze rule explains — instead of a dead end, the way + * to the evidence: the built-in debug-session MCP prompt, or the same investigation as a full prompt + * for clients without MCP prompts, and Report Session for what the product should learn. + */ +export function InvestigateWithAiHint({ sessionId, tenantId, mcpAccess, canDryRun, canReport, source }: InvestigateWithAiHintProps) { + const { copied, copy } = useCopy(); + const onCopy = (value: string, kind: "session_id" | "prompt") => { + trackEvent("session_ai_prompt_copied", { source, kind }); + void copy(value, kind); + }; + + return ( +
+

No analyze rule flagged this failure.

+ {mcpAccess ? ( + <> +

The evidence is still here. Investigate it with your AI assistant:

+
    +
  • + + Clients with MCP prompts: run debug-session for session + + {sessionId} + +
  • +
  • + Any other client: + +
  • +
+

+ {"Not connected yet? "} + + Set up your AI assistant + + . +

+ + ) : ( +

Check the event timeline for the first error and what led up to it.

+ )} + {canReport && ( +

+ Something the product should detect on its own? Send it to the maintainers with Report Session. +

+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/__tests__/InvestigateWithAiHint.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/InvestigateWithAiHint.test.ts new file mode 100644 index 00000000..6e6cc3fa --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/__tests__/InvestigateWithAiHint.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { InvestigateWithAiHint } from "../InvestigateWithAiHint"; + +const SESSION = "3f9c2d71-5b8e-4c0a-9e14-6d2b7a815c40"; + +const render = (props: Partial[0]> = {}) => + renderToStaticMarkup( + createElement(InvestigateWithAiHint, { + sessionId: SESSION, + mcpAccess: true, + canDryRun: true, + canReport: true, + source: "analysis", + ...props, + }), + ); + +describe("InvestigateWithAiHint", () => { + it("offers the debug-session prompt for this session, the full prompt and the client setup", () => { + const html = render(); + expect(html).toContain("No analyze rule flagged this failure."); + expect(html).toContain("debug-session"); + expect(html).toContain(SESSION); + expect(html).toContain("Copy full prompt"); + expect(html).toContain('href="/settings/tenant/ai-integration"'); + }); + + it("without MCP access it points to the timeline and names no AI step", () => { + const html = render({ mcpAccess: false }); + expect(html).toContain("No analyze rule flagged this failure."); + expect(html).toContain("Check the event timeline"); + expect(html).not.toContain("debug-session"); + expect(html).not.toContain("Copy full prompt"); + }); + + it("names Report Session only for roles that have the button", () => { + expect(render({ canReport: true })).toContain("Report Session"); + expect(render({ canReport: false })).not.toContain("Report Session"); + }); + + it("stays in the neutral gray/sky family of the session view", () => { + expect(render()).not.toMatch(/(bg|text|border)-(amber|red|green|violet|indigo)-/); + }); +}); diff --git a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts index a45d0868..621d5ed8 100644 --- a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts +++ b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts @@ -40,10 +40,10 @@ const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [ { name: "landing", Page: Landing, - sections: ["hero", "stats", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"], + sections: ["hero", "stats", "evidence_loop", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"], }, { name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] }, - { name: "ai", Page: AiPage, sections: ["hero", "structured", "questions", "architecture", "connect", "closing"] }, + { name: "ai", Page: AiPage, sections: ["hero", "structured", "questions", "rules", "architecture", "connect", "closing"] }, ]; describe("marketing page tracking", () => { @@ -61,9 +61,9 @@ describe("marketing page tracking", () => { }); } - it("the landing links to the AI page from the navbar, Then just ask and the footer", () => { + it("the landing links to the AI page from the navbar, the evidence loop, Then just ask and the footer", () => { const ids = trackIds(render(Landing)); - expect(ids).toEqual(expect.arrayContaining(["nav_ai", "just_ask_ai", "footer_ai"])); + expect(ids).toEqual(expect.arrayContaining(["nav_ai", "evidence_loop_ai", "just_ask_ai", "footer_ai"])); }); it("the AI page carries every question text in its static HTML", () => { diff --git a/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx b/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx index 95e76dfc..6abc4984 100644 --- a/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx +++ b/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx @@ -1,24 +1,8 @@ -import type { ReactNode } from "react"; import { DOCS_PATHS } from "@/lib/docsPaths"; import { DOCS_URL } from "@/lib/config"; import { CopyServerUrl } from "./CopyServerUrl"; import { ArrowRightIcon } from "./icons"; - -function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { - return ( -
  • -
    - - {n} - -
    -

    {title}

    -
    {children}
    -
    -
    -
  • - ); -} +import { Step } from "./Step"; export function ConnectSection() { return ( diff --git a/src/Web/autopilot-monitor-web/components/ai/RuleLoopSection.tsx b/src/Web/autopilot-monitor-web/components/ai/RuleLoopSection.tsx new file mode 100644 index 00000000..24260969 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/RuleLoopSection.tsx @@ -0,0 +1,122 @@ +import { DOCS_PATHS } from "@/lib/docsPaths"; +import { DOCS_URL } from "@/lib/config"; +import { ArrowRightIcon } from "./icons"; +import { Step } from "./Step"; + +type TraceTone = "ok" | "faint" | "ink"; + +interface TraceLine { + text: string; + tone: TraceTone; + /** Space above the line: separates the three tool calls. */ + gap?: boolean; + /** Free text that may wrap; the aligned trace columns never do. */ + wrap?: boolean; +} + +const TONE: Record = { + ok: "text-[var(--lp-term-ok)]", + faint: "text-[var(--lp-term-faint)]", + ink: "text-[var(--lp-term-ink)]", +}; + +/** + * The trace of a real dry-run (2026-10-11) against the two attempts the landing demo talks about — + * the same sessions, the same rule, anonymized like the demo. Field names and verdicts are the + * test_analyze_rule wire values; the counts are matchingEventCount per condition. + */ +const TRACE: TraceLine[] = [ + { text: "validate_rule({ rule })", tone: "faint" }, + { text: "✓ valid · 0 errors · 0 warnings", tone: "ok" }, + { text: 'test_analyze_rule({ sessionId: "e6a1…f43b", rule }) # attempt 1, failed', tone: "faint", gap: true }, + { text: "verdict fired · confidence 70 + 15 = 85 (threshold 70)", tone: "ok" }, + { text: "✓ unmapped_exit_code app_install_failed 1 event", tone: "ink" }, + { text: " Contoso CRM App: [Win32App] Admin did NOT set mapping for lpExitCode: 60001", tone: "faint" }, + { text: "✓ esp_terminal_failure enrollment_failed 1 event", tone: "ink" }, + { text: "✓ esp_hresult esp_failure_settle_started 1 event +15", tone: "ink" }, + { text: " errorCode 0x8007ea61", tone: "faint" }, + { + text: "explanation Contoso CRM App exited with code 60001, which has no entry in the app's return-code table. The IME reported the install as failed and the ESP stopped the enrollment. The ESP HRESULT carries the exit code's low 16 bits in hex: 0x8007ea61.", + tone: "ink", + gap: true, + wrap: true, + }, + { text: 'test_analyze_rule({ sessionId: "98db…16af", rule }) # retry, succeeded', tone: "faint", gap: true }, + { text: "verdict required_condition_not_met · stays silent", tone: "faint" }, + { text: "✗ unmapped_exit_code app_install_failed 0 events", tone: "faint" }, + { text: "✗ esp_terminal_failure enrollment_failed 0 events", tone: "faint" }, + { text: "✗ esp_hresult esp_failure_settle_started 0 events", tone: "faint" }, +]; + +/** + * "From finding to rule": the last mile of an AI investigation. Backed by the rule-authoring tools — + * validate_rule for every role, test_analyze_rule for Tenant Admins (TenantAdminOrGlobalReader route); + * neither persists anything, so the "Read-only tools" guardrail below stays true. + */ +export function RuleLoopSection() { + return ( +
    +
    +
    +

    Close the loop

    +

    From finding to rule.

    +

    + When your assistant finds a cause that no rule knows yet, it can write the rule. It checks the draft against + the rule schema and dry-runs it against real sessions of your tenant: it should fire on the failed one and + stay silent on a healthy one. Nothing is saved until a Tenant Admin creates the rule in the portal. +

    +
      + + The built-in debug-session prompt walks your assistant from the + session summary to the diagnostics package and the IME logs. Downloading the package needs a client that + works with files. + + + validate_rule checks the draft.{" "} + test_analyze_rule replays it against real sessions and saves + nothing. Dry-runs need the Tenant Admin role. + + + Paste the JSON under Rules → Analyze Rules → Create Custom Rule and test it there against a session. Submit + it to the community pool; once accepted after review, it ships to every tenant. + +
    + + AI-assisted rule authoring guide + + +
    +
    +
    +
    + + ANALYZE-CUSTOM-110 · Unmapped installer exit code stopped the ESP + +
    +
    + {TRACE.map((line, i) => ( +

    + {line.text} +

    + ))} +
    +
    +
    From a real dry-run, anonymized.
    +
    +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/Step.tsx b/src/Web/autopilot-monitor-web/components/ai/Step.tsx new file mode 100644 index 00000000..171ac27f --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/Step.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +/** A numbered step card of the /ai page (Connect, From finding to rule). */ +export function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { + return ( +
  • +
    + + {n} + +
    +

    {title}

    +
    {children}
    +
    +
    +
  • + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx b/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx index 87725911..60d2997a 100644 --- a/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx +++ b/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx @@ -50,11 +50,12 @@ export function StructuredFacts() {

    Structured from the start

    - Your assistant reads facts, not log files. + Facts first. Raw logs when it matters.

    - The agent turns each enrollment into typed events while it runs. Your assistant queries them through dedicated - tools, so its answers rest on data instead of guesswork. + The agent turns each enrollment into typed events while it runs. Your assistant starts there, so its answers + rest on data instead of guesswork. When the facts run out, clients that work with files can fetch the + session's diagnostics package and read the original log lines.

    @@ -87,6 +88,17 @@ export function StructuredFacts() { + {/* The way back: from the answer to the line it came from, when the facts are not enough. */} +
    + +
    +
      {DOMAINS.map(domain => (
    • diff --git a/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx b/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx index 6f9aa528..34f5fddb 100644 --- a/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx @@ -31,7 +31,7 @@ const CAPABILITIES = [ }, { title: "MCP server for AI", - detail: "Query sessions, metrics, and docs from your AI assistant.", + detail: "Ask about sessions and the fleet, dig into diagnostics, draft new rules.", }, { title: "Audit log & open source", diff --git a/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx b/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx index 1942136c..5ee0fbba 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx @@ -46,6 +46,11 @@ const ROWS = [ monitor: "Drill into per-event timeline, IME log patterns, and analyze results", standard: "Manual IME log hunting — slow and error-prone", }, + { + label: "Unknown failures", + monitor: "Evidence already saved — investigate it with AI, then turn the finding into a rule", + standard: "Reproduce the failure and collect the logs again", + }, ]; export function Comparison() { diff --git a/src/Web/autopilot-monitor-web/components/landing/EvidenceLoop.tsx b/src/Web/autopilot-monitor-web/components/landing/EvidenceLoop.tsx new file mode 100644 index 00000000..329d5541 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/landing/EvidenceLoop.tsx @@ -0,0 +1,153 @@ +import Link from "next/link"; +import { Reveal } from "./Reveal"; +import { ArrowRightIcon, RepeatIcon } from "../ai/icons"; + +interface Station { + title: string; + tag: string; + text: string; + /** The half of the loop most visitors don't expect from a monitoring tool — drawn in the accent. */ + accent: boolean; +} + +/** + * The four stations in loop order. Every claim carries its own qualifier: the raw logs exist only + * with diagnostics upload on, and only clients that work with files open them. + */ +const STATIONS: Station[] = [ + { + title: "Capture", + tag: "agent", + text: "Phases, apps, errors and timings stream in while the device enrolls. With diagnostics upload on, failed enrollments also keep their IME and agent logs.", + accent: false, + }, + { + title: "Detect", + tag: "analyze rules", + text: "Built-in, community and your own analyze rules name known problems as they happen.", + accent: false, + }, + { + title: "Investigate", + tag: "MCP", + text: "No rule fits? Your AI assistant reads the session through MCP. Clients that work with files, such as Claude Code, go on into the raw IME logs.", + accent: true, + }, + { + title: "Learn", + tag: "new rule", + text: "The assistant drafts a rule and dry-runs it against your sessions. A Tenant Admin switches it on, or submits it to the community pool.", + accent: true, + }, +]; + +function StationCard({ station, index }: { station: Station; index: number }) { + const frame = station.accent + ? "border-[var(--lp-accent-line)] bg-[var(--lp-accent-soft)]" + : "border-[var(--lp-line)] bg-[var(--lp-bg)]"; + const label = station.accent ? "text-[var(--lp-accent-ink)]" : "text-[var(--lp-ink-faint)]"; + return ( +
      +

      + + {index + 1} · {station.title} + + {station.tag} +

      +

      {station.text}

      +
      + ); +} + +/** A connector of the wide loop; `turn` rotates the right-pointing arrow into the loop's direction. */ +function Connector({ turn, cell }: { turn: string; cell: string }) { + return ( +
    • + ); +} + +function NextTime({ className = "" }: { className?: string }) { + return ( + + + Next time: detected automatically + + ); +} + +/** + * "Evidence first": the loop behind every enrollment — capture, detect, investigate with AI, learn a + * rule — told right after the stats, where most visitors still are. The terminal demo below ("Then + * just ask") plays the investigate-and-learn half for real. + */ +export function EvidenceLoop() { + const [capture, detect, investigate, learn] = STATIONS; + return ( +
      +
      + +

      Evidence first

      +

      + Never debug the same failure twice. +

      +

      + Every enrollment is recorded while it runs, so the evidence is still there after someone resets the + device. Known problems are flagged by analyze rules. Unknown ones you investigate with your AI + assistant — and what you find becomes a rule that catches the next one. +

      +

      + The first failure becomes evidence. The next one is a known problem. +

      + + How the AI digs in + + +
      + + + {/* Wide: a 2×2 loop. DOM order follows the loop for screen readers; grid placement draws the circle. */} +
        +
      1. + +
      2. + +
      3. + +
      4. + +
      5. + +
      6. +
      + {/* Phones: the same loop as a column, closed by the way back. */} +
        + {STATIONS.map((station, i) => ( +
      1. + {i > 0 && ( + + )} + +
      2. + ))} +
      3. + +
      4. +
      +
      +
      +
      + ); +} diff --git a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx index 3cc3524b..aab2833a 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx @@ -49,8 +49,8 @@ export function Hero() { {/* Sub left, CTAs right */}

      - Real-time monitoring, automated root-cause analysis, and on-demand diagnostics for - Windows Autopilot — deployed once via Intune, live minutes later. + Real-time monitoring, automated root-cause analysis, and evidence that survives a + device reset — deployed once via Intune, live minutes later.

      diff --git a/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx b/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx index 4a3a9e01..28f4f812 100644 --- a/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx @@ -13,8 +13,8 @@ export function JustAsk() { Then just ask.

      - Your AI assistant reads the whole session for you — and finds the root cause a human - would dig for all afternoon. This analysis is real. + Your AI assistant reads the whole session for you, finds the root cause a human would dig + for all afternoon, and writes the rule that catches it next time. This analysis is real.

      + diff --git a/src/Web/autopilot-monitor-web/components/landing/McpTerminalDemo.tsx b/src/Web/autopilot-monitor-web/components/landing/McpTerminalDemo.tsx index db17f1b1..29c5cd85 100644 --- a/src/Web/autopilot-monitor-web/components/landing/McpTerminalDemo.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/McpTerminalDemo.tsx @@ -14,7 +14,10 @@ type Step = * Scripted MCP session, based on a real production analysis (anonymized): * a failed first attempt whose ESP HRESULT decodes to an unmapped * installer exit code — the kind of root cause that costs a human an - * afternoon. Tool names mirror the actual MCP server. + * afternoon. Tool names mirror the actual MCP server. The second half turns + * the finding into a rule: validate_rule and both test_analyze_rule verdicts + * are the real results of a dry-run against the same two sessions + * (2026-10-11), anonymized like the rest. */ const SCRIPT: Step[] = [ { kind: "user", text: "CONTOSO-3812 failed enrollment this morning — what happened?" }, @@ -36,7 +39,20 @@ const SCRIPT: Step[] = [ { kind: "out", text: "Recommendation Map exit code 60001 in the Win32 app — better, fix the", accent: "success" }, { kind: "out", text: " PSADT package to log and handle the underlying error.", accent: "success" }, { kind: "gap" }, - { kind: "out", text: "Want me to pull the diagnostics ZIP and extract the installer log?" }, + { kind: "out", text: "Want me to write a rule so this gets flagged automatically next time?" }, + { kind: "gap" }, + { kind: "user", text: "Yes — and test it on both attempts." }, + { kind: "tool", text: "validate_rule({ rule })" }, + { kind: "tool", text: 'test_analyze_rule({ sessionId: "e6a1…f43b", rule })' }, + { kind: "tool", text: 'test_analyze_rule({ sessionId: "98db…16af", rule })' }, + { kind: "gap" }, + { kind: "out", text: 'ANALYZE-CUSTOM-110 "Unmapped installer exit code stopped the ESP"' }, + { kind: "out", text: " valid · 0 errors · 0 warnings", accent: "dim" }, + { kind: "out", text: " #1 fired · confidence 85 · exit 60001 ↔ ESP 0x8007EA61", accent: "success" }, + { kind: "out", text: " #2 required_condition_not_met · stays silent", accent: "dim" }, + { kind: "gap" }, + { kind: "out", text: "Paste the JSON under Rules → Analyze Rules → Create Custom Rule," }, + { kind: "out", text: "or submit it for the community pool." }, ]; const TYPE_MS = 32; @@ -76,10 +92,22 @@ export function McpTerminalDemo() { } const delay = step.kind === "tool" ? TOOL_MS : step.kind === "user" ? 500 : OUT_MS; - const timer = window.setTimeout(() => setStepIndex(i => i + 1), delay); + const timer = window.setTimeout(() => { + setStepIndex(i => i + 1); + // Every user line types from its first character, not from where the previous one ended. + setCharCount(0); + }, delay); return () => window.clearTimeout(timer); }, [active, reduced, stepIndex, charCount]); + // The transcript outgrows the fixed-height window: keep the newest line in view, like a terminal. + // Reduced motion shows the whole transcript at once and leaves scrolling to the reader. + const scrollRef = useRef(null); + useEffect(() => { + const el = scrollRef.current; + if (el && !reduced) el.scrollTop = el.scrollHeight; + }, [reduced, stepIndex, charCount]); + const finished = effectiveStepIndex >= SCRIPT.length; const renderStep = (step: Step, index: number) => { @@ -142,7 +170,10 @@ export function McpTerminalDemo() { AI assistant — connected to Autopilot Monitor MCP
      -
      +
      {SCRIPT.map(renderStep)} {finished && !reduced && (
      diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/investigationPrompt.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/investigationPrompt.test.ts new file mode 100644 index 00000000..04708d38 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/investigationPrompt.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { buildInvestigationPrompt } from "../investigationPrompt"; + +const SESSION = "3f9c2d71-5b8e-4c0a-9e14-6d2b7a815c40"; +const TENANT = "11111111-2222-4333-8444-555555555555"; + +describe("buildInvestigationPrompt", () => { + it("names the session and walks summary → events → diagnostics → report → rule", () => { + const prompt = buildInvestigationPrompt({ sessionId: SESSION, canDryRun: true }); + expect(prompt).toContain(`session ${SESSION} with the Autopilot Monitor MCP tools`); + const order = ["get_session_summary", "search_events", "get_session_diagnostics", "4. Report", "validate_rule"] + .map(marker => prompt.indexOf(marker)); + expect(order.every(i => i >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + expect(prompt).toContain("never read whole"); + }); + + it("adds the tenant only for a cross-tenant session", () => { + expect(buildInvestigationPrompt({ sessionId: SESSION, canDryRun: true })).not.toContain("(tenant "); + expect(buildInvestigationPrompt({ sessionId: SESSION, tenantId: TENANT, canDryRun: true })) + .toContain(`session ${SESSION} (tenant ${TENANT})`); + }); + + it.each([ + { canDryRun: true, expected: "dry-run it with test_analyze_rule", absent: "so a Tenant Admin" }, + { canDryRun: false, expected: "so a Tenant Admin can dry-run it", absent: "dry-run it with test_analyze_rule" }, + ])("canDryRun=$canDryRun names only the tools the caller has", ({ canDryRun, expected, absent }) => { + const prompt = buildInvestigationPrompt({ sessionId: SESSION, canDryRun }); + expect(prompt).toContain(expected); + expect(prompt).not.toContain(absent); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 399e7f89..5a6515f0 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -275,6 +275,9 @@ export const api = { `${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}`, analyzeRuleFromTemplate: (ruleId: string) => `${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}/create-from-template`, + // Side-effect-free dry-run of a draft against one session; a platform role may name any tenant's + // session (the backend resolves its tenant), everyone else stays in the token's tenant. + analyzeDryRun: () => `${API_BASE_URL}/api/rules/analyze/dryrun`, globalAnalyze: (tenantId?: string) => `${API_BASE_URL}/api/global/rules/analyze${qs({ tenantId })}`, // Cross-tenant edit/toggle/delete of an existing analyze rule (Global Admin override). PUT/DELETE diff --git a/src/Web/autopilot-monitor-web/lib/investigationPrompt.ts b/src/Web/autopilot-monitor-web/lib/investigationPrompt.ts new file mode 100644 index 00000000..25631e0a --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/investigationPrompt.ts @@ -0,0 +1,29 @@ +/** + * The copyable deep-analysis prompt offered on a failed session that no analyze rule explains. It + * spells out the whole investigation, so it works in every MCP client — also those that offer no MCP + * prompts (where the built-in `debug-session` prompt does the same). Tool names are the MCP server's; + * the dry-run step is written only for callers whose MCP catalog lists test_analyze_rule. + */ +export interface InvestigationPromptInput { + sessionId: string; + /** Set when the session belongs to another tenant than the viewer's (Global Admin / MSP drill-in). */ + tenantId?: string; + /** Tenant Admin or platform role: test_analyze_rule is available to them. */ + canDryRun: boolean; +} + +export function buildInvestigationPrompt({ sessionId, tenantId, canDryRun }: InvestigationPromptInput): string { + const target = tenantId ? `session ${sessionId} (tenant ${tenantId})` : `session ${sessionId}`; + const ruleStep = canDryRun + ? "Then dry-run it with test_analyze_rule against this session (it should fire) and against a successful session (it should stay silent), and give me the final JSON." + : "Give me the validated JSON so a Tenant Admin can dry-run it and create it in the portal."; + return [ + `Investigate Autopilot Monitor enrollment ${target} with the Autopilot Monitor MCP tools. No analyze rule flagged this failure, so the cause may be new.`, + "", + "1. Call get_session_summary first. Read coverage.gaps before you treat a missing event as evidence.", + "2. Find the first error and what led to it: search_events for the failing area, then get_session_events for the full stream around it. Decode every error code with lookup_error_code and check search_knowledge for rules or IME log patterns that name it.", + "3. Call get_session_diagnostics. If a package is available and you can work with files, download and unzip it, then grep (never read whole) ImeLogs/AppWorkload*.log, ImeLogs/IntuneManagementExtension.log and AgentLogs/agent.log around the first error. Without a package, continue with the session events.", + "4. Report the final outcome and phase, the most likely root cause with the exact evidence (event or log line), concrete remediation, and your confidence.", + `5. If the cause could happen again, draft an analyze rule that would flag it: read get_resource(name="rule_authoring_guide") and check the draft with validate_rule. ${ruleStep}`, + ].join("\n"); +} diff --git a/src/Web/autopilot-monitor-web/public/llms.txt b/src/Web/autopilot-monitor-web/public/llms.txt index 340edc65..46c2c81a 100644 --- a/src/Web/autopilot-monitor-web/public/llms.txt +++ b/src/Web/autopilot-monitor-web/public/llms.txt @@ -1,6 +1,6 @@ # Autopilot Monitor -> Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments managed through Microsoft Intune. A lightweight, temporary agent streams enrollment events to a web portal, analyze rules flag failure patterns automatically, and a Model Context Protocol (MCP) server lets AI assistants query the data. Created and maintained by Oliver Kieselbach (Microsoft MVP); the hosted service is operated by glueckkanja AG and runs in Azure Germany West Central. +> Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments managed through Microsoft Intune. A lightweight, temporary agent streams enrollment events to a web portal while the device enrolls, so the evidence survives a device reset. Analyze rules flag known failure patterns automatically; for the rest, a Model Context Protocol (MCP) server lets AI assistants investigate the session, down to the IME logs when diagnostics upload is on, and draft an analyze rule that catches the failure next time. Created and maintained by Oliver Kieselbach (Microsoft MVP); the hosted service is operated by glueckkanja AG and runs in Azure Germany West Central. Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and stays free: live monitoring, the full rules engine, fleet analytics, and AI integration (MCP) within usage limits. The optional Pro plan adds 365-day instead of 90-day retention, higher usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; it is sold through Microsoft Marketplace and Cleverbridge, and a tenant administrator can try it free for 30 days. @@ -8,7 +8,7 @@ Autopilot Monitor is for IT administrators, helpdesk engineers, and managed serv - [Home](https://www.autopilotmonitor.com/): product overview - [About](https://www.autopilotmonitor.com/about/): what it is, how it works, common questions -- [AI analysis](https://www.autopilotmonitor.com/ai/): ask about your enrollments in your own AI assistant through the MCP server +- [AI analysis](https://www.autopilotmonitor.com/ai/): ask about your enrollments in your own AI assistant through the MCP server, and turn findings into analyze rules - [Get started](https://www.autopilotmonitor.com/get-started/): first steps for a new tenant - [Plans](https://www.autopilotmonitor.com/plans/): Community vs. Pro - [Get Pro](https://www.autopilotmonitor.com/buy/): purchase options and the free 30-day trial @@ -33,8 +33,8 @@ Autopilot Monitor is for IT administrators, helpdesk engineers, and managed serv - [AI integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp) - [Settings reference](https://docs.autopilotmonitor.com/reference/settings) - [Plans](https://docs.autopilotmonitor.com/plans) -- [FAQ](https://docs.autopilotmonitor.com/troubleshooting/faq) -- [Security & privacy FAQ](https://docs.autopilotmonitor.com/trust/security-faq): data residency, isolation, sub-processors +- [FAQ](https://docs.autopilotmonitor.com/troubleshooting-and-support/faq) +- [Security & privacy FAQ](https://docs.autopilotmonitor.com/trust-and-security/security-faq): data residency, isolation, sub-processors - [Platform changelog](https://docs.autopilotmonitor.com/changelog/platform-changelog) - [Agent changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) diff --git a/tools/quality/jscpd-baseline.json b/tools/quality/jscpd-baseline.json index cc9b5fcd..8783f976 100644 --- a/tools/quality/jscpd-baseline.json +++ b/tools/quality/jscpd-baseline.json @@ -17,7 +17,6 @@ "1c01d7e0e959731d": 1, "1c3538550cb144a3": 1, "1d7bffc15ccf7436": 1, - "1ee4eba599bac9b4": 1, "29bbb90f9b9e306a": 1, "2a985532f2e52066": 1, "2eead0be45077bd5": 1,