Compare commits

...

8 Commits

Author SHA1 Message Date
Oliver Kieselbach 3392a23da6 feat(bootstrap): promote dev script v2.2 to stable — download.autopilotmonitor.com endpoint + OOBE-relax (#118)
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.

CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:37:17 +02:00
Oliver Kieselbach 5312216f6b fix(web): hooks-warnings batch 1 — static-components, purity, immutability to zero (#117)
First burn-down batch of the React-Compiler hooks warnings (163 -> 141,
CI ratchet lowered to match).

static-components (14, real remount bugs): SortIcon (geographic-
performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/
ResultsDropdown (GlobalSearch) were defined inside render — a new
component type every render, so React unmounted and rebuilt their
subtrees each time (GlobalSearch tore down its dropdown on every
keystroke). All hoisted to module level with explicit props.

purity (6): Date.now() during render replaced with mount-time clocks
(useState lazy init) for the date-input caps (Maintenance sections) and
trial-expiry checks (TenantManagement, SectionTenantConfigReport);
FeedbackSection refreshes its clock per fetch so "Xm ago" labels track
the data; DownloadProgress falls back to NaN instead of wall-clock for
unparseable event timestamps (rate calc already guards via
Number.isFinite).

immutability (5): fetch functions were declared below the effects that
call them — moved above (diagnosis, fleet-health, DistressReports).
This unblocked deeper compiler analysis, surfacing 3 latent
set-state-in-effect findings in those files (93 -> 96); they belong to
the set-state category and stay for its own batch.

Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean,
vitest 611/611, static export builds.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:21:29 +02:00
Oliver Kieselbach 72392f6f4c chore(web): lint findings to zero errors + CI lint gate (#116)
* feat(web): typed routes — broken internal links are now compile errors

typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): lint findings to zero errors + CI lint gate

Package deal — fixes only land together with the gate, otherwise they
drift right back. CI web job now runs `eslint . --max-warnings 163`:
errors fail outright, the warning cap is a ratchet (lower it when fixing
warnings, never raise it).

Fixed to zero (mechanical categories, behavior unchanged):
- no-unused-vars (49): dead imports/locals removed; where a prop is part
  of a live call-site contract, only the destructured binding dropped
- no-explicit-any (29): real structural types for event payloads
  (EnrollmentEvent.data et al -> Record<string, unknown> + per-event
  interfaces), SignalR passthroughs typed via the library's own
  signatures; five consumer files got typing-only follow-through
- no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes
- no-require-imports (5): scripts/**/*.js override — Node CJS scripts,
  require() is correct there
- one stale eslint-disable directive removed

Deliberately NOT fixed inline: the React-Compiler hooks rules new in
eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26,
static-components 14, purity 6, immutability 5,
preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs —
each is a per-site behavioral refactor in auth/SignalR-adjacent code, to
be burned down incrementally under the ratchet.

Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc
clean, vitest 611/611, static export builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:38 +02:00
Oliver Kieselbach d65157a092 feat(web): typed routes — broken internal links are now compile errors (#115)
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:26 +02:00
Oliver Kieselbach cf45911b69 Web upgrade: Next 16 + React 19 + MSAL v4 (#114)
* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3

react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): react 19 / msal v4 / next 16 code adaptations

- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
  storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
  dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
  (all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
  jumping instead of animating (Next 16 stopped suppressing CSS
  smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
  webpack-only analyzer, use --webpack for ANALYZE runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): untrack Next-managed next-env.d.ts, generate types in CI

Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): migrate lint script to eslint CLI (next lint removed in 16)

Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:54:12 +02:00
Oliver Kieselbach 1593232898 fix(web): every nav click was a full-page reload — skipTrailingSlashRedirect broke RSC fetches
skipTrailingSlashRedirect (added for the next-dev /api proxy 308) also defines
__NEXT_MANUAL_TRAILING_SLASH, which disables the client router's trailing-slash
normalization. Under output:'export' the router then requested RSC payloads as
/<path>.txt while the build only emits /<path>/index.txt — every fetch 404ed and
Next silently degraded EVERY <Link> navigation to an MPA (full-page) load. Most
visible symptom: the sidebar remounted on each click and its expanded groups
collapsed.

- next.config.ts: dev-gate skipTrailingSlashRedirect (prod build normalizes
  links to /foo/ again; RSC fetches hit the emitted index.txt files)
- GlobalSidebar: normalize usePathname() (client nav now yields /foo/, hard
  loads /foo — exact matches against the slash-less nav hrefs need both), and
  re-run the auto-expand effect when visibleExpandableGroups arrives so a hard
  load (deep link, F5) still expands the active group once MSAL resolves

Verified: static export + local serve, marker survives Link nav (no document
request, /terms/index.txt 200); tsc clean; vitest 611/611.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:03:52 +02:00
Oliver Kieselbach e4c06a9564 Landing page v2 — scroll story, portal-first hero, Autopilot Monitor rebrand (#113)
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark

Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
  -> 09:43 MCP terminal demo (full session report via get_session_summary +
  get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
  (now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
  blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
  metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
  the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
  field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
  dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
  hostRouting) and staticwebapp.config.json unchanged

Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing feedback round — linear story timeline, honest examples, dev stats

- Story is now one vertical timeline with a strict top-down reading order
  (time rail left, text left, visual right) instead of the zigzag grid;
  act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
  Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
  installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
  real figures or hides entirely on fetch failure (never fake numbers,
  never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
  "Pull ETL and event logs"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v3 — mission-control cockpit instead of single-session card

The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit

Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header

- Analysis-finding overlay removed: the dashboard shot stands alone (was
  colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
  rescripted from a real production analysis (anonymized): failed attempt
  with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
  transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
  type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
  duplicating the terminal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot

"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v6 — portal-first layout from approved design draft

- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
  Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
  sub left / CTA group right with trust line, portal shot nearly full
  width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
  detected" replaces success rate (underlines automated analysis),
  events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
  the band

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing

- New public /get-started page: five-step schematic (sign in -> request
  access -> consent -> deploy bootstrapper -> watch live); "Get started"
  in navbar and hero now lead here, "Sign in" keeps the login handoff —
  resolving the two-identical-CTAs problem. Wired into hostRouting
  PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
  adaptive card has exactly one action ("Open session") — the invented
  "Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
  shadow only. Final CTA rebuilt as a calm two-column band per draft
  (no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
  under .dark .landing-v2, theme toggle removed from LandingNavbar
  (screenshots are light; portal keeps full dark support).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): about/terms/privacy on the landing shell — no more sidebar flash

The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing navbar anchors root-anchored so they work from subpages

#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): calmer CTA green, about de-blued, public-page typography aligned

- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
  switch from bright #33b161 to the deep accent-ink green — matches the
  dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
  to the single green accent, audience cards/step bubbles/links/quick-link
  hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
  smaller page h1s and section headings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terms callouts on lp tokens — no more stray blue/amber banners

Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about tech stack — drop version numbers, fix stale SSR claim

Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terminal demo app renamed to Contoso CRM App

Neutral fictional app instead of a vendor-adjacent print driver name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started

- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
  CTAs; sign-in stays in the navbar and on /get-started itself

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal brand aligned to landing, subtle Enterprise anchoring

- Portal navbar (both variants) now uses BrandMark + the landing wordmark
  style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
  completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
  subline + /get-started closing note ("same service, nothing to
  migrate") + "Plans" link in the footer Product column, all pointing to
  the docs plans page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): final CTA right-aligned, get-started enterprise note "and more"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social images refreshed to current hero claim and tagline

"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social card wordmark single-color, matching the site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal sidebar selection states green — brand-aligned like the docs

Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links

- About/terms/privacy no longer render half dark for dark-mode visitors:
  the light-only marketing scope now neutralizes the global .dark utility
  overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
  .landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
  just-ask, capabilities, comparison, how-it-works now share the same
  left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
  (old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
  and portal status texts intentionally unchanged)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned

- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
  accent notify box, neutral next-steps, ghost actions — "Preview"
  wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
  the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview page — prominent approval-pending badge, honest status copy

- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
  me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
  blocked tenant never reaches a dashboard — signing in again lands back
  on this page while pending, and goes straight into the portal once
  approved (verified against AuthContext PrivatePreview handling)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about/terms/privacy cards flattened to the landing pinstripe style

All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon

- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
  green-600/700: admin backups, customs archive, vulnerability data,
  session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
  (light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal links and focus rings on brand green

- All focus states unified: focus:ring/border blue-500 and indigo-500
  (207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
  variants (36) -> text-green-700 hover:text-green-800; icon hovers
  hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
  existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): local dev sign-in repaired; progress portal brand accents green

Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
   /api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
   rejects trailing-slash routes, so /me silently failed.
   -> skipTrailingSlashRedirect: true (no effect on the static export;
   SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
   autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
   healthy (v1.5.1027).

Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): loading screens and remaining primary buttons on brand green

- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
  UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
  ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
  replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
  hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
  retry/back buttons, error page, audit, diagnosis, session table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): last blue interactive states green; monitoring headers wrap on mobile

- Time-range toggles (fleet health, software, usage, dashboard, platform
  usage), software lens tabs, MCP-usage segmented controls, SLA report
  button, gather/analyze rule save buttons, and the page-header icons on
  fleet health / geographic performance -> brand green. Zero remaining
  bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
  geographic performance, audit): title row gets flex-wrap + gap-y-3 and
  the control group (tenant scope + range buttons) wraps as a compact
  second line on narrow screens instead of pushing off the right edge;
  range buttons slightly compacted (px-3 py-1.5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map dev crash — per-mount container key for Leaflet

"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): form control accents (radios/checkboxes) green portal-wide

Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): pin native form-control accent-color to brand green

Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): progress portal icon inherits item color again

Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text

- Root cause of "Map container is already initialized": Next 15 app
  router runs its bundled React 19 regardless of the installed react@18,
  and react-leaflet 4.x is incompatible with React 19 StrictMode
  double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
  (@react-leaflet/core 3) and removed our now-counterproductive
  workarounds (MapCleanup manual remove, per-mount container key) — v5
  owns the map lifecycle including unmount teardown. Verified via a
  throwaway repro page: map renders, remount toggle survives, console
  clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
  #8896ad) — notification timestamps and other muted text were nearly
  invisible on raised dark surfaces (unread bg, hover rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): subtler dark hover surfaces — hovered rows stay readable

.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides

The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): notification row hover clearly distinguishable in dark mode

GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): one SegmentedControl for all range/group toggles

New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): MCP usage sections use the shared SegmentedControl too

The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clickable notification deep links in the bell dropdown

GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): npm overrides pin react-leaflet peer deps to installed react

react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:21:18 +02:00
Oliver Kieselbach 9c50d98792 fix(web): sign-in landed back on www — HostRoutingGuard bounced before MSAL settled
Two defects of the same shape in the portal→www direction:
- Deterministic: a signed-in user opening portal root was always bounced to
  www (guard effect fires at hydration, before MSAL restores the session, so
  AuthGate never got to route "/" to /dashboard).
- Race: MSAL's module-level init strips the #code hash before hydration, so
  the auth-response exception saw nothing and the bounce destroyed the
  in-flight token redemption — the user ended up unauthenticated on www.

Portal→www now waits for settled, unauthenticated auth state; www→portal
stays auth-free. Decision extracted as pure decideHostBounce with the safety
rules pinned in hostRouting.guard.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:18:12 +02:00
183 changed files with 4223 additions and 3243 deletions
+2 -2
View File
@@ -194,8 +194,8 @@ jobs:
# Dev variant = literal-string substitution of the two URL/manifest defaults.
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
$stableMan = '$VersionJsonName = "version.json"'
$devMan = '$VersionJsonName = "version-dev.json"'
+12
View File
@@ -69,9 +69,21 @@ jobs:
- run: npm ci
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
# v16); typegen generates them so tsc sees the same types as a build.
- name: Generate Next.js types
run: npx next typegen
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
- name: Lint
run: npm run lint -- --max-warnings 141
- name: Tests
run: npm test
+3
View File
@@ -53,6 +53,9 @@ __azurite_db*__.json
node_modules/
.next/
out/
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
# inside flips between .next/types and .next/dev/types) — must not be tracked.
next-env.d.ts
build/
dist/
!rules/dist/
@@ -1,369 +0,0 @@
<#
.SYNOPSIS
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
.DESCRIPTION
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production blob; override only
for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
Defaults to "version.json"; override only for parallel lab/dev manifests.
.PARAMETER MaxBootstrapWindowHours
Maximum device uptime (hours) within which the bootstrap is still considered
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
(registry deployment marker, WMI/filesystem user profile, last
logged-on user, 12 h bootstrap window).
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
pre-flight check to skip if agent already installed.
2026-02-05 Initial version.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# Positive OOBE check via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
}
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
# Sleep/standby does not reset uptime, only real boot/restart does.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
}
# Guard 5: Is the agent already installed? (leftover from previous run)
if (Test-Path $AgentBinPath) {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
+138 -81
View File
@@ -5,18 +5,28 @@
.DESCRIPTION
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest
(legacy fallback: Content-MD5 header from the blob response)
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production blob; override only
for parallel lab/dev assignments that point at a separate pre-release ZIP.
URL to download the agent ZIP from. Defaults to the production download endpoint;
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
@@ -27,16 +37,35 @@
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
@@ -55,17 +84,20 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.0"
$ScriptVersion = "2.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -85,26 +117,19 @@ function Write-Log {
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
# Positive OOBE check via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$stream = [System.IO.File]::OpenRead($Path)
try {
$hashBytes = $md5.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
return [Convert]::ToBase64String($hashBytes)
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
finally {
$md5.Dispose()
catch {
return $false
}
}
@@ -119,30 +144,65 @@ try {
exit 0
}
# Guard 2: No real user profile should exist yet (primary productive-device guard).
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$profileNames = @(
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { Split-Path $_.LocalPath -Leaf }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
Select-Object -Unique
if ($profileNames) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
@@ -166,7 +226,7 @@ try {
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
@@ -181,38 +241,47 @@ try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
try {
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
catch {
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
$downloadResponse = $null
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadResponse = Invoke-WebRequest `
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
@@ -227,29 +296,17 @@ try {
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
if ($expectedSha256) {
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
else {
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
if ($expectedMd5 -notmatch '\S') {
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
}
else {
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
Write-Log "Validating Content-MD5 header against downloaded ZIP"
if ($actualMd5 -ne $expectedMd5) {
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
}
Write-Log "MD5 integrity check passed"
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
@@ -1,213 +0,0 @@
# Autopilot Monitor Bootstrap Guard Test (DEV)
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
function Write-Step {
param(
[string]$Status,
[string]$Message
)
$prefix = switch ($Status) {
'PASS' { '[PASS]' }
'SKIP' { '[SKIP]' }
'WARN' { '[WARN]' }
'INFO' { '[INFO]' }
default { '[....]' }
}
Write-Host "$prefix $Message"
}
function Get-RegistryValueSafe {
param(
[string]$Path,
[string]$Name
)
try {
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
}
catch {
return $null
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
$wouldInstall = $true
$reasons = New-Object System.Collections.Generic.List[string]
Write-Host ''
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
Write-Host ''
# Guard 1: Agent was already deployed on this device (survives self-destruct)
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
if ($deployed) {
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
$wouldInstall = $false
$reasons.Add("Previously deployed marker exists: $deployed")
} else {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
try {
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
} else {
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
}
}
catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 5: Agent binary already present
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
$reasons.Add("Agent binary already present: $AgentExePath")
} else {
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
}
Write-Host ''
Write-Host '=== Result ==='
Write-Host ''
if ($wouldInstall) {
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
} else {
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
}
Write-Host ''
Write-Host '=== Summary ==='
if ($reasons.Count -eq 0) {
Write-Host 'No blocking reasons found.'
} else {
foreach ($reason in $reasons) {
Write-Host " - $reason"
}
}
Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
Write-Host "UptimeHours : $([string]$uptimeHours)"
Write-Host "AgentExePath : $AgentExePath"
Write-Host ''
+73 -36
View File
@@ -1,8 +1,11 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
@@ -38,6 +41,19 @@ function Get-RegistryValueSafe {
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
@@ -58,63 +74,79 @@ if ($deployed) {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Guard 2: No real user profile should exist yet (primary productive-device guard)
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
$profileNames = @(
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object {
-not $_.Special -and
$_.LocalPath -like 'C:\Users\*'
} |
ForEach-Object {
Split-Path $_.LocalPath -Leaf
}
}
catch {
$wmiProfileQueryFailed = $true
}
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
# Filesystem view -- catches profiles WMI might miss
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) |
Where-Object {
$_ -and $_ -notmatch $excludePattern
} |
Select-Object -Unique
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
if ($profileNames) {
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
# This is "how old can the OOBE state be before I no longer trust it for initial install".
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
@@ -134,7 +166,7 @@ catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 4: Agent binary already present
# Guard 5: Agent binary already present
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
@@ -168,6 +200,11 @@ Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
@@ -131,7 +131,7 @@ public class AuthFunctionSideEffectTests
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Preview Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
null), Times.Once);
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
}
[Fact]
@@ -326,7 +326,8 @@ public class AuthFunction
_ = _globalNotificationService.CreateNotificationAsync(
"preview_signup",
"New Preview Signup",
$"Tenant {tenantId} ({domain}), UPN: {upn}");
$"Tenant {tenantId} ({domain}), UPN: {upn}",
href: $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}");
}
/// <summary>
@@ -137,7 +137,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
_ = _globalNotificationService.CreateNotificationAsync(
"diag_files_report",
"New Diag Files Report",
$"{userIdentifier} (Tenant: {request.TenantId})");
$"{userIdentifier} (Tenant: {request.TenantId})",
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
_logger.LogInformation("Diag-files report submitted: ReportId={ReportId}, By={User}",
metadata.ReportId, userIdentifier);
@@ -140,7 +140,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
_ = _globalNotificationService.CreateNotificationAsync(
"session_report",
"New Session Report",
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})");
$"{userIdentifier} — Session {sessionId} (Tenant: {request.TenantId})",
href: $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(metadata.ReportId)}");
_logger.LogInformation("Session report submitted: ReportId={ReportId}, Session={SessionId}, By={User}",
metadata.ReportId, sessionId, userIdentifier);
@@ -26,13 +26,13 @@ export const metadata: Metadata = {
"Autopilot Monitor overview",
],
openGraph: {
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
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: {
title: "About AutopilotMonitor – Real-Time Windows Autopilot Monitoring",
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.",
},
@@ -1,5 +1,6 @@
import Link from "next/link";
import { PublicPageHeader } from "../../components/PublicPageHeader";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const FEATURES = [
@@ -47,25 +48,40 @@ const FEATURES = [
},
];
// Landing v2: one accent, no rainbow — every feature card uses the
// green brand accent regardless of its legacy color key.
const ACCENT_STYLE = {
bg: "bg-[var(--lp-accent-soft)]",
text: "text-[var(--lp-ink)]",
dot: "bg-[var(--lp-accent)]",
};
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
blue: { bg: "bg-blue-100", text: "text-blue-600", dot: "bg-blue-400" },
indigo: { bg: "bg-indigo-100", text: "text-indigo-600", dot: "bg-indigo-400" },
purple: { bg: "bg-purple-100", text: "text-purple-600", dot: "bg-purple-400" },
green: { bg: "bg-green-100", text: "text-green-600", dot: "bg-green-400" },
orange: { bg: "bg-orange-100", text: "text-orange-600", dot: "bg-orange-400" },
red: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-400" },
blue: ACCENT_STYLE,
indigo: ACCENT_STYLE,
purple: ACCENT_STYLE,
green: ACCENT_STYLE,
orange: ACCENT_STYLE,
red: ACCENT_STYLE,
};
export default function AboutPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="About Autopilot Monitor" />
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-7xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">About</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
About Autopilot Monitor
</h1>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
{/* What is Autopilot Monitor */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">What is Autopilot Monitor?</h2>
<p className="text-gray-700 leading-relaxed mb-4">
<strong>Autopilot Monitor</strong> is a free, open-source, real-time monitoring and troubleshooting
platform for <strong>Windows Autopilot enrollments</strong> managed through{" "}
@@ -100,7 +116,7 @@ export default function AboutPage() {
{FEATURES.map((f) => {
const c = colorMap[f.color];
return (
<div key={f.title} className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<div key={f.title} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-6">
<h3 className={`text-base font-semibold mb-2 ${c.text}`}>{f.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed mb-3">{f.description}</p>
<ul className="space-y-1">
@@ -118,8 +134,8 @@ export default function AboutPage() {
</section>
{/* Who It's For */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Who Is It For?</h2>
<p className="text-gray-700 leading-relaxed mb-5">
Autopilot Monitor is built for anyone responsible for deploying or supporting
Windows devices through Microsoft Intune and Autopilot.
@@ -142,17 +158,17 @@ export default function AboutPage() {
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
},
].map((item) => (
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-2">{item.title}</h3>
<p className="text-sm text-blue-800 leading-relaxed">{item.description}</p>
<div key={item.title} className="rounded-lg bg-[var(--lp-accent-soft)] border border-[var(--lp-accent-line)] p-4">
<h3 className="text-sm font-semibold text-[var(--lp-ink)] mb-2">{item.title}</h3>
<p className="text-sm text-[var(--lp-ink-soft)] leading-relaxed">{item.description}</p>
</div>
))}
</div>
</section>
{/* How It Works */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">How It Works</h2>
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">How It Works</h2>
<p className="text-gray-700 leading-relaxed mb-5">
Autopilot Monitor uses a lightweight .NET agent deployed to devices via an Intune bootstrapper
script. The agent monitors the enrollment process in real time and streams telemetry events
@@ -169,7 +185,7 @@ export default function AboutPage() {
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
].map((item) => (
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--lp-accent)] text-white text-xs font-bold">
{item.step}
</span>
{item.text}
@@ -179,8 +195,8 @@ export default function AboutPage() {
</section>
{/* Tech Stack & Platform */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Technology &amp; Platform</h2>
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Technology &amp; Platform</h2>
<p className="text-gray-700 leading-relaxed mb-5">
Autopilot Monitor is built on modern, enterprise-grade technology designed to scale with large
device fleets and multi-tenant deployments.
@@ -190,7 +206,7 @@ export default function AboutPage() {
{
title: "Backend",
items: [
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
"Azure Functions (.NET Isolated, Flex Consumption) — serverless, scalable API",
"Azure Table Storage — high-throughput event ingestion",
"Azure Blob Storage — diagnostics and log bundle storage",
"Azure SignalR Service — real-time push to the portal",
@@ -199,7 +215,7 @@ export default function AboutPage() {
{
title: "Portal (Web Frontend)",
items: [
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
"Next.js (React) + TypeScript — fast, statically delivered web app",
"Microsoft Entra ID (MSAL) — secure authentication",
"Role-based access control (Admin / Operator / Viewer)",
"Multi-tenant architecture with delegated MSP access",
@@ -241,8 +257,8 @@ export default function AboutPage() {
</section>
{/* Open Source */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Open Source &amp; Free to Use</h2>
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Open Source &amp; Free to Use</h2>
<p className="text-gray-700 leading-relaxed mb-4">
Autopilot Monitor is fully open source and free to use. The complete source code is available on
GitHub under an open license. Contributions, bug reports, and feature requests from the
@@ -257,16 +273,16 @@ export default function AboutPage() {
</p>
<p className="text-gray-700 leading-relaxed mb-4">
The hosted service is operated by <strong>glueckkanja AG</strong> — see the{" "}
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">Imprint</a> for company
details and the <Link href="/terms" className="text-blue-600 hover:text-blue-800 underline">Terms of Use</Link>{" "}
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a> for company
details and the <Link href="/terms" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Terms of Use</Link>{" "}
for what each plan does and does not commit to.
</p>
<div className="flex flex-wrap gap-3 mt-5">
<a
href="https://github.com/okieselbach/Autopilot-Monitor"
href="https://github.com/okieselbach/AutopilotMonitor"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
@@ -274,10 +290,10 @@ export default function AboutPage() {
View on GitHub
</a>
<a
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
>
Submit Feedback
</a>
@@ -285,7 +301,7 @@ export default function AboutPage() {
href="https://www.linkedin.com/in/oliver-kieselbach/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--lp-line-soft)] bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-100 transition-colors"
>
Oliver Kieselbach on LinkedIn
</a>
@@ -293,22 +309,23 @@ export default function AboutPage() {
</section>
{/* Quick Links */}
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Explore Further</h2>
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Explore Further</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
Documentation →
</a>
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
<a href={`${DOCS_URL}/changelog/platform-changelog`} target="_blank" rel="noopener noreferrer" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
Changelog →
</a>
<Link href="/privacy" className="rounded-lg border border-gray-200 px-4 py-3 hover:border-blue-300 hover:bg-blue-50/50 transition-colors text-gray-700 font-medium">
<Link href="/privacy" className="rounded-lg border border-[var(--lp-line-soft)] px-4 py-3 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors text-gray-700 font-medium">
Privacy Policy →
</Link>
</div>
</section>
</main>
<SiteFooter />
</div>
);
}
@@ -3,24 +3,15 @@
import { useMemo } from "react";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { route } from "../../lib/routes";
import {
GearIcon,
NoSymbolIcon,
DocumentTextIcon,
ChartBarIcon,
BuildingOfficeIcon,
ShieldCheckIcon,
} from "../../lib/sidebarIcons";
// Inline icon (same as MetricsSidebar had)
function TrendingUpIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941" />
</svg>
);
}
// Inline icon for Ops (wrench)
function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) {
return (
@@ -47,29 +38,29 @@ function GlobeIcon({ className = "w-5 h-5" }: { className?: string }) {
export function AdminPageSections() {
const items: PageSectionItem[] = useMemo(() => [
// Tenants
{ id: "management", label: "Tenant Management", href: "/admin/tenants/management", group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
{ id: "config-report", label: "Config Report", href: "/admin/tenants/config-report", group: "Tenants" },
{ id: "management", label: "Tenant Management", href: route("/admin/tenants/management"), group: "Tenants", groupIcon: <BuildingOfficeIcon /> },
{ id: "config-report", label: "Config Report", href: route("/admin/tenants/config-report"), group: "Tenants" },
// Metrics
{ id: "platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics", group: "Metrics", groupIcon: <ChartBarIcon /> },
{ id: "usage", label: "Platform Usage", href: "/admin/metrics/usage", group: "Metrics" },
{ id: "mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage", group: "Metrics" },
{ id: "platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics"), group: "Metrics", groupIcon: <ChartBarIcon /> },
{ id: "usage", label: "Platform Usage", href: route("/admin/metrics/usage"), group: "Metrics" },
{ id: "mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage"), group: "Metrics" },
// Reports
{ id: "session-reports", label: "Session Reports", href: "/admin/reports/session-reports", group: "Reports", groupIcon: <DocumentTextIcon /> },
{ id: "user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback", group: "Reports" },
{ id: "session-export", label: "Session Export", href: "/admin/reports/session-export", group: "Reports" },
{ id: "session-reports", label: "Session Reports", href: route("/admin/reports/session-reports"), group: "Reports", groupIcon: <DocumentTextIcon /> },
{ id: "user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback"), group: "Reports" },
{ id: "session-export", label: "Session Export", href: route("/admin/reports/session-export"), group: "Reports" },
// Security
{ id: "device-block", label: "Device Block", href: "/admin/security/device-block", group: "Security", groupIcon: <ShieldCheckIcon /> },
{ id: "version-block", label: "Version Block", href: "/admin/security/version-block", group: "Security" },
{ id: "vulnerability-data", label: "Vulnerability Data", href: "/admin/security/vulnerability-data", group: "Security" },
{ id: "device-block", label: "Device Block", href: route("/admin/security/device-block"), group: "Security", groupIcon: <ShieldCheckIcon /> },
{ id: "version-block", label: "Version Block", href: route("/admin/security/version-block"), group: "Security" },
{ id: "vulnerability-data", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data"), group: "Security" },
// Settings
{ id: "global", label: "Global Settings", href: "/admin/settings/global", group: "Settings", groupIcon: <GearIcon /> },
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths", group: "Settings" },
{ id: "config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed", group: "Settings" },
{ id: "usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans", group: "Settings" },
{ id: "global", label: "Global Settings", href: route("/admin/settings/global"), group: "Settings", groupIcon: <GearIcon /> },
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" },
{ id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" },
{ id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" },
// Ops (single page)
{ id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: <WrenchIcon /> },
@@ -90,7 +90,7 @@ export function RestoreRowDiffModal({
This is an <strong>authentication / authorization</strong> table
(<code className="font-mono">GlobalAdmins</code>, <code className="font-mono">TenantAdmins</code>,{" "}
<code className="font-mono">McpUsers</code>). Restoring this row will overwrite the live{" "}
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row's enable/disable
<code className="font-mono">IsEnabled</code> flag — confirm that the backup row&apos;s enable/disable
state is what you intend.
</div>
</div>
@@ -159,7 +159,7 @@ export function RestoreRowDiffModal({
type="button"
onClick={commit}
disabled={committing}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{committing ? "Restoring…" : "Restore row"}
</button>
@@ -276,7 +276,7 @@ function RestoreInputRow({
// — only null is rejected. Match that here so an Azure Table row with
// a literal empty key (legal per spec) can be previewed/restored.
disabled={disabled || !tableName}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
Preview restore
</button>
@@ -134,7 +134,7 @@ export default function BackupsListPage() {
type="button"
onClick={triggerBackup}
disabled={triggering || activeJob !== null}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{triggering ? "Triggering…" : "Run backup now"}
</button>
@@ -104,7 +104,7 @@ export function AdminConfigSettingsSection({
max="1000"
value={globalRateLimit}
onChange={(e) => setGlobalRateLimit(parseInt(e.target.value) || 100)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -121,7 +121,7 @@ export function AdminConfigSettingsSection({
max="10000"
value={userRateLimit}
onChange={(e) => setUserRateLimit(parseInt(e.target.value) || 120)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -138,7 +138,7 @@ export function AdminConfigSettingsSection({
max="10000"
value={globalAdminRateLimit}
onChange={(e) => setGlobalAdminRateLimit(parseInt(e.target.value) || 600)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -158,7 +158,7 @@ export function AdminConfigSettingsSection({
value={platformStatsBlobSasUrl}
onChange={(e) => setPlatformStatsBlobSasUrl(e.target.value)}
placeholder="https://storageaccount.blob.core.windows.net/publicstats?sv=...&sig=..."
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -177,7 +177,7 @@ export function AdminConfigSettingsSection({
value={agentMigrateApiBaseUrl}
onChange={(e) => setAgentMigrateApiBaseUrl(e.target.value)}
placeholder="https://autopilotmonitor-api-us.azurewebsites.net"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -195,7 +195,7 @@ export function AdminConfigSettingsSection({
onChange={(e) => setAgentMigrateTenantOverridesJson(e.target.value)}
placeholder='{"11111111-2222-3333-4444-555555555555": "https://autopilotmonitor-api-us.azurewebsites.net", "66666666-7777-8888-9999-000000000000": ""}'
rows={3}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -214,7 +214,7 @@ export function AdminConfigSettingsSection({
max="120"
value={collectorIdleTimeoutMinutes}
onChange={(e) => setCollectorIdleTimeoutMinutes(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -233,7 +233,7 @@ export function AdminConfigSettingsSection({
max="60"
value={desktopDetectorNoCandidateTimeoutMinutes}
onChange={(e) => setDesktopDetectorNoCandidateTimeoutMinutes(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -253,7 +253,7 @@ export function AdminConfigSettingsSection({
max="168"
value={slaNotificationCooldownHours}
onChange={(e) => setSlaNotificationCooldownHours(parseInt(e.target.value) || 24)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
@@ -264,7 +264,7 @@ export function AdminConfigSettingsSection({
type="checkbox"
checked={allowAgentDowngrade}
onChange={(e) => setAllowAgentDowngrade(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Allow agent downgrade</span>
@@ -289,7 +289,7 @@ export function AdminConfigSettingsSection({
value={modernDeploymentHarmlessEventIds}
onChange={(e) => setModernDeploymentHarmlessEventIds(e.target.value)}
placeholder="100, 1005, 1010"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-mono text-sm"
/>
</label>
</div>
@@ -300,7 +300,7 @@ export function AdminConfigSettingsSection({
type="checkbox"
checked={enableIndexDualWrite}
onChange={(e) => setEnableIndexDualWrite(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">V2 Decision Engine: Enable index-table dual-write</span>
@@ -328,7 +328,7 @@ export function AdminConfigSettingsSection({
type="checkbox"
checked={sessionDeletionKillSwitch}
onChange={(e) => setSessionDeletionKillSwitch(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 focus:ring-red-500"
className="mt-1 h-5 w-5 rounded border-red-300 dark:border-red-600 text-red-600 accent-red-600 focus:ring-red-500"
/>
<span>
<span className="text-red-900 dark:text-red-100 font-medium">Cascade-Delete Kill-Switch (emergency)</span>
@@ -542,7 +542,7 @@ function DeviceBlockSectionInner({
<td className="px-3 py-2">
<button
onClick={() => navigator.clipboard.writeText(d.tenantId)}
className="text-xs font-mono text-gray-500 hover:text-blue-600 transition-colors"
className="text-xs font-mono text-gray-500 hover:text-green-600 transition-colors"
title={d.tenantId}
>
{d.tenantId.split("-").slice(0, 2).join("-")}...
@@ -90,7 +90,7 @@ export function DiagnosticsLogPathsSection({
updated[idx] = { ...entry, includeSubfolders: !entry.includeSubfolders };
setGlobalDiagPaths(updated);
}}
className="w-3.5 h-3.5 rounded border-teal-400 text-teal-600 focus:ring-teal-500"
className="w-3.5 h-3.5 rounded border-teal-400 text-green-600 focus:ring-green-500"
/>
<span className="text-xs text-teal-600 dark:text-teal-400">Include subfolders</span>
</label>
@@ -148,7 +148,7 @@ export function DiagnosticsLogPathsSection({
type="checkbox"
checked={newDiagSubfolders}
onChange={() => setNewDiagSubfolders(!newDiagSubfolders)}
className="w-3.5 h-3.5 rounded border-gray-400 text-teal-600 focus:ring-teal-500"
className="w-3.5 h-3.5 rounded border-gray-400 text-green-600 focus:ring-green-500"
/>
<span className="text-xs text-gray-600 dark:text-gray-400">Include subfolders</span>
</label>
@@ -146,11 +146,6 @@ export function DistressReportsSection({
return sortDir === "asc" ? cmp : -cmp;
});
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchReports = async () => {
try {
setLoading(true);
@@ -172,6 +167,11 @@ export function DistressReportsSection({
}
};
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Summary stats
const errorTypeCounts = reports.reduce<Record<string, number>>((acc, r) => {
acc[r.errorType] = (acc[r.errorType] ?? 0) + 1;
@@ -35,6 +35,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
const [activeTab, setActiveTab] = useState<FeedbackTab>("InApp");
const [inAppFilter, setInAppFilter] = useState<InAppFilter>("all");
const [currentPage, setCurrentPage] = useState(0);
// Reference clock for the "Xm ago" labels — render must stay pure
// (react-hooks/purity), so it's captured at mount and refreshed per fetch,
// keeping the labels in step with the data they describe.
const [nowMs, setNowMs] = useState(() => Date.now());
const entriesPerPage = 5;
const fetchFeedback = useCallback(async () => {
@@ -56,6 +60,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
return dateB - dateA;
});
setEntries(sorted);
setNowMs(Date.now());
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while fetching feedback");
@@ -114,7 +119,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
const formatTimeAgo = (dateStr: string | null): string => {
if (!dateStr) return "unknown";
const diff = Date.now() - new Date(dateStr).getTime();
const diff = nowMs - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
@@ -17,6 +17,9 @@ export function MaintenanceSection({
}: MaintenanceSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
// Latest selectable day (yesterday), fixed at mount — render must stay pure
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
const [reseedingRules, setReseedingRules] = useState(false);
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
@@ -244,7 +247,7 @@ export function MaintenanceSection({
type="date"
value={maintenanceDate}
onChange={(e) => setMaintenanceDate(e.target.value)}
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
max={maxMaintenanceDate}
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
@@ -17,6 +17,9 @@ export function MaintenanceTriggerSection({
}: MaintenanceTriggerSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
// Latest selectable day (yesterday), fixed at mount — render must stay pure
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
const [refreshingVersions, setRefreshingVersions] = useState(false);
const handleRefreshLatestVersions = async () => {
@@ -143,7 +146,7 @@ export function MaintenanceTriggerSection({
type="date"
value={maintenanceDate}
onChange={(e) => setMaintenanceDate(e.target.value)}
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
max={maxMaintenanceDate}
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
@@ -232,14 +232,6 @@ export function OpsAlertRulesSection({
const enabledRulesCount = rules.filter(r => r.enabled).length;
const enabledProviders = [telegramEnabled, teamsEnabled, slackEnabled].filter(Boolean).length;
// Get category for a given event type
const getCategoryForEvent = (eventType: string): string => {
for (const [cat, types] of Object.entries(OPS_EVENT_TYPES)) {
if (types.includes(eventType)) return cat;
}
return "Unknown";
};
return (
<div className="space-y-6">
{/* Alert Rules */}
@@ -1,6 +1,6 @@
"use client";
import { sessionUrl } from "@/lib/routes";
import { sessionUrl, deviceBlockUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
@@ -558,7 +558,6 @@ export function OpsEventsSection({
const sessionId = extractSessionId(selectedEvent.details);
if (!sessionId) return null;
const reason = buildAutoReason(selectedEvent.eventType, sessionId);
const baseHref = `/admin/security/device-block?sessionId=${encodeURIComponent(sessionId)}&reason=${encodeURIComponent(reason)}`;
return (
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
@@ -574,14 +573,14 @@ export function OpsEventsSection({
View session
</Link>
<Link
href={`${baseHref}&action=Block`}
href={deviceBlockUrl(sessionId, reason, "Block")}
onClick={() => setSelectedEvent(null)}
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:hover:bg-orange-900/60 border border-orange-300 dark:border-orange-700"
>
Block this device
</Link>
<Link
href={`${baseHref}&action=Kill`}
href={deviceBlockUrl(sessionId, reason, "Kill")}
onClick={() => setSelectedEvent(null)}
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-red-700 text-white hover:bg-red-800 dark:bg-red-700 dark:hover:bg-red-800"
>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
@@ -76,7 +77,18 @@ function CopyButton({ value }: { value: string }) {
);
}
export function SessionReportsSection({
export function SessionReportsSection(props: SessionReportsSectionProps) {
// useSearchParams() in the inner component requires a Suspense boundary for
// static prerender (this section renders inside the prerendered
// /admin/reports/session-reports page).
return (
<Suspense fallback={null}>
<SessionReportsSectionInner {...props} />
</Suspense>
);
}
function SessionReportsSectionInner({
getAccessToken,
setError,
}: SessionReportsSectionProps) {
@@ -101,6 +113,25 @@ export function SessionReportsSection({
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
const [pageNumber, setPageNumber] = useState(1);
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
// the first page has loaded. Reports are listed newest-first, so a report reached
// via a fresh notification is on page 1; older ones get a hint instead.
const searchParams = useSearchParams();
const autoOpenedRef = useRef(false);
useEffect(() => {
if (autoOpenedRef.current || loading) return;
const reportIdParam = searchParams?.get("reportId");
if (!reportIdParam) return;
autoOpenedRef.current = true;
const match = reports.find((r) => r.reportId === reportIdParam);
if (match) {
setSelectedReport(match);
} else {
setError(`Report ${reportIdParam} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
}
}, [loading, reports, searchParams, setError]);
useEffect(() => {
if (selectedReport) {
setAdminNoteValue(selectedReport.adminNote ?? "");
@@ -279,7 +310,7 @@ export function SessionReportsSection({
onKeyDown={(e) => {
if (e.key === "Enter") handleApplyTenantFilter();
}}
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<button
onClick={handleApplyTenantFilter}
@@ -514,7 +545,7 @@ export function SessionReportsSection({
readOnly={!canMutate}
rows={3}
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
/>
<div className="flex items-center justify-between mt-1.5">
<div className="text-xs">
@@ -533,7 +564,7 @@ export function SessionReportsSection({
<button
onClick={handleSaveAdminNote}
disabled={!canMutate || savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
>
{savingNote ? (
<>
@@ -555,7 +586,7 @@ export function SessionReportsSection({
<button
onClick={() => handleDownload(selectedReport.blobName)}
disabled={downloadingBlob === selectedReport.blobName}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-sm font-medium"
>
{downloadingBlob === selectedReport.blobName ? (
<>
@@ -423,7 +423,7 @@ export function TenantAdminSection({
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder="admin@tenant.com"
autoComplete="off"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TenantAdminSection } from "./TenantAdminSection";
@@ -48,7 +49,18 @@ export interface TenantManagementSectionProps {
setSuccessMessage: (message: string | null) => void;
}
export function TenantManagementSection({
export function TenantManagementSection(props: TenantManagementSectionProps) {
// useSearchParams() in the inner component requires a Suspense boundary for
// static prerender (this section renders inside the prerendered
// /admin/tenants/management page).
return (
<Suspense fallback={null}>
<TenantManagementSectionInner {...props} />
</Suspense>
);
}
function TenantManagementSectionInner({
tenants,
loadingTenants,
fetchTenants,
@@ -62,6 +74,21 @@ export function TenantManagementSection({
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
const canMutate = useCanMutatePlatform();
const [searchQuery, setSearchQuery] = useState("");
// Mount-time clock for trial-expiry checks — render must stay pure
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
const [nowMs] = useState(() => Date.now());
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
// list opens filtered to the tenant the notification is about.
const searchParams = useSearchParams();
const seededSearchRef = useRef(false);
useEffect(() => {
if (seededSearchRef.current) return;
const tenantIdParam = searchParams?.get("tenantId");
if (!tenantIdParam) return;
seededSearchRef.current = true;
setSearchQuery(tenantIdParam);
}, [searchParams]);
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
const [showOnlyReady, setShowOnlyReady] = useState(false);
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
@@ -331,7 +358,7 @@ export function TenantManagementSection({
placeholder={'Search by domain or tenant ID (use "..." for exact match)'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
{searchQuery && (
<button
@@ -349,7 +376,7 @@ export function TenantManagementSection({
onClick={() => { setShowOnlyReady(v => !v); setCurrentPage(0); }}
className={`flex items-center space-x-1 px-3 py-2 text-sm rounded-lg border transition-colors whitespace-nowrap ${
showOnlyReady
? 'bg-blue-600 text-white border-blue-600'
? 'bg-green-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
@@ -432,7 +459,7 @@ export function TenantManagementSection({
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
previewApproved.has(tenant.tenantId)
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{togglingPreviewTenant === tenant.tenantId
@@ -573,7 +600,7 @@ export function TenantManagementSection({
{(() => {
const isEnterpriseTier = editingTenant.planTier === "enterprise";
const trialActive = !!editingTenant.trialExpiresUtc &&
new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
return (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
@@ -670,7 +697,7 @@ export function TenantManagementSection({
value={notificationEmail}
onChange={(e) => setNotificationEmail(e.target.value)}
placeholder="user@example.com"
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<button
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, notificationEmail)}
@@ -718,7 +745,7 @@ export function TenantManagementSection({
const v = e.target.value.trim();
setEditingTenant({ ...editingTenant, customRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="mt-1 text-xs text-gray-500">Per-device (agent/cert) limit. Leave blank to inherit the global default.</p>
</div>
@@ -736,7 +763,7 @@ export function TenantManagementSection({
const v = e.target.value.trim();
setEditingTenant({ ...editingTenant, customUserRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="mt-1 text-xs text-gray-500">Per-user (portal) limit for standard users. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
</div>
@@ -787,7 +814,7 @@ export function TenantManagementSection({
const val = parseInt(e.target.value);
setEditingTenant({ ...editingTenant, dataRetentionDays: isNaN(val) ? 90 : val });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
{editingTenant.dataRetentionDays === 0 ? (
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Infinite retention — data will never be automatically deleted</p>
@@ -16,7 +16,7 @@ export function TenantSearchSelect({
value,
onChange,
placeholder = "Search tenant by name or ID\u2026",
focusRingClass = "focus:ring-blue-500 focus:border-blue-500",
focusRingClass = "focus:ring-green-500 focus:border-green-500",
}: TenantSearchSelectProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
@@ -472,7 +472,7 @@ export function UnmappedSoftwareTab({
{autoResolving && autoResolveProgress && (
<div className="mb-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
<span>
Auto-resolving... Batch {autoResolveProgress.batchIndex}/{autoResolveProgress.totalBatches}
{autoResolveProgress.resolvedSoFar > 0 && <> &middot; Resolved: {autoResolveProgress.resolvedSoFar}</>}
@@ -532,7 +532,7 @@ export function UnmappedSoftwareTab({
checked={allOnPageSelected && entries.length > 0}
onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
/>
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
@@ -561,7 +561,7 @@ export function UnmappedSoftwareTab({
checked={selectedEntries.has(key)}
onChange={() => toggleRowSelection(entry)}
disabled={autoResolving}
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500 disabled:opacity-40"
/>
)}
</div>
@@ -517,7 +517,7 @@ export function VulnerabilityDataSection({
<button
onClick={handleSyncMsrc}
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
>
{syncingMsrc ? (
@@ -143,7 +143,7 @@ function CustomsArchiveDetailContent() {
type="button"
onClick={load}
disabled={loading}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{loading ? "Loading…" : "Refresh"}
</button>
@@ -102,7 +102,7 @@ export default function CustomsArchivePage() {
type="button"
onClick={load}
disabled={loading}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
{loading ? "Loading…" : "Refresh"}
</button>
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import TruncatedLabel from '@/components/TruncatedLabel';
import { useAuth } from '../../../../contexts/AuthContext';
@@ -138,9 +137,7 @@ function pN(values: number[], percentile: number): number {
// ── Component ──────────────────────────────────────────────────────────────────
export function SectionAgentMetrics() {
const router = useRouter();
const { getAccessToken, user } = useAuth();
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const [loading, setLoading] = useState(true);
@@ -845,8 +842,6 @@ function StatCard({ label, value, detail, color }: { label: string; value: strin
function FootprintBadge({
value,
thresholds,
unit,
formatFn,
}: {
value: number;
thresholds: [number, number, number];
@@ -1,5 +1,6 @@
"use client";
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -134,21 +135,11 @@ export function SectionMcpUsage() {
<p className="text-sm text-gray-500">API usage across all MCP-enabled users</p>
</div>
<div className="flex items-center gap-3">
<div className="flex rounded-md shadow-sm">
{(["7d", "30d", "90d"] as DateRange[]).map((range) => (
<button
key={range}
onClick={() => setDateRange(range)}
className={`px-3 py-1.5 text-sm font-medium border ${
dateRange === range
? "bg-indigo-600 text-white border-indigo-600 z-10"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
} ${range === "7d" ? "rounded-l-md" : ""} ${range === "90d" ? "rounded-r-md" : ""} -ml-px first:ml-0`}
>
{range}
</button>
))}
</div>
<SegmentedControl
options={TIME_RANGE_OPTIONS}
value={dateRange}
onChange={(v) => setDateRange(v as DateRange)}
/>
<button
onClick={() => fetchData(dateRange)}
disabled={loading}
@@ -186,7 +186,7 @@ export function SectionPlatformUsage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<svg className="h-12 w-12 text-red-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -197,7 +197,7 @@ export function SectionPlatformUsage() {
<div className="mt-6 space-y-3">
<button
onClick={() => fetchMetrics()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Retry
</button>
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminConfig } from "../../../AdminConfigContext";
@@ -276,7 +276,7 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
setSelectedTenantId("");
}
}}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 accent-purple-600 focus:ring-purple-500"
/>
<label
htmlFor="restore-browser-only-with-data"
@@ -136,7 +136,7 @@ export function RestoreConfirmDialog({
placeholder="e.g. customer support — accidental delete"
maxLength={200}
disabled={submitting}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-60"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Stored on the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">deletion_restored</code> audit row when the real restore runs. Dry-runs are intentionally audit-free.
@@ -174,7 +174,7 @@ export function RestoreConfirmDialog({
<button
onClick={() => submit(true)}
disabled={submitting}
className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{submitting ? "Running…" : dryRunResult ? "Run dry-run again" : "Run dry-run"}
</button>
@@ -139,7 +139,6 @@ function TabButton({
function InFlightTab({
getAccessToken,
setError,
setSuccessMessage,
}: {
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
setError: (error: string | null) => void;
@@ -191,7 +190,7 @@ function InFlightTab({
renderRowActions={(row) => (
<button
onClick={() => setPreviewing(row)}
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
>
Preview
</button>
@@ -368,7 +367,7 @@ function StrandedTab({
renderRowActions={(row) => (
<button
onClick={() => setPreviewing(row)}
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
>
Preview
</button>
@@ -1,7 +1,7 @@
"use client";
import { notFound } from "next/navigation";
import { REPORTS_NAV_SECTIONS, type ReportsSectionId } from "../reportsNavSections";
import { type ReportsSectionId } from "../reportsNavSections";
import { SectionSessionReports } from "../sections/SectionSessionReports";
import { SectionDistressReports } from "../sections/SectionDistressReports";
import { SectionUserFeedback } from "../sections/SectionUserFeedback";
@@ -1,7 +1,7 @@
"use client";
import { notFound } from "next/navigation";
import { SECURITY_NAV_SECTIONS, type SecuritySectionId } from "../securityNavSections";
import { type SecuritySectionId } from "../securityNavSections";
import { SectionDeviceBlock } from "../sections/SectionDeviceBlock";
import { SectionVersionBlock } from "../sections/SectionVersionBlock";
import { SectionVulnerabilityData } from "../sections/SectionVulnerabilityData";
@@ -144,7 +144,7 @@ export function SectionUsagePlans() {
<button
onClick={handleSave}
disabled={saving || !hasChanges}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? "Saving..." : "Save Changes"}
</button>
@@ -182,7 +182,7 @@ export function SectionUsagePlans() {
<p className="text-gray-500 mb-4">No plan tiers defined yet.</p>
<button
onClick={addTier}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors"
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Create First Plan Tier
</button>
@@ -203,7 +203,7 @@ export function SectionUsagePlans() {
value={tier.name}
onChange={(e) => updateTier(index, "name", e.target.value)}
placeholder="e.g., free, pro, enterprise"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
{/* Daily Limit */}
@@ -214,7 +214,7 @@ export function SectionUsagePlans() {
min={0}
value={tier.dailyRequestLimit}
onChange={(e) => updateTier(index, "dailyRequestLimit", parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
{/* Monthly Limit */}
@@ -225,7 +225,7 @@ export function SectionUsagePlans() {
min={0}
value={tier.monthlyRequestLimit}
onChange={(e) => updateTier(index, "monthlyRequestLimit", parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
{/* Description */}
@@ -236,7 +236,7 @@ export function SectionUsagePlans() {
value={tier.description}
onChange={(e) => updateTier(index, "description", e.target.value)}
placeholder="Brief description"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
@@ -335,6 +335,9 @@ export function SectionTenantConfigReport() {
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState('');
// Mount-time clock for trial-expiry checks — render must stay pure
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
const [nowMs] = useState(() => Date.now());
const [config, setConfig] = useState<TenantConfig | null>(null);
const [loading, setLoading] = useState(false);
const [loadingTenants, setLoadingTenants] = useState(true);
@@ -426,8 +429,6 @@ export function SectionTenantConfigReport() {
})()
: [];
const selectedTenant = tenants.find((t) => t.tenantId === selectedTenantId);
return (
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Header */}
@@ -533,7 +534,7 @@ export function SectionTenantConfigReport() {
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
<ConfigRow
label="Plan Tier"
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
configKey="planTier"
defaults={DEFAULTS}
/>
@@ -578,7 +578,7 @@ export default function AnalyzeRuleCard({
<span>Export</span>
</button>
{canEdit && (
<button onClick={() => onStartEditing(rule)} className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2" title="Edit rule">
<button onClick={() => onStartEditing(rule)} className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2" title="Edit rule">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span>Edit</span>
</button>
@@ -655,7 +655,7 @@ export default function AnalyzeRuleCard({
} else {
onSaveEdit(rule);
}
}} disabled={saving} className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
}} disabled={saving} className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
{saving ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>) : (<span>Save Changes</span>)}
</button>
</div>
@@ -25,7 +25,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
{showRuleId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rule ID <span className="text-red-500">*</span></label>
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`} />
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-green-500 focus:border-green-500"}`} />
{isDuplicate && (
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
)}
@@ -33,31 +33,31 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title <span className="text-red-500">*</span></label>
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe what this rule detects..." rows={2} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" />
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe what this rule detects..." rows={2} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Severity</label>
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500">
{SEVERITIES.map((s) => (<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500">
{CATEGORIES.map((cat) => (<option key={cat} value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Type</label>
<select value={form.trigger} onChange={(e) => setForm({ ...form, trigger: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<select value={form.trigger} onChange={(e) => setForm({ ...form, trigger: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500">
{TRIGGERS.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
@@ -70,7 +70,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<label className="block text-sm font-semibold text-gray-700">Preconditions</label>
<p className="text-xs text-gray-500">Optional. Rule is silently skipped (no result) when ANY precondition fails. Useful e.g. to skip on virtual machines.</p>
</div>
<button type="button" onClick={() => setForm({ ...form, preconditions: [...form.preconditions, { ...EMPTY_PRECONDITION }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Precondition</button>
<button type="button" onClick={() => setForm({ ...form, preconditions: [...form.preconditions, { ...EMPTY_PRECONDITION }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Precondition</button>
</div>
{form.preconditions.length === 0 ? (
<p className="text-xs text-gray-400 italic">No preconditions. Rule applies to every device.</p>
@@ -108,7 +108,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Conditions</label>
<button type="button" onClick={() => setForm({ ...form, conditions: [...form.conditions, { ...EMPTY_CONDITION }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Condition</button>
<button type="button" onClick={() => setForm({ ...form, conditions: [...form.conditions, { ...EMPTY_CONDITION }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Condition</button>
</div>
<div className="space-y-3">
{form.conditions.map((cond, idx) => {
@@ -134,22 +134,22 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
{/* Row 1: Signal, Source, Event Type A */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input type="text" value={cond.signal} onChange={(e) => updateCond({ signal: e.target.value })} placeholder="Signal name" autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<select value={cond.source} onChange={(e) => updateCond({ source: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<input type="text" value={cond.signal} onChange={(e) => updateCond({ signal: e.target.value })} placeholder="Signal name" autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<select value={cond.source} onChange={(e) => updateCond({ source: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-green-500">
{SOURCES.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
<input type="text" value={cond.eventType} onChange={(e) => updateCond({ eventType: e.target.value })} placeholder={isCorrelation ? "Event A type (e.g. app_install_completed)" : "Event type"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={cond.eventType} onChange={(e) => updateCond({ eventType: e.target.value })} placeholder={isCorrelation ? "Event A type (e.g. app_install_completed)" : "Event type"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
</div>
{/* Row 2: Data field / Operator / Value / Required */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
<input type="text" value={cond.dataField} onChange={(e) => updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : isArray ? "Array field (e.g. artifacts)" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<select value={cond.operator} onChange={(e) => updateCond({ operator: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<input type="text" value={cond.dataField} onChange={(e) => updateCond({ dataField: e.target.value })} placeholder={isCorrelation ? "Filter field on Event B" : isArray ? "Array field (e.g. artifacts)" : "Data field"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<select value={cond.operator} onChange={(e) => updateCond({ operator: e.target.value })} className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-green-500">
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
</select>
<input type="text" value={cond.value} onChange={(e) => updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : isArray ? "Value / allow-list regex" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={cond.value} onChange={(e) => updateCond({ value: e.target.value })} placeholder={isCorrelation ? "Filter value on Event B" : isArray ? "Value / allow-list regex" : "Value"} autoComplete="off" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<label className="flex items-center space-x-2 text-sm text-gray-700">
<input type="checkbox" checked={cond.required} onChange={(e) => updateCond({ required: e.target.checked })} className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<input type="checkbox" checked={cond.required} onChange={(e) => updateCond({ required: e.target.checked })} className="rounded border-gray-300 text-green-600 focus:ring-green-500" />
<span>Required</span>
</label>
</div>
@@ -163,15 +163,15 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">Event B type <span className="text-red-500">*</span></label>
<input type="text" value={cond.correlateEventType ?? ""} onChange={(e) => updateCond({ correlateEventType: e.target.value })} placeholder="e.g. app_install_failed" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={cond.correlateEventType ?? ""} onChange={(e) => updateCond({ correlateEventType: e.target.value })} placeholder="e.g. app_install_failed" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Join field <span className="text-red-500">*</span></label>
<input type="text" value={cond.joinField ?? ""} onChange={(e) => updateCond({ joinField: e.target.value })} placeholder="e.g. appId" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={cond.joinField ?? ""} onChange={(e) => updateCond({ joinField: e.target.value })} placeholder="e.g. appId" autoComplete="off" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Time window (seconds, optional)</label>
<input type="number" min={0} value={cond.timeWindowSeconds ?? ""} onChange={(e) => updateCond({ timeWindowSeconds: e.target.value === "" ? null : parseInt(e.target.value) || 0 })} placeholder="e.g. 300" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="number" min={0} value={cond.timeWindowSeconds ?? ""} onChange={(e) => updateCond({ timeWindowSeconds: e.target.value === "" ? null : parseInt(e.target.value) || 0 })} placeholder="e.g. 300" className="w-full px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
</div>
</div>
@@ -179,11 +179,11 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div>
<label className="block text-xs text-gray-500 mb-1">Event A filter (optional)</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input type="text" value={cond.eventAFilterField ?? ""} onChange={(e) => updateCond({ eventAFilterField: e.target.value })} placeholder="Filter field on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<select value={cond.eventAFilterOperator ?? "equals"} onChange={(e) => updateCond({ eventAFilterOperator: e.target.value })} className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<input type="text" value={cond.eventAFilterField ?? ""} onChange={(e) => updateCond({ eventAFilterField: e.target.value })} placeholder="Filter field on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<select value={cond.eventAFilterOperator ?? "equals"} onChange={(e) => updateCond({ eventAFilterOperator: e.target.value })} className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 bg-white focus:outline-none focus:ring-1 focus:ring-green-500">
{OPERATORS.map((o) => (<option key={o} value={o}>{o}</option>))}
</select>
<input type="text" value={cond.eventAFilterValue ?? ""} onChange={(e) => updateCond({ eventAFilterValue: e.target.value })} placeholder="Filter value on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={cond.eventAFilterValue ?? ""} onChange={(e) => updateCond({ eventAFilterValue: e.target.value })} placeholder="Filter value on Event A" autoComplete="off" className="px-3 py-1.5 border border-indigo-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
</div>
</div>
</div>
@@ -212,11 +212,11 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Base Confidence (%)</label>
<input type="number" min={0} max={100} value={form.baseConfidence} onChange={(e) => setForm({ ...form, baseConfidence: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
<input type="number" min={0} max={100} value={form.baseConfidence} onChange={(e) => setForm({ ...form, baseConfidence: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confidence Threshold (%)</label>
<input type="number" min={0} max={100} value={form.confidenceThreshold} onChange={(e) => setForm({ ...form, confidenceThreshold: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
<input type="number" min={0} max={100} value={form.confidenceThreshold} onChange={(e) => setForm({ ...form, confidenceThreshold: parseInt(e.target.value) || 0 })} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
</div>
</div>
@@ -224,7 +224,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Confidence Factors</label>
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: [...form.confidenceFactors, { ...EMPTY_FACTOR }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Factor</button>
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: [...form.confidenceFactors, { ...EMPTY_FACTOR }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Factor</button>
</div>
{form.confidenceFactors.length === 0 ? (
<p className="text-xs text-gray-400 italic">No confidence factors. Click &quot;+ Add Factor&quot; to add one.</p>
@@ -232,9 +232,9 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div className="space-y-2">
{form.confidenceFactors.map((factor, idx) => (
<div key={idx} className="flex items-center gap-2">
<input type="text" value={factor.signal} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], signal: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Signal" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={factor.condition} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], condition: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Condition" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="number" value={factor.weight} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], weight: parseInt(e.target.value) || 0 }; setForm({ ...form, confidenceFactors: f }); }} className="w-20 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={factor.signal} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], signal: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Signal" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<input type="text" value={factor.condition} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], condition: e.target.value }; setForm({ ...form, confidenceFactors: f }); }} placeholder="Condition" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<input type="number" value={factor.weight} onChange={(e) => { const f = [...form.confidenceFactors]; f[idx] = { ...f[idx], weight: parseInt(e.target.value) || 0 }; setForm({ ...form, confidenceFactors: f }); }} className="w-20 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500" />
<span className="text-xs text-gray-500">%</span>
<button type="button" onClick={() => setForm({ ...form, confidenceFactors: form.confidenceFactors.filter((_, i) => i !== idx) })} className="text-red-400 hover:text-red-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
@@ -248,14 +248,14 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
{/* Explanation */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Explanation</label>
<textarea value={form.explanation} onChange={(e) => setForm({ ...form, explanation: e.target.value })} placeholder="Detailed explanation shown when this rule fires..." rows={3} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" />
<textarea value={form.explanation} onChange={(e) => setForm({ ...form, explanation: e.target.value })} placeholder="Detailed explanation shown when this rule fires..." rows={3} className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none" />
</div>
{/* Remediation Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Remediation Steps</label>
<button type="button" onClick={() => setForm({ ...form, remediation: [...form.remediation, { title: "", steps: [""] }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Section</button>
<button type="button" onClick={() => setForm({ ...form, remediation: [...form.remediation, { title: "", steps: [""] }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Section</button>
</div>
{form.remediation.length === 0 ? (
<p className="text-xs text-gray-400 italic">No remediation steps. Click &quot;+ Add Section&quot; to add one.</p>
@@ -289,7 +289,7 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-gray-700">Related Documentation</label>
<button type="button" onClick={() => setForm({ ...form, relatedDocs: [...form.relatedDocs, { title: "", url: "" }] })} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium">+ Add Link</button>
<button type="button" onClick={() => setForm({ ...form, relatedDocs: [...form.relatedDocs, { title: "", url: "" }] })} className="text-xs text-green-700 hover:text-green-800 font-medium">+ Add Link</button>
</div>
{form.relatedDocs.length === 0 ? (
<p className="text-xs text-gray-400 italic">No related docs. Click &quot;+ Add Link&quot; to add one.</p>
@@ -297,8 +297,8 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, exist
<div className="space-y-2">
{form.relatedDocs.map((doc, idx) => (
<div key={idx} className="flex items-center gap-2">
<input type="text" value={doc.title} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], title: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="Link title" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={doc.url} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], url: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="https://..." autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<input type="text" value={doc.title} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], title: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="Link title" autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<input type="text" value={doc.url} onChange={(e) => { const d = [...form.relatedDocs]; d[idx] = { ...d[idx], url: e.target.value }; setForm({ ...form, relatedDocs: d }); }} placeholder="https://..." autoComplete="off" className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500" />
<button type="button" onClick={() => setForm({ ...form, relatedDocs: form.relatedDocs.filter((_, i) => i !== idx) })} className="text-red-400 hover:text-red-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { AnalyzeRule, TemplateVariable } from "../types";
import { AnalyzeRule } from "../types";
interface TemplateConfigModalProps {
rule: AnalyzeRule;
@@ -1,8 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
@@ -25,8 +23,6 @@ import TemplateConfigModal from "./components/TemplateConfigModal";
import { DOCS_URL } from "@/utils/config";
export default function AnalyzeRulesPage() {
const router = useRouter();
const { user, getAccessToken } = useAuth();
const { successMessage, error, showSuccess, showError } = useNotificationMessages();
@@ -563,7 +559,7 @@ export default function AnalyzeRulesPage() {
</svg>
<p className="text-sm text-blue-800">
Missing a rule, found a bug, or have an improvement idea? Help make Autopilot Monitor better —{" "}
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
open a GitHub issue
</a>.
</p>
@@ -749,7 +745,7 @@ export default function AnalyzeRulesPage() {
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Create Custom Analyze Rule</h2>
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href={`${DOCS_URL}/rules/analyze-rules`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href={`${DOCS_URL}/rules/analyze-rules`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:text-green-800 underline">see documentation</a></p>
</div>
</div>
{/* JSON Mode Toggle */}
@@ -804,7 +800,7 @@ export default function AnalyzeRulesPage() {
} else {
handleCreateRule();
}
}} disabled={creating} className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
}} disabled={creating} className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium">
{creating ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Creating...</span></>) : (<span>Save Rule</span>)}
</button>
</div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -254,7 +254,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
placeholder="Search apps by name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm"
/>
</div>
@@ -138,7 +138,7 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
placeholder="Search software by name or publisher…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm"
/>
</div>
@@ -376,11 +376,6 @@ function AppDetailContent() {
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
}
const failureCodeBarColor = (row: Record<string, unknown>): string => {
const c = Number(row.count);
return c >= 5 ? chartColors.danger : c >= 2 ? chartColors.warning : chartColors.muted;
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
@@ -392,7 +387,7 @@ function AppDetailContent() {
// Tenant scope is carried in sessionStorage, so the list restores it on its own.
router.push("/apps");
}}
className="text-sm text-blue-600 hover:underline mb-2 inline-flex items-center"
className="text-sm text-green-700 hover:underline mb-2 inline-flex items-center"
>
← Back to all apps
</button>
@@ -420,7 +415,7 @@ function AppDetailContent() {
onClick={() => setDays(d)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
days === d
? "bg-blue-600 text-white"
? "bg-green-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
@@ -693,7 +688,7 @@ function AppDetailContent() {
}}
className={`px-3 py-1 text-xs rounded-md ${
statusFilter === s
? "bg-blue-600 text-white"
? "bg-green-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
@@ -738,7 +733,7 @@ function AppDetailContent() {
type="checkbox"
checked={visibleSessionColumns.has(col.key)}
onChange={() => toggleSessionColumn(col.key)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-3.5 w-3.5"
className="rounded border-gray-300 text-green-600 focus:ring-green-500 h-3.5 w-3.5"
/>
<span className="flex-1">{col.label}</span>
{col.globalOnly && (
@@ -7,6 +7,7 @@ import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useAggregatedAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
import InstallsTab from "./components/InstallsTab";
import InventoryTab from "./components/InventoryTab";
import VulnerabilitiesTab from "./components/VulnerabilitiesTab";
@@ -39,26 +40,20 @@ function SoftwareHub() {
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto pt-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div>
<h1 className="text-2xl font-normal text-gray-900">Software</h1>
<p className="text-sm text-gray-500 mt-1">
App installs, the installed-software inventory, and vulnerability exposure across enrollments.
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2">
<TenantScopeSelector scope={scope} allowAggregated />
{(["7d", "30d", "90d"] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
timeRange === range ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
</button>
))}
<SegmentedControl
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={(v) => setTimeRange(v as typeof timeRange)}
/>
</div>
</div>
@@ -70,7 +65,7 @@ function SoftwareHub() {
onClick={() => selectTab(t.id)}
className={`-mb-px border-b-2 px-1 py-3 text-sm font-medium transition-colors ${
activeTab === t.id
? "border-blue-600 text-blue-700"
? "border-green-600 text-green-700"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
@@ -2,7 +2,6 @@
import { Fragment, useCallback, useEffect, useState } from 'react';
import { TableSkeleton } from '@/components/skeletons/TableSkeleton';
import { useRouter } from 'next/navigation';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
import { ProtectedRoute } from '../../components/ProtectedRoute';
@@ -63,8 +62,6 @@ function dateInputToIsoEnd(value: string): string {
}
export default function AuditPage() {
const router = useRouter();
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
@@ -248,7 +245,7 @@ export default function AuditPage() {
/>
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div>
<div>
<h1 className="text-2xl font-normal text-gray-900">Audit Log</h1>
@@ -262,7 +259,7 @@ export default function AuditPage() {
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2">
<TenantScopeSelector scope={scope} allowAggregated />
<button
onClick={handleRefresh}
@@ -289,14 +286,14 @@ export default function AuditPage() {
type="date"
value={isoToDateInputValue(dateFromIso)}
onChange={(e) => setDateFromIso(dateInputToIsoStart(e.target.value))}
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<label className="text-sm text-gray-600 dark:text-gray-400">To:</label>
<input
type="date"
value={isoToDateInputValue(dateToIso)}
onChange={(e) => setDateToIso(dateInputToIsoEnd(e.target.value))}
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
@@ -310,7 +307,7 @@ export default function AuditPage() {
placeholder="Search by user, entity, action or details (this page only)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
@@ -318,7 +315,7 @@ export default function AuditPage() {
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="DEFAULT">All (excl. deletions)</option>
<option value="ALL">All Actions</option>
@@ -331,7 +328,7 @@ export default function AuditPage() {
<select
value={entityTypeFilter}
onChange={(e) => setEntityTypeFilter(e.target.value)}
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
>
{entityTypes.map(type => (
<option key={type} value={type}>
@@ -465,7 +462,7 @@ export default function AuditPage() {
<button
onClick={handleNextPage}
disabled={!nextLink || refreshing}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next &rarr;
</button>
@@ -2,6 +2,7 @@
import { sessionUrl } from "@/lib/routes";
import { useRouter } from "next/navigation";
import type { Route } from "next";
import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
import { Session } from "../types";
import { trackEvent } from "@/lib/appInsights";
@@ -103,7 +104,7 @@ interface SessionTableProps {
/** Builds the row's navigation target. Defaults to `/sessions/{id}`; a cross-tenant viewer overrides it to
* append `?tenantId=` so a delegated viewer opens the session in the managed tenant's (read-only) context.
* Receives the whole session because the target tenant is per-row (session.tenantId), not derivable from id. */
sessionLinkTarget?: (session: Session) => string;
sessionLinkTarget?: (session: Session) => Route;
}
export function SessionTable({
@@ -333,7 +334,7 @@ export function SessionTable({
<select
value={sessionsPerPage}
onChange={(e) => onSessionsPerPageChange(Number(e.target.value))}
className="px-2 py-1.5 rounded-lg text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
className="px-2 py-1.5 rounded-lg text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-green-500"
title="Sessions per page"
>
{[10, 15, 20, 50, 100].map((n) => (
@@ -399,7 +400,7 @@ export function SessionTable({
type="checkbox"
checked={visibleColumns.has(col.key)}
onChange={() => toggleColumn(col.key)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-3.5 w-3.5"
className="rounded border-gray-300 text-green-600 focus:ring-green-500 h-3.5 w-3.5"
/>
{col.label}
</label>
@@ -462,7 +463,7 @@ export function SessionTable({
}
}
}}
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
{searchQuery && (
<button
@@ -557,7 +558,7 @@ export function SessionTable({
setShowSearchSuggestions(false);
onLoadAll();
}}
className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
className="text-xs font-medium text-green-700 hover:text-green-800 transition-colors"
>
Search all sessions
</button>
@@ -574,7 +575,7 @@ export function SessionTable({
{(["Succeeded", "InProgress", "Pending", "Stalled", "AwaitingUser", "Failed", "Incomplete"] as const).map((status) => {
const config: Record<string, { bg: string; bgActive: string; text: string; label: string }> = {
Succeeded: { bg: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100", bgActive: "bg-green-600 text-white border-green-600", text: "text-green-600", label: "Succeeded" },
InProgress: { bg: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100", bgActive: "bg-blue-600 text-white border-blue-600", text: "text-blue-600", label: "In Progress" },
InProgress: { bg: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100", bgActive: "bg-green-600 text-white border-blue-600", text: "text-blue-600", label: "In Progress" },
Pending: { bg: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100", bgActive: "bg-amber-500 text-white border-amber-500", text: "text-amber-600", label: "Pending" },
Stalled: { bg: "bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100", bgActive: "bg-orange-600 text-white border-orange-600", text: "text-orange-600", label: "Stalled" },
// Non-terminal reclassification state (Device Setup done, waiting on user) — sky, mirrors SessionStatusBadge.
@@ -740,7 +741,7 @@ export function SessionTable({
<button
onClick={onNextPage}
disabled={(currentPage >= totalPages && !hasMore) || loadingMore}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
{loadingMore && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
@@ -760,7 +761,6 @@ export function SessionTable({
function SessionCell({
columnKey,
session,
adminMode,
globalAdminMode,
blockedDevicesSet,
user,
@@ -803,7 +803,7 @@ function SessionCell({
e.stopPropagation();
navigator.clipboard.writeText(session.tenantId);
}}
className="group flex items-center gap-1 text-xs font-mono text-gray-600 hover:text-blue-600 transition-colors"
className="group flex items-center gap-1 text-xs font-mono text-gray-600 hover:text-green-600 transition-colors"
title={session.tenantId}
>
<span>{session.tenantId.split('-').slice(0, 2).join('-')}...</span>
@@ -1098,7 +1098,7 @@ function SortableHeader({
e.stopPropagation();
onFilterToggle();
}}
className={`p-0.5 rounded transition-colors ${hasActiveFilter ? "text-blue-600 hover:text-blue-800" : "text-gray-300 hover:text-gray-500"}`}
className={`p-0.5 rounded transition-colors ${hasActiveFilter ? "text-green-700 hover:text-green-800" : "text-gray-300 hover:text-gray-500"}`}
title={hasActiveFilter ? `Filtered (${activeFilter!.size} selected)` : "Filter this column"}
>
<svg className="w-3 h-3" fill={hasActiveFilter ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
@@ -1123,7 +1123,7 @@ function SortableHeader({
placeholder="Search..."
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
className="w-full px-2 py-1 text-xs border border-gray-200 rounded text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full px-2 py-1 text-xs border border-gray-200 rounded text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-green-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
@@ -1148,7 +1148,7 @@ function SortableHeader({
}
onFilterChange(filterKey, next);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 h-3 w-3"
className="rounded border-gray-300 text-green-600 focus:ring-green-500 h-3 w-3"
/>
<span className="truncate">{value}</span>
</label>
@@ -68,7 +68,7 @@ export function UserMenu({
<div className="text-xs text-gray-500 truncate">
{email}
</div>
<button className="text-xs text-blue-600 hover:text-blue-800 mt-1">
<button className="text-xs text-green-700 hover:text-green-800 mt-1">
View account
</button>
</div>
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { DOCS_URL } from "@/utils/config";
export function WelcomeMessage() {
@@ -25,23 +24,23 @@ export function WelcomeMessage() {
{/* Steps */}
<ol className="space-y-3 mb-7">
<li className="flex items-start gap-3">
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
<p className="text-sm text-gray-700">
Follow the{" "}
<a href={`${DOCS_URL}/getting-started/portal-setup`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">setup guide</a>
<a href={`${DOCS_URL}/getting-started/portal-setup`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:underline font-medium">setup guide</a>
{" "}to configure your tenant and grant the required permissions.
</p>
</li>
<li className="flex items-start gap-3">
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
<p className="text-sm text-gray-700">
Deploy the{" "}
<a href={`${DOCS_URL}/getting-started/deploy-the-agent`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline font-medium">Intune bootstrapper</a>
<a href={`${DOCS_URL}/getting-started/deploy-the-agent`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:underline font-medium">Intune bootstrapper</a>
{" "}to your Autopilot device groups — the agent installs automatically on first boot.
</p>
</li>
<li className="flex items-start gap-3">
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
<p className="text-sm text-gray-700">Enrollment sessions will appear here in real time as devices go through the enrollment process.</p>
</li>
</ol>
@@ -66,7 +65,7 @@ export function WelcomeMessage() {
</svg>
</a>
<a
href="https://github.com/okieselbach/Autopilot-Monitor"
href="https://github.com/okieselbach/AutopilotMonitor"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-gray-400 hover:bg-gray-100 transition-colors"
@@ -90,9 +90,8 @@ function HomeContent() {
const mainClassName = fullWidth
? "w-full px-4 sm:px-6 lg:px-8 py-4"
: "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8";
const { user, logout, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth();
const { user, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth();
const { addNotification } = useNotifications();
const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked");
// `?tenant=<id>` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill
// a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there).
const initialTenantFilter = searchParams?.get("tenant") ?? "";
@@ -109,7 +108,7 @@ function HomeContent() {
// Drives the stats refetch — server-side stats follow the submitted scope so
// typing into the filter input doesn't trigger a backend round-trip per keystroke.
const [submittedTenantIdFilter, setSubmittedTenantIdFilter] = useState(initialTenantFilter);
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
const { adminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
const signalR = useSignalR();
const { tenantId } = useTenant();
@@ -232,7 +231,7 @@ function HomeContent() {
// Auto-load ALL remaining sessions when search is active and local results are insufficient.
// Uses a 500ms debounce so rapid typing doesn't trigger unnecessary loads.
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout>>();
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (autoLoadTimerRef.current) clearTimeout(autoLoadTimerRef.current);
@@ -299,7 +298,7 @@ function HomeContent() {
href={`${DOCS_URL}/changelog/platform-changelog`}
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
Private Preview Changelog
</a>{" "}
@@ -308,17 +307,17 @@ function HomeContent() {
href={`${DOCS_URL}/troubleshooting/service-announcements`}
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
Service Announcements
</a>
.{" "}
Feedback or bug report?{" "}
<a
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
Open a GitHub issue
</a>
@@ -327,7 +326,7 @@ function HomeContent() {
href="https://www.linkedin.com/in/oliver-kieselbach/"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
className="underline font-medium hover:text-green-600 dark:hover:text-blue-200"
>
LinkedIn
</a>
@@ -64,87 +64,8 @@ function DiagnosisContent() {
const { globalAdminMode } = useAdminMode();
useEffect(() => {
if (!sessionId) return;
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
sessionIdRef.current = sessionId;
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
sessionFetchDone.current = false;
analysisFetchDone.current = false;
setSessionTenantId(null);
setLoading(true);
}
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
// Performance: eager-set sessionTenantId from TenantContext when known, so
// the events/analysis effect fires in parallel with fetchSessionDetails
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
// Global Admins in all-tenant view fall through with null (backend resolves
// the tenant from the session itself).
if (!globalAdminMode && isGuid(tenantId)) {
setSessionTenantId(tenantId);
}
fetchSessionDetails();
}, [sessionId, tenantId, globalAdminMode]);
useEffect(() => {
if (sessionTenantId && sessionId) {
Promise.all([fetchEvents(), fetchAnalysisResults()]);
}
}, [sessionTenantId, sessionId]);
// SignalR groups - subscribe-then-fetch pattern
useEffect(() => {
const effectiveTenantId = sessionTenantId || tenantId;
if (!sessionId || !isConnected || !effectiveTenantId) return;
if (!hasJoinedGroups.current) {
const joinAndCatchUp = async () => {
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
hasJoinedGroups.current = true;
// Re-fetch after group join to catch any missed during join
Promise.all([fetchEvents(), fetchAnalysisResults()]);
};
joinAndCatchUp();
}
return () => {
if (hasJoinedGroups.current && effectiveTenantId) {
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
hasJoinedGroups.current = false;
}
};
}, [sessionId, isConnected, sessionTenantId, tenantId]);
// Real-time analysis updates
useEffect(() => {
const handleEventStream = (data: {
sessionId: string;
session: Session;
newRuleResults?: RuleResult[];
}) => {
if (data.sessionId !== sessionIdRef.current) return;
if (data.session) setSession(data.session);
if (data.newRuleResults?.length) {
setAnalysisResults((prev) => {
const existingIds = new Set(prev.map((r) => r.ruleId));
const newResults = data.newRuleResults!.filter(
(r) => !existingIds.has(r.ruleId)
);
return [...prev, ...newResults].sort(
(a, b) => b.confidenceScore - a.confidenceScore
);
});
}
};
on("eventStream", handleEventStream);
return () => {
off("eventStream", handleEventStream);
};
}, [on, off]);
// Declared ahead of the effects that call them (react-hooks/immutability:
// a later declaration would freeze the effect's view of the binding).
const fetchSessionDetails = async () => {
try {
// Fetch the single session directly instead of pulling the entire session
@@ -234,6 +155,87 @@ function DiagnosisContent() {
}
};
useEffect(() => {
if (!sessionId) return;
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
sessionIdRef.current = sessionId;
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
sessionFetchDone.current = false;
analysisFetchDone.current = false;
setSessionTenantId(null);
setLoading(true);
}
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
// Performance: eager-set sessionTenantId from TenantContext when known, so
// the events/analysis effect fires in parallel with fetchSessionDetails
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
// Global Admins in all-tenant view fall through with null (backend resolves
// the tenant from the session itself).
if (!globalAdminMode && isGuid(tenantId)) {
setSessionTenantId(tenantId);
}
fetchSessionDetails();
}, [sessionId, tenantId, globalAdminMode]);
useEffect(() => {
if (sessionTenantId && sessionId) {
Promise.all([fetchEvents(), fetchAnalysisResults()]);
}
}, [sessionTenantId, sessionId]);
// SignalR groups - subscribe-then-fetch pattern
useEffect(() => {
const effectiveTenantId = sessionTenantId || tenantId;
if (!sessionId || !isConnected || !effectiveTenantId) return;
if (!hasJoinedGroups.current) {
const joinAndCatchUp = async () => {
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
hasJoinedGroups.current = true;
// Re-fetch after group join to catch any missed during join
Promise.all([fetchEvents(), fetchAnalysisResults()]);
};
joinAndCatchUp();
}
return () => {
if (hasJoinedGroups.current && effectiveTenantId) {
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
hasJoinedGroups.current = false;
}
};
}, [sessionId, isConnected, sessionTenantId, tenantId]);
// Real-time analysis updates
useEffect(() => {
const handleEventStream = (data: {
sessionId: string;
session: Session;
newRuleResults?: RuleResult[];
}) => {
if (data.sessionId !== sessionIdRef.current) return;
if (data.session) setSession(data.session);
if (data.newRuleResults?.length) {
setAnalysisResults((prev) => {
const existingIds = new Set(prev.map((r) => r.ruleId));
const newResults = data.newRuleResults!.filter(
(r) => !existingIds.has(r.ruleId)
);
return [...prev, ...newResults].sort(
(a, b) => b.confidenceScore - a.confidenceScore
);
});
}
};
on("eventStream", handleEventStream);
return () => {
off("eventStream", handleEventStream);
};
}, [on, off]);
const copyRemediation = (result: RuleResult) => {
const text = result.remediation
.map((r) => {
@@ -343,7 +345,7 @@ function DiagnosisContent() {
onClick={() =>
router.push(sessionUrl(sessionId))
}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
className="px-4 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Full Details
</button>
@@ -521,7 +523,7 @@ function DiagnosisContent() {
href={primaryResult.relatedDocs[0].url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800"
className="inline-flex items-center space-x-1 text-sm text-green-700 hover:text-green-800"
>
<svg
className="w-4 h-4"
@@ -654,7 +656,7 @@ function DiagnosisContent() {
e.stopPropagation();
copyRemediation(result);
}}
className="text-xs text-blue-600 hover:text-blue-800"
className="text-xs text-green-700 hover:text-green-800"
>
{copiedId === result.ruleId
? "Copied!"
@@ -719,7 +721,7 @@ function DiagnosisContent() {
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 underline"
className="text-xs text-green-700 hover:text-green-800 underline"
>
{doc.title}
</a>
@@ -802,12 +804,14 @@ function DiagnosisContent() {
);
}
function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record<string, any>; sessionId: string }) {
function EvidenceEventLinks({ matchedConditions, sessionId }: { matchedConditions: Record<string, unknown>; sessionId: string }) {
const eventLinks: { signal: string; eventId: string; eventType?: string }[] = [];
const seenEventIds = new Set<string>();
for (const [signal, evidence] of Object.entries(matchedConditions)) {
for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) {
if (signal.startsWith("factor_")) continue;
// Evidence values are rule-engine JSON; only object-shaped entries carry event links.
const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null;
if (evidence && typeof evidence === "object" && evidence.eventId) {
// Multiple matched conditions can extract different fields from the
// same event — one chip per distinct event, not per condition.
+2 -2
View File
@@ -29,7 +29,7 @@ export default function Error({
}, [error]);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
<div className="text-4xl mb-3">!</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Something went wrong</h2>
@@ -39,7 +39,7 @@ export default function Error({
<div className="flex gap-3 justify-center flex-wrap">
<button
onClick={() => router.push("/")}
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
className="px-5 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium text-sm"
>
Back to Home
</button>
@@ -217,7 +217,7 @@ export default function DeviceJourneySection({
<div className="flex items-center justify-between gap-2">
<Link
href={sessionHref(d.lastSessionId)}
className="text-gray-700 hover:text-blue-600 font-mono text-xs truncate"
className="text-gray-700 hover:text-green-600 font-mono text-xs truncate"
title={`Open the latest enrollment of ${d.serialNumber}`}
>
{d.serialNumber}
@@ -2,6 +2,7 @@
import { useEffect, useState, useRef, useMemo } from "react";
import Link from "next/link";
import type { Route } from "next";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
@@ -17,6 +18,7 @@ import { useFleetHealth } from "./hooks/useFleetHealth";
import { useAggregatedAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
@@ -75,6 +77,29 @@ export default function FleetHealthPage() {
signalR: { on, off, isConnected },
});
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
try {
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = routeGlobal
? api.metrics.globalApp(d, selectedTenantId || undefined)
: api.metrics.app(tenantId, d);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const json = await response.json();
setAppMetrics(json);
} else {
addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error');
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error("Failed to fetch app metrics:", error);
addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error');
}
}
};
// App install metrics are already backend-aggregated; fetch alongside fleet data.
useEffect(() => {
if (!scopeInitialized) return;
@@ -145,29 +170,6 @@ export default function FleetHealthPage() {
};
}, [isConnected, effectiveTenantId]);
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
try {
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = routeGlobal
? api.metrics.globalApp(d, selectedTenantId || undefined)
: api.metrics.app(tenantId, d);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const json = await response.json();
setAppMetrics(json);
} else {
addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error');
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error("Failed to fetch app metrics:", error);
addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error');
}
}
};
// Server payload, with presentation-friendly defaults so the JSX renders
// without null guards once loading clears. `avgDuration` keeps the card's
// original field name (server sends avgDurationMinutes).
@@ -191,7 +193,7 @@ export default function FleetHealthPage() {
// model. The model key is "{Manufacturer} {Model}", which the dashboard search matches
// against its combined manufacturer+model text. Carry the selected tenant so a global
// admin scoped to one tenant lands on that tenant's list rather than their default scope.
const dashboardModelHref = (model: string) => {
const dashboardModelHref = (model: string): Route => {
const params = new URLSearchParams({ status: "Failed", search: model });
if (isGlobalAdmin && selectedTenantId) params.set("tenant", selectedTenantId);
return `/dashboard?${params.toString()}`;
@@ -238,11 +240,11 @@ export default function FleetHealthPage() {
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div>
<div className="flex items-center space-x-3">
<svg
className="w-8 h-8 text-blue-600"
className="w-8 h-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -259,25 +261,13 @@ export default function FleetHealthPage() {
</h1>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2">
<TenantScopeSelector scope={scope} allowAggregated />
{(["7d", "30d", "90d"] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
timeRange === range
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{range === "7d"
? "Last 7 Days"
: range === "30d"
? "Last 30 Days"
: "Last 90 Days"}
</button>
))}
<SegmentedControl
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={(v) => setTimeRange(v as typeof timeRange)}
/>
</div>
</div>
</div>
@@ -733,7 +723,7 @@ export default function FleetHealthPage() {
title={`Show failed enrollments for ${m.model}`}
>
<div className="flex items-baseline justify-between mb-1">
<span className="text-sm text-gray-700 group-hover:text-blue-600 break-words leading-snug">{m.model}</span>
<span className="text-sm text-gray-700 group-hover:text-green-600 break-words leading-snug">{m.model}</span>
<span className="ml-3 flex-shrink-0 text-sm font-medium text-gray-900">
{successRate !== null ? `${successRate}%` : "—"} <span className="text-xs font-normal text-gray-400">({m.total} devices)</span>
</span>
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, EMPTY_FORM, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types";
import { GatherRule, NewRuleForm, CATEGORY_COLORS, COLLECTOR_TYPE_LABELS, formatTrigger, formatGatherPhase, withDerivedScopeMode } from "../types";
import { GatherRuleFormFields } from "./GatherRuleFormFields";
import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle";
import { validateGatherRuleTarget } from "@/utils/guardValidation";
@@ -373,7 +373,7 @@ export function GatherRuleCard({
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); onStartEditing(); }}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2"
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2"
title="Edit rule"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -455,7 +455,7 @@ export function GatherRuleCard({
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
onSaveEdit(rule, withDerivedScopeMode({ ...editForm, ...parsed }));
} catch (e) {
} catch {
// jsonError is handled by parent
}
} else {
@@ -463,7 +463,7 @@ export function GatherRuleCard({
}
}}
disabled={saving}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
>
{saving ? (
<>
@@ -55,7 +55,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
placeholder="e.g., custom-network-check"
autoComplete="off"
className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`}
className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-green-500 focus:border-green-500"}`}
/>
{isDuplicate && (
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
@@ -72,7 +72,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="e.g., Custom Network Check"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</div>
</div>
@@ -85,7 +85,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Describe what this rule collects and why..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-none"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors resize-none"
/>
</div>
@@ -96,7 +96,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
@@ -110,7 +110,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.collectorType}
onChange={(e) => setForm({ ...form, collectorType: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
{COLLECTOR_TYPES.map((ct) => (
<option key={ct} value={ct}>
@@ -132,7 +132,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, target: e.target.value })}
placeholder={TARGET_PLACEHOLDERS[form.collectorType] || "Target for data collection"}
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-gray-400">{TARGET_HINTS[form.collectorType] || "Registry path, WMI class, event log name, file path, or command depending on collector type"}</p>
@@ -152,7 +152,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
placeholder="e.g., IsRecoveryAllowed (leave empty to read all values)"
autoComplete="off"
disabled={form.listSubkeys}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors disabled:opacity-50 disabled:bg-gray-50"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors disabled:opacity-50 disabled:bg-gray-50"
/>
<p className="text-xs text-gray-400 mt-1">Specific registry value to read. Leave empty to read all values in the key.</p>
</div>
@@ -161,7 +161,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
type="checkbox"
checked={form.listSubkeys}
onChange={(e) => setForm({ ...form, listSubkeys: e.target.checked, valueName: e.target.checked ? "" : form.valueName })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span>List subkeys instead of values</span>
</label>
@@ -180,7 +180,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
placeholder="e.g., 62407 (leave empty for all events)"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Filter by specific Event ID. Leave empty to collect all events.</p>
</div>
@@ -193,7 +193,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
value={form.maxEntries}
onChange={(e) => setForm({ ...form, maxEntries: e.target.value })}
placeholder="10"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Maximum number of events to return (1-50, default: 10).</p>
</div>
@@ -207,7 +207,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, source: e.target.value })}
placeholder="e.g., Microsoft-Windows-Kernel-General"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Filter by event provider/source name. Leave empty for all sources.</p>
</div>
@@ -219,7 +219,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, messageFilter: e.target.value })}
placeholder="e.g., *ESPProgress* (leave empty for no filter)"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Filter by message text. Use * as wildcard prefix/suffix.</p>
</div>
@@ -235,7 +235,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
type="checkbox"
checked={form.readContent}
onChange={(e) => setForm({ ...form, readContent: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span className="text-sm font-medium text-gray-700">Read file content</span>
</label>
@@ -251,7 +251,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.logFormat}
onChange={(e) => setForm({ ...form, logFormat: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
<option value="cmtrace">CMTrace (default)</option>
<option value="text">Plain Text</option>
@@ -274,7 +274,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
? `e.g., (?<timestamp>\\d{4}-\\d{2}-\\d{2}).*(?<level>ERROR|WARN|INFO).*(?<message>.*)`
: `e.g., (?<action>Install|Uninstall).*(?<appName>[A-Za-z0-9_-]+)`}
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Regex with named capture groups. Each match emits a separate event. Named groups become event data fields.</p>
</div>
@@ -288,7 +288,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
value={form.maxLines}
onChange={(e) => setForm({ ...form, maxLines: e.target.value })}
placeholder="1000"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Max lines to parse per file per execution (default: 1000).</p>
</div>
@@ -298,7 +298,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
type="checkbox"
checked={form.trackPosition}
onChange={(e) => setForm({ ...form, trackPosition: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span className="text-sm font-medium text-gray-700">Track position</span>
</label>
@@ -321,7 +321,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, jsonPath: e.target.value })}
placeholder="e.g., $.settings.tenantId or $..errorCode"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">
JSONPath query to extract values. Examples: <code className="bg-gray-100 px-1 rounded">$.key</code> (root property),{" "}
@@ -339,7 +339,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
value={form.maxResults}
onChange={(e) => setForm({ ...form, maxResults: e.target.value })}
placeholder="20"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Maximum number of matches to return (1-100, default: 20).</p>
</div>
@@ -359,11 +359,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, xpath: e.target.value })}
placeholder="e.g., /configuration/appSettings/add[@key='Setting1']/@value"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">
XPath query to extract values. Examples: <code className="bg-gray-100 px-1 rounded">/root/element</code> (path),{" "}
<code className="bg-gray-100 px-1 rounded">//element</code> (anywhere),{" "}
<code className="bg-gray-100 px-1 rounded">{"//element"}</code> (anywhere),{" "}
<code className="bg-gray-100 px-1 rounded">/root/item[@attr=&apos;value&apos;]</code> (filter),{" "}
<code className="bg-gray-100 px-1 rounded">/root/element/text()</code> (text content).
</p>
@@ -377,7 +377,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, xmlNamespaces: e.target.value })}
placeholder="e.g., ns=http://schemas.example.com/config"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">
Optional. Format: <code className="bg-gray-100 px-1 rounded">prefix=uri;prefix2=uri2</code>
@@ -392,7 +392,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
value={form.maxResults}
onChange={(e) => setForm({ ...form, maxResults: e.target.value })}
placeholder="20"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Max matches to return (1-100, default: 20).</p>
</div>
@@ -406,7 +406,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.trigger}
onChange={(e) => setForm({ ...form, trigger: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
{TRIGGERS.map((t) => (
<option key={t} value={t}>{formatTrigger(t)}</option>
@@ -424,7 +424,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
max={3600}
value={form.intervalSeconds}
onChange={(e) => setForm({ ...form, intervalSeconds: parseInt(e.target.value) || 60 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">How often to run this rule (5 - 3600 seconds)</p>
</div>
@@ -436,7 +436,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.triggerPhase}
onChange={(e) => setForm({ ...form, triggerPhase: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
<option value="">Any phase</option>
{form.triggerPhase && !GATHER_PHASES.some((p) => p.value === form.triggerPhase) && (
@@ -469,7 +469,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, triggerEventType: e.target.value })}
placeholder="e.g., session_stalled, enrollment_failed, modern_deployment_error"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<datalist id="gather-rule-event-types">
{KNOWN_EVENT_TYPES.map((et) => (
@@ -528,7 +528,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
activeFromPhase: scopeMode === "from" ? form.activeFromPhase : "",
});
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
<option value="always">All phases (always)</option>
<option value="during">Only during specific phases</option>
@@ -547,7 +547,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.emitMode}
onChange={(e) => setForm({ ...form, emitMode: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
{EMIT_MODES.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
@@ -576,7 +576,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
: form.activePhases.filter((x) => x !== p.value),
})
}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span>{p.label}</span>
</label>
@@ -596,7 +596,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.activeFromPhase}
onChange={(e) => setForm({ ...form, activeFromPhase: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
<option value="">Select a phase…</option>
{GATHER_PHASES.map((p) => (
@@ -623,7 +623,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, outputEventType: e.target.value })}
placeholder="e.g., CustomNetworkStatus"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</div>
<div>
@@ -631,7 +631,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<select
value={form.outputSeverity}
onChange={(e) => setForm({ ...form, outputSeverity: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
>
{SEVERITIES.map((s) => (
<option key={s} value={s}>
@@ -1,8 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
@@ -16,14 +14,12 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
import { GatherRule, NewRuleForm, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
import { DOCS_URL } from "@/utils/config";
export default function GatherRulesPage() {
const router = useRouter();
const { user, getAccessToken } = useAuth();
const { successMessage, error, showSuccess, showError } = useNotificationMessages();
@@ -449,7 +445,7 @@ export default function GatherRulesPage() {
</svg>
<p className="text-sm text-blue-800">
Missing a rule, found a bug, or have an improvement idea? Help make Autopilot Monitor better —{" "}
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
open a GitHub issue
</a>.
</p>
@@ -512,7 +508,7 @@ export default function GatherRulesPage() {
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Create Custom Rule</h2>
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href={`${DOCS_URL}/rules/gather-rules`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">see documentation</a></p>
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href={`${DOCS_URL}/rules/gather-rules`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:text-green-800 underline">see documentation</a></p>
</div>
</div>
<JsonModeToggleButtons
@@ -584,7 +580,7 @@ export default function GatherRulesPage() {
}
}}
disabled={creating}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
>
{creating ? (
<>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo } from "react";
import { MapContainer, TileLayer, CircleMarker, Tooltip, useMap } from "react-leaflet";
import "leaflet/dist/leaflet.css";
@@ -61,22 +61,6 @@ function formatThroughput(bytesPerSec: number): string {
return `${bytesPerSec.toFixed(0)} B/s`;
}
// Clean up Leaflet map on unmount to prevent _leaflet_pos errors during client-side navigation.
// map.remove() fully destroys the instance, preventing stale transitionend callbacks.
function MapCleanup() {
const map = useMap();
const mapRef = useRef(map);
mapRef.current = map;
useEffect(() => {
return () => {
try { mapRef.current.remove(); } catch { /* already removed by react-leaflet */ }
};
}, []);
return null;
}
// Component to auto-fit bounds when locations change
function FitBounds({ locations }: { locations: { coords: [number, number] }[] }) {
const map = useMap();
@@ -131,7 +115,6 @@ export default function GeoMap({ locations, globalAvgDuration, selectedLocation,
style={{ height: "100%", width: "100%", borderRadius: "0.5rem", zIndex: 0 }}
scrollWheelZoom={true}
>
<MapCleanup />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
@@ -13,6 +13,7 @@ import { useAggregatedAdminScope } from "@/hooks";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
import { CalculatingCard } from "@/components/CalculatingCard";
// A cross-tenant geo aggregation can take tens of seconds server-side; the default 30s fetch
@@ -81,8 +82,16 @@ interface GeographicMetricsResponse {
type GroupBy = "city" | "region" | "country";
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgApiLatencyMs" | "avgDoPercentPeerCaching";
type TimeRange = "7d" | "30d" | "90d";
// Module-level so React keeps a stable component identity across renders
// (react-hooks/static-components: an inline component type remounts on every render).
function SortIcon({ col, sortBy, sortDesc }: { col: SortBy; sortBy: SortBy; sortDesc: boolean }) {
if (sortBy !== col) return <span className="text-gray-300 ml-1">&#8597;</span>;
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
}
const durationColor = (value: number, globalAvg: number) => {
if (globalAvg <= 0) return "text-gray-700";
const ratio = value / globalAvg;
@@ -143,7 +152,7 @@ export default function GeographicPerformancePage() {
// Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants),
// and scope flags. Default selection is the GA's own tenant.
const scope = useAggregatedAdminScope();
const { isGlobalAdmin, routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
const { routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
const progress = useFetchProgress("geoPerf.lastFetchMs");
const { begin: progressBegin, finish: progressFinish } = progress;
@@ -219,11 +228,6 @@ export default function GeographicPerformancePage() {
}
};
const SortIcon = ({ col }: { col: SortBy }) => {
if (sortBy !== col) return <span className="text-gray-300 ml-1">&#8597;</span>;
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
};
if (loading) {
return (
<CalculatingCard
@@ -245,49 +249,33 @@ export default function GeographicPerformancePage() {
/>
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div>
<div className="flex items-center space-x-3">
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 className="text-2xl font-normal text-gray-900">Geographic Performance</h1>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex flex-wrap items-center gap-2">
<TenantScopeSelector scope={scope} allowAggregated />
{/* Time Range Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
{(["7d", "30d", "90d"] as TimeRange[]).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
timeRange === range
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
</button>
))}
</div>
<SegmentedControl
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={(v) => setTimeRange(v as TimeRange)}
/>
{/* Group By Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
{(["city", "region", "country"] as GroupBy[]).map((g) => (
<button
key={g}
onClick={() => setGroupBy(g)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors capitalize ${
groupBy === g
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{g}
</button>
))}
</div>
<SegmentedControl
options={[
{ value: "city", label: "City" },
{ value: "region", label: "Region" },
{ value: "country", label: "Country" },
]}
value={groupBy}
onChange={(v) => setGroupBy(v as GroupBy)}
/>
</div>
</div>
</div>
@@ -449,7 +437,7 @@ export default function GeographicPerformancePage() {
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
onClick={() => handleSort("sessionCount")}
>
Sessions <SortIcon col="sessionCount" />
Sessions <SortIcon col="sessionCount" sortBy={sortBy} sortDesc={sortDesc} />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Success
@@ -458,7 +446,7 @@ export default function GeographicPerformancePage() {
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
onClick={() => handleSort("avgDurationMinutes")}
>
Avg Duration <SortIcon col="avgDurationMinutes" />
Avg Duration <SortIcon col="avgDurationMinutes" sortBy={sortBy} sortDesc={sortDesc} />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
P95
@@ -467,26 +455,26 @@ export default function GeographicPerformancePage() {
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
onClick={() => handleSort("appLoadScore")}
>
App-Load-Score <SortIcon col="appLoadScore" />
App-Load-Score <SortIcon col="appLoadScore" sortBy={sortBy} sortDesc={sortDesc} />
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
onClick={() => handleSort("avgThroughputBytesPerSec")}
>
Throughput <SortIcon col="avgThroughputBytesPerSec" />
Throughput <SortIcon col="avgThroughputBytesPerSec" sortBy={sortBy} sortDesc={sortDesc} />
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
onClick={() => handleSort("avgApiLatencyMs")}
title="Agent→backend HTTP round-trip measured on the devices — high values indicate network distance to the backend region"
>
API Latency <SortIcon col="avgApiLatencyMs" />
API Latency <SortIcon col="avgApiLatencyMs" sortBy={sortBy} sortDesc={sortDesc} />
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
onClick={() => handleSort("avgDoPercentPeerCaching")}
>
P2P % <SortIcon col="avgDoPercentPeerCaching" />
P2P % <SortIcon col="avgDoPercentPeerCaching" sortBy={sortBy} sortDesc={sortDesc} />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
vs Global
@@ -223,7 +223,6 @@ function LocationSessionsContent() {
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<button
onClick={() => {
const tr = days === "7" ? "7d" : days === "30" ? "30d" : "90d";
router.push(`/geographic-performance`);
}}
className="text-sm text-gray-600 hover:text-gray-900 mb-2 flex items-center"
@@ -231,7 +230,7 @@ function LocationSessionsContent() {
&larr; Back to Geographic Performance
</button>
<div className="flex items-center space-x-3">
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { LoginButton } from "../../components/landing/LoginButton";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL, SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
title: "Get started",
description:
"From sign-in to your first live Autopilot enrollment in five steps: sign in, request access, grant consent, deploy the bootstrapper via Intune, watch live.",
alternates: {
canonical: `${SITE_URL}/get-started`,
},
};
const STEPS = [
{
title: "Sign in with Microsoft",
description:
"Use your work account — there is no signup form. The first sign-in creates nothing in your tenant.",
note: "30 seconds",
},
{
title: "Request access",
description:
"Autopilot Monitor is in preview: your tenant is queued and approved — you'll see the status right after signing in.",
note: "Preview approval",
},
{
title: "Grant consent once",
description:
"Approve the Entra ID application for your tenant so the portal can operate. One admin consent, fully revocable.",
note: "One-time admin consent",
},
{
title: "Deploy the bootstrapper via Intune",
description:
"Assign one PowerShell script to your Autopilot scope. That is the whole rollout — no servers, no gateways, no infrastructure on your side.",
note: "One script assignment",
},
{
title: "Watch your first enrollment live",
description:
"The next device that enrolls streams in minutes later: phases, app installs, analysis results, diagnostics on demand.",
note: "Minutes later",
},
];
export default function GetStartedPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex flex-col">
<LandingNavbar />
<main className="flex-1 px-6 pt-16 sm:pt-20 pb-20">
<div className="max-w-3xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">
Get started
</p>
<h1 className="mt-4 text-4xl sm:text-5xl font-bold tracking-tight text-[var(--lp-ink)] leading-[1.08] text-balance">
From sign-in to your first live enrollment.
</h1>
<p className="mt-4 text-lg text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
Five steps, no infrastructure on your side. Most teams see their first live session
the same day.
</p>
{/* Step sequence */}
<div className="relative mt-12">
<div className="absolute left-[15px] top-3 bottom-3 w-px bg-[var(--lp-line)]" aria-hidden="true" />
<ol className="space-y-8">
{STEPS.map((step, i) => (
<li key={step.title} className="relative pl-14">
<span className="absolute left-0 top-0 w-8 h-8 rounded-full bg-[var(--lp-accent)] text-white text-sm font-bold flex items-center justify-center">
{i + 1}
</span>
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-[var(--lp-ink)]">{step.title}</h2>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
{step.note}
</span>
</div>
<p className="mt-1.5 text-[15px] text-[var(--lp-ink-soft)] leading-relaxed">{step.description}</p>
</li>
))}
</ol>
</div>
{/* CTA */}
<div className="mt-14 flex flex-wrap items-center gap-3">
<LoginButton className="px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all">
Sign in to get started
</LoginButton>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="px-7 py-3 rounded-lg border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
>
Read the docs
</a>
</div>
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
When you need more later: the{" "}
<a
href={`${DOCS_URL}/plans`}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Enterprise plan
</a>{" "}
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
same service, nothing to migrate.
</p>
</div>
</main>
<SiteFooter />
</div>
);
}
@@ -9,7 +9,6 @@
* it replaces the entire root layout when triggered.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
+194 -5
View File
@@ -27,12 +27,71 @@ html {
:root {
--background: #f9fafb;
--foreground: #171717;
/* ===== Landing v2 design tokens (lp-*) =====
Semantic tokens for the marketing surface (landing + shared footer).
Components reference these via arbitrary-value utilities
(bg-[var(--lp-surface)]) so the global `.dark .bg-white` override
block never fights them — dark mode only redefines the variables. */
--lp-bg: #fafafa;
--lp-surface: #ffffff;
--lp-surface-2: #f4f4f5;
--lp-nav: rgba(255, 255, 255, 0.72);
--lp-ink: #18181b;
--lp-ink-soft: #52525b;
--lp-ink-faint: #9ca3af;
--lp-line: #e4e4e7;
--lp-line-soft: #ededf0;
--lp-accent: #33b161;
--lp-accent-ink: #1e8a4c;
--lp-accent-soft: rgba(51, 177, 97, 0.1);
--lp-accent-line: rgba(51, 177, 97, 0.35);
--lp-warn: #d97706;
--lp-warn-soft: rgba(217, 119, 6, 0.1);
--lp-danger: #dc2626;
/* Terminal is dark in both themes */
--lp-term-bg: #0d1424;
--lp-term-line: #22304a;
--lp-term-ink: #d6e1f0;
--lp-term-faint: #7b8ba3;
}
.dark {
--lp-bg: #0d1424;
--lp-surface: #131d31;
--lp-surface-2: #1a2740;
--lp-nav: rgba(13, 20, 36, 0.72);
--lp-ink: #e7edf6;
--lp-ink-soft: #9fb0c5;
--lp-ink-faint: #64748b;
--lp-line: #23304a;
--lp-line-soft: #1c2740;
--lp-accent: #3bc06c;
--lp-accent-ink: #55c57f;
--lp-accent-soft: rgba(59, 192, 108, 0.12);
--lp-accent-line: rgba(59, 192, 108, 0.4);
--lp-warn: #f5a623;
--lp-warn-soft: rgba(245, 166, 35, 0.12);
--lp-danger: #f87171;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
/* Native radios/checkboxes: without @tailwindcss/forms the text-* utilities
do nothing — the check color follows the OS accent color. Pin it to the
brand green; semantic exceptions (danger red, global-admin purple)
override per control via Tailwind accent-* utilities. */
input[type="checkbox"],
input[type="radio"] {
accent-color: #16a34a; /* green-600 */
}
.dark input[type="checkbox"],
.dark input[type="radio"] {
accent-color: #22c55e; /* green-500 */
}
@layer utilities {
@@ -80,10 +139,13 @@ body {
color: #94a3b8 !important;
}
.dark .text-gray-500 {
color: #64748b !important;
/* was #64748b — too dark on raised dark surfaces (notification items,
hover rows); lightened for readable muted text, hierarchy kept. */
color: #8896ad !important;
}
.dark .text-gray-400 {
color: #64748b !important;
/* was #64748b — see text-gray-500 note; one step more muted than 500. */
color: #7b8ba3 !important;
}
/* -- Borders -- */
@@ -118,11 +180,14 @@ body {
}
/* -- Hover states -- */
/* Subtle, DARK hover surfaces: the former #334155 washed out muted text
(notification timestamps, table metadata) on hovered rows. One step
above the card surface (#1e293b) is enough as a hover signal. */
.dark .hover\:bg-gray-50:hover {
background-color: #334155 !important;
background-color: #283548 !important;
}
.dark .hover\:bg-gray-100:hover {
background-color: #334155 !important;
background-color: #2e3c52 !important;
}
.dark .hover\:bg-gray-200:hover {
background-color: #334155 !important;
@@ -559,6 +624,9 @@ html:hover ::-webkit-scrollbar-thumb {
.dark .text-blue-700 {
color: #60a5fa !important; /* blue-400 — step icons etc. on dark surfaces */
}
.dark .text-green-700 {
color: #4ade80 !important; /* green-400 — brand links/accents on dark surfaces */
}
.dark .text-indigo-700 {
color: #a5b4fc !important; /* indigo-300 — category badge, correlation labels, doc links */
}
@@ -729,6 +797,127 @@ html:hover ::-webkit-scrollbar-thumb {
animation: dotPulse 12.6s linear infinite;
}
/* ===== LANDING V2 (scroll reveals, event stream, terminal) ===== */
/* The marketing surface is light-only for now: product screenshots are
light, and a half-dark landing reads unfinished. Re-declare the light
tokens inside .landing-v2 so a dark OS/portal preference does not
flip the landing. Remove this block to re-enable dark marketing. */
.dark .landing-v2 {
--lp-bg: #fafafa;
--lp-surface: #ffffff;
--lp-surface-2: #f4f4f5;
--lp-nav: rgba(255, 255, 255, 0.72);
--lp-ink: #18181b;
--lp-ink-soft: #52525b;
--lp-ink-faint: #9ca3af;
--lp-line: #e4e4e7;
--lp-line-soft: #ededf0;
--lp-accent: #33b161;
--lp-accent-ink: #1e8a4c;
--lp-accent-soft: rgba(51, 177, 97, 0.1);
--lp-accent-line: rgba(51, 177, 97, 0.35);
--lp-warn: #d97706;
--lp-warn-soft: rgba(217, 119, 6, 0.1);
--lp-danger: #dc2626;
}
/* Neutralize the global .dark utility overrides inside the light-only
marketing surface: about/terms/privacy still use plain bg-white /
text-gray-* cards, which the app-wide dark overrides would otherwise
flip and leave those pages half dark. Dies together with the
light-only decision above. */
.dark .landing-v2 .bg-white { background-color: #ffffff !important; }
.dark .landing-v2 .bg-gray-50 { background-color: #f9fafb !important; }
.dark .landing-v2 .bg-gray-100 { background-color: #f3f4f6 !important; }
.dark .landing-v2 .text-gray-900 { color: #111827 !important; }
.dark .landing-v2 .text-gray-800 { color: #1f2937 !important; }
.dark .landing-v2 .text-gray-700 { color: #374151 !important; }
.dark .landing-v2 .text-gray-600 { color: #4b5563 !important; }
.dark .landing-v2 .text-gray-500 { color: #6b7280 !important; }
.dark .landing-v2 .text-gray-400 { color: #9ca3af !important; }
.dark .landing-v2 .border-gray-200 { border-color: #e5e7eb !important; }
.dark .landing-v2 .border-gray-300 { border-color: #d1d5db !important; }
.dark .landing-v2 .hover\:bg-gray-50:hover { background-color: #f9fafb !important; }
.dark .landing-v2 .hover\:bg-gray-100:hover { background-color: #f3f4f6 !important; }
/* Scroll-triggered reveal: Reveal.tsx adds .lp-in when the element
enters the viewport. Reduced motion renders everything visible. */
.lp-reveal {
opacity: 0;
transform: translateY(18px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.lp-reveal.lp-in {
opacity: 1;
transform: none;
}
@keyframes lpFadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.lp-event-in {
animation: lpFadeUp 0.4s ease-out both;
}
@keyframes lpBlink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.lp-cursor::after {
content: "";
display: inline-block;
width: 8px;
height: 1.05em;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--lp-accent);
animation: lpBlink 1s steps(1) infinite;
}
@keyframes lpPulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(51, 177, 97, 0.35);
}
60% {
box-shadow: 0 0 0 6px rgba(51, 177, 97, 0);
}
}
.lp-live-dot {
animation: lpPulse 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.lp-reveal {
opacity: 1;
transform: none;
transition: none;
}
.lp-event-in {
animation: none;
}
.lp-cursor::after {
animation: none;
}
.lp-live-dot {
animation: none;
}
}
/* ===== FEEDBACK BUBBLE ANIMATION ===== */
@keyframes fadeInUp {
from {
@@ -14,7 +14,7 @@ interface HealthCheck {
description: string;
status: string;
message: string;
details?: Record<string, any>;
details?: Record<string, unknown>;
}
interface HealthCheckResult {
@@ -183,7 +183,7 @@ export default function HealthCheckPage() {
<button
onClick={() => { performHealthCheck(); performMcpCheck(); }}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium flex items-center gap-2"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium flex items-center gap-2"
>
{loading ? (
<>
@@ -250,7 +250,7 @@ export default function HealthCheckPage() {
<dd className="mt-1 font-mono text-gray-900 dark:text-gray-100">
{healthResult.commitHash ? (
<a
href={`https://github.com/okieselbach/Autopilot-Monitor/commit/${healthResult.commitHash}`}
href={`https://github.com/okieselbach/AutopilotMonitor/commit/${healthResult.commitHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline"
@@ -334,7 +334,7 @@ export default function HealthCheckPage() {
<button
onClick={testGroupJoin}
disabled={groupJoinTest === 'testing'}
className="mt-3 px-3 py-1.5 bg-blue-600 text-white rounded text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
className="mt-3 px-3 py-1.5 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{groupJoinTest === 'testing' ? 'Testing...' : groupJoinTest === 'failed' ? 'Retry Join Test' : 'Test Group Join'}
</button>
@@ -427,8 +427,8 @@ export default function HealthCheckPage() {
updates in place without blocking any of the cards above. */}
{(() => {
const display = mcpLoading
? { name: 'MCP Server', description: 'AI query interface availability', status: 'checking', message: 'Probing MCP server — waking it from idle if needed…', details: undefined as Record<string, any> | undefined }
: (mcpCheck ?? { name: 'MCP Server', description: 'AI query interface availability', status: 'unknown', message: 'Not checked yet', details: undefined as Record<string, any> | undefined });
? { name: 'MCP Server', description: 'AI query interface availability', status: 'checking', message: 'Probing MCP server — waking it from idle if needed…', details: undefined as Record<string, unknown> | undefined }
: (mcpCheck ?? { name: 'MCP Server', description: 'AI query interface availability', status: 'unknown', message: 'Not checked yet', details: undefined as Record<string, unknown> | undefined });
const colors = getStatusColor(display.status);
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 ${colors.accent}`}>
@@ -251,7 +251,7 @@ export default function ImePatternCard({
{canEdit && (
<button
onClick={() => onStartEditing(pattern)}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2"
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2"
title="Edit pattern (Global Admin)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -330,7 +330,7 @@ export default function ImePatternCard({
<select
value={editForm.category}
onChange={(e) => setEditForm({ ...editForm, category: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-green-500 focus:border-green-500"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
@@ -344,7 +344,7 @@ export default function ImePatternCard({
value={editForm.pattern}
onChange={(e) => setEditForm({ ...editForm, pattern: e.target.value })}
rows={3}
className="w-full font-mono text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full font-mono text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-green-500 focus:border-green-500"
spellCheck={false}
placeholder="e.g., EMS Agent Started"
/>
@@ -356,7 +356,7 @@ export default function ImePatternCard({
<select
value={editForm.action}
onChange={(e) => setEditForm({ ...editForm, action: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:ring-2 focus:ring-green-500 focus:border-green-500"
>
{ACTIONS.map((a) => (
<option key={a} value={a}>{ACTION_LABELS[a]} ({a})</option>
@@ -371,7 +371,7 @@ export default function ImePatternCard({
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={2}
className="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-green-500 focus:border-green-500"
placeholder="What this pattern detects and why it matters..."
/>
</div>
@@ -382,7 +382,7 @@ export default function ImePatternCard({
id="edit-enabled"
checked={editForm.enabled}
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
className="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-green-500"
/>
<label htmlFor="edit-enabled" className="text-sm font-medium text-gray-700">Enabled</label>
</div>
@@ -403,7 +403,7 @@ export default function ImePatternCard({
updated[key] = e.target.value;
setEditForm({ ...editForm, parameters: updated });
}}
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-indigo-500"
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-green-500"
/>
<button
onClick={() => {
@@ -428,14 +428,14 @@ export default function ImePatternCard({
value={newParamKey}
onChange={(e) => onSetNewParamKey(e.target.value)}
placeholder="Key"
className="w-32 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-indigo-500"
className="w-32 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-green-500"
/>
<input
type="text"
value={newParamValue}
onChange={(e) => onSetNewParamValue(e.target.value)}
placeholder="Value"
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-indigo-500"
className="flex-1 text-sm px-2 py-1 border border-gray-300 rounded text-gray-900 focus:ring-1 focus:ring-green-500"
/>
<button
onClick={() => {
@@ -449,7 +449,7 @@ export default function ImePatternCard({
}
}}
disabled={!newParamKey.trim()}
className="px-3 py-1 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
@@ -468,7 +468,7 @@ export default function ImePatternCard({
<button
onClick={() => onSaveEdit(pattern.patternId)}
disabled={saving}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{saving ? (
<>
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
@@ -18,8 +17,6 @@ import ImePatternCard from "./components/ImePatternCard";
import { trackEvent } from "@/lib/appInsights";
export default function ImeLogPatternsPage() {
const router = useRouter();
const { tenantId } = useTenant();
const { user } = useAuth();
const isGlobalAdmin = user?.isGlobalAdmin ?? false;
@@ -256,7 +253,7 @@ export default function ImeLogPatternsPage() {
</svg>
<p className="text-sm text-blue-800">
Missing a pattern, found a bug, or have an improvement idea? Help make Autopilot Monitor better —{" "}
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="font-medium underline hover:text-blue-900">
open a GitHub issue
</a>.
</p>
+12 -9
View File
@@ -24,9 +24,9 @@ const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
absolute: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
default: "AutopilotMonitor",
template: "%s | AutopilotMonitor",
absolute: "Autopilot Monitor – Real-Time Windows Enrollment Monitoring",
default: "Autopilot Monitor",
template: "%s | Autopilot Monitor",
},
description:
"Real-time monitoring and troubleshooting for Windows deployments. Track every enrollment phase, detect issues automatically with Analyze Rules, and resolve failures faster.",
@@ -52,14 +52,14 @@ export const metadata: Metadata = {
type: "website",
locale: "en_US",
url: SITE_URL,
siteName: "AutopilotMonitor",
title: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
siteName: "Autopilot Monitor",
title: "Autopilot Monitor – Real-Time Windows Enrollment Monitoring",
description:
"Real-time monitoring and troubleshooting for Windows Autopilot deployments. Track every enrollment phase, detect issues automatically, and resolve failures faster.",
},
twitter: {
card: "summary_large_image",
title: "AutopilotMonitor – Real-Time Windows Enrollment Monitoring",
title: "Autopilot Monitor – Real-Time Windows Enrollment Monitoring",
description:
"Real-time monitoring and troubleshooting for Windows Autopilot deployments. Track every enrollment phase, detect issues automatically, and resolve failures faster.",
},
@@ -87,7 +87,7 @@ export const metadata: Metadata = {
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "AutopilotMonitor",
name: "Autopilot Monitor",
description:
"Real-time monitoring and troubleshooting platform for Windows deployments. Gives IT teams full visibility into enrollment phases, app progress, errors, and timelines.",
applicationCategory: "BusinessApplication",
@@ -103,7 +103,7 @@ const jsonLd = {
url: "https://www.linkedin.com/in/oliver-kieselbach/",
},
url: SITE_URL,
codeRepository: "https://github.com/okieselbach/Autopilot-Monitor",
codeRepository: "https://github.com/okieselbach/AutopilotMonitor",
keywords:
"Windows Autopilot, Intune, enrollment monitoring, autopilot troubleshooting, Windows deployment",
};
@@ -113,8 +113,11 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// data-scroll-behavior: Next 16 no longer auto-suppresses CSS smooth-scroll
// during route navigation; without the opt-in, globals.css's
// `scroll-behavior: smooth` would animate every route change to top.
return (
<html lang="en">
<html lang="en" data-scroll-behavior="smooth">
<body className={inter.className}>
<script
type="application/ld+json"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 150 KiB

+27 -854
View File
@@ -1,863 +1,36 @@
import { PublicSiteNavbar } from "../components/PublicSiteNavbar";
import { AuthGate } from "../components/landing/AuthGate";
import { LoginButton } from "../components/landing/LoginButton";
import { PlatformStats } from "../components/landing/PlatformStats";
import { DOCS_URL } from "@/utils/config";
const QUICK_START = [
{
title: "Sign in and grant access",
description: "Authenticate with Microsoft and approve tenant access once.",
},
{
title: "Deploy bootstrapper in Intune",
description: "Assign the bootstrap script to your Autopilot scope.",
},
{
title: "Watch live telemetry",
description: "Track phases, apps, failures, and actions in real time.",
},
];
const FLOW_STEPS = [
{
title: "Intune assignment",
description: "Target the bootstrapper script to your selected Autopilot device groups.",
icon: "users",
},
{
title: "Bootstrapper execution",
description: "Device runs the bootstrapper script and installs Autopilot Monitor Agent.",
icon: "code",
},
{
title: "Live monitoring",
description: "Phase transitions, app installs, and progress are captured continuously.",
icon: "monitor",
},
{
title: "Event upload",
description: "Predefined and custom events are uploaded to the backend pipeline.",
icon: "cloud",
},
{
title: "Rule analysis",
description: "Backend correlates phases and runs analyze rules for instant insights.",
icon: "rules",
},
{
title: "Completion and notifications",
description: "Session status is finalized and Teams notifications are triggered.",
icon: "bell",
},
{
title: "Diagnostics download",
description: "Grab diagnostic bundles quickly for fast root-cause validation.",
icon: "download",
},
];
function StepIcon({ icon }: { icon: string }) {
switch (icon) {
case "users":
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-1a4 4 0 00-5-3.87M9 20H2v-1a4 4 0 015-3.87m9-5.13a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
);
case "code":
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m8 9-3 3 3 3m8-6 3 3-3 3M13 7l-2 10" />
</svg>
);
case "monitor":
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4h18v12H3zM8 20h8m-4-4v4m-3-8 2-2 2 3 3-4 2 3" />
</svg>
);
case "cloud":
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 18a4 4 0 01-.3-8A5 5 0 1117 8h1a4 4 0 010 8h-3m-3 0v-7m0 7-3-3m3 3 3-3" />
</svg>
);
case "rules":
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5h10M9 9h10M9 13h10M9 17h10M4 5h.01M4 9h.01M4 13h.01M4 17h.01" />
</svg>
);
case "bell":
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 10-12 0v3.2c0 .5-.2 1-.6 1.4L4 17h5m6 0a3 3 0 11-6 0h6z" />
</svg>
);
default:
return (
<svg className="w-4 h-4 text-blue-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v10m0 0-3-3m3 3 3-3M5 19h14" />
</svg>
);
}
}
import { LandingNavbar } from "../components/landing/LandingNavbar";
import { Hero } from "../components/landing/Hero";
import { StatsBand } from "../components/landing/StatsBand";
import { JustAsk } from "../components/landing/JustAsk";
import { Story } from "../components/landing/Story";
import { CapabilitiesStrip } from "../components/landing/CapabilitiesStrip";
import { Comparison } from "../components/landing/Comparison";
import { HowItWorks } from "../components/landing/HowItWorks";
import { FinalCta } from "../components/landing/FinalCta";
import { SiteFooter } from "../components/SiteFooter";
/**
* Landing page v2 — a scroll story of one enrollment.
* All theming goes through the lp-* tokens (globals.css); sections live
* in components/landing/. Auth behavior (AuthGate redirect, LoginButton
* portal handoff) is unchanged from v1.
*/
export default function LandingPage() {
return (
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
{/* Client component: handles auth redirect + loading overlay */}
<AuthGate />
<PublicSiteNavbar showSectionLinks={true} />
{/* Hero Section */}
<div className="pt-20 pb-20 px-6">
<div className="max-w-7xl mx-auto">
<div className="text-center max-w-4xl mx-auto">
<div className="mb-7 inline-flex items-center gap-3 rounded-2xl border border-blue-300/70 bg-gradient-to-r from-blue-50 via-indigo-50 to-blue-50 px-4 py-2.5 shadow-md ring-1 ring-blue-200/60">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-white shadow-sm">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-1a4 4 0 00-5-3.87M9 20H2v-1a4 4 0 015-3.87m9-5.13a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
<p className="text-sm font-semibold text-blue-900">
Community-driven Analyze Rules support
<span className="font-normal text-blue-800">: discover, build, and share rules for everyone.</span>
</p>
</div>
<div className="relative inline-block mb-6">
<h1 className="text-6xl font-bold text-gray-900 leading-tight">
Advanced Monitoring for
<span className="bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent"> Windows Enrollments</span>
</h1>
<div className="pointer-events-none absolute -right-10 -top-1 rotate-[13deg] inline-flex items-center rounded-md border border-amber-300/80 bg-gradient-to-r from-amber-500 to-orange-500 px-3 py-1 shadow-md">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-white whitespace-nowrap">
Private Preview Running
</span>
</div>
</div>
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
Real-time insights, intelligent troubleshooting, and comprehensive analytics for your Autopilot deployments.
Monitor every phase, run customizable analyze rules, and resolve issues faster than ever before.
</p>
<div className="flex items-center justify-center space-x-4">
<LoginButton
className="px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-0.5 transition-all flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
</svg>
<span>Get Started</span>
</LoginButton>
</div>
<p className="mt-4 text-sm text-gray-500">
Free to use • Open-Source • Sign in to request early access
</p>
{/* Platform Stats - client component fetches live data */}
<PlatformStats />
{/* Product Preview Showcase */}
<div className="mt-14 relative max-w-5xl mx-auto">
{/* Glow effects */}
<div className="absolute -top-10 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-gradient-to-br from-blue-400/20 via-indigo-400/15 to-purple-400/10 blur-3xl rounded-full pointer-events-none" />
{/* Main screenshot — Fleet Health Dashboard */}
<div className="relative z-10 rounded-xl border border-gray-200/80 bg-white shadow-2xl overflow-hidden">
{/* Browser chrome */}
<div className="flex items-center gap-1.5 px-4 py-2.5 bg-gray-50 border-b border-gray-200">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-amber-400" />
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
<div className="ml-3 flex-1 bg-gray-200/80 rounded-md px-3 py-1 text-[10px] text-gray-400 font-mono">autopilotmonitor.com/fleet-health</div>
</div>
{/* Fleet Health mock content */}
<div className="p-5 md:p-7">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded bg-blue-100 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6m6 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0h6m4-10v10" /></svg>
</div>
<h4 className="text-lg font-bold text-gray-900">Fleet Health</h4>
</div>
<div className="flex gap-1.5">
<span className="px-3 py-1 text-[10px] font-semibold bg-blue-600 text-white rounded-md">Last 7 Days</span>
<span className="px-3 py-1 text-[10px] font-medium text-gray-500 bg-gray-100 rounded-md">Last 30 Days</span>
<span className="px-3 py-1 text-[10px] font-medium text-gray-500 bg-gray-100 rounded-md hidden sm:block">Last 90 Days</span>
</div>
</div>
{/* KPI cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div className="rounded-lg border-l-4 border-l-green-500 border border-gray-100 bg-green-50/50 p-3">
<p className="text-[10px] text-gray-500 mb-0.5">Success Rate</p>
<p className="text-2xl font-bold text-green-600">80.0%</p>
<p className="text-[9px] text-gray-400">8 of 10 enrollments</p>
</div>
<div className="rounded-lg border-l-4 border-l-blue-500 border border-gray-100 bg-blue-50/50 p-3">
<p className="text-[10px] text-gray-500 mb-0.5">Avg. Enrollment Time</p>
<p className="text-2xl font-bold text-blue-600">18 min</p>
<p className="text-[9px] text-gray-400">Completed enrollments</p>
</div>
<div className="rounded-lg border-l-4 border-l-red-500 border border-gray-100 bg-red-50/50 p-3">
<p className="text-[10px] text-gray-500 mb-0.5">Failed</p>
<p className="text-2xl font-bold text-red-500">2</p>
<p className="text-[9px] text-gray-400">Needs attention</p>
</div>
<div className="rounded-lg border-l-4 border-l-indigo-500 border border-gray-100 bg-indigo-50/50 p-3">
<p className="text-[10px] text-gray-500 mb-0.5">Active Now</p>
<p className="text-2xl font-bold text-indigo-600">0</p>
<p className="text-[9px] text-gray-400">Currently enrolling</p>
</div>
</div>
{/* Chart area */}
<div className="rounded-lg border border-gray-100 bg-gray-50/60 p-4">
<p className="text-sm font-semibold text-gray-800 mb-3">Enrollments Timeline</p>
<div className="flex items-end gap-3 h-28 px-2">
{[0, 0, 0, { g: 65, r: 25 }, { g: 50, r: 30 }, 0, { g: 40, r: 20 }, 0].map((bar, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-0.5">
{typeof bar === 'object' ? (
<div className="w-full flex flex-col gap-px">
<div className="w-full rounded-t bg-green-400" style={{ height: `${bar.g}px` }} />
<div className="w-full rounded-b bg-red-300" style={{ height: `${bar.r}px` }} />
</div>
) : (
<div className="w-full rounded bg-gray-200/60" style={{ height: '2px' }} />
)}
<span className="text-[8px] text-gray-400 mt-1">{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon'][i]}</span>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-2 ml-2">
<span className="flex items-center gap-1 text-[9px] text-gray-500"><span className="w-2 h-2 rounded-sm bg-green-400" />Success (8)</span>
<span className="flex items-center gap-1 text-[9px] text-gray-500"><span className="w-2 h-2 rounded-sm bg-red-300" />Failed (2)</span>
</div>
</div>
</div>
</div>
{/* Floating card — Session Details (overlapping bottom-left) */}
<div className="absolute -bottom-8 -left-4 md:-left-8 z-20 w-64 md:w-80 rounded-xl border border-gray-200/80 bg-white shadow-xl overflow-hidden transform rotate-[-2deg] hover:rotate-0 transition-transform duration-300">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<div className="w-2 h-2 rounded-full bg-red-400" />
<div className="w-2 h-2 rounded-full bg-amber-400" />
<div className="w-2 h-2 rounded-full bg-green-400" />
<span className="ml-2 text-[8px] text-gray-400 font-mono">Session Details</span>
</div>
<div className="p-3.5">
<p className="text-xs font-bold text-gray-900 mb-2">Enrollment Progress</p>
{/* Progress steps */}
<div className="flex items-center gap-0">
{['Start', 'Prep', 'Setup', 'Apps', 'Account', 'Apps', 'Final', 'Done'].map((step, i) => (
<div key={i} className="flex items-center">
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center shrink-0">
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
{i < 7 && <div className="w-2 md:w-3 h-0.5 bg-green-400" />}
</div>
))}
</div>
<div className="flex justify-between mt-1 px-0.5">
{['Start', '', '', '', '', '', '', 'Done'].map((label, i) => (
<span key={i} className="text-[7px] text-gray-400">{label}</span>
))}
</div>
{/* Analysis result */}
<div className="mt-3 rounded-lg border-l-3 border-l-orange-400 bg-orange-50 p-2">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="px-1.5 py-0.5 text-[7px] font-bold bg-orange-500 text-white rounded">HIGH</span>
<span className="text-[8px] text-gray-500 font-mono">ANALYZE-APP-006</span>
</div>
<p className="text-[10px] font-semibold text-gray-800">Reboot Required Loop</p>
<div className="flex items-center gap-1 mt-1">
<span className="text-[8px] text-gray-400">Confidence:</span>
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden"><div className="w-[70%] h-full bg-orange-500 rounded-full" /></div>
<span className="text-[8px] text-gray-500">70%</span>
</div>
</div>
</div>
</div>
{/* Floating card — Performance Metrics (overlapping bottom-right) */}
<div className="absolute -bottom-6 -right-4 md:-right-6 z-20 w-60 md:w-72 rounded-xl border border-gray-200/80 bg-white shadow-xl overflow-hidden transform rotate-[2deg] hover:rotate-0 transition-transform duration-300">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 border-b border-gray-100">
<div className="w-2 h-2 rounded-full bg-red-400" />
<div className="w-2 h-2 rounded-full bg-amber-400" />
<div className="w-2 h-2 rounded-full bg-green-400" />
<span className="ml-2 text-[8px] text-gray-400 font-mono">Performance Metrics</span>
</div>
<div className="p-3.5">
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg border border-gray-100 p-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] text-gray-500">CPU</span>
<span className="text-[10px] font-bold text-red-500">92%</span>
</div>
<div className="h-6 flex items-end gap-px">
{[40, 55, 70, 85, 92, 88, 75, 60, 80, 92].map((v, i) => (
<div key={i} className="flex-1 rounded-t bg-red-400/70" style={{ height: `${v * 0.24}px` }} />
))}
</div>
</div>
<div className="rounded-lg border border-gray-100 p-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] text-gray-500">Memory</span>
<span className="text-[10px] font-bold text-amber-600">77%</span>
</div>
<div className="h-6 flex items-end gap-px">
{[60, 65, 68, 70, 72, 75, 74, 76, 77, 77].map((v, i) => (
<div key={i} className="flex-1 rounded-t bg-amber-400/70" style={{ height: `${v * 0.24}px` }} />
))}
</div>
</div>
</div>
{/* Download progress */}
<div className="mt-2.5">
<p className="text-[9px] font-semibold text-gray-700 mb-1.5">Download Progress</p>
{['RealmJoin Setup Launcher', 'BGInfo46', 'CMTrace.exe'].map((app, i) => (
<div key={i} className="mb-1.5">
<div className="flex items-center justify-between">
<span className="text-[8px] text-gray-600 flex items-center gap-1">
<svg className="w-2.5 h-2.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
{app}
</span>
<span className="text-[8px] text-green-600 font-medium">100%</span>
</div>
<div className="w-full h-1 bg-gray-100 rounded-full mt-0.5"><div className="w-full h-full bg-green-500 rounded-full" /></div>
</div>
))}
</div>
</div>
</div>
</div>
{/* spacer for floating cards */}
<div className="h-14" />
</div>
</div>
</div>
{/* Eye-Catcher Workflow Section */}
<section id="how-it-works" className="py-20 px-6 scroll-mt-14">
<div className="max-w-7xl mx-auto">
<div className="text-center max-w-4xl mx-auto">
<p className="text-sm font-semibold text-blue-600 uppercase tracking-[0.22em] mb-3">
How It Works
</p>
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 leading-tight">
From Intune rollout to deep insights in minutes
</h2>
<p className="mt-5 text-lg text-gray-600 leading-relaxed">
Simple onboarding, clear phase visibility, and automated analysis in one workflow.
Deploy once, monitor everything, and react faster when issues appear.
</p>
</div>
<div className="mt-10 grid grid-cols-1 md:grid-cols-3 gap-4">
{QUICK_START.map((item, index) => (
<div
key={item.title}
className="rounded-2xl border border-blue-100 bg-white/90 backdrop-blur-sm p-5 shadow-md"
>
<div className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold px-2">
{index + 1}
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.title}</h3>
<p className="mt-2 text-sm text-gray-600 leading-relaxed">{item.description}</p>
</div>
))}
</div>
<div className="mt-8 md:mt-10 text-center">
<p className="text-sm md:text-base text-gray-600 max-w-3xl mx-auto leading-relaxed">
This is what happens after rollout: each phase is captured, correlated, and translated into clear, actionable insights.
</p>
</div>
<div className="mt-6 md:mt-7 rounded-3xl border border-blue-100 bg-gradient-to-br from-white via-blue-50/60 to-indigo-50/60 p-5 md:p-6 shadow-xl overflow-hidden relative">
<div className="absolute -top-16 -left-16 w-48 h-48 bg-blue-200/30 blur-3xl rounded-full pointer-events-none" />
<div className="absolute -bottom-20 -right-16 w-56 h-56 bg-indigo-200/25 blur-3xl rounded-full pointer-events-none" />
<div className="relative z-10">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 className="text-gray-900 text-2xl md:text-3xl font-bold">
One pipeline from deployment to action
</h3>
</div>
<LoginButton
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold shadow-lg hover:shadow-xl transition-all"
>
Start Now
</LoginButton>
</div>
<div className="mt-6 relative">
{/* Desktop: independent left/right stacks for true vertical overlap */}
<div className="hidden md:grid grid-cols-[minmax(0,1fr)_40px_minmax(0,1fr)] gap-4 items-start relative isolate">
<div className="space-y-20 relative z-30">
{FLOW_STEPS.filter((_, i) => i % 2 === 0).map((step, leftIndex) => {
const index = leftIndex * 2;
return (
<div
key={step.title}
className="step-card relative rounded-xl border border-blue-100 bg-white/90 backdrop-blur-sm p-3 md:p-3.5 shadow-md"
style={{ animationDelay: `${index * 0.08}s` }}
>
<div className="absolute right-[-36px] top-[calc(50%+1px)] -translate-y-1/2 h-px w-[36px] bg-blue-200 z-10" />
<span
className="dot-pulse absolute right-[-42px] top-[calc(50%-4px)] -translate-y-1/2 h-3 w-3 rounded-full bg-blue-500 ring-4 ring-white z-50"
style={{ animationDelay: `${index * 1.8}s` }}
/>
<p className="text-blue-600 text-xs font-semibold uppercase tracking-[0.18em]">
Step {index + 1}
</p>
<div className="mt-1.5 flex items-start justify-between gap-3">
<h4 className="text-gray-900 font-semibold">{step.title}</h4>
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-blue-200 bg-blue-50">
<StepIcon icon={step.icon} />
</span>
</div>
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{step.description}</p>
</div>
);
})}
</div>
<div className="relative self-stretch z-0">
<div className="absolute left-1/2 -translate-x-1/2 top-2 bottom-2 w-px bg-blue-200 z-0" />
</div>
<div className="space-y-20 pt-24 relative z-30">
{FLOW_STEPS.filter((_, i) => i % 2 === 1).map((step, rightIndex) => {
const index = rightIndex * 2 + 1;
return (
<div
key={step.title}
className="step-card relative rounded-xl border border-blue-100 bg-white/90 backdrop-blur-sm p-3 md:p-3.5 shadow-md"
style={{ animationDelay: `${index * 0.08}s` }}
>
<div className="absolute left-[-36px] top-[calc(50%+1px)] -translate-y-1/2 h-px w-[36px] bg-blue-200 z-10" />
<span
className="dot-pulse absolute left-[-42px] top-[calc(50%-4px)] -translate-y-1/2 h-3 w-3 rounded-full bg-blue-500 ring-4 ring-white z-50"
style={{ animationDelay: `${index * 1.8}s` }}
/>
<p className="text-blue-600 text-xs font-semibold uppercase tracking-[0.18em]">
Step {index + 1}
</p>
<div className="mt-1.5 flex items-start justify-between gap-3">
<h4 className="text-gray-900 font-semibold">{step.title}</h4>
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-blue-200 bg-blue-50">
<StepIcon icon={step.icon} />
</span>
</div>
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{step.description}</p>
</div>
);
})}
</div>
</div>
{/* Mobile: linear timeline */}
<div className="md:hidden relative isolate">
<div className="absolute left-4 top-3 bottom-3 w-px bg-blue-200" />
<div className="space-y-5">
{FLOW_STEPS.map((step, index) => (
<div key={step.title} className="relative">
<div className="absolute left-4 top-[calc(2rem+1px)] h-px w-6 bg-blue-200 z-10" />
<span
className="dot-pulse absolute left-4 top-[calc(1.75rem-4px)] h-3 w-3 rounded-full bg-blue-500 ring-4 ring-white z-50"
style={{ animationDelay: `${index * 1.8}s` }}
/>
<div
className="step-card ml-10 rounded-xl border border-blue-100 bg-white/90 backdrop-blur-sm p-3 shadow-md"
style={{ animationDelay: `${index * 0.08}s` }}
>
<p className="text-blue-600 text-xs font-semibold uppercase tracking-[0.18em]">
Step {index + 1}
</p>
<div className="mt-1.5 flex items-start justify-between gap-3">
<h4 className="text-gray-900 font-semibold">{step.title}</h4>
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-blue-200 bg-blue-50">
<StepIcon icon={step.icon} />
</span>
</div>
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{step.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-3">
{[
"Know exactly what happens at each enrollment phase",
"Detect app bottlenecks and policy issues immediately",
"Move from alert to diagnostics with minimal friction",
].map(item => (
<div key={item} className="rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900">
{item}
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* Features Grid */}
<div id="features" className="py-20 px-6 bg-white/50 backdrop-blur-sm scroll-mt-14">
<div className="max-w-7xl mx-auto">
<p className="text-sm font-semibold text-center text-blue-600 uppercase tracking-[0.22em] mb-3">
Features
</p>
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
Everything you need for full Autopilot visibility
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Feature 1 - Real-Time Monitoring */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Real-Time Monitoring</h3>
<p className="text-gray-600 mb-4">
Watch Autopilot deployments in near real-time with live event streaming. Track every phase from device registration to user login.
</p>
<ul className="space-y-2">
{["Live phase tracking", "Near realtime push updates", "Per-device event stream"].map(item => (
<li key={item} className="flex items-center text-sm text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 mr-2 shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Feature 2 - Rich Analytics */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Rich Analytics</h3>
<p className="text-gray-600 mb-4">
Comprehensive metrics on deployment success rates, performance trends, and hardware insights — powered by customizable analyze rules.
</p>
<ul className="space-y-2">
{["Customizable analyze rules", "Success & failure rates", "Hardware model insights"].map(item => (
<li key={item} className="flex items-center text-sm text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-indigo-400 mr-2 shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Feature 3 - Fleet Health */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Fleet Health</h3>
<p className="text-gray-600 mb-4">
Live overview of your entire device fleet. Spot unhealthy devices, track blocked enrollments, and monitor per-tenant health at a glance.
</p>
<ul className="space-y-2">
{["Device health scoring", "Blocked device detection", "Tenant-level overview"].map(item => (
<li key={item} className="flex items-center text-sm text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 mr-2 shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Feature 4 - Diagnostics Collection */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Diagnostics Collection</h3>
<p className="text-gray-600 mb-4">
Trigger on-demand diagnostic uploads directly from the portal. Collect ETL logs, event logs, and system info from any enrolled device.
</p>
<ul className="space-y-2">
{["On-demand ZIP upload", "ETL & event log collection", "Per-device retrieval"].map(item => (
<li key={item} className="flex items-center text-sm text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 mr-2 shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Feature 5 - Event Timeline */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Event Timeline</h3>
<p className="text-gray-600 mb-4">
Detailed event timeline for every deployment session. Drill down into events, errors, and warnings to troubleshoot efficiently.
</p>
<ul className="space-y-2">
{["Phase-by-phase breakdown", "App install details", "Error & warning highlights"].map(item => (
<li key={item} className="flex items-center text-sm text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-orange-400 mr-2 shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Feature 6 - Audit Logging */}
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Audit Logging</h3>
<p className="text-gray-600 mb-4">
Complete audit trail of all actions and changes. Meet compliance requirements with detailed logging and data retention policies.
</p>
<ul className="space-y-2">
{["Admin action history", "Configurable retention", "Tamper-evident records"].map(item => (
<li key={item} className="flex items-center text-sm text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-red-400 mr-2 shrink-0" />
{item}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
{/* Comparison Table */}
<div id="comparison" className="py-20 px-6 bg-white/50 backdrop-blur-sm scroll-mt-14">
<div className="max-w-5xl mx-auto">
<p className="text-sm font-semibold text-center text-blue-600 uppercase tracking-widest mb-3">Comparison</p>
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
Standard Autopilot vs. Monitored Autopilot
</h2>
<p className="text-center text-gray-500 mb-12 max-w-2xl mx-auto">
See what you're missing without Autopilot Monitor — and what you gain the moment you deploy it.
</p>
{/* Header */}
<div className="grid grid-cols-[1fr_1fr_1fr] gap-0 mb-1">
<div />
<div className="bg-gradient-to-br from-blue-600 to-indigo-600 text-white text-center py-4 px-6 rounded-t-2xl mx-1">
<div className="font-bold text-lg">Autopilot Monitor</div>
<div className="text-blue-200 text-sm mt-0.5">Fully Monitored</div>
</div>
<div className="bg-gray-100 text-center py-4 px-6 rounded-t-2xl mx-1">
<div className="font-semibold text-gray-700 text-lg">Standard Autopilot</div>
<div className="text-gray-400 text-sm mt-0.5">Out of the Box</div>
</div>
</div>
{/* Rows */}
{[
{
label: "Deployment Visibility",
monitor: "Real-time phase tracking with live push updates",
standard: "None — black box until it finishes or fails",
},
{
label: "Download Progress",
monitor: "Per-app download speed, bytes transferred, % complete",
standard: "No visibility into what's downloading or how long it takes",
},
{
label: "User-Facing Progress Page",
monitor: "Progress view with live app status & download info",
standard: "Generic ESP screen — no details for the end user",
},
{
label: "Fleet Health Dashboard",
monitor: "Success rates, failure trends, avg. duration across all devices",
standard: "Limited manual report extraction from Intune",
},
{
label: "Analyze Rules",
monitor: "Built-in + fully customizable rules for automated issue detection",
standard: "Manual log review required after every failed deployment",
},
{
label: "Extended Data Gathering",
monitor: "Custom gather rules to capture registry, files, or WMI on any event",
standard: "No automated data collection during enrollment",
},
{
label: "Geo & Network Context",
monitor: "Device location, and network info captured at enrollment start",
standard: "No location or network context in deployment records",
},
{
label: "Performance Monitoring",
monitor: "CPU, memory, disk, and network snapshots during deployment",
standard: "Not captured — no way to detect resource bottlenecks",
},
{
label: "Troubleshooting Speed",
monitor: "Drill into per-event timeline, IME log patterns, and analyze results",
standard: "Manual IME log hunting — slow and error-prone",
},
].map((row, i) => (
<div
key={row.label}
className={`grid grid-cols-[1fr_1fr_1fr] gap-0 border-b border-gray-100 ${i % 2 === 0 ? "bg-white" : "bg-gray-50/60"}`}
>
<div className="py-4 px-5 font-semibold text-gray-800 text-sm flex items-center">{row.label}</div>
<div className="py-4 px-5 mx-1 bg-blue-50/50 text-sm text-blue-900 flex items-start gap-2">
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{row.monitor}
</div>
<div className="py-4 px-5 mx-1 text-sm text-gray-400 flex items-start gap-2">
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{row.standard}
</div>
</div>
))}
{/* Bottom cap */}
<div className="grid grid-cols-[1fr_1fr_1fr] gap-0">
<div />
<div className="bg-gradient-to-br from-blue-600 to-indigo-600 rounded-b-2xl mx-1 py-3 text-center text-blue-100 text-xs font-medium">
Full observability from day one
</div>
<div className="bg-gray-100 rounded-b-2xl mx-1 py-3 text-center text-gray-400 text-xs">
Limited to what Intune reports after the fact
</div>
</div>
</div>
</div>
{/* CTA Section */}
<div className="py-20 px-6">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold text-gray-900 mb-6">
Ready to transform your Autopilot monitoring?
</h2>
<p className="text-xl text-gray-600 mb-8">
Join organizations using Autopilot Monitor to react faster and monitor more reliably.
</p>
<LoginButton
className="px-8 py-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-semibold text-lg shadow-xl hover:shadow-2xl transform hover:-translate-y-0.5 transition-all"
>
Start Monitoring Now
</LoginButton>
</div>
</div>
{/* Footer */}
<footer className="border-t border-gray-200/80 bg-white/60 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 py-10">
<div className="flex flex-col md:flex-row md:items-start gap-10">
{/* Brand — left, fixed width with extra right margin */}
<div className="shrink-0 md:w-52 md:mr-32">
<div className="flex items-center space-x-2.5 mb-3">
<div className="w-7 h-7 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none">
<rect x="5.0" y="12.2" width="2.8" height="7.8" rx="0.9" fill="currentColor" />
<rect x="10.6" y="10.9" width="2.8" height="9.1" rx="0.9" fill="currentColor" />
<rect x="16.2" y="8.6" width="2.8" height="11.4" rx="0.9" fill="currentColor" />
<path d="M4.4 8.9L8.6 6.8L12.0 7.4L15.4 5.5L18.8 4.9" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17.8 4.2L19.1 4.9L17.9 5.9" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-sm font-bold text-gray-900">Autopilot Monitor</span>
</div>
<p className="text-xs text-gray-500 leading-relaxed mb-4">
Real-time monitoring and analytics for Windows enrollments.
</p>
<div className="flex items-center gap-3">
<a href="https://www.linkedin.com/in/oliver-kieselbach/" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 transition-colors" title="LinkedIn">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</a>
<a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-gray-900 transition-colors" title="GitHub">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</a>
</div>
</div>
{/* Link columns — spread across remaining space */}
<div className="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-8">
{/* Product */}
<div>
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Product</h4>
<ul className="space-y-1.5">
<li><a href="#features" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Features</a></li>
<li><a href="#how-it-works" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">How It Works</a></li>
<li><a href="#comparison" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Comparison</a></li>
</ul>
</div>
{/* Resources */}
<div>
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Resources</h4>
<ul className="space-y-1.5">
<li><a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Documentation</a></li>
<li><a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Feedback</a></li>
<li><a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">GitHub</a></li>
</ul>
</div>
{/* About */}
<div>
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">About</h4>
<ul className="space-y-1.5">
<li><a href="/about" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Overview</a></li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="text-[11px] font-semibold text-gray-900 uppercase tracking-wider mb-2.5">Legal</h4>
<ul className="space-y-1.5">
<li><a href="/privacy" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Privacy Policy</a></li>
<li><a href="/terms" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Terms of Use</a></li>
<li><a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Imprint</a></li>
</ul>
</div>
</div>
</div>
{/* Bottom bar */}
<div className="mt-8 pt-5 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-2">
<p className="text-[11px] text-gray-400">
&copy; 2026 Autopilot Monitor. Built with ❤️ by Oliver Kieselbach.{" "}
<span className="inline-block">Hosted on Azure by glueckkanja AG.</span>
</p>
<p className="text-[11px] text-gray-400">
Open source. Star us on{' '}
<a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-blue-600 transition-colors">GitHub</a>
</p>
</div>
</div>
</footer>
<LandingNavbar />
<Hero />
<StatsBand />
<JustAsk />
<Story />
<CapabilitiesStrip />
<Comparison />
<HowItWorks />
<FinalCta />
<SiteFooter />
</div>
);
}
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { DOCS_URL } from "@/utils/config";
import { BrandMark } from "../../components/BrandMark";
export default function PreviewPage() {
const { isAuthenticated, isLoading, user, isPreviewBlocked, previewMessage, logout, getAccessToken } = useAuth();
@@ -14,8 +15,16 @@ export default function PreviewPage() {
const [emailStatus, setEmailStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [emailError, setEmailError] = useState("");
// ?demo=1 renders the page without auth/redirects (placeholder data) so
// the design can be reviewed locally and on SWA preview environments.
const [demo, setDemo] = useState<boolean | null>(null);
useEffect(() => {
setDemo(new URLSearchParams(window.location.search).has("demo"));
}, []);
// If not preview-blocked (e.g. approved tenant navigates here), redirect away
useEffect(() => {
if (demo === null || demo) return;
if (!isLoading && isAuthenticated && user && !isPreviewBlocked) {
if (user.isTenantAdmin || user.isGlobalAdmin) {
router.push("/dashboard");
@@ -26,7 +35,7 @@ export default function PreviewPage() {
if (!isLoading && !isAuthenticated) {
router.push("/");
}
}, [isAuthenticated, isLoading, user, isPreviewBlocked, router]);
}, [demo, isAuthenticated, isLoading, user, isPreviewBlocked, router]);
const handleSaveEmail = async () => {
const email = notificationEmail.trim();
@@ -67,63 +76,64 @@ export default function PreviewPage() {
}
};
if (isLoading) {
if (!demo && isLoading) {
return (
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center">
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--lp-accent)] mx-auto"></div>
<p className="mt-4 text-[var(--lp-ink-soft)]">Loading...</p>
</div>
</div>
);
}
const upn = user?.upn ?? "you@contoso.com";
const tenantId = user?.tenantId ?? "00000000-0000-0000-0000-000000000000";
return (
<div className="landing-page min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center px-6">
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)] flex items-center justify-center px-6 py-12">
<div className="max-w-lg w-full text-center">
{/* Logo */}
<div className="flex items-center justify-center space-x-3 mb-8">
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Autopilot Monitor
</span>
{/* Brand */}
<div className="flex items-center justify-center gap-2.5 mb-8">
<BrandMark className="w-7 h-7" />
<span className="text-xl font-bold tracking-tight text-[var(--lp-ink)]">Autopilot Monitor</span>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-xl p-10">
{/* Clock icon */}
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<div className="bg-[var(--lp-surface)] border border-[var(--lp-line)] rounded-2xl shadow-xl shadow-black/[0.06] p-8 sm:p-10">
<div className="w-14 h-14 bg-[var(--lp-warn-soft)] rounded-full flex items-center justify-center mx-auto mb-5">
<svg className="w-7 h-7 text-[var(--lp-warn)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-2xl font-normal text-gray-900 mb-3">
Private Preview
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[var(--lp-warn-soft)] text-[var(--lp-warn)] text-xs font-bold uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--lp-warn)]" />
Approval pending
</span>
</div>
<h1 className="text-2xl font-bold tracking-tight text-[var(--lp-ink)] mb-2">
Almost there — you&apos;re on the list
</h1>
<p className="text-gray-600 mb-6 leading-relaxed">
{previewMessage || "Autopilot Monitor is currently in private preview. Your organization has been added to the waitlist."}
<p className="text-[15px] text-[var(--lp-ink-soft)] mb-6 leading-relaxed">
{previewMessage || "Autopilot Monitor is currently in preview. Your organization has been added to the waitlist."}
</p>
<div className="bg-blue-50 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-700">
Signed in as <span className="font-semibold">{user?.upn}</span>
</p>
<p className="text-xs text-blue-500 mt-1">
Tenant: {user?.tenantId}
<div className="bg-[var(--lp-surface-2)] border border-[var(--lp-line-soft)] rounded-xl p-3.5 mb-5">
<p className="text-sm text-[var(--lp-ink-soft)]">
Signed in as <span className="font-semibold text-[var(--lp-ink)]">{upn}</span>
</p>
<p className="text-xs text-[var(--lp-ink-faint)] mt-0.5 font-mono">Tenant: {tenantId}</p>
</div>
{/* Notification Email */}
<div className="text-left bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-6">
<p className="text-sm font-semibold text-indigo-900 mb-1">Get notified when approved</p>
<p className="text-sm text-indigo-700 mb-3">
Enter your email and we'll notify you as soon as your Private Preview access is granted.
<div className="text-left bg-[var(--lp-accent-soft)] border border-[var(--lp-accent-line)] rounded-xl p-4 mb-5">
<p className="text-sm font-semibold text-[var(--lp-ink)] mb-1">Get notified when approved</p>
<p className="text-sm text-[var(--lp-ink-soft)] mb-3">
Enter your email and we&apos;ll notify you as soon as your preview access is granted.
</p>
<div className="flex gap-2">
<input
@@ -131,73 +141,72 @@ export default function PreviewPage() {
value={notificationEmail}
onChange={(e) => { setNotificationEmail(e.target.value); setEmailStatus("idle"); setEmailError(""); }}
placeholder="your@email.com"
className="flex-1 px-3 py-2 border border-indigo-300 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
className="flex-1 px-3 py-2 border border-[var(--lp-line)] bg-[var(--lp-surface)] rounded-lg text-sm text-[var(--lp-ink)] placeholder-[var(--lp-ink-faint)] focus:outline-none focus:ring-2 focus:ring-[var(--lp-accent)] focus:border-[var(--lp-accent)] transition-colors"
onKeyDown={(e) => { if (e.key === "Enter") handleSaveEmail(); }}
/>
<button
onClick={handleSaveEmail}
disabled={emailStatus === "saving" || !notificationEmail.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
className="px-4 py-2 text-sm font-semibold text-white bg-[var(--lp-accent-ink)] rounded-lg hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed transition-all whitespace-nowrap"
>
{emailStatus === "saving" ? "Saving..." : "Notify me"}
</button>
</div>
{emailStatus === "saved" && (
<p className="text-xs text-green-600 mt-2 font-medium">
Email saved! We'll send you a notification when your access is approved.
<p className="text-xs text-[var(--lp-accent-ink)] mt-2 font-medium">
Email saved! We&apos;ll send you a notification when your access is approved.
</p>
)}
{emailStatus === "error" && emailError && (
<p className="text-xs text-red-600 mt-2 font-medium">{emailError}</p>
<p className="text-xs text-[var(--lp-danger)] mt-2 font-medium">{emailError}</p>
)}
</div>
<div className="text-left bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-sm font-semibold text-amber-900 mb-1">Next steps</p>
<p className="text-sm text-amber-800">
Please sign out and contact me on LinkedIn or open a GitHub issue to request access to the Private Preview. I check incoming requests regularly and will approve them as quickly as possible if I have enough capacity left.
<div className="text-left bg-[var(--lp-surface-2)] border border-[var(--lp-line-soft)] rounded-xl p-4 mb-6">
<p className="text-sm font-semibold text-[var(--lp-ink)] mb-1">Next steps</p>
<p className="text-sm text-[var(--lp-ink-soft)]">
Please sign out and wait a bit or contact me on LinkedIn or open a GitHub issue to request
access to the preview. I check incoming requests regularly and will approve them as quickly
as possible.
</p>
<p className="text-sm text-amber-800 mt-2">
In the meantime, you can already review the setup and configuration in the <a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline hover:no-underline">documentation</a>.
<p className="text-sm text-[var(--lp-ink-soft)] mt-2">
In the meantime, you can already review the setup and configuration in the{" "}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">documentation</a>.
</p>
<p className="text-sm text-amber-800 mt-2">
When you signed-up, sign in again later to view the updated approval status on your dashboard.
<p className="text-sm text-[var(--lp-ink-soft)] mt-2">
Sign in again later to check: while your approval is pending you&apos;ll land back on this
page — once approved, you&apos;ll go straight into the portal.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<a
href="https://www.linkedin.com/in/oliver-kieselbach/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 transition-colors"
>
LinkedIn
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
<a
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 transition-colors"
>
GitHub Issues
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
{[
{ label: "LinkedIn", href: "https://www.linkedin.com/in/oliver-kieselbach/" },
{ label: "GitHub Issues", href: "https://github.com/okieselbach/AutopilotMonitor/issues" },
].map(link => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-[var(--lp-ink)] border border-[var(--lp-line)] bg-[var(--lp-surface)] rounded-lg hover:border-[var(--lp-ink-faint)] transition-colors"
>
{link.label}
<svg className="w-3.5 h-3.5 text-[var(--lp-ink-faint)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
))}
</div>
</div>
<button
onClick={logout}
className="px-6 py-3 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors"
className="px-6 py-2.5 border border-[var(--lp-line)] bg-[var(--lp-surface)] text-[var(--lp-ink)] rounded-lg font-semibold hover:border-[var(--lp-ink-faint)] transition-colors"
>
Sign Out
</button>
</div>
<p className="mt-6 text-sm text-gray-400">
<p className="mt-6 text-sm text-[var(--lp-ink-faint)]">
&copy; 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.
</p>
</div>
@@ -6,7 +6,7 @@ export const metadata: Metadata = {
description:
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data when using the Windows Autopilot monitoring platform.",
openGraph: {
title: "Privacy Policy | AutopilotMonitor",
title: "Privacy Policy | Autopilot Monitor",
description:
"Privacy Policy for Autopilot Monitor. Learn how we collect, store, and protect your data.",
url: `${SITE_URL}/privacy`,
@@ -1,4 +1,5 @@
import { PublicPageHeader } from "../../components/PublicPageHeader";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const LAST_UPDATED = "29 July 2026";
@@ -11,7 +12,7 @@ function DocsLink({ href, children }: { href: string; children: React.ReactNode
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800 underline"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
{children}
</a>
@@ -20,10 +21,18 @@ function DocsLink({ href, children }: { href: string; children: React.ReactNode
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-gray-50">
<PublicPageHeader title="Privacy Policy" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-7xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Legal</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
Privacy Policy
</h1>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6 text-[15px]">
<div className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-6 space-y-4">
<p className="text-sm text-gray-500">Last updated: {LAST_UPDATED}</p>
<p className="text-gray-700">
Autopilot Monitor collects technical telemetry about Windows Autopilot enrollments so that IT teams can see
@@ -32,7 +41,7 @@ export default function PrivacyPage() {
published in the <DocsLink href={DOCS_SECURITY_FAQ}>Security &amp; Privacy FAQ</DocsLink>.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Roles: Who Is Responsible for What</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Roles: Who Is Responsible for What</h2>
<p className="text-gray-700">
For enrollment telemetry, <strong>your organization is the controller and Autopilot Monitor acts as your
processor</strong>. You decide which devices are monitored, what additional data your gather rules collect,
@@ -45,7 +54,7 @@ export default function PrivacyPage() {
<p className="text-gray-700">
glueckkanja AG, a German company certified to ISO/IEC 27001, operates Autopilot Monitor for{" "}
<strong>both plans</strong> — the same service, the same infrastructure, the same protection measures. Company
details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a>.
The project is maintained by Oliver Kieselbach, who acts in that role on behalf of glueckkanja AG and is the
contact for the open-source project and the Community edition.
</p>
@@ -54,7 +63,7 @@ export default function PrivacyPage() {
glueckkanja AG. On the Enterprise plan it forms part of the written agreement.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Collect</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">What We Collect</h2>
<h3 className="text-lg font-medium text-gray-800 mt-4">From enrolling devices</h3>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
@@ -78,7 +87,7 @@ export default function PrivacyPage() {
notification address, that address is copied once as the initial contact and is yours to change from then on.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Do Not Collect</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">What We Do Not Collect</h2>
<p className="text-gray-700">
During Autopilot provisioning the user signs in once to start the process and then does not interact with the
device while it provisions. The agent captures <strong>no browsing history, no file or document content, no
@@ -91,7 +100,7 @@ export default function PrivacyPage() {
and establishing persistence are hard-blocked and cannot be enabled by configuration.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">IP Addresses and Geolocation</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">IP Addresses and Geolocation</h2>
<p className="text-gray-700">
Geolocation is a tenant setting and is <strong>enabled by default</strong>. While it is enabled:
</p>
@@ -106,7 +115,7 @@ export default function PrivacyPage() {
tenant setting and also an agent command-line switch. Only the Geographic Performance view is affected.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Where Your Data Is Stored</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Where Your Data Is Stored</h2>
<p className="text-gray-700">
All customer data — sessions, events, configuration, audit logs, diagnostics, and backups — is stored in
Microsoft Azure in <strong>Germany West Central</strong>. The only component outside that region is the portal
@@ -114,7 +123,7 @@ export default function PrivacyPage() {
replication and no transfer of customer data outside the EU by the platform.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Who Can Access Your Data</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Who Can Access Your Data</h2>
<p className="text-gray-700">
Your data is not sold, and it is not shared with third parties for their own purposes. Access is limited to:
</p>
@@ -128,7 +137,7 @@ export default function PrivacyPage() {
isolation model is in the <DocsLink href={DOCS_SECURITY_FAQ}>Security &amp; Privacy FAQ</DocsLink>.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">External Services</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">External Services</h2>
<p className="text-gray-700">
<strong>Microsoft Azure is the only place your data is stored.</strong> Resend delivers the onboarding
approval email when a tenant is activated — it receives an administrator&apos;s email address and the tenant
@@ -144,7 +153,7 @@ export default function PrivacyPage() {
the parties engaged and the terms of their engagement.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Diagnostics Uploads</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Diagnostics Uploads</h2>
<p className="text-gray-700">
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
@@ -163,7 +172,7 @@ export default function PrivacyPage() {
tenant that has no upload destination configured.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
<p className="text-gray-700">
Retention is <strong>configured by you per tenant, defaulting to 90 days</strong> — 7 to 90 days on the
Community plan and 7 to 365 days on Enterprise. Expired sessions are purged automatically. You additionally
@@ -185,7 +194,7 @@ export default function PrivacyPage() {
Either can be removed on request.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Device authentication by mutual TLS</strong> using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads</li>
<li><strong>Autopilot device validation via Microsoft Graph</strong> — only devices registered in your tenant are accepted, with an optional hardware allow-list on top</li>
@@ -199,7 +208,7 @@ export default function PrivacyPage() {
<li><strong>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
</ul>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Your Rights</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Your Rights</h2>
<p className="text-gray-700">
Where the GDPR applies, you have the right to access, correction, deletion, restriction of processing,
portability, and to object to processing. We act on such requests within the statutory time limits. In
@@ -211,24 +220,25 @@ export default function PrivacyPage() {
organization; we support you in fulfilling them.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Changes to This Policy</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Changes to This Policy</h2>
<p className="text-gray-700">
Material changes are reflected in the &quot;last updated&quot; date above and announced through the service
announcements in the portal. If you hold a signed data processing agreement, notification follows the terms of
that agreement.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">Contact</h2>
<h2 className="text-lg font-semibold text-gray-900 mt-6">Contact</h2>
<p className="text-gray-700">
For privacy questions, a data processing agreement, or a data subject request, contact glueckkanja AG using
the details in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
the details in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a>.
For the open-source project and the Community edition you can also reach the maintainer via{" "}
<a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">LinkedIn</a>{" "}
<a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">LinkedIn</a>{" "}
or open a{" "}
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issue</a>.
<a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">GitHub issue</a>.
</p>
</div>
</main>
<SiteFooter />
</div>
);
}
@@ -3,6 +3,30 @@
import { useEffect, useMemo, useState } from "react";
import { EnrollmentEvent, Session } from "@/types";
// Minimal structural view of the event payloads this hook reads — only fields used in a
// typed position are declared (the agent serializes them as strings); everything else
// stays `unknown` via the index signature.
interface ProgressEventData {
[key: string]: unknown;
totalApps?: string;
total_apps?: string;
installed?: string;
failed?: string;
installing?: string;
downloading?: string;
blocking_apps_total?: string;
blockingAppsTotal?: string;
blocking_apps_completed?: string;
blockingAppsCompleted?: string;
app_name?: string;
appName?: string;
file_name?: string;
fileName?: string;
appId?: string;
app_id?: string;
status?: string;
}
export interface AppSummary {
total: number;
installed: number;
@@ -54,7 +78,7 @@ export function useProgressDerivedData(
const summaryEvents = events.filter((e) => e.eventType === "app_tracking_summary");
if (summaryEvents.length > 0) {
const latest = summaryEvents[summaryEvents.length - 1];
const d = latest.data;
const d = latest.data as ProgressEventData | undefined;
if (d) {
const total = parseInt(d.totalApps ?? d.total_apps ?? "0", 10);
if (total > 0) {
@@ -73,7 +97,7 @@ export function useProgressDerivedData(
const espEvents = events.filter((e) => e.eventType === "esp_ui_state");
if (espEvents.length > 0) {
const latest = espEvents[espEvents.length - 1];
const d = latest.data;
const d = latest.data as ProgressEventData | undefined;
if (d) {
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
const installed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
@@ -95,7 +119,7 @@ export function useProgressDerivedData(
{ bytesDownloaded: number; bytesTotal: number; downloadRateBps: number; isComplete: boolean }
>();
for (const evt of downloadEvents) {
const d = evt.data;
const d = evt.data as ProgressEventData | undefined;
if (!d) continue;
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
if (!appName) continue;
@@ -119,7 +143,7 @@ export function useProgressDerivedData(
const completedCount = Array.from(appLatest.values()).filter((v) => v.isComplete).length;
for (let i = downloadEvents.length - 1; i >= 0; i--) {
const d = downloadEvents[i].data;
const d = downloadEvents[i].data as ProgressEventData | undefined;
if (!d) continue;
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
if (!appName) continue;
@@ -157,7 +181,7 @@ export function useProgressDerivedData(
const appState = new Map<string, { state: string; startedAt?: string }>();
for (const evt of sorted) {
const d = evt.data;
const d = evt.data as ProgressEventData | undefined;
if (!d) continue;
const appName = d.appName ?? d.app_name ?? d.appId ?? d.app_id ?? null;
if (!appName) continue;
@@ -25,7 +25,7 @@ interface UseProgressEventsParams {
export interface UseProgressEventsReturn {
events: EnrollmentEvent[];
setEvents: React.Dispatch<React.SetStateAction<EnrollmentEvent[]>>;
sessionRef: React.MutableRefObject<Session | null>;
sessionRef: React.RefObject<Session | null>;
scheduleFetchEvents: (delayMs?: number) => void;
}
@@ -15,7 +15,7 @@ interface SignalRApi {
interface UseProgressSignalRParams {
session: Session | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionRef: React.RefObject<Session | null>;
signalR: SignalRApi;
scheduleFetchEvents: (delayMs?: number) => void;
}
@@ -89,7 +89,7 @@ export default function ProgressPortalPage() {
>
<div className="flex items-center space-x-2 min-w-0">
<svg
className="w-4 h-4 text-blue-600 flex-shrink-0"
className="w-4 h-4 text-green-600 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -106,9 +106,9 @@ export default function ProgressPortalPage() {
</span>
</div>
<div className="flex items-center space-x-1.5 flex-shrink-0 ml-3">
<span className="text-xs text-blue-600 font-medium">Change device</span>
<span className="text-xs text-green-700 font-medium">Change device</span>
<svg
className="w-3.5 h-3.5 text-blue-600"
className="w-3.5 h-3.5 text-green-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -147,9 +147,9 @@ export default function ProgressPortalPage() {
<span>Collapse</span>
</button>
)}
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<svg
className="w-8 h-8 text-blue-600"
className="w-8 h-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -179,13 +179,13 @@ export default function ProgressPortalPage() {
onChange={(e) => setSerialInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter serial number or device name..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg"
/>
</div>
<button
onClick={searchBySerial}
disabled={searching || !serialInput.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{searching ? "Searching..." : "Check Status"}
</button>
+1 -1
View File
@@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/privacy", "/terms"],
allow: ["/", "/about", "/get-started", "/privacy", "/terms"],
disallow: [
"/dashboard",
"/fleet-health",
@@ -271,7 +271,7 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 underline"
className="text-sm text-green-700 hover:text-green-800 underline"
>
{doc.title}
</a>
@@ -304,12 +304,14 @@ function scrollToEvent(eventId: string) {
}
}
function EvidenceEventLinks({ matchedConditions }: { matchedConditions: Record<string, any> }) {
function EvidenceEventLinks({ matchedConditions }: { matchedConditions: Record<string, unknown> }) {
const eventLinks: { signal: string; eventId: string; eventType?: string }[] = [];
const seenEventIds = new Set<string>();
for (const [signal, evidence] of Object.entries(matchedConditions)) {
for (const [signal, evidenceRaw] of Object.entries(matchedConditions)) {
if (signal.startsWith("factor_")) continue;
// Evidence values are rule-engine JSON; only object-shaped entries carry event links.
const evidence = evidenceRaw as { eventId?: string; eventType?: string } | string | null;
if (evidence && typeof evidence === "object" && evidence.eventId) {
// Multiple matched conditions can extract different fields from the
// same event — one chip per distinct event, not per condition.
@@ -267,7 +267,7 @@ export default function CollectLogsButton({
</p>
<p className="text-sm text-gray-700">
<span className="font-semibold">Enable &amp; Collect</span> switches the upload destination to
{" "}<span className="font-semibold">hosted storage</span> (managed by AutopilotMonitor) with mode
{" "}<span className="font-semibold">hosted storage</span> (managed by Autopilot Monitor) with mode
{" "}<span className="font-semibold">On failure</span>, then collects the logs from this device right away.
</p>
{/* Data-boundary disclosure. This dialog is the SECOND opt-in path into hosted
@@ -277,7 +277,7 @@ export default function CollectLogsButton({
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3 space-y-1">
<p className="text-sm text-sky-900">
<span className="font-semibold">Uploaded packages leave your own Azure tenant boundary.</span>{" "}
They are stored in the AutopilotMonitor backend&apos;s Azure Storage (Germany West Central),
They are stored in the Autopilot Monitor backend&apos;s Azure Storage (Germany West Central),
isolated per tenant, and each upload uses a fresh blob-scoped, write-only token.
</p>
<p className="text-xs text-sky-800">
@@ -5,16 +5,35 @@ import { EnrollmentEvent, Session } from "@/types";
import OobeConfigModal from "./OobeConfigModal";
import { compareVersions, stripGitHashSuffix } from "@/utils/bootstrapVersion";
// Minimal structural shapes for the event payloads this card reads. Values arrive as
// deserialized JSON; only the fields the JSX uses in a typed position (string props,
// arithmetic, Date construction) are declared — everything else stays `unknown`.
interface AgentStartedData { [key: string]: unknown; agentVersion?: string }
interface BootTimeData { [key: string]: unknown; bootTimeUtc?: string; bootTime?: string; uptimeMinutes?: number }
interface OsInfoData { [key: string]: unknown; osVersion?: string; displayVersion?: string; currentBuild?: string; buildRevision?: string; edition?: string; compositionEdition?: string; buildBranch?: string }
interface NetworkAdaptersData { [key: string]: unknown; adapters?: unknown[] }
interface DnsConfigData { [key: string]: unknown; dnsEntries?: unknown[] }
interface NetworkInterfaceInfoData { [key: string]: unknown; status?: string; connectionType?: string; adapterDescription?: string; linkSpeedMbps?: number; gateways?: string }
interface WifiSignalInfoData { [key: string]: unknown; wifiSsid?: string; wifiSignalPercent?: number; wifiRadioType?: string; wifiChannel?: number }
interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: string; proxyServer?: string; autoConfigUrl?: string; winHttpProxy?: string }
interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string }
interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string }
interface RealmJoinInfoData { [key: string]: unknown; productVersion?: string; releaseChannel?: string }
interface TpmStatusData { [key: string]: unknown; specVersion?: string }
interface BitLockerStatusData { [key: string]: unknown; volumes?: unknown[] }
interface DeviceLocationData { [key: string]: unknown; country?: string; Country?: string; timezone?: string; Timezone?: string }
interface HwSpecData { [key: string]: unknown; cpuName?: string; cpuCores?: string; cpuLogicalProcessors?: string; cpuMaxClockSpeedGHz?: string; cpuArchitecture?: string; ramTotalGB?: string; biosVersion?: string; biosReleaseDate?: string; smbiosVersion?: string; disks?: unknown[]; gpus?: unknown[] }
export default function DeviceDetailsCard({ events, latestAgentVersion, session }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null; session?: Session | null }) {
const [expanded, setExpanded] = useState(false);
const [showIpv6, setShowIpv6] = useState<Record<number, boolean>>({});
const [showOobeModal, setShowOobeModal] = useState(false);
const getEventData = (eventType: string): Record<string, any> | null => {
const getEventData = <T extends Record<string, unknown> = Record<string, unknown>>(eventType: string): T | null => {
const matchingEvents = events.filter(e => e.eventType === eventType);
if (matchingEvents.length === 0) return null;
const latestEvent = matchingEvents[matchingEvents.length - 1];
return latestEvent?.data ?? null;
return (latestEvent?.data as T | undefined) ?? null;
};
const isIpv6 = (ip: string): boolean => {
@@ -68,7 +87,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
return names[method] ?? `Unknown (${method})`;
};
const normalizeAutopilotProfile = (profile: Record<string, any> | null): Record<string, any> | null => {
const normalizeAutopilotProfile = (profile: AutopilotProfileData | null): AutopilotProfileData | null => {
if (!profile) return null;
const normalized = { ...profile };
@@ -104,13 +123,15 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
return normalized;
};
const hasValue = (value: unknown): boolean => value !== undefined && value !== null && `${value}` !== "";
// Type predicate so guarded fields (e.g. PolicyDownloadDate) narrow away undefined.
const hasValue = (value: unknown): value is NonNullable<unknown> => value !== undefined && value !== null && `${value}` !== "";
const agentStarted = getEventData("agent_started");
const bootTime = getEventData("boot_time");
const agentStarted = getEventData<AgentStartedData>("agent_started");
const bootTime = getEventData<BootTimeData>("boot_time");
const estimatedBootTime = (bootTime?.bootTimeUtc || bootTime?.bootTime)
? new Date(bootTime?.bootTimeUtc ?? bootTime?.bootTime)
// Non-null: the surrounding guard proves one of the two fields is set.
? new Date((bootTime?.bootTimeUtc ?? bootTime?.bootTime)!)
: null;
const uptimeUntilEnrollment = useMemo(() => {
@@ -140,26 +161,26 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
return null;
}, [bootTime, events]);
const osInfo = getEventData("os_info");
const networkAdapters = getEventData("network_adapters");
const dnsConfig = getEventData("dns_configuration");
const proxyConfig = getEventData("proxy_configuration");
const networkInterfaceInfo = getEventData("network_interface_info");
const wifiSignalInfo = getEventData("wifi_signal_info");
const autopilotProfile = normalizeAutopilotProfile(getEventData("autopilot_profile"));
const osInfo = getEventData<OsInfoData>("os_info");
const networkAdapters = getEventData<NetworkAdaptersData>("network_adapters");
const dnsConfig = getEventData<DnsConfigData>("dns_configuration");
const proxyConfig = getEventData<ProxyConfigData>("proxy_configuration");
const networkInterfaceInfo = getEventData<NetworkInterfaceInfoData>("network_interface_info");
const wifiSignalInfo = getEventData<WifiSignalInfoData>("wifi_signal_info");
const autopilotProfile = normalizeAutopilotProfile(getEventData<AutopilotProfileData>("autopilot_profile"));
// Explicit agent signal (new sessions) OR ProfileAvailable=0 in the cached profile
// (also covers sessions recorded before autopilot_profile_missing existed).
const autopilotProfileMissing =
getEventData("autopilot_profile_missing") !== null ||
(autopilotProfile !== null && `${autopilotProfile.ProfileAvailable}` === "0");
const aadJoinStatus = getEventData("aad_join_status");
const imeVersion = getEventData("ime_agent_version");
const realmJoinInfo = getEventData("realmjoin_detected");
const bitLockerStatus = getEventData("bitlocker_status");
const imeVersion = getEventData<ImeVersionData>("ime_agent_version");
const realmJoinInfo = getEventData<RealmJoinInfoData>("realmjoin_detected");
const bitLockerStatus = getEventData<BitLockerStatusData>("bitlocker_status");
const secureBootStatus = getEventData("secureboot_status");
const tpmStatus = getEventData("tpm_status");
const deviceLocation = getEventData("device_location");
const hwSpec = getEventData("hardware_spec");
const tpmStatus = getEventData<TpmStatusData>("tpm_status");
const deviceLocation = getEventData<DeviceLocationData>("device_location");
const hwSpec = getEventData<HwSpecData>("hardware_spec");
// Connectivity: agent→backend measurements. Latency comes from the session row (persisted
// at ingest from the cumulative snapshot counters); bandwidth/volume from their events.
@@ -216,7 +237,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
<DetailSection title="Network">
{networkAdapters && networkAdapters.adapters && (
(networkAdapters.adapters as unknown[]).map((adapter, i: number) => {
const a = adapter as Record<string, unknown>;
const a = adapter as { [key: string]: unknown; macAddress?: string };
const { ipv4, ipv6 } = a.ipAddresses ? splitIpAddresses(a.ipAddresses as string) : { ipv4: [], ipv6: [] };
const hasIpv6 = ipv6.length > 0;
const isIpv6Shown = showIpv6[i] ?? false;
@@ -231,7 +252,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
<div className="mt-1">
<button
onClick={() => setShowIpv6(prev => ({ ...prev, [i]: !isIpv6Shown }))}
className="text-xs text-blue-600 hover:text-blue-800 flex items-center space-x-1"
className="text-xs text-green-700 hover:text-green-800 flex items-center space-x-1"
>
<svg className={`w-3 h-3 transition-transform duration-200 ${isIpv6Shown ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
@@ -424,10 +445,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
/>
)}
{(deviceLocation?.country || deviceLocation?.Country) && (
<DetailRow label="Country" value={deviceLocation.country ?? deviceLocation.Country} />
<DetailRow label="Country" value={(deviceLocation.country ?? deviceLocation.Country)!} />
)}
{(deviceLocation?.timezone || deviceLocation?.Timezone) && (
<DetailRow label="Timezone" value={deviceLocation.timezone ?? deviceLocation.Timezone} />
<DetailRow label="Timezone" value={(deviceLocation.timezone ?? deviceLocation.Timezone)!} />
)}
</DetailSection>
)}
@@ -462,7 +483,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
<span className="text-xs text-gray-500 min-w-[120px]">OOBE Config</span>
<button
onClick={() => setShowOobeModal(true)}
className="text-xs text-right text-blue-600 hover:text-blue-800 hover:underline cursor-pointer font-mono transition-colors"
className="text-xs text-right text-green-700 hover:text-green-800 hover:underline cursor-pointer font-mono transition-colors"
title="Click to decode bitmask"
>
{`${autopilotProfile.CloudAssignedOobeConfig}`}
@@ -526,7 +547,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
{hwSpec.disks && Array.isArray(hwSpec.disks) && (hwSpec.disks as unknown[]).map((disk, i: number) => {
const d = disk as Record<string, unknown>;
return (
<DetailRow key={`disk-${i}`} label={hwSpec.disks.length > 1 ? `Disk ${i + 1}` : 'Disk'} value={`${d.model || 'Unknown'}${d.sizeGB ? ` (${d.sizeGB} GB)` : ''}${d.mediaType && d.mediaType !== 'Unknown' ? ` — ${d.mediaType}` : ''}`} />
<DetailRow key={`disk-${i}`} label={hwSpec.disks!.length > 1 ? `Disk ${i + 1}` : 'Disk'} value={`${d.model || 'Unknown'}${d.sizeGB ? ` (${d.sizeGB} GB)` : ''}${d.mediaType && d.mediaType !== 'Unknown' ? ` — ${d.mediaType}` : ''}`} />
);
})}
{hwSpec.systemDriveFreeGB !== undefined && hwSpec.systemDriveTotalGB !== undefined && (
@@ -555,7 +576,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
{hwSpec.gpus && Array.isArray(hwSpec.gpus) && (hwSpec.gpus as unknown[]).map((gpu, i: number) => {
const g = gpu as Record<string, unknown>;
return (
<DetailRow key={`gpu-${i}`} label={hwSpec.gpus.length > 1 ? `GPU ${i + 1}` : 'GPU'} value={`${g.name || 'Unknown'}${g.adapterRAMGB ? ` (${g.adapterRAMGB} GB)` : ''}`} />
<DetailRow key={`gpu-${i}`} label={hwSpec.gpus!.length > 1 ? `GPU ${i + 1}` : 'GPU'} value={`${g.name || 'Unknown'}${g.adapterRAMGB ? ` (${g.adapterRAMGB} GB)` : ''}`} />
);
})}
</DetailSection>
@@ -166,7 +166,7 @@ export default function DeviceHistoryBanner({
{isCurrent ? (
<span className="text-xs text-amber-800">this session</span>
) : (
<Link href={sessionHref(r.sessionId)} className="text-xs text-blue-600 hover:text-blue-800 underline underline-offset-2">
<Link href={sessionHref(r.sessionId)} className="text-xs text-green-700 hover:text-green-800 underline underline-offset-2">
open
</Link>
)}
@@ -3,7 +3,7 @@
import { useState, useMemo } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
import { getErrorCodeEntry, getEnrichedOrLookup, formatErrorCode } from "@/utils/errorCodeMap";
import { getEnrichedOrLookup, formatErrorCode, type ErrorCodeEntry } from "@/utils/errorCodeMap";
interface EventTimelineProps {
filteredEvents: EnrollmentEvent[];
@@ -44,7 +44,6 @@ export default function EventTimeline({
expandAll,
collapseAll,
isWhiteGloveSession,
whiteGloveSplitSequence,
orderedPhases,
eventsByPhase,
preProvGrouped,
@@ -91,7 +90,7 @@ export default function EventTimeline({
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search events..."
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500"
/>
{searchQuery && (
<button
@@ -332,7 +331,7 @@ function PhaseSection({
onClick={onToggle}
className="flex items-center justify-between w-full text-left mb-3 group"
>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-green-600">
{phaseName} ({events.length} events)
</h3>
<svg className={`w-5 h-5 text-gray-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -342,7 +341,7 @@ function PhaseSection({
{isExpanded && (
<div className="space-y-3">
{events.map((event, index) => (
{events.map((event) => (
<EventRow
key={event.eventId || `${event.sessionId}-${event.sequence}`}
event={event}
@@ -444,7 +443,6 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
}
};
const hasData = isTruncated || (detailData && Object.keys(detailData).length > 0);
const hasDetails = true; // Every event has at least the metadata block
const { isBackfilled, recordedAt } = getBackfillInfo(event);
@@ -474,14 +472,14 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
<p className="mt-1 text-sm text-gray-600" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
{/* Exit code / HRESULT badge for app install events */}
{(event.eventType === "app_install_failed" || event.eventType === "app_install_completed") && (() => {
const ec = event.data?.exitCode ?? event.data?.exit_code;
const hr = event.data?.hresultFromWin32 ?? event.data?.hresult_from_win32;
const ec = (event.data?.exitCode ?? event.data?.exit_code) as string | number | undefined;
const hr = (event.data?.hresultFromWin32 ?? event.data?.hresult_from_win32) as string | number | undefined;
const hasNonZero = (ec && String(ec) !== "0") || (hr && String(hr) !== "0");
if (!hasNonZero) return null;
// Prefer backend-enriched *Info sibling, fall back to local lookup for older
// responses that pre-date the backend ErrorCodeEnricher.
const ecEntry = ec ? getEnrichedOrLookup(event.data?.exitCodeInfo, String(ec)) : null;
const hrEntry = hr ? getEnrichedOrLookup(event.data?.hresultFromWin32Info, String(hr)) : null;
const ecEntry = ec ? getEnrichedOrLookup(event.data?.exitCodeInfo as ErrorCodeEntry | undefined, String(ec)) : null;
const hrEntry = hr ? getEnrichedOrLookup(event.data?.hresultFromWin32Info as ErrorCodeEntry | undefined, String(hr)) : null;
return (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
{ec && String(ec) !== "0" && (
@@ -517,12 +515,13 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
(e.g. "Apps (0x87d1041c)") and surfaced as top-level event data so the UI can
render it without parsing nested text. */}
{(event.eventType === "enrollment_failed" || event.eventType === "esp_provisioning_status" || event.eventType === "esp_failure_advisory" || event.eventType === "esp_appx_failure_analysis") && (() => {
const code = event.data?.errorCode ?? event.data?.failedSubcategoryErrorCode ?? event.data?.espErrorCode;
const sub = event.data?.failedSubcategory ?? event.data?.failedSubcategories;
const code = (event.data?.errorCode ?? event.data?.failedSubcategoryErrorCode ?? event.data?.espErrorCode) as string | number | undefined;
const sub = (event.data?.failedSubcategory ?? event.data?.failedSubcategories) as string | undefined;
const likelyCulpritApps = event.data?.likelyCulpritApps as string | undefined;
const isAdvisory = event.eventType === "esp_failure_advisory";
if (!code && !isAdvisory) return null;
const codeStr = code ? String(code) : null;
const entry = codeStr ? getEnrichedOrLookup(event.data?.errorCodeInfo, codeStr) : null;
const entry = codeStr ? getEnrichedOrLookup(event.data?.errorCodeInfo as ErrorCodeEntry | undefined, codeStr) : null;
// Advisory path uses warning-color palette (amber); the device continued past the
// failure via ContinueAnyway, so this is not a hard error. PR1 Session 4fa5a2d4.
const badgeBg = isAdvisory ? "bg-amber-100 text-amber-800" : "bg-red-100 text-red-800";
@@ -552,12 +551,12 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
subcategory: <span className="font-mono">{String(sub)}</span>
</span>
)}
{event.data?.likelyCulpritApps && (
{likelyCulpritApps && (
<span
className="px-1.5 py-0.5 rounded bg-orange-100 text-orange-800 font-medium"
title="Tracked app(s) ESP most likely failed on — never-started apps ranked first (snapshot at failure time)."
>
Likely app: {String(event.data.likelyCulpritApps)}
Likely app: {String(likelyCulpritApps)}
</span>
)}
</div>
@@ -571,7 +570,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
const isRetry = event.eventType === "esp_failure_retry_detected";
const label = isRetry ? "User retry" : "Recovered";
const badgeCls = isRetry ? "bg-sky-100 text-sky-800" : "bg-emerald-100 text-emerald-800";
const cat = event.data?.category;
const cat = event.data?.category as string | undefined;
const sub = event.data?.subcategory ?? event.data?.failedSubcategory;
const mins = event.data?.minutesSinceFailure ?? event.data?.minutesSinceAdvisory;
return (
@@ -603,7 +602,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
{hasDetails && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800 ml-4 flex-shrink-0"
className="text-xs text-green-700 hover:text-green-800 ml-4 flex-shrink-0"
>
{showDetails ? 'Hide' : hasGatherOutput ? 'Output' : 'Details'}
</button>
@@ -622,7 +621,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
type="button"
onClick={copyEventId}
title={copied ? 'Copied!' : 'Copy EventId'}
className="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-5 h-5 rounded border border-gray-200 bg-white text-gray-400 opacity-0 group-hover/meta:opacity-100 focus:opacity-100 hover:bg-gray-50 hover:text-blue-600 transition-opacity"
className="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-5 h-5 rounded border border-gray-200 bg-white text-gray-400 opacity-0 group-hover/meta:opacity-100 focus:opacity-100 hover:bg-gray-50 hover:text-green-600 transition-opacity"
>
{copied ? (
<svg className="w-3 h-3 text-green-500" viewBox="0 0 20 20" fill="currentColor">
@@ -814,7 +813,7 @@ function RawEventRow({ event }: { event: EnrollmentEvent }) {
<span className="text-gray-900 font-medium flex-shrink-0">{event.eventType}</span>
<span className="text-gray-500 truncate flex-1 min-w-0" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</span>
{hasDetails && (
<button onClick={() => setExpanded(!expanded)} className="text-gray-400 hover:text-blue-600 flex-shrink-0 ml-1">
<button onClick={() => setExpanded(!expanded)} className="text-gray-400 hover:text-green-600 flex-shrink-0 ml-1">
{expanded ? '−' : '+'}
</button>
)}
@@ -29,7 +29,7 @@ export default function MarkFailedModal({ show, session, onConfirm, onCancel }:
You are about to manually mark this session as <span className="font-semibold text-red-600">Failed</span>.
</p>
<p className="text-sm text-gray-700 mb-2">
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason &quot;Manually marked as failed by administrator&quot;.
</p>
<p className="text-sm text-gray-600">
This action will update the session status and cannot be undone. Do you want to continue?
@@ -40,7 +40,6 @@ export default function OobeConfigModal({ show, value, onClose }: OobeConfigModa
if (!show) return null;
const hexValue = `0x${value.toString(16).toUpperCase().padStart(4, "0")}`;
const binaryValue = value.toString(2).padStart(11, "0");
const isSelfDeploying = (value & 0x0020) !== 0 && (value & 0x0040) !== 0;
const profileType = isSelfDeploying ? "Self-Deploying" : "User-Driven";
@@ -40,7 +40,8 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
// Check for app_tracking_summary events (new strategic events)
const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary");
if (trackingSummary?.data) {
const d = trackingSummary.data;
// Counters are serialized as strings on the wire.
const d = trackingSummary.data as Record<string, string | undefined>;
const completed = parseInt(d.completedApps ?? d.appsCompleted ?? "0", 10);
const total = parseInt(d.totalApps ?? "0", 10);
if (total > 0) {
@@ -51,7 +52,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
// Check for esp_ui_state events (legacy) to show app install progress
const espState = phaseEvents.find(e => e.eventType === "esp_ui_state");
if (espState?.data) {
const d = espState.data;
const d = espState.data as Record<string, string | undefined>;
const completed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
const currentItem = d.current_item ?? d.currentItem ?? d.status_text ?? d.statusText;
@@ -76,7 +77,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
// Check for download_progress to show active download (legacy)
const downloadEvt = phaseEvents.find(e => e.eventType === "download_progress");
if (downloadEvt?.data) {
const d = downloadEvt.data;
const d = downloadEvt.data as Record<string, string | undefined>;
const appName = d.app_name ?? d.appName ?? "content";
const pct = d.bytes_total && d.bytes_downloaded
? Math.round((parseInt(d.bytes_downloaded) / parseInt(d.bytes_total)) * 100)
@@ -245,7 +245,7 @@ export default function ReportSessionModal({
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Describe what seems incorrect or unexpected..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
rows={3}
disabled={submitting}
/>
@@ -261,7 +261,7 @@ export default function ReportSessionModal({
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="your.email@company.com"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
disabled={submitting}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
@@ -365,7 +365,7 @@ export default function ReportSessionModal({
checked={hasDiagnostics && includeDiagnostics}
onChange={e => setIncludeDiagnostics(e.target.checked)}
disabled={submitting || !hasDiagnostics}
className="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
className="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Include uploaded diagnostics archive
@@ -424,7 +424,7 @@ export default function ReportSessionModal({
<button
onClick={handleSubmit}
disabled={submitting || !!agentLogError || !!screenshotError}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{submitting ? (
<>
@@ -94,6 +94,15 @@ function confidenceTooltip(confidence?: string): string {
return "Unknown identification confidence";
}
type FindingsSortKey = "severity" | "software" | "cves" | "cvss";
// Module-level so React keeps a stable component identity across renders
// (react-hooks/static-components: an inline component type remounts on every render).
function SortIndicator({ column, sortKey, sortAsc }: { column: FindingsSortKey; sortKey: FindingsSortKey; sortAsc: boolean }) {
if (sortKey !== column) return <span className="text-gray-300 ml-1">&#8597;</span>;
return <span className="ml-1">{sortAsc ? "▲" : "▼"}</span>;
}
export default function VulnerabilityReportSection({
events,
vulnerabilityReport,
@@ -104,7 +113,7 @@ export default function VulnerabilityReportSection({
const [activeTab, setActiveTab] = useState<TabKey>("findings");
const [rescanning, setRescanning] = useState(false);
const [findingsSearch, setFindingsSearch] = useState("");
const [findingsSortKey, setFindingsSortKey] = useState<"severity" | "software" | "cves" | "cvss">("severity");
const [findingsSortKey, setFindingsSortKey] = useState<FindingsSortKey>("severity");
const [findingsSortAsc, setFindingsSortAsc] = useState(false);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [inventorySearch, setInventorySearch] = useState("");
@@ -124,7 +133,7 @@ export default function VulnerabilityReportSection({
// --- Findings data (wrapped in try/catch to prevent page crash on malformed report data) ---
const { findings, unmatchedSoftware, mappedNames, unmappedNames, cleanNames, kevTotal, dataFreshness } = useMemo(() => { try {
const findingsMap = new Map<string, Finding>();
let unmatched: string[] = [];
const unmatched: string[] = [];
const mapped = new Set<string>();
const unmapped = new Set<string>();
const clean = new Set<string>();
@@ -248,7 +257,8 @@ export default function VulnerabilityReportSection({
const chunkGroups = new Map<string, { items: InventoryItem[]; chunkCount: number; seenChunks: Set<number> }>();
for (const evt of inventoryEvents) {
const d = evt.data || {};
// Chunk-envelope fields read in a typed position; the payload itself stays unknown.
const d = (evt.data || {}) as { [key: string]: unknown; triggered_at?: string; triggeredAt?: string; chunk_index?: number; chunkIndex?: number; chunk_count?: number; chunkCount?: number };
const triggeredAt = d.triggered_at || d.triggeredAt || evt.timestamp || "default";
const chunkIndex = d.chunk_index ?? d.chunkIndex ?? 0;
const chunkCount = d.chunk_count ?? d.chunkCount ?? 1;
@@ -396,11 +406,6 @@ export default function VulnerabilityReportSection({
});
};
const SortIndicator = ({ column }: { column: typeof findingsSortKey }) => {
if (findingsSortKey !== column) return <span className="text-gray-300 ml-1">&#8597;</span>;
return <span className="ml-1">{findingsSortAsc ? "\u25B2" : "\u25BC"}</span>;
};
// Guard: no relevant data → render nothing (placed after all hooks to satisfy Rules of Hooks)
if (vulnReportData.length === 0 && inventoryEvents.length === 0) return null;
@@ -562,7 +567,7 @@ export default function VulnerabilityReportSection({
placeholder="Search software, versions, CVE IDs..."
value={findingsSearch}
onChange={e => setFindingsSearch(e.target.value)}
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 outline-none"
/>
</div>
@@ -572,18 +577,18 @@ export default function VulnerabilityReportSection({
<thead>
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("severity")}>
Risk <SortIndicator column="severity" />
Risk <SortIndicator column="severity" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("software")}>
Software <SortIndicator column="software" />
Software <SortIndicator column="software" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
</th>
<th className="py-2 px-3">Version</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cves")}>
CVEs <SortIndicator column="cves" />
CVEs <SortIndicator column="cves" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
</th>
<th className="py-2 px-3">KEV</th>
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cvss")}>
Max CVSS <SortIndicator column="cvss" />
Max CVSS <SortIndicator column="cvss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
</th>
</tr>
</thead>
@@ -665,7 +670,7 @@ export default function VulnerabilityReportSection({
placeholder="Search name, version, publisher, source..."
value={inventorySearch}
onChange={e => setInventorySearch(e.target.value)}
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
className="w-full sm:w-80 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 outline-none"
/>
</div>
<div className="overflow-x-auto">
@@ -917,7 +922,7 @@ function FindingRow({ finding, isExpanded, onToggle }: {
href={`https://nvd.nist.gov/vuln/detail/${cve.cveId}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-600 hover:text-blue-800 underline"
className="text-sm font-medium text-green-700 hover:text-green-800 underline"
>
{cve.cveId}
</a>
@@ -70,7 +70,8 @@ export function useSessionDerivedData(
const summaryEvents = events.filter(e => e.eventType === "app_tracking_summary");
if (summaryEvents.length === 0) return null;
const latest = summaryEvents[summaryEvents.length - 1];
const d = latest.data;
// Flat V1 summary schema; counters are serialized as strings on the wire.
const d = latest.data as Record<string, string | undefined> | undefined;
if (!d) return null;
return {
totalApps: parseInt(d.totalApps ?? "0", 10),
@@ -46,8 +46,8 @@ export interface UseSessionDetailReturn {
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
fetchSessionDetails: () => Promise<void>;
resolveEffectiveTenantId: () => string | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionIdRef: React.MutableRefObject<string>;
sessionRef: React.RefObject<Session | null>;
sessionIdRef: React.RefObject<string>;
}
/**
@@ -63,7 +63,7 @@ interface UseSessionEventsParams {
sessionTenantId: string | null;
sessionStatus: string | null | undefined;
resolveEffectiveTenantId: () => string | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionRef: React.RefObject<Session | null>;
fetchSessionDetails: () => Promise<void>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
isConnected: boolean;
@@ -20,8 +20,8 @@ interface UseSessionSignalRParams {
tenantId: string;
sessionTenantIdFromSession: string | undefined;
globalAdminMode: boolean;
sessionIdRef: React.MutableRefObject<string>;
sessionRef: React.MutableRefObject<Session | null>;
sessionIdRef: React.RefObject<string>;
sessionRef: React.RefObject<Session | null>;
resolveEffectiveTenantId: () => string | null;
signalR: SignalRApi;
scheduleFetchEvents: (delayMs?: number) => void;
@@ -167,7 +167,7 @@ function Tabs({
onClick={() => onChange(t.id)}
className={`border-b-2 px-1 py-2 text-sm font-medium transition-colors ${
activeTab === t.id
? "border-blue-600 text-blue-600"
? "border-green-600 text-green-700"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
}`}
>
@@ -184,7 +184,7 @@ function NoV2DataNotice({ sessionId }: { sessionId: string }) {
<div className="rounded border border-amber-200 bg-amber-50 p-6 text-amber-900">
<h2 className="font-semibold">No V2 decision data for this session.</h2>
<p className="mt-2 text-sm">
The Inspector relies on the V2-agent's signal log and decision journal.
The Inspector relies on the V2-agent&apos;s signal log and decision journal.
This session likely ran on the legacy (V1) agent, or the V2 telemetry
never reached the backend. Use the regular{" "}
<Link

Some files were not shown because too many files have changed in this diff Show More