mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Landing, /ai and portal: the evidence loop
- Landing: section evidence_loop right after the stats ("Never debug the same failure
twice." — capture, detect, investigate, learn), hero sub-line, Compare row "Unknown
failures", capabilities tile. The terminal demo continues with the rule step: validate_rule
and both test_analyze_rule verdicts are the real results of a dry-run against the demo's
two sessions, anonymized like the rest; the window scrolls with the newest line.
- /ai: "Facts first. Raw logs when it matters." with the way back to the raw line; new
section rules ("From finding to rule") with the anonymized trace of the same dry-run;
the step card is shared with Connect.
- Portal: a failed session without any rule finding offers the AI investigation in Analysis
and Guided Diagnosis (the debug-session prompt for clients with MCP prompts, a copyable
full prompt for every other client). A failed session without a diagnostics package on a
tenant without a usable upload links to the diagnostics settings. The analyze-rule editor
tests a draft against a session on the existing dry-run route; create, save and test now
share one rule body.
- llms.txt (evidence loop, two docs links without a redirect), about FAQ, marketing guard
sections, three tracking events; jscpd baseline -1 for the removed payload clone.
D-348
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
<AiHero />
|
||||
<StructuredFacts />
|
||||
<QuestionsSection />
|
||||
<RuleLoopSection />
|
||||
<ArchitectureSection />
|
||||
<ConnectSection />
|
||||
<ClosingCta />
|
||||
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
>
|
||||
<AnalyzeRuleFormFields form={editForm} setForm={setEditForm} showRuleId={false} />
|
||||
</FormJsonToggle>
|
||||
<RuleDryRunPanel idPrefix={`edit-${rule.ruleId}`} getDraft={() => editorDraft(jsonModeEdit, jsonText, editForm, rule)} />
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
|
||||
<button onClick={onCancelEdit} disabled={saving} className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium">Cancel</button>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<Outcome | null>(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<DryRunAnalyzeRuleResponse>(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<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 });
|
||||
} catch (e) {
|
||||
setOutcome(null);
|
||||
setError(apiErrorText(e, "The test could not run."));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label htmlFor={inputId} className="flex min-w-0 flex-1 flex-col gap-1 text-xs font-medium text-gray-700">
|
||||
Test against a session
|
||||
<input
|
||||
id={inputId}
|
||||
value={sessionId}
|
||||
onChange={(e) => setSessionId(e.target.value)}
|
||||
placeholder="Session ID"
|
||||
spellCheck={false}
|
||||
className="w-full max-w-md rounded-md border border-gray-300 bg-white px-3 py-1.5 font-mono text-xs text-gray-900"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
disabled={running}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{running ? "Testing…" : "Run test"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-gray-500">
|
||||
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.
|
||||
</p>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-red-700">{error}</p>}
|
||||
{outcome && <DryRunResult outcome={outcome} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DryRunResult({ outcome }: { outcome: Outcome }) {
|
||||
const { result, explanation } = outcome;
|
||||
const fired = result.verdict === "fired";
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${fired ? "bg-sky-100 text-sky-800 dark:text-sky-200" : "bg-gray-100 text-gray-700"}`}>
|
||||
{verdictLabel(result.verdict)}
|
||||
</span>
|
||||
<span className="text-gray-700">Confidence {confidenceSummary(result)}</span>
|
||||
<span className="text-gray-400" aria-hidden="true">·</span>
|
||||
<span className="text-gray-500 tabular-nums">{result.eventCount} events</span>
|
||||
{fired && (
|
||||
<>
|
||||
<span className="text-gray-400" aria-hidden="true">·</span>
|
||||
<span className="text-gray-500">
|
||||
{result.wouldMarkSessionAsFailed ? "Would mark the session as failed" : "Does not mark the session as failed"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.preconditions.length > 0 && (
|
||||
<ul className="mt-3 space-y-1 text-xs text-gray-600">
|
||||
{result.preconditions.map((p, i) => (
|
||||
<li key={i}>
|
||||
Precondition <span className="font-mono">{[p.eventType, p.dataField, p.operator, p.value].filter(Boolean).join(" ")}</span>
|
||||
{p.passed ? " — passed" : " — failed, the rule was skipped"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ul className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200 bg-white text-xs">
|
||||
{result.conditions.map((c) => {
|
||||
const evidence = evidenceText(c);
|
||||
return (
|
||||
<li key={c.signal} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-3 py-2">
|
||||
<span className={`font-bold ${c.matched ? "text-green-700" : "text-gray-400"}`} aria-hidden="true">
|
||||
{c.matched ? "✓" : "✗"}
|
||||
</span>
|
||||
<span className="sr-only">{c.matched ? "Matched:" : "Not matched:"}</span>
|
||||
<span className="font-mono font-medium text-gray-900">{c.signal}</span>
|
||||
<span className="text-gray-500">
|
||||
{[c.eventType ?? c.source, c.required ? "required" : "optional", eventCountText(c)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
{evidence && <span className="basis-full pl-5 text-gray-600 break-words">{evidence}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{fired && explanation && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
>
|
||||
<AnalyzeRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} existingRuleIds={rulesList.map(r => r.ruleId)} />
|
||||
</FormJsonToggle>
|
||||
<RuleDryRunPanel idPrefix="create" getDraft={() => editorDraft(jsonModeCreate, jsonText, newRule)} />
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
|
||||
<button onClick={() => { setShowCreateForm(false); setJsonModeCreate(false); setJsonError(null); setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }} disabled={creating} className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium">Cancel</button>
|
||||
<button onClick={() => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { RuleDryRun, RuleDryRunCondition } from "@/lib/generated/wire-types.generated";
|
||||
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). */
|
||||
export type DryRunDraft = { rule: Record<string, unknown> } | { error: string };
|
||||
|
||||
/**
|
||||
* The draft a dry-run sends, read from whichever mode the editor is in — the same way Save reads it: JSON
|
||||
* mode parses the text (merged onto the form when editing), and an edit keeps the stored rule's other
|
||||
* fields underneath the form's.
|
||||
*/
|
||||
export function editorDraft(jsonMode: boolean, jsonText: string, form: RuleForm, stored?: AnalyzeRule): DryRunDraft {
|
||||
let source = form;
|
||||
if (jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as PastedAnalyzeJson;
|
||||
source = jsonToForm(stored ? { ...form, ...parsed } : parsed);
|
||||
} catch (e) {
|
||||
return { error: `Fix the JSON first: ${e instanceof Error ? e.message : "it does not parse"}.` };
|
||||
}
|
||||
}
|
||||
return { rule: { ...(stored ?? {}), ...formToDryRunRule(source) } };
|
||||
}
|
||||
|
||||
/** 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",
|
||||
skipped_by_precondition: "Skipped by a precondition",
|
||||
required_condition_not_met: "A required condition did not match",
|
||||
no_conditions_matched: "No condition matched",
|
||||
below_confidence_threshold: "Below the confidence threshold",
|
||||
no_events: "The session has no events",
|
||||
};
|
||||
|
||||
export function verdictLabel(verdict: string): string {
|
||||
return VERDICT_LABELS[verdict] ?? verdict;
|
||||
}
|
||||
|
||||
/**
|
||||
* The confidence line: "85 (base 70 + 15, threshold 70)", or why there is no score — the engine stops
|
||||
* before the confidence stage when a precondition or a required condition fails.
|
||||
*/
|
||||
export function confidenceSummary(result: RuleDryRun): string {
|
||||
if (result.finalConfidence == null) return `not scored (threshold ${result.confidenceThreshold})`;
|
||||
const bonus = result.confidenceFactors.filter(f => f.matched).reduce((sum, f) => sum + f.weight, 0);
|
||||
const parts = bonus > 0 ? `base ${result.baseConfidence} + ${bonus}` : `base ${result.baseConfidence}`;
|
||||
return `${result.finalConfidence} (${parts}, threshold ${result.confidenceThreshold})`;
|
||||
}
|
||||
|
||||
const MAX_EVIDENCE_CHARS = 240;
|
||||
|
||||
/**
|
||||
* One line of evidence for a condition: the matched value, or the evaluator's reason when it missed
|
||||
* (the wire carries a dictionary for a match and a string for a miss).
|
||||
*/
|
||||
export function evidenceText(condition: RuleDryRunCondition): string | null {
|
||||
const evidence = condition.evidence;
|
||||
if (evidence == null) return null;
|
||||
let text: string;
|
||||
if (typeof evidence === "string") {
|
||||
text = evidence;
|
||||
} else if (typeof evidence === "object" && "value" in evidence && (evidence as { value: unknown }).value != null) {
|
||||
const value = (evidence as { value: unknown }).value;
|
||||
text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
} else {
|
||||
text = JSON.stringify(evidence);
|
||||
}
|
||||
return text.length > MAX_EVIDENCE_CHARS ? `${text.slice(0, MAX_EVIDENCE_CHARS - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** "1 event" / "0 events"; null when the condition names no event type. */
|
||||
export function eventCountText(condition: RuleDryRunCondition): string | null {
|
||||
if (condition.matchingEventCount == null) return null;
|
||||
return `${condition.matchingEventCount} ${condition.matchingEventCount === 1 ? "event" : "events"}`;
|
||||
}
|
||||
@@ -303,6 +303,39 @@ export function formToEvaluateOn(form: RuleForm): string[] | undefined {
|
||||
return triggers.length > 0 ? triggers : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule body every write builds from the form: create, save and the dry-run send the same fields,
|
||||
* trimmed and with half-filled list rows dropped, so a test runs exactly what a save would store.
|
||||
*/
|
||||
export function formRuleBody(form: RuleForm) {
|
||||
return {
|
||||
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()),
|
||||
};
|
||||
}
|
||||
|
||||
/** The draft a dry-run sends: the stored body plus the fields the engine reads besides it. */
|
||||
export function formToDryRunRule(form: RuleForm) {
|
||||
return {
|
||||
...formRuleBody(form),
|
||||
ruleId: form.ruleId.trim() || "DRAFT",
|
||||
evaluateOn: formToEvaluateOn(form),
|
||||
markSessionAsFailedDefault: form.markSessionAsFailedDefault ?? false,
|
||||
tags: form.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side pre-flight for the on_event list (the backend enforces the same rules on save):
|
||||
* lowercase snake_case event types, none of the hard-blocked high-frequency telemetry types.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { isGuid } from "@/lib/inputValidation";
|
||||
import { safeHttpUrl } from "@/lib/safeDocUrl";
|
||||
import { DocsLink } from "@/components/DocsLink";
|
||||
import { InvestigateWithAiHint } from "@/components/InvestigateWithAiHint";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type { GetRuleResultsResponse, GetSessionEventsResponse, GetSessionResponse } from "@/lib/generated/wire-types.generated";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
@@ -41,6 +42,8 @@ function DiagnosisContent() {
|
||||
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
|
||||
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
|
||||
// Any stored finding, resolved ones included: only a session without any may say "no rule flagged it".
|
||||
const [hasAnyRuleResult, setHasAnyRuleResult] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedResult, setExpandedResult] = useState<string | null>(null);
|
||||
const [showEvidence, setShowEvidence] = useState<string | null>(null);
|
||||
@@ -64,7 +67,7 @@ function DiagnosisContent() {
|
||||
|
||||
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
|
||||
const { tenantId } = useTenant();
|
||||
const { getAccessToken } = useAuth();
|
||||
const { getAccessToken, user } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
@@ -120,6 +123,7 @@ function DiagnosisContent() {
|
||||
const data = await fetchJson<GetRuleResultsResponse>(api.sessions.analysis(sessionId, effectiveTenantId), getAccessToken);
|
||||
{
|
||||
if (data.results) {
|
||||
setHasAnyRuleResult(data.results.length > 0);
|
||||
// Resolved findings (no longer detected by a later evaluation) are audit-only —
|
||||
// the diagnosis flow must not present them as the probable cause.
|
||||
setAnalysisResults(
|
||||
@@ -268,6 +272,9 @@ function DiagnosisContent() {
|
||||
}
|
||||
|
||||
const primaryResult = analysisResults[0] || null;
|
||||
// Global Admin / MSP drill-in into another tenant's session: the copied prompt names the tenant, and
|
||||
// the actions that stay in the viewer's own tenant (dry-run as Tenant Admin, Report Session) drop out.
|
||||
const crossTenant = !!sessionTenantId && !!tenantId && sessionTenantId.toLowerCase() !== tenantId.toLowerCase();
|
||||
const otherResults = analysisResults.slice(1);
|
||||
const primaryDocUrl = safeHttpUrl(primaryResult?.relatedDocs?.[0]?.url);
|
||||
|
||||
@@ -369,13 +376,26 @@ function DiagnosisContent() {
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No Diagnosis Available
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-4">
|
||||
{session?.status === "InProgress"
|
||||
? "Analysis results will appear here as they are generated during the enrollment."
|
||||
: session?.status === "Succeeded"
|
||||
? "This session completed successfully. No issues were detected."
|
||||
: "No analysis rules matched for this session. Check the event timeline for more details."}
|
||||
</p>
|
||||
{session?.status === "Failed" && !hasAnyRuleResult ? (
|
||||
<div className="mx-auto mb-4 max-w-lg">
|
||||
<InvestigateWithAiHint
|
||||
sessionId={sessionId}
|
||||
tenantId={crossTenant ? sessionTenantId ?? undefined : undefined}
|
||||
mcpAccess={user?.hasMcpAccess ?? false}
|
||||
canDryRun={(user?.isGlobalAdmin ?? false) || (user?.isGlobalReader ?? false) || ((user?.isTenantAdmin ?? false) && !crossTenant)}
|
||||
canReport={!crossTenant && ((user?.isTenantAdmin ?? false) || user?.role === "Operator" || (user?.isGlobalAdmin ?? false))}
|
||||
source="diagnosis"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 mb-4">
|
||||
{session?.status === "InProgress"
|
||||
? "Analysis results will appear here as they are generated during the enrollment."
|
||||
: session?.status === "Succeeded"
|
||||
? "This session completed successfully. No issues were detected."
|
||||
: "No analysis rules matched for this session. Check the event timeline for more details."}
|
||||
</p>
|
||||
)}
|
||||
{errorEvents.length > 0 && (
|
||||
<div className="mt-6 text-left max-w-lg mx-auto">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { RuleResult } from "@/types";
|
||||
@@ -36,6 +36,9 @@ interface AnalysisResultsSectionProps {
|
||||
* drill-in) the rule-stats fetch switches to the global route scoped to THIS tenant, so the
|
||||
* hit-rate and fleet-context numbers describe the session's fleet — not the viewer's. */
|
||||
sessionTenantId?: string;
|
||||
/** Replaces the generic "No issues detected yet" text when the page knows better (a failed session no
|
||||
* rule explains gets the way to an AI investigation instead of a dead end). */
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
export default function AnalysisResultsSection({
|
||||
@@ -47,6 +50,7 @@ export default function AnalysisResultsSection({
|
||||
persistFailureRuleIds,
|
||||
canReanalyze = true,
|
||||
sessionTenantId,
|
||||
emptyState,
|
||||
}: AnalysisResultsSectionProps) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { tenantId } = useTenant();
|
||||
@@ -187,6 +191,8 @@ export default function AnalysisResultsSection({
|
||||
)}
|
||||
{loadingAnalysis && analysisResults.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">Running analysis...</div>
|
||||
) : openResults.length === 0 && resolvedResults.length === 0 && emptyState ? (
|
||||
emptyState
|
||||
) : openResults.length === 0 && resolvedResults.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">
|
||||
{canReanalyze
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { route } from "@/lib/routes";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
|
||||
/**
|
||||
* Shown on a failed session without a diagnostics package when the tenant has no usable upload
|
||||
* (sessionHintsLogic.showDiagnosticsSetupHint). Points to the settings instead of a one-click switch:
|
||||
* the destination is a deliberate choice (own storage or hosted, with its disclosure), and a finished
|
||||
* session has nothing to collect any more.
|
||||
*/
|
||||
export function DiagnosticsSetupHint({ canConfigure }: { canConfigure: boolean }) {
|
||||
return (
|
||||
<div className="mt-4 flex items-start gap-2 px-3 py-2 rounded-lg bg-sky-50 border border-sky-200 text-sm text-sky-800 dark:bg-sky-900/30 dark:border-sky-800 dark:text-sky-200">
|
||||
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
<strong>No diagnostics package for this failed enrollment</strong>
|
||||
{" — diagnostics upload isn't set up. With Upload Mode “On Failure Only”, the next failed enrollment keeps its IME and agent logs. "}
|
||||
{canConfigure ? (
|
||||
<Link
|
||||
href={route("/settings/agent/diagnostics")}
|
||||
onClick={() => trackEvent("session_diag_hint_settings_clicked")}
|
||||
className="font-medium underline hover:no-underline"
|
||||
>
|
||||
Open diagnostics settings
|
||||
</Link>
|
||||
) : (
|
||||
"A Tenant Admin can turn it on under Settings → Agent → Diagnostics Package."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Session } from "@/types";
|
||||
import { SessionStatusBadge } from "@/components/SessionStatusBadge";
|
||||
import FailureSnapshotBlock from "./FailureSnapshotBlock";
|
||||
@@ -16,9 +16,11 @@ interface SessionInfoCardProps {
|
||||
configMgrDetected?: { ccmVersion?: string; ccmServiceState?: string; siteCode?: string; confidenceScore?: number } | null;
|
||||
/** Sleep/standby seconds inside the enrollment window (system_sleep_episode ground truth) — explains an inflated wall-clock duration. */
|
||||
standbySeconds?: number | null;
|
||||
/** A page-decided hint rendered after the card's own hints (e.g. the missing-diagnostics hint of a failed session). */
|
||||
footerHint?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected, standbySeconds }: SessionInfoCardProps) {
|
||||
export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected, standbySeconds, footerHint }: SessionInfoCardProps) {
|
||||
const lastContactTooltip = session.lastEventAt
|
||||
? `Last contact: ${new Date(session.lastEventAt).toLocaleString([], { dateStyle: "short", timeStyle: "medium" })}`
|
||||
: undefined;
|
||||
@@ -145,6 +147,7 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{footerHint}
|
||||
<FailureSnapshotBlock failureSnapshotJson={session.failureSnapshotJson} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { DiagnosticsSetupHint } from "../DiagnosticsSetupHint";
|
||||
|
||||
const render = (canConfigure: boolean) => renderToStaticMarkup(createElement(DiagnosticsSetupHint, { canConfigure }));
|
||||
|
||||
describe("DiagnosticsSetupHint", () => {
|
||||
it("says why the package is missing and what On Failure Only would keep", () => {
|
||||
const html = render(true);
|
||||
expect(html).toContain("No diagnostics package for this failed enrollment");
|
||||
expect(html).toContain("On Failure Only");
|
||||
expect(html).toContain("IME and agent logs");
|
||||
});
|
||||
|
||||
it("links an admin to the diagnostics settings, never to a one-click switch", () => {
|
||||
const html = render(true);
|
||||
expect(html).toContain('href="/settings/agent/diagnostics"');
|
||||
expect(html).toContain("Open diagnostics settings");
|
||||
});
|
||||
|
||||
it("tells everyone else who can turn it on", () => {
|
||||
const html = render(false);
|
||||
expect(html).not.toContain("href=");
|
||||
expect(html).toContain("A Tenant Admin can turn it on under Settings → Agent → Diagnostics Package.");
|
||||
});
|
||||
|
||||
it("uses the sky info family of the session view's hints", () => {
|
||||
const html = render(true);
|
||||
expect(html).toContain("bg-sky-50");
|
||||
expect(html).not.toMatch(/bg-(amber|red|green)-/);
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
showDiagnosticsSetupHint,
|
||||
showInvestigateHint,
|
||||
type DiagnosticsSetupHintInput,
|
||||
type InvestigateHintInput,
|
||||
} from "../sessionHintsLogic";
|
||||
|
||||
const diag: DiagnosticsSetupHintInput = {
|
||||
status: "Failed",
|
||||
hasDiagnosticsPackage: false,
|
||||
featureFlagsLoaded: true,
|
||||
diagnosticsUploadConfigured: false,
|
||||
isCrossTenantView: false,
|
||||
isGatherRulesSession: false,
|
||||
};
|
||||
|
||||
describe("showDiagnosticsSetupHint", () => {
|
||||
it("shows on a failed enrollment without a package when the tenant has no usable upload", () => {
|
||||
expect(showDiagnosticsSetupHint(diag)).toBe(true);
|
||||
});
|
||||
|
||||
it.each<[string, Partial<DiagnosticsSetupHintInput>]>([
|
||||
["a succeeded session", { status: "Succeeded" }],
|
||||
["a session still running", { status: "InProgress" }],
|
||||
["a session that has a package", { hasDiagnosticsPackage: true }],
|
||||
["flags not loaded yet (or failed to load)", { featureFlagsLoaded: false }],
|
||||
["a tenant with a working upload", { diagnosticsUploadConfigured: true }],
|
||||
["a cross-tenant view", { isCrossTenantView: true }],
|
||||
["a gather-rules session", { isGatherRulesSession: true }],
|
||||
])("stays hidden for %s", (_label, patch) => {
|
||||
expect(showDiagnosticsSetupHint({ ...diag, ...patch })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const investigate: InvestigateHintInput = { status: "Failed", analysisLoaded: true, loadingAnalysis: false, findingCount: 0 };
|
||||
|
||||
describe("showInvestigateHint", () => {
|
||||
it("shows on a failed session whose loaded analysis has no finding", () => {
|
||||
expect(showInvestigateHint(investigate)).toBe(true);
|
||||
});
|
||||
|
||||
it.each<[string, Partial<InvestigateHintInput>]>([
|
||||
["a succeeded session", { status: "Succeeded" }],
|
||||
["an analysis that never loaded", { analysisLoaded: false }],
|
||||
["a running re-analysis", { loadingAnalysis: true }],
|
||||
["an open or resolved finding", { findingCount: 1 }],
|
||||
])("stays hidden for %s", (_label, patch) => {
|
||||
expect(showInvestigateHint({ ...investigate, ...patch })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Gates for the two "no dead end" hints on a failed session. Kept React-free so the full matrix is
|
||||
* testable; the page hands in what it already knows.
|
||||
*/
|
||||
|
||||
export interface DiagnosticsSetupHintInput {
|
||||
status: string | undefined;
|
||||
/** session.diagnosticsBlobName is set (truthiness — the backend omits null fields). */
|
||||
hasDiagnosticsPackage: boolean;
|
||||
/** The feature-flags read succeeded; before it (and on an error) the flag below is only a default. */
|
||||
featureFlagsLoaded: boolean;
|
||||
diagnosticsUploadConfigured: boolean;
|
||||
/** Global Admin / MSP drill-in: the viewer cannot act on another tenant's settings from here. */
|
||||
isCrossTenantView: boolean;
|
||||
isGatherRulesSession: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* "No diagnostics package — diagnostics upload isn't set up": only on a failed enrollment, only when the
|
||||
* tenant really has no usable upload (Off, or on without a working destination), never on a guess.
|
||||
*/
|
||||
export function showDiagnosticsSetupHint(input: DiagnosticsSetupHintInput): boolean {
|
||||
return (
|
||||
input.status === "Failed" &&
|
||||
!input.hasDiagnosticsPackage &&
|
||||
input.featureFlagsLoaded &&
|
||||
!input.diagnosticsUploadConfigured &&
|
||||
!input.isCrossTenantView &&
|
||||
!input.isGatherRulesSession
|
||||
);
|
||||
}
|
||||
|
||||
export interface InvestigateHintInput {
|
||||
status: string | undefined;
|
||||
/** At least one analysis read succeeded — an empty list before that (or after an error) proves nothing. */
|
||||
analysisLoaded: boolean;
|
||||
loadingAnalysis: boolean;
|
||||
/** Open plus resolved findings. */
|
||||
findingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "No analyze rule flagged this failure": replaces the generic empty state of a failed session. Worded so
|
||||
* it stays literally true while the end-of-enrollment analysis is still queued — findings that arrive
|
||||
* later refresh the section over SignalR and the hint disappears.
|
||||
*/
|
||||
export function showInvestigateHint(input: InvestigateHintInput): boolean {
|
||||
return input.status === "Failed" && input.analysisLoaded && !input.loadingAnalysis && input.findingCount === 0;
|
||||
}
|
||||
@@ -13,6 +13,9 @@ export function useSessionAnalysis(
|
||||
) {
|
||||
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
|
||||
// "unknown", not "no rule fired" — the failed-session hint must not claim the latter on a guess.
|
||||
const [analysisLoaded, setAnalysisLoaded] = useState(false);
|
||||
const [vulnerabilityReport, setVulnerabilityReport] = useState<unknown>(null);
|
||||
// Rule IDs whose results failed to persist during the most recent reanalyze.
|
||||
// Backend reports them in /sessions/{id}/analysis response so the UI can render
|
||||
@@ -35,6 +38,7 @@ export function useSessionAnalysis(
|
||||
// more StoreRuleResultAsync calls returned false during the reanalyze loop. We always
|
||||
// set it so a successful retry clears a previous warning.
|
||||
setPersistFailureRuleIds(Array.isArray(data.persistFailureRuleIds) ? data.persistFailureRuleIds : []);
|
||||
setAnalysisLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch analysis results:", error);
|
||||
} finally {
|
||||
@@ -72,6 +76,7 @@ export function useSessionAnalysis(
|
||||
return {
|
||||
analysisResults,
|
||||
loadingAnalysis,
|
||||
analysisLoaded,
|
||||
vulnerabilityReport,
|
||||
setVulnerabilityReport,
|
||||
fetchAnalysisResults,
|
||||
|
||||
@@ -11,6 +11,8 @@ interface UseSessionTenantConfigReturn {
|
||||
enableIntegrityBypassAnalyzer: boolean;
|
||||
/** Whether an on-demand diagnostics upload can succeed right now (drives the Collect Logs button). */
|
||||
diagnosticsUploadConfigured: boolean;
|
||||
/** True once the flags were actually read — before that (and after a failed read) the values are defaults. */
|
||||
featureFlagsLoaded: boolean;
|
||||
/** Optimistic flip after a successful quick-config from the session page — avoids a refetch round-trip. */
|
||||
markDiagnosticsConfigured: () => void;
|
||||
}
|
||||
@@ -28,6 +30,7 @@ export function useSessionTenantConfig(
|
||||
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
|
||||
const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true);
|
||||
const [diagnosticsUploadConfigured, setDiagnosticsUploadConfigured] = useState(false);
|
||||
const [featureFlagsLoaded, setFeatureFlagsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionTenantId) return;
|
||||
@@ -43,6 +46,7 @@ export function useSessionTenantConfig(
|
||||
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
|
||||
setEnableIntegrityBypassAnalyzer(cfg.enableIntegrityBypassAnalyzer ?? true);
|
||||
setDiagnosticsUploadConfigured(cfg.diagnosticsUploadConfigured ?? false);
|
||||
setFeatureFlagsLoaded(true);
|
||||
} catch { /* non-fatal */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
@@ -55,6 +59,7 @@ export function useSessionTenantConfig(
|
||||
enableSoftwareInventoryAnalyzer,
|
||||
enableIntegrityBypassAnalyzer,
|
||||
diagnosticsUploadConfigured,
|
||||
featureFlagsLoaded,
|
||||
markDiagnosticsConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ import TimeAttributionLane, { SessionTimeBreakdownDto } from "./components/TimeA
|
||||
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 { InvestigateWithAiHint } from "@/components/InvestigateWithAiHint";
|
||||
import VulnerabilityReportSection from "./components/VulnerabilityReportSection";
|
||||
import IntegrityBypassSection from "./components/IntegrityBypassSection";
|
||||
import AdminOverrideModal from "./components/AdminOverrideModal";
|
||||
@@ -201,7 +204,7 @@ function SessionDetailContent() {
|
||||
// Returns an empty map when the tenant hasn't granted DeviceManagementScripts.Read.All.
|
||||
// Map keys are "{Kind}:{Id}" -- the renderer uses lookupScriptDisplayName to read them.
|
||||
const scriptDisplayNamesByRefKey = useScriptDisplayNames(sessionTenantId ?? tenantId, events, getAccessToken);
|
||||
const { analysisResults, loadingAnalysis, vulnerabilityReport, fetchAnalysisResults, fetchVulnerabilityReport, persistFailureRuleIds } = analysis;
|
||||
const { analysisResults, loadingAnalysis, analysisLoaded, vulnerabilityReport, fetchAnalysisResults, fetchVulnerabilityReport, persistFailureRuleIds } = analysis;
|
||||
const { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer } = tenantConfig;
|
||||
const {
|
||||
filteredEvents,
|
||||
@@ -606,6 +609,14 @@ function SessionDetailContent() {
|
||||
ntpOffset={ntpOffset}
|
||||
configMgrDetected={configMgrDetected}
|
||||
standbySeconds={standbySeconds}
|
||||
footerHint={showDiagnosticsSetupHint({
|
||||
status: session.status,
|
||||
hasDiagnosticsPackage: !!session.diagnosticsBlobName,
|
||||
featureFlagsLoaded: tenantConfig.featureFlagsLoaded,
|
||||
diagnosticsUploadConfigured: tenantConfig.diagnosticsUploadConfigured,
|
||||
isCrossTenantView,
|
||||
isGatherRulesSession,
|
||||
}) ? <DiagnosticsSetupHint canConfigure={(user?.isTenantAdmin ?? false) || (user?.isGlobalAdmin ?? false)} /> : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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,
|
||||
}) ? (
|
||||
<InvestigateWithAiHint
|
||||
sessionId={sessionId}
|
||||
tenantId={isCrossTenantView ? viewedTenantId : undefined}
|
||||
mcpAccess={user?.hasMcpAccess ?? false}
|
||||
// The dry-run route resolves another tenant's session only for a platform role; a Tenant
|
||||
// Admin's dry-run stays in their own tenant.
|
||||
canDryRun={(user?.isGlobalAdmin ?? false) || (user?.isGlobalReader ?? false) || ((user?.isTenantAdmin ?? false) && !isCrossTenantView)}
|
||||
canReport={canReportSession && !isReadOnlyView}
|
||||
source="analysis"
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-left">
|
||||
<p className="text-sm font-semibold text-gray-900">No analyze rule flagged this failure.</p>
|
||||
{mcpAccess ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-gray-600">The evidence is still here. Investigate it with your AI assistant:</p>
|
||||
<ul className="mt-3 space-y-2 text-sm text-gray-700">
|
||||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>
|
||||
Clients with MCP prompts: run <code className={CODE}>debug-session</code> for session
|
||||
</span>
|
||||
<code className={CODE}>{sessionId}</code>
|
||||
<button type="button" onClick={() => onCopy(sessionId, "session_id")} className={COPY_LINK}>
|
||||
{copied === "session_id" ? "Copied" : "Copy ID"}
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>Any other client:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(buildInvestigationPrompt({ sessionId, tenantId, canDryRun }), "prompt")}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-white border border-sky-300 text-sky-700 dark:text-sky-300 rounded-lg hover:bg-sky-50"
|
||||
>
|
||||
{copied === "prompt" ? "Copied" : "Copy full prompt"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
{"Not connected yet? "}
|
||||
<Link href={route("/settings/tenant/ai-integration")} className="underline hover:no-underline">
|
||||
Set up your AI assistant
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-gray-600">Check the event timeline for the first error and what led up to it.</p>
|
||||
)}
|
||||
{canReport && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Something the product should detect on its own? Send it to the maintainers with Report Session.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Parameters<typeof InvestigateWithAiHint>[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)-/);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<li className="rounded-2xl border border-[var(--lp-line)] bg-[var(--lp-bg)] p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--lp-accent)] text-sm font-bold text-white">
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[15px] font-semibold text-[var(--lp-ink)]">{title}</h3>
|
||||
<div className="mt-1 text-sm leading-relaxed text-[var(--lp-ink-soft)]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
import { Step } from "./Step";
|
||||
|
||||
export function ConnectSection() {
|
||||
return (
|
||||
|
||||
@@ -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<TraceTone, string> = {
|
||||
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 (
|
||||
<section
|
||||
id="rules"
|
||||
data-track-section="rules"
|
||||
className="scroll-mt-20 border-t border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-6 py-20 sm:py-24"
|
||||
>
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 items-start gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-14">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Close the loop</p>
|
||||
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">From finding to rule.</h2>
|
||||
<p className="mt-4 text-lg leading-relaxed text-[var(--lp-ink-soft)]">
|
||||
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.
|
||||
</p>
|
||||
<ol className="mt-8 grid grid-cols-1 gap-4">
|
||||
<Step n={1} title="Investigate">
|
||||
The built-in <span className="font-mono text-[12.5px]">debug-session</span> 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.
|
||||
</Step>
|
||||
<Step n={2} title="Draft and dry-run">
|
||||
<span className="font-mono text-[12.5px]">validate_rule</span> checks the draft.{" "}
|
||||
<span className="font-mono text-[12.5px]">test_analyze_rule</span> replays it against real sessions and saves
|
||||
nothing. Dry-runs need the Tenant Admin role.
|
||||
</Step>
|
||||
<Step n={3} title="Switch it on, or share it">
|
||||
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.
|
||||
</Step>
|
||||
</ol>
|
||||
<a
|
||||
href={`${DOCS_URL}${DOCS_PATHS.aiRuleAuthoring}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-track="rules_docs"
|
||||
className="mt-6 inline-flex items-center gap-2 text-[15px] font-semibold text-[var(--lp-accent-ink)] hover:opacity-80"
|
||||
>
|
||||
AI-assisted rule authoring guide
|
||||
<ArrowRightIcon />
|
||||
</a>
|
||||
</div>
|
||||
<figure className="m-0 min-w-0 lg:sticky lg:top-24">
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--lp-term-line)] bg-[var(--lp-term-bg)] shadow-2xl shadow-black/20">
|
||||
<div className="border-b border-[var(--lp-term-line)] bg-[var(--lp-term-deep)] px-4 py-2.5">
|
||||
<span className="block truncate font-mono text-[11px] text-[var(--lp-term-faint)]">
|
||||
ANALYZE-CUSTOM-110 · Unmapped installer exit code stopped the ESP
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto p-4 font-mono text-[11.5px] leading-[1.7] sm:p-5">
|
||||
{TRACE.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`m-0 ${line.wrap ? "whitespace-pre-wrap" : "whitespace-pre"} ${line.gap ? "mt-3" : ""} ${TONE[line.tone]}`}
|
||||
>
|
||||
{line.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="mt-3 text-[13px] text-[var(--lp-ink-faint)]">From a real dry-run, anonymized.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<li className="rounded-2xl border border-[var(--lp-line)] bg-[var(--lp-bg)] p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--lp-accent)] text-sm font-bold text-white">
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[15px] font-semibold text-[var(--lp-ink)]">{title}</h3>
|
||||
<div className="mt-1 text-sm leading-relaxed text-[var(--lp-ink-soft)]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -50,11 +50,12 @@ export function StructuredFacts() {
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Structured from the start</p>
|
||||
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-[var(--lp-ink)] sm:text-4xl">
|
||||
Your assistant reads facts, not log files.
|
||||
Facts first. Raw logs when it matters.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-relaxed text-[var(--lp-ink-soft)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +88,17 @@ export function StructuredFacts() {
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
{/* The way back: from the answer to the line it came from, when the facts are not enough. */}
|
||||
<div className="mt-5 flex items-center gap-3 text-[13px] text-[var(--lp-ink-soft)]">
|
||||
<ArrowRightIcon className="h-5 w-5 shrink-0 rotate-180 text-[var(--lp-accent-ink)]" />
|
||||
<span aria-hidden="true" className="hidden flex-1 border-t-2 border-dashed border-[var(--lp-accent-line)] lg:block" />
|
||||
<p className="m-0 min-w-0">
|
||||
<span className="font-mono font-semibold text-[var(--lp-ink)]">get_session_diagnostics</span>
|
||||
{" — back to the original line, for clients that work with files"}
|
||||
</p>
|
||||
<span aria-hidden="true" className="hidden flex-1 border-t-2 border-dashed border-[var(--lp-accent-line)] lg:block" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-14 grid grid-cols-1 gap-x-10 gap-y-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{DOMAINS.map(domain => (
|
||||
<li key={domain.title} className="flex items-start gap-3">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
<div className={`h-full rounded-2xl border p-4 ${frame}`}>
|
||||
<p className={`flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] ${label}`}>
|
||||
<span>
|
||||
{index + 1} · {station.title}
|
||||
</span>
|
||||
<span className="font-mono normal-case tracking-normal">{station.tag}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[var(--lp-ink-soft)]">{station.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<li aria-hidden="true" className={`flex items-center justify-center text-[var(--lp-ink-faint)] ${cell}`}>
|
||||
<ArrowRightIcon className={`w-5 h-5 ${turn}`} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function NextTime({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 font-semibold text-[var(--lp-accent-ink)] ${className}`}>
|
||||
<RepeatIcon className="w-3.5 h-3.5 shrink-0" />
|
||||
Next time: detected automatically
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "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 (
|
||||
<section
|
||||
id="evidence"
|
||||
data-track-section="evidence_loop"
|
||||
className="py-16 sm:py-20 px-6 scroll-mt-20 border-y border-[var(--lp-line-soft)] bg-[var(--lp-surface)]"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto grid lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-10 lg:gap-14 items-center">
|
||||
<Reveal className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--lp-accent-ink)]">Evidence first</p>
|
||||
<h2 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)] text-balance">
|
||||
Never debug the same failure twice.
|
||||
</h2>
|
||||
<p className="mt-4 text-[16px] text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-5 text-[16px] font-semibold text-[var(--lp-ink)]">
|
||||
The first failure becomes evidence. The next one is a known problem.
|
||||
</p>
|
||||
<Link
|
||||
href="/ai#rules"
|
||||
prefetch={false}
|
||||
data-track="evidence_loop_ai"
|
||||
className="mt-5 inline-flex items-center gap-1.5 text-[15px] font-semibold text-[var(--lp-accent-ink)] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
How the AI digs in
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delayMs={120} className="min-w-0">
|
||||
{/* Wide: a 2×2 loop. DOM order follows the loop for screen readers; grid placement draws the circle. */}
|
||||
<ol className="hidden sm:grid grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1fr)] grid-rows-[auto_2.75rem_auto]">
|
||||
<li className="col-start-1 row-start-1"><StationCard station={capture} index={0} /></li>
|
||||
<Connector turn="" cell="col-start-2 row-start-1" />
|
||||
<li className="col-start-3 row-start-1"><StationCard station={detect} index={1} /></li>
|
||||
<Connector turn="rotate-90" cell="col-start-3 row-start-2" />
|
||||
<li className="col-start-3 row-start-3"><StationCard station={investigate} index={2} /></li>
|
||||
<Connector turn="rotate-180" cell="col-start-2 row-start-3" />
|
||||
<li className="col-start-1 row-start-3"><StationCard station={learn} index={3} /></li>
|
||||
<Connector turn="-rotate-90" cell="col-start-1 row-start-2" />
|
||||
<li className="col-start-2 row-start-2 self-center justify-self-center whitespace-nowrap text-[11px]">
|
||||
<NextTime />
|
||||
</li>
|
||||
</ol>
|
||||
{/* Phones: the same loop as a column, closed by the way back. */}
|
||||
<ol className="sm:hidden flex flex-col gap-2">
|
||||
{STATIONS.map((station, i) => (
|
||||
<li key={station.title} className="flex flex-col gap-2">
|
||||
{i > 0 && (
|
||||
<span aria-hidden="true" className="flex justify-center text-[var(--lp-ink-faint)]">
|
||||
<ArrowRightIcon className="w-5 h-5 rotate-90" />
|
||||
</span>
|
||||
)}
|
||||
<StationCard station={station} index={i} />
|
||||
</li>
|
||||
))}
|
||||
<li className="flex justify-center pt-1 text-[12px]">
|
||||
<NextTime />
|
||||
</li>
|
||||
</ol>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -49,8 +49,8 @@ export function Hero() {
|
||||
{/* Sub left, CTAs right */}
|
||||
<div className="mt-8 flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
|
||||
<p className="text-lg sm:text-xl text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
|
||||
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.
|
||||
</p>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -13,8 +13,8 @@ export function JustAsk() {
|
||||
Then just ask.
|
||||
</h2>
|
||||
<p className="mt-3 text-[15px] text-[var(--lp-ink-soft)] leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<Link
|
||||
href="/ai"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AuthGate } from "./AuthGate";
|
||||
import { LandingNavbar } from "./LandingNavbar";
|
||||
import { Hero } from "./Hero";
|
||||
import { StatsBand } from "./StatsBand";
|
||||
import { EvidenceLoop } from "./EvidenceLoop";
|
||||
import { JustAsk } from "./JustAsk";
|
||||
import { Story } from "./Story";
|
||||
import { CapabilitiesStrip } from "./CapabilitiesStrip";
|
||||
@@ -32,6 +33,7 @@ export function LandingPage({ statsSnapshot }: { statsSnapshot: PlatformStatsSna
|
||||
<LandingNavbar />
|
||||
<Hero />
|
||||
<StatsBand snapshot={statsSnapshot} />
|
||||
<EvidenceLoop />
|
||||
<JustAsk />
|
||||
<Story />
|
||||
<CapabilitiesStrip />
|
||||
|
||||
@@ -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<HTMLDivElement>(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
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 sm:px-5 py-4 font-mono text-[11px] sm:text-[12.5px] leading-[1.7] h-[430px] sm:h-[460px] overflow-y-hidden overflow-x-auto">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`px-4 sm:px-5 py-4 font-mono text-[11px] sm:text-[12.5px] leading-[1.7] h-[430px] sm:h-[460px] overflow-x-auto ${reduced ? "overflow-y-auto" : "overflow-y-hidden"}`}
|
||||
>
|
||||
{SCRIPT.map(renderStep)}
|
||||
{finished && !reduced && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user