diff --git a/src/Web/autopilot-monitor-web/app/about/faq.ts b/src/Web/autopilot-monitor-web/app/about/faq.ts index 63057862..8e41550c 100644 --- a/src/Web/autopilot-monitor-web/app/about/faq.ts +++ b/src/Web/autopilot-monitor-web/app/about/faq.ts @@ -82,7 +82,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [ { question: "Can I ask an AI assistant about my enrollments?", answer: - "Yes. Autopilot Monitor exposes a Model Context Protocol (MCP) server. Connect Claude Desktop, VS Code with Claude, or any MCP client that supports Streamable HTTP with OAuth, and ask questions like \"show me all failed enrollments from the last 24 hours\" or \"why did this session fail?\". Access follows your portal role and is scoped to your tenant, with usage limits tied to the tenant's plan.", + "Yes. Autopilot Monitor exposes a Model Context Protocol (MCP) server. Connect Claude, ChatGPT, VS Code with GitHub Copilot, or a command-line client such as Claude Code, Codex or Gemini CLI, and ask questions like \"show me all failed enrollments from the last 24 hours\" or \"why did this session fail?\". An AI client your organization hosts itself is registered once by a Tenant Admin. Access follows your portal role, with usage limits tied to the tenant's plan.", }, { question: "Who builds and operates Autopilot Monitor?", diff --git a/src/Web/autopilot-monitor-web/app/about/layout.tsx b/src/Web/autopilot-monitor-web/app/about/layout.tsx index df6f8dbb..f386bbe8 100644 --- a/src/Web/autopilot-monitor-web/app/about/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/about/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "About – Real-Time Windows Autopilot Monitoring", @@ -26,12 +27,14 @@ export const metadata: Metadata = { "Autopilot Monitor overview", ], openGraph: { + ...OPEN_GRAPH_BASE, title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring", description: "Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster — built by Oliver Kieselbach.", url: `${SITE_URL}/about`, }, twitter: { + ...TWITTER_BASE, title: "About Autopilot Monitor – Real-Time Windows Autopilot Monitoring", description: "Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments. Track every phase, run analyze rules, and resolve issues faster.", diff --git a/src/Web/autopilot-monitor-web/app/ai/layout.tsx b/src/Web/autopilot-monitor-web/app/ai/layout.tsx new file mode 100644 index 00000000..6a5a8ddb --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/ai/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata"; + +const SHARE_TITLE = "AI Analysis for Windows Autopilot – Bring Your Own AI"; +const SHARE_DESCRIPTION = + "Ask your Autopilot enrollments anything in Claude, ChatGPT, VS Code, a terminal agent or a self-hosted client. Read-only, Microsoft sign-in, included in every plan."; + +export const metadata: Metadata = { + title: "AI Analysis – Ask Your Autopilot Enrollments", + description: + "Connect Claude, ChatGPT, VS Code with GitHub Copilot, a terminal agent or a self-hosted client to the Autopilot Monitor MCP server and ask about your Windows Autopilot enrollments in plain language. Read-only, Microsoft sign-in, included in every plan.", + keywords: [ + "Windows Autopilot AI analysis", + "Autopilot MCP server", + "Model Context Protocol Intune", + "Intune AI assistant", + "Claude Autopilot troubleshooting", + "ChatGPT Intune enrollment", + "GitHub Copilot MCP Intune", + "Autopilot enrollment root cause AI", + "bring your own AI", + ], + openGraph: { + ...OPEN_GRAPH_BASE, + title: SHARE_TITLE, + description: SHARE_DESCRIPTION, + url: `${SITE_URL}/ai`, + }, + twitter: { + ...TWITTER_BASE, + title: SHARE_TITLE, + description: SHARE_DESCRIPTION, + }, + alternates: { + canonical: `${SITE_URL}/ai`, + }, +}; + +export default function AiLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/Web/autopilot-monitor-web/app/ai/page.tsx b/src/Web/autopilot-monitor-web/app/ai/page.tsx new file mode 100644 index 00000000..1f494dea --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/ai/page.tsx @@ -0,0 +1,32 @@ +import { LandingNavbar } from "../../components/landing/LandingNavbar"; +import { SiteFooter } from "../../components/SiteFooter"; +import { MarketingTracker } from "../../components/MarketingTracker"; +import { AiHero } from "../../components/ai/AiHero"; +import { StructuredFacts } from "../../components/ai/StructuredFacts"; +import { QuestionsSection } from "../../components/ai/QuestionsSection"; +import { ArchitectureSection } from "../../components/ai/ArchitectureSection"; +import { ConnectSection } from "../../components/ai/ConnectSection"; +import { ClosingCta } from "../../components/ai/ClosingCta"; + +/** + * AI analysis with MCP: what you can ask, where the model runs, how to connect. Every claim on the + * page must hold against the MCP access model and the customer docs (D-305). + */ +export default function AiPage() { + return ( +
+ + +
+ + + + + + +
+ +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/buy/layout.tsx b/src/Web/autopilot-monitor-web/app/buy/layout.tsx index 6e03601f..07a94ae0 100644 --- a/src/Web/autopilot-monitor-web/app/buy/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/buy/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Get Pro – Purchase Options", @@ -13,12 +14,14 @@ export const metadata: Metadata = { "Windows Autopilot monitoring Pro", ], openGraph: { + ...OPEN_GRAPH_BASE, title: "Get Pro – Autopilot Monitor", description: "How to purchase Autopilot Monitor Pro: through Microsoft Marketplace or online via Cleverbridge.", url: `${SITE_URL}/buy`, }, twitter: { + ...TWITTER_BASE, title: "Get Pro – Autopilot Monitor", description: "How to purchase Autopilot Monitor Pro: through Microsoft Marketplace or online via Cleverbridge.", diff --git a/src/Web/autopilot-monitor-web/app/events/__tests__/events.guard.test.ts b/src/Web/autopilot-monitor-web/app/events/__tests__/events.guard.test.ts index 5c359478..83da0879 100644 --- a/src/Web/autopilot-monitor-web/app/events/__tests__/events.guard.test.ts +++ b/src/Web/autopilot-monitor-web/app/events/__tests__/events.guard.test.ts @@ -67,7 +67,9 @@ describe("event follow-up pages", () => { it("mark every call to action for the click event", async () => { for (const event of FOLLOWUP_EVENTS) { const html = await renderEventPage(event.slug); - const ids = [...html.matchAll(/data-track="([^"]+)"/g)].map((m) => m[1]); + // SiteFooter carries the marketing ids of the shared footer (footer_*); it sits outside the + // event tracker, so those clicks never become event_followup_clicked. + const ids = [...html.matchAll(/data-track="([^"]+)"/g)].map((m) => m[1]).filter((id) => !id.startsWith("footer_")); expect(ids.sort()).toEqual( [ "logo", diff --git a/src/Web/autopilot-monitor-web/app/events/eventClicks.ts b/src/Web/autopilot-monitor-web/app/events/eventClicks.ts index 28b43fd9..8f04c2c2 100644 --- a/src/Web/autopilot-monitor-web/app/events/eventClicks.ts +++ b/src/Web/autopilot-monitor-web/app/events/eventClicks.ts @@ -1,11 +1,4 @@ /** The one custom event the event follow-up pages send: properties `event` (slug) and `button`. */ export const EVENT_FOLLOWUP_CLICKED = "event_followup_clicked"; -interface TrackableTarget { - closest(selector: string): { getAttribute(name: string): string | null } | null; -} - -/** The data-track id of the clicked element or its nearest marked ancestor, else null. */ -export function trackIdOf(target: TrackableTarget | null): string | null { - return target?.closest("[data-track]")?.getAttribute("data-track") || null; -} +export { trackIdOf } from "@/lib/clickTracking"; diff --git a/src/Web/autopilot-monitor-web/app/get-started/page.tsx b/src/Web/autopilot-monitor-web/app/get-started/page.tsx index fc770ebc..fe64e186 100644 --- a/src/Web/autopilot-monitor-web/app/get-started/page.tsx +++ b/src/Web/autopilot-monitor-web/app/get-started/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { LandingNavbar } from "../../components/landing/LandingNavbar"; import { SignupConsentCta } from "../../components/landing/SignupConsentCta"; import { SiteFooter } from "../../components/SiteFooter"; +import { MarketingTracker } from "../../components/MarketingTracker"; import { DOCS_URL, SITE_URL } from "@/utils/config"; export const metadata: Metadata = { @@ -49,69 +50,73 @@ const STEPS = [ export default function GetStartedPage() { return (
- + + -
-
-

- Get started -

-

- From sign-in to your first live enrollment. -

-

- Five steps, no infrastructure on your side. Most teams see their first live session - the same day. -

+
+
+

+ Get started +

+

+ From sign-in to your first live enrollment. +

+

+ Five steps, no infrastructure on your side. Most teams see their first live session + the same day. +

- {/* Step sequence */} -
- + + {/* CTA — sign-in waits for the Terms + DPA tick */} + + + Read the docs + + + +

+ When you need more later: the{" "} + + Pro plan + {" "} + adds SLAs, support commitments, MSP delegation, higher operating limits and more — + same service, nothing to migrate. +

+
- {/* CTA — sign-in waits for the Terms + DPA tick */} - - - Read the docs - - - -

- When you need more later: the{" "} - - Pro plan - {" "} - adds SLAs, support commitments, MSP delegation, higher operating limits and more — - same service, nothing to migrate. -

-
-
- - + +
); } diff --git a/src/Web/autopilot-monitor-web/app/globals.css b/src/Web/autopilot-monitor-web/app/globals.css index 03ba9986..9369131e 100644 --- a/src/Web/autopilot-monitor-web/app/globals.css +++ b/src/Web/autopilot-monitor-web/app/globals.css @@ -48,14 +48,20 @@ html { --lp-accent-line: rgba(51, 177, 97, 0.35); --lp-warn: #d97706; --lp-warn-soft: rgba(217, 119, 6, 0.1); + /* Warning text on light surfaces: --lp-warn is too light for body-size text (contrast) */ + --lp-warn-ink: #b45309; --lp-danger: #dc2626; /* GitHub star glyph only (GitHubStarLink) — never text or a fill */ --lp-star: #eab308; /* Terminal is dark in both themes */ --lp-term-bg: #0d1424; + --lp-term-deep: #0a101d; --lp-term-line: #22304a; --lp-term-ink: #d6e1f0; --lp-term-faint: #7b8ba3; + --lp-term-ok: #55c57f; + --lp-term-warn: #f5a623; + --lp-term-high: #ff9d66; } .dark { @@ -74,6 +80,7 @@ html { --lp-accent-line: rgba(59, 192, 108, 0.4); --lp-warn: #f5a623; --lp-warn-soft: rgba(245, 166, 35, 0.12); + --lp-warn-ink: #f5a623; --lp-danger: #f87171; --lp-star: #facc15; } @@ -764,6 +771,7 @@ html.auth-pending .landing-v2::after { --lp-accent-line: rgba(51, 177, 97, 0.35); --lp-warn: #d97706; --lp-warn-soft: rgba(217, 119, 6, 0.1); + --lp-warn-ink: #b45309; --lp-danger: #dc2626; --lp-star: #eab308; } @@ -892,6 +900,72 @@ html.auth-pending .landing-v2::after { } } +/* ===== AI page data-path diagram (components/ai/DataPath.tsx) ===== + The nodes stack on phones and sit in one row from lg, so every link turns with them. The + conversation link draws its dashes with a gradient: a thin dashed border with a radius renders as + a solid line, and the link must keep the data links' box or the dots have no track to run on. */ +.ai-flow-line { + position: relative; + flex-shrink: 0; + width: 2px; + height: 56px; + border-radius: 2px; + background: var(--lp-accent); +} +.ai-flow-line--talk { + border-radius: 0; + background: repeating-linear-gradient(to bottom, var(--lp-ink-faint) 0 4px, transparent 4px 8px); +} +.ai-flow-dot { + position: absolute; + left: -3px; + top: 0; + width: 8px; + height: 8px; + border-radius: 9999px; + background: var(--lp-accent); + box-shadow: 0 0 0 3px rgba(51, 177, 97, 0.18); + animation: aiFlowDown 2.4s linear infinite; +} +.ai-flow-dot--back { + animation-name: aiFlowUp; + animation-delay: -1.2s; + background: var(--lp-accent-ink); +} +.ai-flow-line--talk .ai-flow-dot { + background: var(--lp-ink-faint); + box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.2); +} +@keyframes aiFlowDown { from { top: -4px; } to { top: calc(100% - 4px); } } +@keyframes aiFlowUp { from { top: calc(100% - 4px); } to { top: -4px; } } +@keyframes aiFlowRight { from { left: -4px; } to { left: calc(100% - 4px); } } +@keyframes aiFlowLeft { from { left: calc(100% - 4px); } to { left: -4px; } } +@media (min-width: 1024px) { + .ai-flow-line { + width: 100%; + height: 2px; + } + .ai-flow-line--talk { + background: repeating-linear-gradient(to right, var(--lp-ink-faint) 0 6px, transparent 6px 12px); + } + .ai-flow-dot { + top: -3px; + left: 0; + animation-name: aiFlowRight; + } + .ai-flow-dot--back { + animation-name: aiFlowLeft; + } +} +@media (prefers-reduced-motion: reduce) { + .ai-flow-dot { + animation: none; + } + .ai-flow-dot--back { + display: none; + } +} + /* ===== FEEDBACK BUBBLE ANIMATION ===== */ @keyframes fadeInUp { from { diff --git a/src/Web/autopilot-monitor-web/app/help/layout.tsx b/src/Web/autopilot-monitor-web/app/help/layout.tsx index 1538905e..e5cdf9e6 100644 --- a/src/Web/autopilot-monitor-web/app/help/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/help/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Help & Support – How to Get Help", @@ -14,12 +15,14 @@ export const metadata: Metadata = { "Oliver Kieselbach", ], openGraph: { + ...OPEN_GRAPH_BASE, title: "Help & Support – Autopilot Monitor", description: "How to get help with Autopilot Monitor: open a GitHub issue or reach out directly on LinkedIn.", url: `${SITE_URL}/help`, }, twitter: { + ...TWITTER_BASE, title: "Help & Support – Autopilot Monitor", description: "How to get help with Autopilot Monitor: open a GitHub issue or reach out directly on LinkedIn.", diff --git a/src/Web/autopilot-monitor-web/app/page.tsx b/src/Web/autopilot-monitor-web/app/page.tsx index f14de38a..a05267c3 100644 --- a/src/Web/autopilot-monitor-web/app/page.tsx +++ b/src/Web/autopilot-monitor-web/app/page.tsx @@ -10,6 +10,7 @@ import { HowItWorks } from "../components/landing/HowItWorks"; import { Testimonials } from "../components/landing/Testimonials"; import { FinalCta } from "../components/landing/FinalCta"; import { SiteFooter } from "../components/SiteFooter"; +import { MarketingTracker } from "../components/MarketingTracker"; /** * Landing page v2 — a scroll story of one enrollment. @@ -22,17 +23,19 @@ export default function LandingPage() {
{/* Client component: handles auth redirect + loading overlay */} - - - - - - - - - - - + + + + + + + + + + + + +
); } diff --git a/src/Web/autopilot-monitor-web/app/plans/layout.tsx b/src/Web/autopilot-monitor-web/app/plans/layout.tsx index 941eafae..ffb1f9d3 100644 --- a/src/Web/autopilot-monitor-web/app/plans/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/plans/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE, TWITTER_BASE } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Plans – Community & Pro", @@ -14,12 +15,14 @@ export const metadata: Metadata = { "Autopilot monitoring MSP", ], openGraph: { + ...OPEN_GRAPH_BASE, title: "Plans – Autopilot Monitor", description: "Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.", url: `${SITE_URL}/plans`, }, twitter: { + ...TWITTER_BASE, title: "Plans – Autopilot Monitor", description: "Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.", diff --git a/src/Web/autopilot-monitor-web/app/privacy/layout.tsx b/src/Web/autopilot-monitor-web/app/privacy/layout.tsx index 986bc8af..e8aa21cc 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/layout.tsx @@ -1,11 +1,13 @@ import type { Metadata } from "next"; import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Privacy Policy", description: "Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data when using the Windows Autopilot monitoring platform.", openGraph: { + ...OPEN_GRAPH_BASE, title: "Privacy Policy | Autopilot Monitor", description: "Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data.", diff --git a/src/Web/autopilot-monitor-web/app/robots.ts b/src/Web/autopilot-monitor-web/app/robots.ts index 7b7d6065..8c53e30e 100644 --- a/src/Web/autopilot-monitor-web/app/robots.ts +++ b/src/Web/autopilot-monitor-web/app/robots.ts @@ -5,7 +5,7 @@ import type { MetadataRoute } from "next"; import { SITE_URL } from "@/utils/config"; // Public marketing / legal pages. -const PUBLIC_PATHS = ["/", "/about", "/buy", "/get-started", "/help", "/plans", "/privacy", "/terms"]; +const PUBLIC_PATHS = ["/", "/about", "/ai", "/buy", "/get-started", "/help", "/plans", "/privacy", "/terms"]; // Authenticated portal routes: nothing to index, and the static shell would // only ever render a sign-in redirect. diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAiIntegration.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAiIntegration.tsx index ea896b53..3cc927ac 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAiIntegration.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAiIntegration.tsx @@ -8,6 +8,7 @@ import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { useCopy } from "@/hooks/useCopy"; import { MCP_SERVER_URL } from "@/utils/config"; import type { CreateMcpClientRegistrationRequest, @@ -21,20 +22,6 @@ function formatDay(iso: string | undefined | null): string { return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); } -function useCopy() { - const [copied, setCopied] = useState(null); - const copy = useCallback(async (value: string, key: string) => { - try { - await navigator.clipboard.writeText(value); - setCopied(key); - setTimeout(() => setCopied(null), 2000); - } catch { - setCopied(null); - } - }, []); - return { copied, copy }; -} - /** * Settings → Tenant → AI Integration. Part 1 explains the normal connection (Claude, ChatGPT, VS Code and * other supported AI clients: add the server URL, sign in — nothing to register here). Part 2, for Tenant diff --git a/src/Web/autopilot-monitor-web/app/sitemap.ts b/src/Web/autopilot-monitor-web/app/sitemap.ts index 7a3b3020..9319c878 100644 --- a/src/Web/autopilot-monitor-web/app/sitemap.ts +++ b/src/Web/autopilot-monitor-web/app/sitemap.ts @@ -33,6 +33,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "monthly", priority: 0.7, }, + { + url: sitePageUrl("/ai"), + lastModified: lastmod("/ai"), + changeFrequency: "monthly", + priority: 0.7, + }, { url: sitePageUrl("/get-started"), lastModified: lastmod("/get-started"), diff --git a/src/Web/autopilot-monitor-web/app/terms/layout.tsx b/src/Web/autopilot-monitor-web/app/terms/layout.tsx index 26ba6e51..40d50a94 100644 --- a/src/Web/autopilot-monitor-web/app/terms/layout.tsx +++ b/src/Web/autopilot-monitor-web/app/terms/layout.tsx @@ -1,11 +1,13 @@ import type { Metadata } from "next"; import { SITE_URL } from "@/utils/config"; +import { OPEN_GRAPH_BASE } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Terms of Use", description: "Terms of Use for Autopilot Monitor. Read the usage terms, conditions, and acceptable use policies for the Windows Autopilot monitoring and troubleshooting platform.", openGraph: { + ...OPEN_GRAPH_BASE, title: "Terms of Use | Autopilot Monitor", description: "Terms of Use for Autopilot Monitor. Read the usage terms, conditions, and acceptable use policies for the monitoring platform.", url: `${SITE_URL}/terms`, diff --git a/src/Web/autopilot-monitor-web/components/GitHubStarLink.tsx b/src/Web/autopilot-monitor-web/components/GitHubStarLink.tsx index 9e4c5a28..4577c3b0 100644 --- a/src/Web/autopilot-monitor-web/components/GitHubStarLink.tsx +++ b/src/Web/autopilot-monitor-web/components/GitHubStarLink.tsx @@ -6,10 +6,21 @@ import { GitHubIcon } from "./GitHubIcon"; * The counter segment only renders when the deploy baked a star count in (utils/githubStars.ts). * No hooks, so server components (Testimonials) and client components (LandingNavbar) share it. */ -export function GitHubStarLink({ label }: { label: string }) { +export function GitHubStarLink({ + label, + track, + labelClassName, +}: { + label: string; + /** data-track id for the anonymous marketing click count (components/MarketingTracker.tsx). */ + track?: string; + /** Lets a tight bar hide the word at some widths; the aria-label still names the link. */ + labelClassName?: string; +}) { return ( - {label} + {label} {GITHUB_STARS !== null && ( diff --git a/src/Web/autopilot-monitor-web/components/MarketingTracker.tsx b/src/Web/autopilot-monitor-web/components/MarketingTracker.tsx new file mode 100644 index 00000000..932e8814 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/MarketingTracker.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useRef, type MouseEvent, type ReactNode } from "react"; +import { trackEvent } from "@/lib/appInsights"; +import { + MARKETING_CTA_CLICKED, + MARKETING_SECTION_VIEWED, + isSectionSeen, + trackIdOf, +} from "@/lib/clickTracking"; + +const THRESHOLDS = [0, 0.1, 0.2, 1 / 3]; + +/** + * Anonymous usage counts for one public marketing page: one event per click on an element marked + * data-track="", and one per section marked data-track-section="" the first time it is + * seen in this page load. `display: contents` keeps the page layout as it is, and the sections + * stay server components. + */ +export function MarketingTracker({ page, children }: { page: string; children: ReactNode }) { + const ref = useRef(null); + + useEffect(() => { + const root = ref.current; + if (!root || typeof IntersectionObserver === "undefined") return; + const seen = new Set(); + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + const section = (entry.target as HTMLElement).dataset.trackSection; + if (!section || seen.has(section) || !entry.isIntersecting) continue; + if (!isSectionSeen(entry.intersectionRatio, entry.intersectionRect.height, window.innerHeight)) continue; + seen.add(section); + observer.unobserve(entry.target); + trackEvent(MARKETING_SECTION_VIEWED, { page, section }); + } + }, + { threshold: THRESHOLDS } + ); + root.querySelectorAll("[data-track-section]").forEach(el => observer.observe(el)); + return () => observer.disconnect(); + }, [page]); + + const onClick = (e: MouseEvent) => { + const button = trackIdOf(e.target as Element); + if (button) trackEvent(MARKETING_CTA_CLICKED, { page, button }); + }; + + return ( +
+ {children} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/SiteFooter.tsx b/src/Web/autopilot-monitor-web/components/SiteFooter.tsx index 91b4f945..71d4fa8f 100644 --- a/src/Web/autopilot-monitor-web/components/SiteFooter.tsx +++ b/src/Web/autopilot-monitor-web/components/SiteFooter.tsx @@ -7,34 +7,35 @@ const LINK_COLUMNS = [ { title: "Product", links: [ - { label: "The Story", href: "/#story" }, - { label: "Capabilities", href: "/#features" }, - { label: "Compare", href: "/#comparison" }, - { label: "Plans", href: "/plans" }, + { label: "The Story", href: "/#story", track: "story" }, + { label: "Capabilities", href: "/#features", track: "capabilities" }, + { label: "Compare", href: "/#comparison", track: "compare" }, + { label: "AI analysis", href: "/ai/", track: "ai" }, + { label: "Plans", href: "/plans", track: "plans" }, ], }, { title: "Resources", links: [ - { label: "Documentation", href: DOCS_URL, external: true }, - { label: "Help & Support", href: "/help" }, - { label: "Feedback", href: "https://github.com/okieselbach/AutopilotMonitor/issues", external: true }, - { label: "GitHub", href: GITHUB_REPO_URL, external: true }, + { label: "Documentation", href: DOCS_URL, external: true, track: "docs" }, + { label: "Help & Support", href: "/help", track: "help" }, + { label: "Feedback", href: "https://github.com/okieselbach/AutopilotMonitor/issues", external: true, track: "feedback" }, + { label: "GitHub", href: GITHUB_REPO_URL, external: true, track: "github" }, ], }, { title: "Company", links: [ - { label: "About", href: "/about" }, - { label: "glueckkanja AG", href: "https://www.glueckkanja.com", external: true }, + { label: "About", href: "/about", track: "about" }, + { label: "glueckkanja AG", href: "https://www.glueckkanja.com", external: true, track: "glueckkanja" }, ], }, { title: "Legal", links: [ - { label: "Privacy Policy", href: "/privacy" }, - { label: "Terms of Use", href: "/terms" }, - { label: "Imprint", href: "https://www.glueckkanja.com/en/imprint", external: true }, + { label: "Privacy Policy", href: "/privacy", track: "privacy" }, + { label: "Terms of Use", href: "/terms", track: "terms" }, + { label: "Imprint", href: "https://www.glueckkanja.com/en/imprint", external: true, track: "imprint" }, ], }, ]; @@ -63,6 +64,7 @@ export function SiteFooter() {
{link.label} 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 new file mode 100644 index 00000000..acaf80bd --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createElement, type ComponentType } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +// LandingNavbar and LoginButton read the auth context; the public pages render signed out. +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => ({ login: async () => undefined, isAuthenticated: false, user: null, getAccessToken: async () => null }), +})); +// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links. +vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null })); + +import LandingPage from "@/app/page"; +import GetStartedPage from "@/app/get-started/page"; +import AiPage from "@/app/ai/page"; +import robots from "@/app/robots"; +import sitemap from "@/app/sitemap"; +import { QUESTIONS } from "@/components/ai/questions"; +import { TRACK_ID_PATTERN, isSectionSeen, trackIdOf } from "@/lib/clickTracking"; +import { sitePageUrl } from "@/utils/config"; + +/** + * The UX report counts clicks and section views on the marketing pages (D-305). A link or button + * without a data-track id is invisible there, so every one carries an id; the What's new bell is + * the exception, it sends whats_new_opened itself. + */ + +const render = (Page: ComponentType) => renderToStaticMarkup(createElement(Page)); +const trackIds = (html: string) => [...html.matchAll(/data-track="([^"]+)"/g)].map(m => m[1]); +const sectionIds = (html: string) => [...html.matchAll(/data-track-section="([^"]+)"/g)].map(m => m[1]); +const untracked = (html: string) => + [...html.matchAll(/<(?:a|button)\b[^>]*>/g)] + .map(m => m[0]) + .filter(tag => !/\sdata-track="/.test(tag) && !/aria-label="What's new"/.test(tag)); + +const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [ + { + name: "landing", + Page: LandingPage, + sections: ["hero", "stats", "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"] }, +]; + +describe("marketing page tracking", () => { + for (const { name, Page, sections } of PAGES) { + it(`${name}: every link and button carries a valid, unique data-track id`, () => { + const html = render(Page); + expect(untracked(html)).toEqual([]); + const ids = trackIds(html); + for (const id of ids) expect(id).toMatch(TRACK_ID_PATTERN); + expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]); + }); + + it(`${name}: marks its sections`, () => { + expect(sectionIds(render(Page))).toEqual(sections); + }); + } + + it("the landing links to the AI page from the navbar, Then just ask and the footer", () => { + const ids = trackIds(render(LandingPage)); + expect(ids).toEqual(expect.arrayContaining(["nav_ai", "just_ask_ai", "footer_ai"])); + }); + + it("the AI page carries every question text in its static HTML", () => { + const html = render(AiPage); + const escape = (text: string) => text.replace(/&/g, "&").replace(/'/g, "'"); + for (const q of QUESTIONS) expect(html, q.id).toContain(escape(q.text)); + }); + + it("the AI page is in the sitemap, robots.txt and llms.txt", () => { + expect(sitemap().map(e => e.url)).toContain(sitePageUrl("/ai")); + const allow = robots().rules; + expect(Array.isArray(allow) ? allow[0].allow : allow.allow).toContain("/ai"); + const llms = readFileSync(join(__dirname, "..", "..", "public", "llms.txt"), "utf-8"); + expect(llms).toContain(sitePageUrl("/ai")); + }); +}); + +describe("clickTracking helpers", () => { + it("reads the id of the nearest marked element", () => { + const target = { closest: (selector: string) => (selector === "[data-track]" ? { getAttribute: () => "nav_ai" } : null) }; + expect(trackIdOf(target)).toBe("nav_ai"); + expect(trackIdOf({ closest: () => null })).toBeNull(); + expect(trackIdOf(null)).toBeNull(); + }); + + it("counts a section once a third is visible, or half the viewport for tall sections", () => { + expect(isSectionSeen(0.34, 100, 800)).toBe(true); + expect(isSectionSeen(0.2, 420, 800)).toBe(true); + expect(isSectionSeen(0.2, 200, 800)).toBe(false); + expect(isSectionSeen(0, 0, 0)).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/components/ai/AiHero.tsx b/src/Web/autopilot-monitor-web/components/ai/AiHero.tsx new file mode 100644 index 00000000..c9d2fc07 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/AiHero.tsx @@ -0,0 +1,52 @@ +import { AskBar } from "./AskBar"; + +/** Clients documented in the customer docs (integrations/ai-integration-mcp.md, "Supported AI clients"). */ +const CLIENTS = ["Claude", "ChatGPT", "VS Code & GitHub Copilot", "Claude Code", "Codex", "Gemini CLI", "Self-hosted clients"]; + +export function AiHero() { + return ( +
+
+

AI analysis with MCP

+

+ Ask your enrollments anything. +

+

+ Autopilot Monitor records every enrollment as structured data. Connect the AI assistant you already use and ask + in plain language, about one device or the whole fleet. +

+
+
+ +
+
+ Works in + {CLIENTS.map(client => ( + + {client} + + ))} +
+
+ + Connect your assistant + + + See example questions + +
+

Included in every plan · read-only · Microsoft sign-in

+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/ArchitectureSection.tsx b/src/Web/autopilot-monitor-web/components/ai/ArchitectureSection.tsx new file mode 100644 index 00000000..451f4a0d --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/ArchitectureSection.tsx @@ -0,0 +1,47 @@ +import { DataPath } from "./DataPath"; +import { CheckChip } from "./icons"; + +/** Each line is backed by the MCP access model: READ_ONLY tool annotations, Entra OAuth, portal-role authorization per request (D-239, D-283). */ +const GUARDRAILS = [ + { title: "Read-only tools", text: "Your assistant reads. Sessions, rules and settings stay untouched." }, + { title: "Microsoft sign-in", text: "Work accounts through Microsoft Entra ID. There are no API keys to hand out." }, + { title: "Your portal role", text: "The assistant sees exactly what the person's role allows in the portal." }, + { title: "Access follows the role", text: "Remove a role or block an account, and access ends within minutes." }, +]; + +export function ArchitectureSection() { + return ( +
+
+
+

Bring your own AI

+

+ We provide the data. You pick the AI. +

+

+ MCP is an open standard for connecting AI assistants to data. Use the assistant your organization already + approved, wherever its model runs. +

+
+ + + +
    + {GUARDRAILS.map(item => ( +
  • + +
    +

    {item.title}

    +

    {item.text}

    +
    +
  • + ))} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/AskBar.tsx b/src/Web/autopilot-monitor-web/components/ai/AskBar.tsx new file mode 100644 index 00000000..79b8b60f --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/AskBar.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useInViewActive } from "@/hooks/useInViewActive"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { ArrowRightIcon } from "./icons"; + +const QUESTIONS = [ + "Why did CONTOSO-3812 fail this morning?", + "Which app keeps device setup waiting the longest?", + "How many devices enrolled right the first time last month?", + "Which CVEs on new devices should we fix first?", + "Write this week's enrollment report for the change board.", +]; + +const HOLD_MS = 2800; +const ERASE_MS = 16; +const TYPE_MS = 34; +const NEXT_MS = 260; + +type Phase = "hold" | "erase" | "type"; + +/** + * The hero's "ask" bar: cycles through example questions with a typing effect and links to the + * gallery. The first question is prerendered and stays put under reduced motion; timers run only + * while the bar is on screen in a visible tab. + */ +export function AskBar() { + const ref = useRef(null); + const reduced = useMediaQuery("(prefers-reduced-motion: reduce)"); + const active = useInViewActive(ref, { startThreshold: 0.5, disabled: reduced }); + const [index, setIndex] = useState(0); + const [chars, setChars] = useState(QUESTIONS[0].length); + const [phase, setPhase] = useState("hold"); + + useEffect(() => { + if (!active) return; + let timer: number; + if (phase === "hold") { + timer = window.setTimeout(() => setPhase("erase"), HOLD_MS); + } else if (phase === "erase") { + timer = + chars > 0 + ? window.setTimeout(() => setChars(c => Math.max(0, c - 3)), ERASE_MS) + : window.setTimeout(() => { + setIndex(i => (i + 1) % QUESTIONS.length); + setPhase("type"); + }, NEXT_MS); + } else { + timer = + chars < QUESTIONS[index].length + ? window.setTimeout(() => setChars(c => c + 1), TYPE_MS) + : window.setTimeout(() => setPhase("hold"), 0); + } + return () => window.clearTimeout(timer); + }, [active, phase, chars, index]); + + return ( + + + + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/AssistantPanel.tsx b/src/Web/autopilot-monitor-web/components/ai/AssistantPanel.tsx new file mode 100644 index 00000000..f5ef5e39 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/AssistantPanel.tsx @@ -0,0 +1,402 @@ +import { + FILE_ACCESS_SKINS, + type Block, + type Question, + type Skin, + type Span, + type Tone, + type ToolCall, +} from "./questions"; + +/** + * One answer from the /ai gallery, drawn as a chat app, VS Code, a terminal agent or a self-hosted + * front end. Same content in every skin; only chrome and type change. Colors are lp-* tokens or + * arbitrary values: the app-wide `.dark` overrides would otherwise flip palette classes. + */ + +const TONE_LIGHT: Record = { + ok: "text-[var(--lp-accent-ink)]", + warn: "text-[var(--lp-warn-ink)]", + high: "text-[var(--lp-danger)]", + dim: "text-[var(--lp-ink-soft)]", +}; +const TONE_DARK: Record = { + ok: "text-[var(--lp-term-ok)]", + warn: "text-[var(--lp-term-warn)]", + high: "text-[var(--lp-term-high)]", + dim: "text-[var(--lp-term-faint)]", +}; + +interface SkinStyle { + dark: boolean; + frame: string; + head: string; + body: string; + user: string; + tools: string; + tool: string; + who: string | null; + input: string; + code: string; + list: string; + th: string; + td: string; + ticket: string; + ticketText: string; + note: string; +} + +const STYLES: Record = { + chat: { + dark: false, + frame: "grid-cols-1 border-[var(--lp-line)] bg-[var(--lp-surface)]", + head: "border-b border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)]", + body: "gap-3.5 text-[14.5px] leading-[1.6] text-[var(--lp-ink)]", + user: "self-end max-w-[82%] rounded-[18px] rounded-br-md bg-[var(--lp-surface-2)] px-3.5 py-2.5", + tools: "flex flex-wrap gap-1.5", + tool: "inline-flex items-center gap-1.5 rounded-full bg-[var(--lp-accent-soft)] px-2.5 py-1 font-mono text-[11.5px] text-[var(--lp-accent-ink)]", + who: "Assistant", + input: "rounded-[14px] border border-[var(--lp-line)] bg-[var(--lp-surface)] px-3.5 py-2.5 text-sm text-[var(--lp-ink-faint)]", + code: "rounded-[5px] bg-[var(--lp-surface-2)] px-[5px] py-px font-mono text-[0.88em]", + list: "list-disc pl-5", + th: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4 text-[11.5px] font-semibold text-[var(--lp-ink-faint)]", + td: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4", + ticket: "rounded-xl border border-[var(--lp-line)] bg-[var(--lp-bg)] px-3.5 py-3 text-[13.5px]", + ticketText: "text-[var(--lp-ink-soft)]", + note: "inline-flex items-center rounded-full border border-[var(--lp-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-ink-soft)]", + }, + vscode: { + dark: true, + frame: "grid-cols-[44px_minmax(0,1fr)] border-[var(--lp-term-line)] bg-[var(--lp-term-bg)]", + head: "border-b border-[var(--lp-term-line)] text-[var(--lp-term-faint)]", + body: "gap-3.5 text-[13.5px] leading-[1.6] text-[var(--lp-term-ink)]", + user: "rounded-lg border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.04)] px-3.5 py-2.5", + tools: "flex flex-col gap-[3px]", + tool: "text-xs text-[var(--lp-term-faint)]", + who: "Copilot", + input: "rounded-lg border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.04)] px-3.5 py-2.5 text-sm text-[var(--lp-term-faint)]", + code: "rounded-[5px] bg-[rgba(255,255,255,0.07)] px-[5px] py-px font-mono text-[0.88em]", + list: "list-disc pl-5", + th: "border-b border-[var(--lp-term-line)] py-1.5 pr-4 text-[11.5px] font-semibold text-[var(--lp-term-faint)]", + td: "border-b border-[var(--lp-term-line)] py-1.5 pr-4", + ticket: "rounded-xl border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.03)] px-3.5 py-3 text-[13.5px]", + ticketText: "text-[var(--lp-term-faint)]", + note: "inline-flex items-center rounded-full border border-[var(--lp-term-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-term-faint)]", + }, + terminal: { + dark: true, + frame: "grid-cols-1 border-[var(--lp-term-line)] bg-[var(--lp-term-bg)]", + head: "border-b border-[var(--lp-term-line)]", + body: "gap-2.5 font-mono text-[12.5px] leading-[1.7] text-[var(--lp-term-ink)]", + user: "", + tools: "flex flex-col pl-4", + tool: "break-all text-[var(--lp-term-faint)]", + who: null, + input: "font-mono text-[12.5px] text-[var(--lp-term-faint)]", + code: "", + list: "list-none", + th: "pr-[18px] font-normal text-[var(--lp-term-faint)]", + td: "pr-[18px]", + ticket: "rounded-xl border border-[var(--lp-term-line)] bg-[rgba(255,255,255,0.03)] px-3.5 py-3", + ticketText: "text-[var(--lp-term-faint)]", + note: "inline-flex items-center rounded-full border border-[var(--lp-term-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-term-faint)]", + }, + selfhosted: { + dark: false, + frame: "grid-cols-1 border-[var(--lp-line)] bg-[var(--lp-surface)]", + head: "border-b border-[var(--lp-line-soft)] bg-[var(--lp-surface)]", + body: "gap-3.5 text-[14.5px] leading-[1.6] text-[var(--lp-ink)]", + user: "self-end max-w-[82%] rounded-[18px] rounded-br-md bg-[var(--lp-accent-soft)] px-3.5 py-2.5 shadow-[inset_0_0_0_1px_var(--lp-accent-line)]", + tools: "flex flex-wrap gap-1.5", + tool: "inline-flex items-center gap-1.5 rounded-full bg-[var(--lp-surface-2)] px-2.5 py-1 font-mono text-[11.5px] text-[var(--lp-ink-soft)]", + who: "Assistant · your model", + input: "rounded-[14px] border border-[var(--lp-line)] bg-[var(--lp-surface)] px-3.5 py-2.5 text-sm text-[var(--lp-ink-faint)]", + code: "rounded-[5px] bg-[var(--lp-surface-2)] px-[5px] py-px font-mono text-[0.88em]", + list: "list-disc pl-5", + th: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4 text-[11.5px] font-semibold text-[var(--lp-ink-faint)]", + td: "border-b border-[var(--lp-line-soft)] py-1.5 pr-4", + ticket: "rounded-xl border border-[var(--lp-line)] bg-[var(--lp-bg)] px-3.5 py-3 text-[13.5px]", + ticketText: "text-[var(--lp-ink-soft)]", + note: "inline-flex items-center rounded-full border border-[var(--lp-line)] px-2.5 py-1 font-sans text-xs text-[var(--lp-ink-soft)]", + }, +}; + +const BAR_LIGHT = { base: "bg-[var(--lp-accent)]", warn: "bg-[var(--lp-warn)]", dim: "bg-[var(--lp-line)]" }; +const BAR_DARK = { base: "bg-[var(--lp-accent)]", warn: "bg-[var(--lp-term-warn)]", dim: "bg-[var(--lp-term-line)]" }; + +function toneClass(tone: Tone, skin: Skin): string { + return (STYLES[skin].dark ? TONE_DARK : TONE_LIGHT)[tone]; +} + +function SpanView({ span, skin }: { span: Span; skin: Skin }) { + if (typeof span === "string") return <>{span}; + const content = span.code ? {span.text} : span.text; + const classes = [span.tone ? toneClass(span.tone, skin) : "", span.bold ? "font-semibold" : ""].filter(Boolean).join(" "); + return classes ? {content} : <>{content}; +} + +function Spans({ spans, skin }: { spans: Span[]; skin: Skin }) { + return ( + <> + {spans.map((span, i) => ( + + ))} + + ); +} + +function TableView({ block, skin }: { block: Extract; skin: Skin }) { + const style = STYLES[skin]; + const numeric = new Set(block.numeric ?? []); + const hasBar = block.rows.some(row => row.bar); + const columns = (block.head?.length ?? Math.max(...block.rows.map(row => row.cells.length))) + (hasBar ? 1 : 0); + const bars = style.dark ? BAR_DARK : BAR_LIGHT; + const align = (i: number) => (numeric.has(i) ? "text-right tabular-nums" : "text-left"); + return ( +
+ + {block.head && ( + + + {block.head.map((heading, i) => ( + + ))} + {hasBar && + + )} + + {block.rows.map((row, ri) => ( + + {row.fullWidth ? ( + + ) : ( + <> + {row.cells.map((cell, ci) => ( + + ))} + {hasBar && ( + + )} + + )} + + ))} + +
+ {heading} + } +
+ + + + + {row.bar && ( + + )} +
+
+ ); +} + +function BlockView({ block, skin }: { block: Block; skin: Skin }) { + const style = STYLES[skin]; + switch (block.kind) { + case "p": + return ( +

+ +

+ ); + case "list": + return ( +
    + {block.items.map((item, i) => ( +
  • + {skin === "terminal" ? "- " : null} + +
  • + ))} +
+ ); + case "table": + return ; + case "ticket": + return ( +
+

{block.title}

+ {block.fields.map(field => ( +

+ {`${field.label}:`} + {` ${field.text}`} +

+ ))} +
+ ); + case "note": + return ( +

+ {block.text} +

+ ); + case "byFileAccess": + return ; + } +} + +function BlockList({ blocks, skin }: { blocks: Block[]; skin: Skin }) { + return ( +
+ {blocks.map((block, i) => ( + + ))} +
+ ); +} + +function ToolLine({ call, skin }: { call: ToolCall; skin: Skin }) { + const style = STYLES[skin]; + if (skin === "terminal") { + return ( + + {"✓ "} + {`${call.name}(${call.args ?? ""})`} + + ); + } + if (skin === "vscode") { + return ( + + {"✓ "} + {call.local ? "Ran terminal command: " : "Ran "} + {call.local ? call.args : call.name} + {call.local ? null : " · Autopilot Monitor (MCP Server)"} + + ); + } + return ( + + + {call.name} + + ); +} + +function PanelHead({ skin }: { skin: Skin }) { + const row = "flex min-h-[46px] flex-wrap items-center gap-x-2.5 gap-y-1.5 px-3.5 py-2.5 text-[13px]"; + switch (skin) { + case "chat": + return ( +
+ Enrollment questions + + + Autopilot Monitor + +
+ ); + case "vscode": + return ( +
+ Chat + Agent · Autopilot Monitor (MCP) +
+ ); + case "terminal": + return ( +
+
+ ); + case "selfhosted": + return ( +
+ + chat.contoso.internal + + + Model: your choice + + + Runs on your server + +
+ ); + } +} + +function InputLine({ skin }: { skin: Skin }) { + const style = STYLES[skin]; + if (skin === "terminal") { + return ( +
+ {"❯ "} + +
+ ); + } + return
{skin === "selfhosted" ? "Message your assistant…" : "Ask a follow-up…"}
; +} + +export function AssistantPanel({ skin, question }: { skin: Skin; question: Question }) { + const style = STYLES[skin]; + const calls = question.tools.filter(call => !call.local || FILE_ACCESS_SKINS.has(skin)); + return ( +
+ {skin === "vscode" && ( + + )} +
+
+ +
+
+ {skin === "terminal" ? ( +
+ {"❯ "} + {question.text} +
+ ) : ( +
{question.text}
+ )} +
+ {calls.map(call => ( + + ))} +
+
+ {style.who && ( +
+ {style.who} +
+ )} + +
+
+
+ +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx b/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx new file mode 100644 index 00000000..da68b0b9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; + +export function ClosingCta() { + return ( +
+
+

+ Your enrollment data has the answers. Start asking. +

+
+ + Get started + +

+ Free, open source, deployed in minutes. +
+ {"Need SLAs, support, or MSP delegation? "} + + There's a Pro plan + + . +

+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx b/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx new file mode 100644 index 00000000..7564b63e --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/ConnectSection.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; +import { DOCS_PATHS } from "@/lib/docsPaths"; +import { DOCS_URL } from "@/utils/config"; +import { CopyServerUrl } from "./CopyServerUrl"; +import { ArrowRightIcon } from "./icons"; + +function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { + return ( +
  • +
    + + {n} + +
    +

    {title}

    +
    {children}
    +
    +
    +
  • + ); +} + +export function ConnectSection() { + return ( +
    +
    +
    +

    Connect

    +

    Connected in a minute.

    +

    + There are no API keys and no local server to install. You need a role in your organization's Autopilot + Monitor tenant. +

    + + Setup guide for every client + + +
    +
      + + Paste this URL as a custom connector or MCP server in your client. + + + + Your browser opens the Microsoft sign-in. Use the work account you use for Autopilot Monitor. + + + {"For example: "} + {"“Which enrollments failed this week, and why?”"} + +
    +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/CopyServerUrl.tsx b/src/Web/autopilot-monitor-web/components/ai/CopyServerUrl.tsx new file mode 100644 index 00000000..c383264f --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/CopyServerUrl.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useCopy } from "@/hooks/useCopy"; +import { MCP_SERVER_URL } from "@/utils/config"; +import { CopyIcon } from "./icons"; + +export function CopyServerUrl() { + const { copied, copy } = useCopy(); + return ( +
    + {MCP_SERVER_URL} + +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/DataPath.tsx b/src/Web/autopilot-monitor-web/components/ai/DataPath.tsx new file mode 100644 index 00000000..bef9563d --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/DataPath.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { BrandMark } from "../BrandMark"; +import { + BuildingIcon, + ChatIcon, + ChipIcon, + CloudIcon, + CodeIcon, + LaptopIcon, + RepeatIcon, + ServerIcon, + TerminalIcon, +} from "./icons"; +import { SEGMENT_BUTTON, SEGMENT_GROUP, SEGMENT_OFF, SEGMENT_ON } from "./segmented"; + +type Placement = "vendor" | "devtools" | "own"; + +const PLACEMENTS: { + id: Placement; + label: string; + clients: string; + processedBy: string; + setup: string; +}[] = [ + { + id: "vendor", + label: "Your AI vendor", + clients: "Claude on web, desktop and mobile. ChatGPT.", + processedBy: "Your AI vendor, under the agreement your organization already has with them.", + setup: "Add the server URL as a connector and sign in with Microsoft. Nothing to register.", + }, + { + id: "devtools", + label: "Your developer tools", + clients: "VS Code with GitHub Copilot. Claude Code, Codex and Gemini CLI.", + processedBy: "The model behind your editor or terminal, under the policy that already covers your developer tools.", + setup: + "Add the server to the tool's MCP configuration and sign in. Terminal agents can also download the diagnostics package and search the logs on your machine.", + }, + { + id: "own", + label: "Your own infrastructure", + clients: "A chat front end your organization hosts, connected to a model you run. Or a scheduled report that runs without a person.", + processedBy: "Your own systems. The model can run in your cloud subscription or on your own hardware.", + setup: + "A Tenant Admin registers the client's callback URL once under Settings → Tenant → AI Integration. Only accounts of your tenant can sign in. Automation uses an app registration instead.", + }, +]; + +interface Row { + icon: ReactNode; + label: string; + sub: string; + placements: Placement[]; +} + +const CLIENT_ROWS: Row[] = [ + { icon: , label: "Chat app", sub: "Claude, ChatGPT", placements: ["vendor"] }, + { icon: , label: "Code editor", sub: "VS Code with GitHub Copilot", placements: ["devtools"] }, + { icon: , label: "Terminal agent", sub: "Claude Code, Codex, Gemini CLI", placements: ["devtools"] }, + { icon: , label: "Self-hosted front end", sub: "registered once by a Tenant Admin", placements: ["own"] }, + { icon: , label: "Automation", sub: "its own app identity", placements: ["own"] }, +]; + +const MODEL_ROWS: Row[] = [ + { icon: , label: "Your AI vendor's cloud", sub: "under your agreement with them", placements: ["vendor", "devtools"] }, + { icon: , label: "Your cloud subscription", sub: "a model you deploy yourself", placements: ["own"] }, + { icon: , label: "Your own hardware", sub: "a local model on your servers", placements: ["own"] }, +]; + +function Node({ tag, accent = false, children }: { tag: string; accent?: boolean; children: ReactNode }) { + return ( +
    + + {tag} + + {children} +
    + ); +} + +function NodeTitle({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( +
    + {icon} + {children} +
    + ); +} + +function Rows({ rows, placement }: { rows: Row[]; placement: Placement }) { + return ( +
    + {rows.map(row => { + const on = row.placements.includes(placement); + return ( +
    + {row.icon} + + {row.label} + {row.sub} + +
    + ); + })} +
    + ); +} + +/** One link of the diagram; talk = the conversation, which never passes Autopilot Monitor. */ +function FlowLink({ label, talk = false, both = false }: { label: string; talk?: boolean; both?: boolean }) { + return ( + + ); +} + +/** + * Where the model runs: devices → Autopilot Monitor (MCP) → the AI client → the model. Picking a + * placement highlights the matching clients and models and explains who processes the + * conversation and what to set up. + */ +export function DataPath() { + const [placement, setPlacement] = useState("vendor"); + const active = PLACEMENTS.find(p => p.id === placement) ?? PLACEMENTS[0]; + + return ( +
    +
    +
    +

    Where should the model run?

    +

    Pick one. The data and the tools stay the same.

    +
    +
    + {PLACEMENTS.map(p => ( + + ))} +
    +
    + +
    + + }>Your devices +

    The agent records each enrollment while it runs.

    +
    + + + }>Autopilot Monitor +
    + {["Enrollment data", "Analysis rules", "Error-code catalog", "Product docs"].map(chip => ( + + {chip} + + ))} +
    +
    + MCP server + read-only +
    +

    Answers tool calls. Calls no AI provider itself.

    +
    + + + }>Your AI client + + + + + }>The model + + +
    + +
    + + + Reaches Autopilot Monitor: telemetry from your devices, tool calls from your assistant. + + + + Never reaches Autopilot Monitor: your prompts and the model's answers. + +
    + +
    +
    +

    Typical clients

    +

    {active.clients}

    +
    +
    +

    Who processes the conversation

    +

    {active.processedBy}

    +
    +
    +

    What you set up

    +

    {active.setup}

    +
    +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/QuestionGallery.tsx b/src/Web/autopilot-monitor-web/components/ai/QuestionGallery.tsx new file mode 100644 index 00000000..b5d9c570 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/QuestionGallery.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import { AssistantPanel } from "./AssistantPanel"; +import { QUESTIONS, SKINS, TOPICS, type Skin, type TopicId } from "./questions"; +import { SEGMENT_BUTTON, SEGMENT_GROUP, SEGMENT_OFF, SEGMENT_ON } from "./segmented"; + +/** + * The /ai question gallery: pick a topic and a question, see the tool calls and the answer, and + * switch the client the answer is shown in. Every question text is in the static HTML; the + * inactive topic lists are only hidden. + */ +export function QuestionGallery() { + const [questionId, setQuestionId] = useState(QUESTIONS[0].id); + const [skin, setSkin] = useState("chat"); + const question = QUESTIONS.find(q => q.id === questionId) ?? QUESTIONS[0]; + const skinNote = SKINS.find(s => s.id === skin)?.note ?? ""; + + const selectTopic = (topic: TopicId) => { + const first = QUESTIONS.find(q => q.topic === topic); + if (first) setQuestionId(first.id); + }; + + return ( +
    +
    +
    + {TOPICS.map(topic => { + const selected = topic.id === question.topic; + return ( + + ); + })} +
    + + {TOPICS.map(topic => ( +
    + {QUESTIONS.filter(q => q.topic === topic.id).map(q => { + const pressed = q.id === question.id; + return ( + + ); + })} +
    + ))} + +

    + {"Built-in prompts such as "} + investigate-failed-session + {" and "} + cve-exposure-audit + {" give your assistant a head start."} +

    +
    + +
    +
    +

    {skinNote}

    +
    + {SKINS.map(s => ( + + ))} +
    +
    +
    + +
    +

    Sample data. The tools and the shape of each answer are real.

    +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/QuestionsSection.tsx b/src/Web/autopilot-monitor-web/components/ai/QuestionsSection.tsx new file mode 100644 index 00000000..4c47eb29 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/QuestionsSection.tsx @@ -0,0 +1,29 @@ +import { ArrowDownIcon } from "./icons"; +import { QuestionGallery } from "./QuestionGallery"; + +export function QuestionsSection() { + return ( +
    +
    +
    +

    Try it

    +

    + Questions teams ask every day. +

    +

    + Pick a question to see which tools the assistant calls and what comes back. All answers use sample data. +

    + + How the connection works + + +
    + +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx b/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx new file mode 100644 index 00000000..87725911 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/StructuredFacts.tsx @@ -0,0 +1,104 @@ +import { ArrowRightIcon, CheckChip } from "./icons"; + +const IME_LINE = + ''; + +const EVENT_FIELDS: [string, string][] = [ + ["event", "app_install_failed"], + ["app", "Contoso CRM 4.2"], + ["exit code", "60001 · no mapping"], + ["phase", "Device setup"], + ["attempt", "1"], + ["install time", "1 min 32 s"], +]; + +const DOMAINS = [ + { title: "Sessions and phases", text: "Status, duration, phase timeline and reboots of every enrollment." }, + { title: "App installs", text: "Attempts, exit codes and install times per app, plus Delivery Optimization." }, + { title: "Errors, decoded", text: "Error codes with their meaning, IME log matches and rule findings." }, + { title: "Devices and network", text: "Model, TPM, OS build, connection type and, if enabled, location." }, + { title: "Software and CVEs", text: "Optional inventory matched against known CVEs, ranked by KEV and EPSS." }, + { title: "Knowledge built in", text: "Analysis rules, the error-code catalog and the product docs." }, +]; + +function Caption({ label, meta, accent = false }: { label: string; meta: string; accent?: boolean }) { + const color = accent ? "text-[var(--lp-accent-ink)]" : "text-[var(--lp-ink-faint)]"; + return ( +
    + {label} + {meta} +
    + ); +} + +function PipeArrow() { + return ( + + ); +} + +export function StructuredFacts() { + return ( +
    +
    +
    +

    Structured from the start

    +

    + Your assistant reads facts, not log files. +

    +

    + 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. +

    +
    + +
    +
    + +
    +              {IME_LINE}
    +            
    +

    One line among thousands, spread over several log files.

    +
    + +
    + +
    + {EVENT_FIELDS.map(([key, value]) => ( +
    +
    {key}
    +
    {value}
    +
    + ))} +
    +
    + +
    + +
    + {"“Contoso CRM exited with 60001, a code the app has no mapping for, so the ESP stopped. The retry an hour later installed cleanly.”"} +
    +
    +
    + +
      + {DOMAINS.map(domain => ( +
    • + +
      +

      {domain.title}

      +

      {domain.text}

      +
      +
    • + ))} +
    +
    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/__tests__/questions.test.ts b/src/Web/autopilot-monitor-web/components/ai/__tests__/questions.test.ts new file mode 100644 index 00000000..587bfaa4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/__tests__/questions.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { QUESTIONS, SKINS, TOPICS, type Block } from "../questions"; +import { TRACK_ID_PATTERN } from "@/lib/clickTracking"; + +/** + * The /ai gallery says "the tools and the shape of each answer are real". These checks keep that + * true when the MCP server changes: every tool named exists, every tenant user has it, and every + * argument shown is one the tool declares. + */ + +const MCP_TOOLS_DIR = path.resolve(__dirname, "../../../../../McpServer/autopilot-monitor-mcp/src/tools"); + +interface Registration { + gate: string | null; + block: string; +} + +/** Every tool the MCP server registers, with the `if (…)` it is registered under and its source block. */ +function mcpRegistrations(): Map { + const registrations = new Map(); + const pattern = /(?:if\s*\(([^)]*)\)\s*)?server\.registerTool\(\s*['"]([a-z_]+)['"]/g; + for (const file of readdirSync(MCP_TOOLS_DIR).filter(f => f.endsWith(".ts"))) { + const source = readFileSync(path.join(MCP_TOOLS_DIR, file), "utf-8"); + const matches = [...source.matchAll(pattern)]; + matches.forEach((match, i) => { + const end = i + 1 < matches.length ? matches[i + 1].index : source.length; + registrations.set(match[2], { gate: match[1]?.trim() || null, block: source.slice(match.index, end) }); + }); + } + return registrations; +} + +/** Registered for every tenant member: no role gate, or only the exclusion of delegated admins. */ +const openToTenantUsers = (gate: string | null) => gate === null || gate === "!delegated"; + +function tablesOf(blocks: Block[]): Extract[] { + return blocks.flatMap(block => + block.kind === "table" + ? [block] + : block.kind === "byFileAccess" + ? [...tablesOf(block.withFiles), ...tablesOf(block.withoutFiles)] + : [] + ); +} + +describe("AI page question gallery", () => { + it("has unique question ids and at least one question per topic", () => { + const ids = QUESTIONS.map(q => q.id); + expect(new Set(ids).size).toBe(ids.length); + for (const topic of TOPICS) expect(QUESTIONS.some(q => q.topic === topic.id), topic.id).toBe(true); + }); + + it("gives every question an MCP tool call and an answer", () => { + for (const q of QUESTIONS) { + expect(q.tools.some(t => !t.local), q.id).toBe(true); + expect(q.answer.length, q.id).toBeGreaterThan(0); + } + }); + + it("keeps table rows as wide as their heading", () => { + for (const q of QUESTIONS) { + for (const table of tablesOf(q.answer)) { + const width = table.head?.length ?? table.rows[0].cells.length; + for (const row of table.rows.filter(r => !r.fullWidth)) expect(row.cells.length, q.id).toBe(width); + } + } + }); + + it("builds valid tracking ids from topics, questions and skins", () => { + const ids = [ + ...TOPICS.map(t => `topic:${t.id}`), + ...QUESTIONS.map(q => `question:${q.id}`), + ...SKINS.map(s => `skin:${s.id}`), + ]; + for (const id of ids) expect(id).toMatch(TRACK_ID_PATTERN); + }); +}); + +describe("MCP tools named on the AI page", () => { + const registrations = mcpRegistrations(); + + it("reads the server's registrations, gated and open", () => { + expect(registrations.size).toBeGreaterThanOrEqual(50); + expect(openToTenantUsers(registrations.get("get_session_summary")!.gate)).toBe(true); + expect(openToTenantUsers(registrations.get("query_table")!.gate)).toBe(false); + }); + + it("names only tools every tenant user has", () => { + for (const q of QUESTIONS) { + for (const call of q.tools.filter(t => !t.local)) { + const registration = registrations.get(call.name); + expect(registration, `${q.id}: ${call.name} is not registered by the MCP server`).toBeDefined(); + expect(openToTenantUsers(registration!.gate), `${q.id}: ${call.name} is registered under if (${registration!.gate})`).toBe(true); + } + } + }); + + it("shows only arguments the tool declares", () => { + for (const q of QUESTIONS) { + for (const call of q.tools.filter(t => !t.local && t.args)) { + const block = registrations.get(call.name)!.block; + const keys = [...call.args!.matchAll(/(?:^|[{,])\s*([A-Za-z_]\w*)\s*:/g)].map(m => m[1]); + for (const key of keys) expect(new RegExp(`\\b${key}\\s*:`).test(block), `${q.id}: ${call.name} has no argument ${key}`).toBe(true); + } + } + }); +}); diff --git a/src/Web/autopilot-monitor-web/components/ai/icons.tsx b/src/Web/autopilot-monitor-web/components/ai/icons.tsx new file mode 100644 index 00000000..5efee209 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/icons.tsx @@ -0,0 +1,104 @@ +import type { ReactNode } from "react"; + +/** Stroke icons for the /ai page (24-unit grid, currentColor). */ + +type IconProps = { className?: string }; + +function Icon({ className = "w-4 h-4", children }: IconProps & { children: ReactNode }) { + return ( + + ); +} + +export const ChatIcon = (p: IconProps) => ( + + + +); +export const CodeIcon = (p: IconProps) => ( + + + +); +export const TerminalIcon = (p: IconProps) => ( + + + +); +export const ServerIcon = (p: IconProps) => ( + + + + + +); +export const RepeatIcon = (p: IconProps) => ( + + + + + + +); +export const LaptopIcon = (p: IconProps) => ( + + + + +); +export const ChipIcon = (p: IconProps) => ( + + + + + +); +export const CloudIcon = (p: IconProps) => ( + + + +); +export const BuildingIcon = (p: IconProps) => ( + + + + +); +export const ArrowRightIcon = (p: IconProps) => ( + + + +); +export const ArrowDownIcon = (p: IconProps) => ( + + + +); +export const CopyIcon = (p: IconProps) => ( + + + + +); + +/** The check chip used in landing lists (CapabilitiesStrip). */ +export function CheckChip() { + return ( + + + + ); +} diff --git a/src/Web/autopilot-monitor-web/components/ai/questions.ts b/src/Web/autopilot-monitor-web/components/ai/questions.ts new file mode 100644 index 00000000..13648f85 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/questions.ts @@ -0,0 +1,395 @@ +/** + * Content of the /ai question gallery: topics, example questions, the MCP tools each one calls and + * the answer, as typed blocks. Data instead of JSX, so every string keeps its own spaces (Turbopack + * trims JSX text at inline-element boundaries) and the tool names can be checked against the MCP + * server (components/ai/__tests__/questions.test.ts). All numbers and names are sample data. + */ + +export type Skin = "chat" | "vscode" | "terminal" | "selfhosted"; +export type Tone = "ok" | "warn" | "high" | "dim"; +export type TopicId = "troubleshoot" | "fleet" | "apps" | "security" | "reporting"; + +/** Plain text, or a text run with emphasis. Strings carry their own leading/trailing spaces. */ +export type Span = string | { text: string; tone?: Tone; bold?: boolean; code?: boolean }; + +export interface TableRow { + cells: Span[]; + /** Bar drawn in an extra last column, width in px. */ + bar?: { width: number; tone?: "warn" | "dim" }; + /** One cell spanning the whole table (a "… and N more" line). */ + fullWidth?: boolean; +} + +export type Block = + | { kind: "p"; spans: Span[]; tone?: Tone } + | { kind: "list"; items: Span[][] } + | { kind: "table"; head?: string[]; numeric?: number[]; rows: TableRow[] } + | { kind: "ticket"; title: string; fields: { label: string; text: string }[] } + | { kind: "note"; text: string } + /** The answer differs between clients that can read local files and those that cannot. */ + | { kind: "byFileAccess"; withFiles: Block[]; withoutFiles: Block[] }; + +/** An MCP tool call, or (`local`) a command a file-capable client runs on its own machine. */ +export interface ToolCall { + name: string; + args?: string; + local?: true; +} + +export interface Question { + id: string; + topic: TopicId; + text: string; + tools: ToolCall[]; + answer: Block[]; +} + +export const TOPICS: { id: TopicId; label: string }[] = [ + { id: "troubleshoot", label: "Troubleshoot" }, + { id: "fleet", label: "Fleet" }, + { id: "apps", label: "Apps" }, + { id: "security", label: "Security" }, + { id: "reporting", label: "Reporting" }, +]; + +export const SKINS: { id: Skin; label: string; note: string }[] = [ + { id: "chat", label: "Chat app", note: "Claude on the web, desktop or mobile, or ChatGPT." }, + { id: "vscode", label: "VS Code", note: "VS Code with GitHub Copilot in agent mode, next to your scripts." }, + { id: "terminal", label: "Terminal", note: "Claude Code, Codex or Gemini CLI. They can also download the diagnostics package." }, + { id: "selfhosted", label: "Self-hosted", note: "A chat front end your organization hosts, with the model you choose." }, +]; + +/** Clients with file access can download the diagnostics package and search it locally. */ +export const FILE_ACCESS_SKINS: ReadonlySet = new Set(["vscode", "terminal"]); + +const dim = (text: string): Span => ({ text, tone: "dim" }); +const code = (text: string): Span => ({ text, code: true }); +const label = (text: string, tone: Tone): Span => ({ text, tone, bold: true }); + +export const QUESTIONS: Question[] = [ + { + id: "t1", + topic: "troubleshoot", + text: "CONTOSO-3812 failed enrollment this morning. What happened?", + tools: [ + { name: "search_sessions", args: '{ deviceName: "CONTOSO-3812" }' }, + { name: "get_session_summary", args: '{ sessionId: "e6a1…f43b" }' }, + ], + answer: [ + { kind: "p", spans: ["Two attempts today. The first failed, the retry succeeded."] }, + { + kind: "list", + items: [ + [label("✗ Failed", "high"), " 11:05–11:17 · ESP error ", code("0x8007EA61"), " · 7 of 8 apps"], + [label("✓ Succeeded", "ok"), " 12:05–12:20 · 8 of 8 apps"], + ], + }, + { + kind: "p", + spans: [ + label("Cause:", "warn"), + " Contoso CRM exited with ", + code("60001"), + ". The Win32 app has no mapping for that code, so the ESP stopped. ", + code("0x8007EA61"), + " is 60001 as an HRESULT.", + ], + }, + { kind: "p", tone: "dim", spans: ["60001 is PSADT's generic code for an unhandled error."] }, + { kind: "p", spans: [label("Fix:", "ok"), " map 60001 to retry, or handle the error inside the package."] }, + ], + }, + { + id: "t2", + topic: "troubleshoot", + text: "What does 0x87D1041C mean, and where do we see it?", + tools: [ + { name: "lookup_error_code", args: '{ code: "0x87D1041C" }' }, + { name: "search_events", args: '{ query: "app not detected after install" }' }, + ], + answer: [ + { kind: "p", spans: [code("0x87D1041C"), " means the app was not detected after the installer reported success."] }, + { + kind: "p", + tone: "dim", + spans: ["The usual cause is a detection rule that checks a file, version or registry value the installer does not write."], + }, + { kind: "p", spans: [label("Seen in 14 sessions", "warn"), " in the last 30 days, all with Contoso VPN 7.1."] }, + { kind: "p", spans: [label("Next step:", "ok"), " compare the detection rule with what version 7.1 installs."] }, + ], + }, + { + id: "t3", + topic: "troubleshoot", + text: "Get the diagnostics package of that session and check the IME log.", + tools: [ + { name: "get_session_diagnostics", args: '{ sessionId: "e6a1…f43b" }' }, + { name: "Bash", args: 'unzip -q diag.zip && grep -n "Contoso CRM" AppWorkload.log', local: true }, + ], + answer: [ + { + kind: "byFileAccess", + withoutFiles: [ + { kind: "p", spans: ["Here is a download link for the diagnostics package. It stays valid for about 10 minutes."] }, + { + kind: "p", + tone: "dim", + spans: [ + "I can't open ZIP files in this chat. A client with file access, such as Claude Code or VS Code, unpacks the package and searches the IME logs for you.", + ], + }, + ], + withFiles: [ + { kind: "p", spans: ["Downloaded the package and searched AppWorkload.log:"] }, + { + kind: "table", + rows: [ + { cells: [dim("11:13:26"), "Contoso CRM: install started, attempt 1"] }, + { cells: [dim("11:14:58"), { text: "Installer exited with 60001 after 92 s", tone: "high" }] }, + { cells: [dim("11:14:58"), "No mapping for 60001, install reported as failed"] }, + { cells: [dim("12:09:41"), { text: "Attempt 2: installed in 75 s", tone: "ok" }] }, + ], + }, + { kind: "p", spans: [label("Result:", "ok"), " the package works on a second run. The failure is transient."] }, + ], + }, + ], + }, + { + id: "f1", + topic: "fleet", + text: "Where does our enrollment time go, and what would save the most?", + tools: [{ name: "get_time_attribution" }], + answer: [ + { kind: "p", spans: ["Median user-driven enrollment over the last 30 days: ", { text: "38 min", bold: true }, "."] }, + { + kind: "table", + head: ["Phase", "Median"], + numeric: [1], + rows: [ + { cells: ["Device preparation", "4 min"], bar: { width: 25 } }, + { cells: [{ text: "Device setup apps", tone: "warn" }, { text: "19 min", tone: "warn" }], bar: { width: 120, tone: "warn" } }, + { cells: ["Windows Hello", "2 min"], bar: { width: 13 } }, + { cells: ["Account setup", "9 min"], bar: { width: 57 } }, + { cells: ["Desktop handoff", "1 min"], bar: { width: 6 } }, + { cells: [dim("Not attributed"), dim("3 min")], bar: { width: 19, tone: "dim" } }, + ], + }, + { + kind: "p", + spans: [ + label("Biggest lever:", "warn"), + " Contoso CAD Suite blocks device setup for 7 min. Installing it after setup would save up to 7 min per device.", + ], + }, + ], + }, + { + id: "f2", + topic: "fleet", + text: "How many devices enrolled right the first time last month?", + tools: [ + { name: "get_device_history", args: "{ days: 30 }" }, + { name: "search_sessions", args: '{ fields: "serialNumber,model,status" }' }, + ], + answer: [ + { kind: "p", spans: [label("First-time-right: 412 of 448 devices (92%).", "ok"), " 36 devices needed two or more attempts."] }, + { kind: "p", spans: [label("21 of the 36 are the same model:", "warn"), " Contoso Book 14 G3."] }, + { kind: "p", spans: ["Check the driver and firmware apps for that model first."] }, + ], + }, + { + id: "f3", + topic: "fleet", + text: "Are enrollments at the Munich office slower than elsewhere?", + tools: [{ name: "get_geographic_metrics", args: '{ groupBy: "city" }' }], + answer: [ + { + kind: "table", + head: ["Location", "Sessions", "Median", "Success"], + numeric: [1, 2, 3], + rows: [ + { cells: [{ text: "Munich", tone: "warn" }, "64", { text: "52 min", tone: "warn" }, "97%"] }, + { cells: ["All sites", "487", "38 min", "95%"] }, + ], + }, + { + kind: "p", + spans: [ + "The devices in Munich don't fail, they are slow. API latency matches the other sites, so the distance to the backend is not the cause.", + ], + }, + { kind: "p", spans: [label("Next step:", "ok"), " check the proxy and Delivery Optimization at that site."] }, + ], + }, + { + id: "p1", + topic: "apps", + text: "Which app breaks our enrollments most often?", + tools: [{ name: "get_app_install_metrics", args: "{ days: 30 }" }], + answer: [ + { kind: "p", spans: ["Top failing apps in the last 30 days:"] }, + { + kind: "table", + head: ["App", "Failures", "Rate", "Most common code"], + numeric: [1, 2], + rows: [ + { cells: [{ text: "Contoso VPN 7.1", tone: "high" }, { text: "14", tone: "high" }, "6.2%", code("0x87D1041C")] }, + { cells: ["Contoso CRM 4.2", "5", "2.4%", code("exit 60001")] }, + { cells: ["Printer Pack 2.0", "3", "0.7%", code("0x80070643")] }, + ], + }, + { kind: "p", spans: [label("Takeaway:", "ok"), " Contoso VPN alone causes more than half of all app failures."] }, + ], + }, + { + id: "p2", + topic: "apps", + text: "Which apps keep device setup waiting the longest?", + tools: [{ name: "get_time_attribution" }], + answer: [ + { kind: "p", spans: ["Apps that block device setup, by median active install time:"] }, + { + kind: "table", + head: ["App", "Install time"], + numeric: [1], + rows: [ + { cells: [{ text: "Contoso CAD Suite", tone: "warn" }, { text: "7 min 10 s", tone: "warn" }], bar: { width: 120, tone: "warn" } }, + { cells: ["Microsoft 365 Apps", "4 min 40 s"], bar: { width: 78 } }, + { cells: ["Contoso VPN 7.1", "1 min 50 s"], bar: { width: 31 } }, + ], + }, + { kind: "p", spans: [label("Biggest saving:", "ok"), " taking CAD Suite off the blocking list saves up to 7 min per device."] }, + ], + }, + { + id: "p3", + topic: "apps", + text: "How much install traffic came from peers or Connected Cache?", + tools: [{ name: "get_app_install_metrics", args: "{ days: 30 }" }], + answer: [ + { kind: "p", spans: ["1.8 TB were downloaded for app installs in the last 30 days."] }, + { + kind: "table", + head: ["Source", "Share"], + numeric: [1], + rows: [ + { cells: [{ text: "Peers and Microsoft Connected Cache", tone: "ok" }, { text: "62%", tone: "ok" }], bar: { width: 120 } }, + { cells: ["Internet", "38%"], bar: { width: 74, tone: "dim" } }, + ], + }, + { kind: "p", spans: ["That kept about 1.1 TB off your internet line."] }, + ], + }, + { + id: "s1", + topic: "security", + text: "How exposed are the devices we enrolled this month?", + tools: [{ name: "get_vulnerability_summary", args: "{ days: 30 }" }], + answer: [ + { kind: "p", spans: ["17 distinct CVEs across 236 devices."] }, + { kind: "p", spans: [label("Act now:", "high"), " 2 CVEs are on CISA's Known Exploited list."] }, + { + kind: "table", + head: ["Software", "Priority", "CVSS", "Devices"], + numeric: [2, 3], + rows: [ + { cells: ["Contoso PDF Reader 11.2", { text: "KEV", tone: "high" }, "7.8", "188"] }, + { cells: ["Contoso Archiver 3.1", { text: "KEV", tone: "high" }, "8.8", "9"] }, + ], + }, + { kind: "p", spans: [label("Attend:", "warn"), " 4 CVEs with a high exploit probability (EPSS). ", dim("Track: 11.")] }, + { kind: "p", spans: [label("Start with PDF Reader:", "ok"), " one update covers 188 devices."] }, + { kind: "note", text: "Uses the optional software inventory with CVE matching." }, + ], + }, + { + id: "s2", + topic: "security", + text: "Which devices still get the vulnerable PDF Reader?", + tools: [{ name: "search_sessions_by_cve", args: '{ cveId: "CVE-2026-…" }' }], + answer: [ + { kind: "p", spans: ["188 sessions in the last 30 days. ", label("All of them report version 11.2, none has 11.3 yet.", "warn")] }, + { + kind: "table", + head: ["Device", "Model", "Enrolled"], + rows: [ + { cells: ["CONTOSO-4471", "Contoso Book 14 G3", "today 09:12"] }, + { cells: ["CONTOSO-4466", "Contoso Book 14 G3", "today 08:47"] }, + { cells: ["CONTOSO-4459", "Contoso Desk 7", "yesterday 16:05"] }, + { cells: [dim("… and 185 more")], fullWidth: true }, + ], + }, + { kind: "p", tone: "dim", spans: ["Want the full list as a CSV for the patch team?"] }, + ], + }, + { + id: "s3", + topic: "security", + text: "Did any device enroll with an old TPM version?", + tools: [{ name: "search_sessions", args: '{ deviceProperties: { "tpm_status.specVersion": "1.2" } }' }], + answer: [{ kind: "p", spans: [label("No.", "ok"), " All 448 devices from the last 30 days report TPM 2.0."] }], + }, + { + id: "r1", + topic: "reporting", + text: "Write this week's enrollment report for the change advisory board.", + tools: [ + { name: "get_metrics", args: "{ days: 7 }" }, + { name: "get_device_history", args: "{ days: 7 }" }, + { name: "get_app_install_metrics", args: "{ days: 7 }" }, + ], + answer: [ + { kind: "p", spans: [{ text: "Enrollment report, week 40", bold: true }] }, + { + kind: "list", + items: [ + ["118 enrollments, 95% succeeded (last week 91%)"], + ["First-time-right: 93%"], + ["Median duration 37 min, 4 min faster than last week"], + [{ text: "Open issue: Contoso VPN 7.1 detection rule, 4 failures", tone: "warn" }], + ], + }, + { kind: "p", tone: "dim", spans: ["Want it as a table or as an email draft?"] }, + ], + }, + { + id: "r2", + topic: "reporting", + text: "Draft a ticket for the packaging team about the VPN failures.", + tools: [ + { name: "search_events", args: '{ query: "Contoso VPN not detected" }' }, + { name: "get_session_summary", args: "… × 3" }, + ], + answer: [ + { kind: "p", spans: ["Here is a draft:"] }, + { + kind: "ticket", + title: "Contoso VPN 7.1 not detected after install (0x87D1041C)", + fields: [ + { label: "Impact", text: "14 failed installs in 30 days, all during device setup." }, + { label: "Evidence", text: "3 example sessions with timeline, linked below." }, + { label: "Likely cause", text: "the detection rule does not match version 7.1." }, + { label: "Ask", text: "align the detection rule with 7.1 and test it on one device." }, + ], + }, + ], + }, + { + id: "r3", + topic: "reporting", + text: "Summarize last month for my manager in five sentences.", + tools: [ + { name: "get_metrics", args: "{ days: 30 }" }, + { name: "get_time_attribution" }, + ], + answer: [ + { + kind: "p", + spans: [ + "In September we enrolled 448 devices, and 95% succeeded. 92% of them worked on the first attempt. A typical enrollment took 38 minutes. Most failures came from one VPN package, which packaging is fixing. Moving one large app out of device setup could save up to 7 minutes per device.", + ], + }, + ], + }, +]; diff --git a/src/Web/autopilot-monitor-web/components/ai/segmented.ts b/src/Web/autopilot-monitor-web/components/ai/segmented.ts new file mode 100644 index 00000000..7df2277d --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/ai/segmented.ts @@ -0,0 +1,8 @@ +/** Segmented control used on /ai (answer client, where the model runs). */ +export const SEGMENT_GROUP = + "inline-flex flex-wrap gap-1 rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)] p-1"; +export const SEGMENT_BUTTON = + "rounded-[9px] px-3.5 py-2.5 text-[13.5px] font-semibold leading-tight transition-colors"; +export const SEGMENT_ON = + "bg-[var(--lp-surface)] text-[var(--lp-ink)] shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_var(--lp-line)]"; +export const SEGMENT_OFF = "text-[var(--lp-ink-soft)] hover:text-[var(--lp-ink)]"; diff --git a/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx b/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx index 97a2d8c5..6f9aa528 100644 --- a/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/CapabilitiesStrip.tsx @@ -41,7 +41,7 @@ const CAPABILITIES = [ export function CapabilitiesStrip() { return ( -
    +

    Capabilities

    diff --git a/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx b/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx index 5309fc44..1942136c 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Comparison.tsx @@ -50,7 +50,7 @@ const ROWS = [ export function Comparison() { return ( -
    +

    Compare

    diff --git a/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx b/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx index ab4c54c1..f203891b 100644 --- a/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx @@ -1,6 +1,6 @@ export function FinalCta() { return ( -
    +

    Your next enrollment doesn't have to be a black box. @@ -8,6 +8,7 @@ export function FinalCta() {
    Start monitoring now @@ -18,6 +19,7 @@ export function FinalCta() { Need SLAs, support, or MSP delegation?{" "} There's a Pro plan diff --git a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx index 237aaccb..9a1b8613 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx @@ -15,12 +15,13 @@ const DEVICE_ASSOCIATION_DOCS_URL = `${DOCS_URL}/getting-started/autopilot-devic export function Hero() { return ( -
    +
    {/* Release-day banner: Microsoft shipped Autopilot device association on 2026-08-25; the validator is live in the same week — the pill exists to make that visible. */} Get started +

    How it works

    diff --git a/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx b/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx index 18d94f44..4a3a9e01 100644 --- a/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/JustAsk.tsx @@ -1,8 +1,9 @@ +import Link from "next/link"; import { McpTerminalDemo } from "./McpTerminalDemo"; export function JustAsk() { return ( -
    +

    @@ -15,6 +16,17 @@ export function JustAsk() { 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.

    + + See what else you can ask + +
    diff --git a/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx b/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx index ef6bb40d..b34fc25e 100644 --- a/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/LandingNavbar.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { useAuth } from "../../contexts/AuthContext"; import { getPortalLoginUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting"; @@ -12,15 +13,22 @@ import { GITHUB_REPO_URL } from "@/utils/githubStars"; import { useWhatsNew } from "@/hooks/useWhatsNew"; // Root-anchored (/#…) so the links also work from subpages -// like /get-started, /about, /terms, /privacy. +// like /get-started, /about, /terms, /privacy. `track` names the link in the +// marketing click events: nav_ in the bar, menu_ in the burger menu. const NAV_LINKS = [ - { href: "/#story", label: "Product" }, - { href: "/#features", label: "Capabilities" }, - { href: "/#comparison", label: "Compare" }, - { href: "/plans", label: "Plans" }, - { href: DOCS_URL, label: "Docs", external: true }, + { href: "/#story", label: "Product", track: "product" }, + { href: "/#features", label: "Capabilities", track: "capabilities" }, + { href: "/#comparison", label: "Compare", track: "compare" }, + { href: "/ai/", label: "AI", track: "ai" }, + { href: "/plans", label: "Plans", track: "plans" }, + { href: DOCS_URL, label: "Docs", track: "docs", external: true }, ]; +/** With trailingSlash: true a page shows as /foo/ after client navigation and /foo on a hard load. */ +function withoutTrailingSlash(path: string): string { + return path.length > 1 ? path.replace(/\/+$/, "") : path; +} + function BellIcon({ className }: { className?: string }) { // Ringing bell: the public site announces "there is news" without claiming a count — // anonymous visitors have no seen mark, so a counter would read 9+ for everyone. @@ -43,6 +51,10 @@ export function LandingNavbar() { const { login, isAuthenticated } = useAuth(); const [menuOpen, setMenuOpen] = useState(false); const whatsNew = useWhatsNew(); + const pathname = withoutTrailingSlash(usePathname() ?? ""); + // A route link (not an anchor, not external) whose page is the current one. + const isCurrent = (link: (typeof NAV_LINKS)[number]) => + !link.external && !link.href.includes("#") && withoutTrailingSlash(link.href) === pathname; useEffect(() => { if (!menuOpen) return; @@ -73,10 +85,12 @@ export function LandingNavbar() { // see components/NavLink.tsx.) return (