Tenants seeded in the pre-global-partition era can still carry per-tenant
copies of built-in rules (IsBuiltIn=false debris, e.g. ANALYZE-ID-002 on one
prod tenant). They pass the custom-rule filter and put the same RuleId twice
into the merged list, blowing up ToDictionary(r => r.RuleId) in
AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync and silently
dropping rule channel notifications for that tenant.
Guard the merge in AnalyzeRuleService + GatherRuleService: a tenant row whose
RuleId collides with a merged global rule is skipped (global wins) with a
warning log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod report 2026-08-01 (tenant b54dc1af): corporate-identifier validation
"came back on" after every disable. Audit log shows the truth: not a single
True->False entry ever - the disable NEVER reached the server. The validation
section has no save bar, and only the consent-driven ENABLE paths persisted;
the toggle setters (disable via confirm dialog, and enabling the second gate
while the first already carries the consent) wrote plain React state that
silently reverted on the next config load.
New TenantConfigContext.saveValidationGate routes those setter calls through
the shared config PUT with explicit override values; local gate state re-syncs
from the server response (or snaps back on failure), and the section save
indicator covers the write.
tsc clean, vitest 631/631, ESLint ratchet holds (115).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The validation info box and the detect-access error still claimed Application
Administrator could grant the consent. Per MS Learn (grant-admin-consent),
Application/Cloud Application Administrator are excluded for Microsoft Graph
app roles (application permissions) - our consent includes the app-only
DeviceManagementServiceConfig.Read.All. Correct roles: Global Administrator
or Privileged Role Administrator, matching the funnel banner and docs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Post-consent verification now POLLS the access-check (up to 5 attempts,
15s apart) instead of a single shot: right after a successful admin consent
the app-only token often mints with an EMPTY roles claim for 30-90s
(observed live: tenant 5ca2b350, GrantedRoleCount=0 at T+43s), which the
single-shot probe misreported as "permission not granted" while the homing
flip and consent were actually fine. The access-check invalidates and
re-mints fresh per call, so the poll converges as soon as Microsoft has
propagated the role; the consent spinner stays up meanwhile. Final "absent"
message now names propagation as the likely cause instead of sounding fatal.
- Funnel banner gains always-visible actions ("Grant consent for the new app"
+ "Detect existing access" on the autopilot gate, idempotent when already
enabled): tenants whose validations are already ON had no consent button to
click, and the per-toggle detect link under Corporate Identifier would
enable that validation as a side effect.
- Dark mode: funnel/flipped/legacy banners and the admins-only notice were
light-palette-only and unreadable on dark.
tsc clean, vitest 631/631, ESLint ratchet holds (115).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod incident 2026-07-31 18:03Z (tenant b54dc1af): the consent-driven homing
flip landed and 600ms later the frontend persisted the validation toggle via
the generic config PUT - round-tripping a config loaded BEFORE the flip. The
GA branch treated the stale null HomedAppClientId as an intentional revert to
legacy, silently undoing the flip (App Insights: AppHomingFlipped 18:03:00.44,
PUT revert warning 18:03:01.03).
- HomedAppClientId is now IMMUTABLE via the generic PUT for ALL callers, GA
included: a full-model round-trip can never carry flip intent. The only
writers are the consent auto-flip and POST config/{tenantId}/app-homing
(which the GA tenant-management panel already uses).
- HandleAuthClientIdTrackingAsync (same failure class, cross-instance): the
last-seen tracking write now mutates a cache-BYPASSING fresh read via new
TenantConfigurationService.GetConfigurationFreshAsync instead of blind-saving
the up-to-5-min-stale cached view - the login right after a flip is exactly
the one that carries a changed audience and triggers this write.
3 regression tests (fresh-entity mutation preserves a concurrent flip,
fresh-already-current suppresses the write, steady state stays zero-I/O).
Backend xUnit 3767/3767.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plain info box was easy to overlook. Upgrade to a bordered gradient card
with icon and heading, state that admin consent needs a tenant-wide-consent
role (Global Administrator / Privileged Role Administrator), and link the
portal-setup docs article describing the required permissions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.
- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
primary only, probe-gated; GA gets force + revert. Pure decision helper
EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
(AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
GA tenant management (typed homedAppClientId hardens the PUT round-trip),
funnel/success banners in tenant settings with switchAuthApp re-login,
admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
events survive; strategic events across the whole flow (app_homing_flipped,
consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
auth_app_homing_learned, app_homing_manual_flip*).
Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The static MonitoredPoisonQueues watch-list had already drifted twice
(tenant-auto-approve-poison, session-deletion-maintenance-poison missing).
Health check and maintenance watcher now enumerate every existing *-poison
queue in the storage account at runtime; an absent queue has never had a
poison-move and is healthy by definition, so enumeration covers everything
a list would. Enumeration failure surfaces as warning (never a silent
all-clear) on the health card and skips the maintenance tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).
Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
the acting app per tenant (no-op until the legacy config pair is set); Graph
client-credentials, admin-consent URLs and the surfaced client id follow the
homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
the middleware audience builders (was single-audience - would 401 legacy
tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
mcp-server.bicep default client id -> C4A8 app.
Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feature-maturity badges were a leftover of the preview era — with the product
publicly available, a lone "Beta" pill only signals uncertainty. Both features
are fully functional; the dashboard banner already carries the actively-developed
framing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wasAuthenticated was written AND read during render (the only such
case). Historical per-mount state belongs in useState, adjusted during
render via the guarded official pattern — behavior-identical: the value
is only consulted in !isAuthenticated renders, which can only follow a
render that already set it. Auth-gate control flow (auto re-login,
loading early-return, post-login return URL) untouched.
react-hooks/refs: 26 -> 0. Lint ratchet 115.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nine render-phase mirror writes replaced: scope/filter/pagination
mirrors sync post-commit via useLatest; consuming callbacks list the
stable refs in their deps. Drops two write-only refs
(joinGlobalAdminsRef, loadingMoreRef) that nothing read. Imperative
refs (fetchGen/fetchLock/loadAllToken/submittedFilter) untouched.
Lint ratchet 119.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven scope mirror refs sync post-commit via useLatest instead of
render-phase writes; fetchStats lists the stable refs it reads in its
deps. Lint ratchet 128.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four scope mirror refs (routeGlobal, selectedTenantId, tenantId, days)
now sync post-commit via useLatest instead of render-phase writes.
fetchData lists the stable refs it reads in its deps. Lint ratchet
135.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Render-phase ref mirror writes tear under concurrent rendering
(react-hooks/refs). New hooks/useLatest.ts syncs the mirror in a passive
effect instead; refs from custom hooks are not linter-known-stable, so
consumers list them in dep arrays (identity is stable — no re-creation).
Lint ratchet 141 -> 139.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
* 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>
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): 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>
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>
* 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>
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>
- New "phase" stage: OnPhaseChanged writes a transition marker line
(phase change: Old -> New) so rule-level lines read against the current
phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
and capture groups (<=100 chars, same selection as event data), capped at
10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.
Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.
Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.
/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.
Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.
- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
triggered the MSAL redirect), usage-metrics, fleet-health, sla and
admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
pages) left untouched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:
- session.csv: append the 15 newer Sessions fields that had drifted out
of the export (failureSource, reconcileReason, adminMarkedAction,
validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
eventStreamActive in the request and report-metadata.json; the modal
warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
attachments status in metadata instead of silently dropping invalid
base64, SanitizeZipEntryName strips path components from customer-
supplied file names (both report types)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.
- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
report), reuses the Hosted/CustomerSas read paths, re-verifies the
hosted tenant prefix via ClassifyBlobName, honours the admin size cap
and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
(written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).
Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".
Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.
Web tsc + 603/603, backend build clean + SLA suite 38/38.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
HostRoutingGuard's replace, signing the user in on the wrong origin.
ProtectedRoute now stands down on the public host and lets the guard move
the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
including the automatic portal-side re-auth where Entra's session cookie
would complete silently. login({auto:true}) (ProtectedRoute) drops the
prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
keep the picker.
Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.
Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
no path — a bounce destroys the sign-in), plus a sessionStorage bounce
counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
window.location.href navigation (getPortalLoginUrl pattern) instead of
router.push + guard cascade.
tsc clean, vitest 603/603, export build 87 pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.
- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
/diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
/admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
consolidated. Suspense boundaries added everywhere useSearchParams drives
page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
the *NavSections registries, dynamicParams=false, client bodies extracted to
SectionClient) — 37 pages; server redirect() index pages became
ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
webhook notifications cannot be edited): SWA wildcard rewrites serve the
family page and the globally mounted LegacyPathRedirect rewrites to the
canonical query URL, preserving query + #event hashes (mapping pinned by
lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
override, all security headers incl. literalized prod CSP (no unsafe-eval),
Content-Type routes for the extension-less og images. Guarded by the new
utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
(query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.
Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.
- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
(transport-derived, unspoofable; IPv4-mapped addresses normalized).
Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
(FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
Used by GetBootstrapScriptFunction (traffic arrives via
go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.
- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
raw string; golden test pins byte parity with the TS template
(TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
/api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
section, index/log entries.
Backend tests 3611/3611, web vitest 651/651, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The HTTP/1.1 / six-connection-cap claim was wrong — it came from the
Schannel curl build that cannot speak HTTP/2. ALPN (openssl s_client)
and .NET HttpClient confirm the portal negotiates h2; the queue is
server-side in the SWA SSR runtime. Doc, index, log and the
GlobalSidebar comment now describe the real mechanism.
Adds the 2026-07-28 outcome section: Standard plan changed nothing
(stratified p50s match, Mann-Whitney p=0.81); prefetch={false} removed
the freezes (worst totalDuration 225s -> 4.9s) and also removed an
accidental keep-alive (~150ms added to cold navigations).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customer feedback: backfilled modern_deployment_error events (pre-agent
Autopilot event-log errors replayed at agent start) rendered as plain
Error rows with one identical emission timestamp — indistinguishable
from a live failure.
Web (EventTimeline.tsx, effective for already-stored events):
- "⟲ Backfilled" badge (slate, explanatory tooltip) on data.backfilled
- row time now shows the original event-log timeCreated with a
"(reported …)" suffix; metadata block gains a "Recorded" line;
raw mode gets a compact ⟲ marker
- severity deliberately NOT downgraded — unresolved pre-agent errors
must stay visible in the Error filter
Agent (ModernDeploymentTracker):
- backfill scan buffers the capped record set, emits an Info summary
event first (modern_deployment_log, backfillSummary: true +
replayedCount/droppedOverCap/lookbackMinutes) announcing the block,
then replays oldest-first so timeline sequence follows event-log
chronology (cap semantics unchanged: newest 25 win)
No new EventType, no ConfigVersion bump. Tests: summary emission x2,
tracker subset 12/12; tsc + vitest 651/651 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.
- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
failures swallowed, optional console echo), GatherRuleContext.DebugLog
(null-safe no-op), trace points in executor + collectors; never writes
under _scopeLock (reasons captured inside, written after release);
merger bool->path projection with --gather-debug-log CLI override
tolerance; deliberately NOT in the offline-cache strip lists;
--run-gather-rules always traces; zero-rules delivery leaves a one-line
file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upload the diagnostics package as a pre-provisioning intermediate when
Part 1 seals (suffix "preprov"). No new config field: the existing mode
semantics carry the desired behaviour — Always uploads at the seal,
OnFailure skips (the seal terminates as Succeeded; a Part-1 failure goes
through the regular Failed terminal path, which already uploads), Off
stays off. The upload runs before the whiteglove_part1_complete emit so
diagnostics_* events keep a lower sequence than the Part-1/Part-2
boundary marker. Best-effort: a fast reseal-reboot just loses the
intermediate package. Backend needs nothing — ingest already stamps
diagnostics_uploaded independent of terminal state (proven live by the
on-demand collection path).
Also make the on-demand path honest: CreateAndUploadAsync now takes
bool? enrollmentSucceeded — null means "no outcome yet" (mid-session),
so the OnFailure gate only skips a KNOWN success and sessioninfo.txt
records "In Progress" instead of the former enrollmentSucceeded:false
workaround that stamped in-flight packages "Failed".
10 new tests (WG seal mode matrix, preprov suffix, event ordering vs the
Part-1 marker, unchanged Part-1 exit contract; nullable-outcome gate
matrix via HTTP-free sentinel, sessioninfo labels). Agent suite
2112/2112 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>