Compare commits

..

27 Commits

Author SHA1 Message Date
Oliver Kieselbach 8608ee6272 feat(agent): netsh WiFi fallback labels for all enrollment-geography languages
Derived the language set from actual enrollment geography
(get_geographic_metrics, 365d, 10k sessions across ~50 countries) and
documented the country->language mapping on the key arrays. Newly
covered: Finnish, Hungarian, Lithuanian, Latvian, Romanian, Croatian/
Bosnian, Slovak, Indonesian/Malay, Vietnamese, Thai, Arabic, Bulgarian
radio-type variant and Swedish "Radiotyp". New fi/id parser tests; the
unknown-language tests now use Greek (outside the geography set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:18:34 +02:00
Oliver Kieselbach f762b932a2 feat(agent): CJK + Russian labels in netsh WiFi fallback parser
Extend the localized label sets with the remaining languages seen in
enrollments: Russian, Japanese, Chinese (Simplified + Traditional,
incl. the 信道/通道 channel variants) and Korean (incl. the two radio-
type spellings). SSID stays untranslated everywhere. New zh-CN and ko
parser tests; suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:14:07 +02:00
Oliver Kieselbach 173bfc40a9 feat(agent): multi-language label support in netsh WiFi fallback
The netsh fallback previously matched English labels only, silently
dropping fields on localized OS images. The parser now matches the
label variants of the languages commonly seen in enrollments
(EN/DE/FR/ES/PT/IT/NL/PL/CS/TR; Scandinavian locales reuse the EN/DE
spellings) and is split into a pure Parse(output) function covered by
unit tests (EN/DE/ES samples, "94 %" spacing, AP-BSSID collision,
unknown-language degradation - SSID itself is untranslated in all
locales). Unknown labels still fall through harmlessly; the native
WLAN API remains the primary path.

V2.Core suite 2095/2095 green; fallback live-verified against netsh
on dev hardware (identical values to the API path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:08:46 +02:00
Oliver Kieselbach 609d9c47af fix(agent): netsh fallback + step diagnostics for WiFi info collection
Field regression on 2.0.1383 (session f7c56065, HP ProBook 450 G7,
build 26200.8037): the native WLAN API path returned nothing in the
OOBE/SYSTEM context and the null path was silent, so wifi_signal_info
disappeared entirely - while netsh-based 2.0.1380/1382 sessions on the
same build kept reporting SSID/signal the same morning. Suspected (not
yet proven) cause: Wi-Fi location gating of WlanQueryInterface on
24H2+; inbox netsh is unaffected.

- WifiInfoReader: new TryGetCurrentConnection overload reporting WHY
  the read yielded nothing (native rc per step, interface state);
  callers log it at Debug so the agent log explains missing WiFi info.
- NetshWifiFallback (new): parses netsh wlan show interfaces ONLY when
  the WLAN API yields nothing on a WiFi-connected device; SystemPaths.
  Netsh restored for it.
- WifiConnectionInfo.SignalPercent is now int? (netsh fallback cannot
  always parse localized "Signal"); event payload emits the field only
  when present - same shape as before.

V2.Core suite 2085/2085 green; interactive positive path re-verified
(SSID/94%/802.11ax/ch116, empty diagnostics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:55:46 +02:00
Oliver Kieselbach 5496d575c1 style: two-line Started column (date over time) in session list
Narrows the column so more columns fit on one page; matches the
two-line style of Device/Model/Status cells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:37:01 +02:00
Oliver Kieselbach 1c3d29f30b refactor(agent): read WiFi info via native WLAN API instead of netsh parsing
netsh wlan show interfaces output is localized: "Radio type"/"Channel"
(and on some languages even "Signal") never matched on non-English
images, silently dropping fields from wifi_signal_info and network
change snapshots. It also spawned a child process per collection.

Replace both netsh call sites with wlanapi.dll P/Invoke
(WlanQueryInterface: current_connection + channel_number):

- New WlanNativeMethods (raw interop) + WifiInfoReader (fail-soft,
  prefers the active NIC's interface GUID, falls back to the first
  connected WLAN interface; returns null on machines without WLAN).
- DeviceInfoCollector.CollectWiFiSignalInfo and
  NetworkChangeDetector.CollectWiFiInfo now use the reader; RadioType
  strings stay netsh-compatible ("802.11ax" ...), event fields
  (wifiSsid/wifiSignalPercent/wifiRadioType/wifiChannel) unchanged.
- SystemPaths.Netsh removed (no remaining references).

V2.Core suite 2085/2085 green incl. new WifiInfoReader tests; struct
marshaling verified against native sizeof (604/68/36/532).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:20:12 +02:00
Oliver Kieselbach bb4accfe96 feat: API Latency as optional column in dashboard session list
Off by default; sortable via avgApiLatencyMs. Data comes from the
SessionsIndex mirror the list already reads — no extra query or table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:11:20 +02:00
Oliver Kieselbach 0ec481b479 feat(agent): outcome-calibrated agent_late_start severity and phrasing
Fleet data (2026-07-28, agent 2.0.1380) showed the late-start detector was
outcome-blind: tenant 659c3a90 collected 12 Warnings with the post-mortem/
hung-script note on enrollments that succeeded — the bootstrap merely ran
late in the ESP queue and the agent observed the healthy final minutes live.

New pure helper ObservationCoverage.DescribeLateStart:
- Succeeded -> Info with a tail-only-coverage note (IME-replay hint,
  bootstrap-ordering review if recurring); no alarm framing.
- Failed/TimedOut -> Warning with the existing post-mortem/hung-script note.
- oobeStateAtAgentStart refines the message only: "completed" = pure
  post-mortem, "in_progress" = live tail observed; not_started/unavailable
  add no claim (value flips to not_started after mid-OOBE reboots and
  "completed" is routine on post-desktop restarts).

The coverage gate (10 min boot-to-start / 5 min uptime) is unchanged, and
the OOBE state is deliberately NOT a gate: the canonical hung-script
failure (f148976f) terminates on the still-shown ESP screen, i.e. with
OOBE reading in_progress.

8 new tests (outcome x oobe-state matrix); V2.Core suite 2067 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:53:17 +02:00
Oliver Kieselbach 3ec716a3ec feat: expose API latency to MCP field projection and tool docs
- SearchSessionsFunction fields whitelist += avgApiLatencyMs,
  apiRequestCount so search_sessions lean sweeps (e.g.
  fields=geoCountry,avgApiLatencyMs,apiRequestCount) can aggregate
  latency without full SessionSummary payloads
- search_sessions tool doc lists the new fields incl. the weighting
  hint; get_geographic_metrics doc names avgApiLatencyMs /
  apiLatencyVsGlobalPct as the backend-region distance metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:28:01 +02:00
Oliver Kieselbach b0bb7233e4 chore: drop Outbound IP row from Connectivity section
The outbound_ip trace event stays available for analysis (timeline,
MCP); only the UI row is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:20:35 +02:00
Oliver Kieselbach 6758dc59e4 refactor: move API latency into Device Details "Connectivity" section
The extra grid cell in the Session Info card forced a fourth row once
Device Validation was present. The latency reading is a "when needed"
detail, so it moves into the collapsed Device Details card as a new
Connectivity section instead — together with values that were collected
but never displayed anywhere: the estimated internet bandwidth
(network_bandwidth_estimate incl. DO-throttle hint), total telemetry
upload volume (agent_metrics_snapshot), and the outbound IP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:12:08 +02:00
Oliver Kieselbach ffd70e0383 feat: geographic API-latency view (per-location weighted avg by geo)
Surfaces the persisted per-session AvgApiLatencyMs in the geographic
views so operators can compare regions when deciding on new backend
locations (Asia/US):

- ComputeGeographicMetrics: per-location and global request-weighted
  latency (sum(avg*requests)/sum(requests)) + ApiLatencyVsGlobalPct;
  serves both the tenant and the GA cross-tenant endpoint
- GeoMetricsSessionProjection extended (column-projected scan would
  otherwise drop the new fields); equivalence test pins it
- Drilldown: LocationSessionRow carries the per-session values; sessions
  table + summary tile show them
- geographic-performance page: sortable "API Latency" column with
  absolute color buckets (<250 green .. >=800 red), global benchmark
- PlatformMetricsService: session AvgLatency now exact from cumulative
  counters when present (fallback: mean of interval averages)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:58:53 +02:00
Oliver Kieselbach c18154d76d feat: persist per-session agent-to-backend API latency (AvgApiLatencyMs)
The V2 agent already measures HTTP round-trip latency per request
(NetworkMetrics, cumulative _totalLatencyMs) but only emitted the
per-interval delta average. Operators deciding on new backend regions
(Asia/US) need the session-wide average, aggregatable by GeoCountry.
Measured baseline: Jakarta sessions average ~600-1050 ms client-side vs
94 ms server-side processing — the gap is pure network distance to GWC.

- Agent: agent_metrics_snapshot now carries cumulative net_total_latency_ms
  (total/requests = whole-session average; idempotent by construction)
- Ingest: last matching snapshot of a batch projects AvgApiLatencyMs +
  ApiRequestCount onto the Session row (fire-and-forget, fail-soft,
  last-write-wins; agents predating the field simply never match)
- SessionsIndex: both fields mirrored (merge + BuildSessionIndexEntity
  superset) so list/GA views can aggregate latency per geo
- Web: session detail shows "API Latency" when present

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:44:46 +02:00
Oliver Kieselbach f524473305 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-07-28 09:54:07 +02:00
Oliver Kieselbach 8fe420b902 updated commands 2026-07-28 09:53:57 +02:00
dependabot[bot] 2e8c2cdb2f chore(deps): bump @modelcontextprotocol/sdk (#110)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).


Updates `@modelcontextprotocol/sdk` from 1.29.0 to 1.30.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.29.0...1.30.0)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:44:13 +02:00
dependabot[bot] e04b75dd53 chore(deps): bump the web group (#111)
Bumps the web group in /src/Web/autopilot-monitor-web with 3 updates: [next](https://github.com/vercel/next.js), [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `next` from 15.5.21 to 15.5.22
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.21...v15.5.22)

Updates `@next/bundle-analyzer` from 16.2.11 to 16.2.12
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.12/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 15.5.21 to 15.5.22
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.22/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.2.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 15.5.22
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:41:30 +02:00
Oliver Kieselbach d55b0ff864 test(backend): pin the cross-tenant redaction on metrics/ime-versions
GET metrics/ime-versions is MemberRead and the archive is one global
partition, so every tenant member reads the same platform-wide rows. The only
thing between an ordinary tenant user and another tenant's identifiers was an
inline projection with no test behind it.

There is no HTTP-level harness in this suite - nothing constructs an
HttpRequestData with a RequestContext - so the projection was unreachable from
a test while it lived inside Run. Extracted it to a static
BuildResponsePayload(versions, hasGlobalScope), matching the pattern
GetTpmPssUnsupportedFunction already uses for the same reason. Behaviour is
unchanged: the payload is returned as `object`, and System.Text.Json resolves
an `object` declared type via the runtime type, so both branches keep the wire
format they had inline. A test asserts that rather than trusting it.

10 tests, asserting on serialized JSON in camelCase (Program.cs's naming
policy) because a property dropped from an anonymous type is only a real
redaction once it is absent from the wire:

  - non-global: firstSeenTenantId / firstSeenSessionId absent, and their GUID
    VALUES absent anywhere in the payload (a model rename must not let the
    identifier survive under another key)
  - non-global: exactly {version, firstSeenAt, lastSeenAt, sessionCount}
  - non-global: sessionCount survives - the counts are platform-wide by
    decision, pinned so it cannot drift either way unnoticed
  - global (Admin or Reader): full entry including both identifiers
  - both scopes: array shape, empty archive, row order preserved

Mutation-checked both directions: forcing the global branch fails 3 tests,
dropping sessionCount from the projection fails 2. Full suite 3521 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:33:56 +02:00
Oliver Kieselbach 123ba0e55e fix(web): fleet-health crash in GA all-tenants scope - repeatDevices absent
The worker serializer omits null fields (WhenWritingNull), so the
aggregated global/metrics/device-journeys response carries no
repeatDevices at all. The strict `=== null` check missed undefined and
fell through to repeatDevices.length, crashing the page. Verified
against the deployed chunk: crash column 11464 is the `0===o.length`
read behind the `null===o` guard.

Loose `== null` check + optional DTO field; absent and null both render
the "select a tenant" hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:28:14 +02:00
Oliver Kieselbach c8175ff7b1 fix(mcp): drop npm from the runtime image, bump tar to 7.5.22
Defender for Cloud reports CVEs against the mcp container image that the
repo scanners cannot see. The subassessment evidence paths split them in two:

  /usr/local/lib/node_modules/npm/**  - the npm 10.9.8 that node:22-slim
  bundles, vendoring @sigstore/core 2.0.0, brace-expansion 2.0.2, tar 7.5.11
  (critical), sigstore, picomatch, ip-address, plus npm itself (high, no fix
  available). Present in all 20 images including the current one. Dependabot
  reads package-lock.json, so none of this is visible to it.

  /app/node_modules/**  - body-parser 2.2.2, fast-uri 3.1.2, hono, protobufjs,
  tar 7.5.16, all confined to images from 2026-07-21 and earlier and already
  fixed by 7df208fa/c3e9621a. Those findings age out with the old ACR tags.

npm is build-time only here: CMD is `node dist/index.js`, the HEALTHCHECK is
`node -e`, the container app spec overrides no command and probes over HTTP,
and src/ contains no child_process call. So remove npm rather than upgrade it
- npm 11.x still vendors @sigstore/core 3.1.0, itself inside the vulnerable
<= 3.2.0 range, and would clear nothing.

Separately, tar 7.5.20 -> 7.5.22 in our own tree (GHSA-r292-9mhp-454m, via
@huggingface/transformers -> onnxruntime-node). 7.5.22 satisfies the declared
^7.0.1, so no override is needed. fast-uri deliberately stays on 3.1.4: it is
patched against all four fast-uri advisories, and ajv 8.18.0 declares ^3.0.1,
so forcing 4.x would be an override against a major range.

npm audit --omit=dev: 0 vulnerabilities. tsc clean, 455 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:00:07 +02:00
Oliver Kieselbach 0efeb0f734 fix(backend): alert-webhook 500 on non-seekable body - bounded read replaces Length check
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:52:59 +02:00
Oliver Kieselbach 7b975d46b6 feat(backend): Azure Monitor alert webhook - platform alerts join the ops-event bus
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.

Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.

Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).

Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:39:22 +02:00
Oliver Kieselbach 7680bdff7b fix(web): disable RSC prefetch on nav links - portal freeze root cause
Navbar + GlobalSidebar render the full navigation on every page, so the
App Router's default viewport prefetch fired one ?_rsc= request per
visible link against the SWA runtime (~10.4k requests / 14 days). That
origin serves document, chunks and RSC payloads over HTTP/1.1 (6
connections per origin); when the runtime queued the prefetches (p95
896ms, tail up to 234s), they held every socket and the portal froze
until the queue drained. Measured on the 2026-07-27 17:25 incident:
/admin/presence?_rsc= at 151s while the backend API answered in <2.2s.

All 16 nav links now set prefetch={false}. Every target route is a
client component behind ProtectedRoute that fetches its own data after
mount, so the prefetched RSC payload carried no benefit.

New OKF doc docs/web/portal-navigation-prefetch.md records the full
evidence chain and where browser telemetry lives (separate App Insights
resource, not reachable via MCP query_backend_logs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:27:24 +02:00
Oliver Kieselbach 93094653a2 fix(web): tenant-scope seed races on usage-metrics/sla - own-tenant stats shown for override
A GA with a persisted tenant override opening /usage-metrics (or /sla) saw
their OWN tenant's stats until a manual refresh. useGlobalAdminScope seeded
the persisted selection in a post-mount effect, so the page effect first saw
effectiveTenantId = own tenant and fired an own-tenant fetch immediately
followed by the override fetch; with raw authenticatedFetch (no latest-wins
guard, unlike useAuthenticatedFetch since 82934a37) the last-RESOLVED
response won.

- useGlobalAdminScope: seed the GA selection render-time (same pattern as
  the delegated seed) so consumers never observe a transient own-tenant
  effectiveTenantId - the wrong fetch no longer starts at all.
- usage-metrics/sla pages: latest-wins sequence guard in fetchMetrics so a
  superseded request can neither write data/notifications nor clear the
  newer request's loading state (covers rapid selector switches).

Verified: tsc 0 errors, vitest 651/651.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:08:12 +02:00
Oliver Kieselbach ba7d14b070 fix(insights): post-deploy MCP findings - projection drop, aggregate starvation, duration artifacts
Three defects found by measuring production after the 1.5.1012 deploy:

- AppMetricsProjection dropped TerminalState, so the PR0 skip
  classification never reached metrics/app (totalSkipped stuck at 0).
  Column added; equivalence fixture now populates TerminalState/AppId/
  EspBlocking/AppIdCollision and asserts them (proven: test fails
  without the projection fix).
- BuildTimeAttributionAggregates excluded breakdowns on ANY quality
  flag; 903/1000 carry BlockingSetUnknown, starving fleet stats
  (Clean=12 vs 331 excluded on the largest tenant). New
  TimeAttributionFlagQuality.DurationCriticalFlags mask - only
  ClockSkew/PartialObservation/WGAnchors exclude; blocking-set flags
  keep their measured spans (positive-evidence semantics unchanged).
- HasMeasuredDuration gets an upper plausibility bound (6h = default
  AgentMaxLifetimeMinutes): 490 rows carried back-stamped completions
  (up to 117 days; 60175s app rows in a 2697s session) that dominated
  slowestApps with six-figure averages. Read-side, heals old + new rows.

Backend 3484/3484 green; solution build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:50:55 +02:00
Oliver Kieselbach 7f7330e9e5 feat(web): auto-recover from post-deploy stale-chunk failures (one reload, loop-guarded)
A tab still running the previous deployment's bundle requests lazily-loaded
chunks (next/dynamic charts, route chunks) whose content-hashed filenames no
longer exist after the SWA swap - the user is stuck on "Loading chart..." or a
dead navigation until a manual refresh. Standard remedy: reload once so the
fresh document references the new bundle.

- utils/chunkReloadRecovery.ts: chunk-error detection (webpack ChunkLoadError +
  Chromium/Firefox/Safari dynamic-import variants) and a sessionStorage guard -
  at most one auto-reload per tab per 5 minutes; without working storage no
  auto-reload at all, so a reload loop is structurally impossible. Emits a
  ChunkReloadRecovery App Insights event before reloading.
- components/ChunkReloadRecovery.tsx (root layout): catches rejected dynamic
  imports via unhandledrejection and failed /_next/ script/link tags via a
  capture-phase error listener (third-party scripts never trigger a reload).
- app/error.tsx: a chunk error reaching the boundary reloads once instead of
  showing the error card; inside the guard window it falls through to the card.

Verified: tsc clean, vitest 651/651 (8 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:47:34 +02:00
Oliver Kieselbach 82934a3719 fix(web): tenant-aware unrestricted badge + latest-wins fetch guard on rules pages
GA override on gather-rules called GET /api/global/config/{tenantId}, a route
that does not exist in the backend; the swallowed 404 left the unrestricted
badge showing the GA's HOME tenant's mode for every foreign tenant. Both paths
now read the member-readable config/{tenantId}/feature-flags (MemberRead has a
global-scope bypass and the route is RouteParam-scoped), with a reset-on-switch
so a failed fetch never carries the previous tenant's mode over. The dead
globalConfig.tenant entry is removed from the URL register.

useAuthenticatedFetch now enforces latest-wins: the scope hook seeds a
persisted foreign tenant only after mount, so an own-tenant fetch races the
override fetch and the last-RESOLVED response used to win, leaving the rules
list on the home tenant while the selector showed the override. Stale requests
no longer write data/error/loading; the spinner is owned by the last-started
non-silent request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:26:01 +02:00
73 changed files with 3061 additions and 278 deletions
+4
View File
@@ -41,6 +41,10 @@ https://docs.autopilotmonitor.com (separate repository).
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
# Web Portal
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the six connections that also serve the document and the route chunks. Includes where browser telemetry actually lives.
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
+2
View File
@@ -2,6 +2,8 @@
## 2026-07-27
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). That origin serves the document, the route chunks and the RSC payloads over **HTTP/1.1**, so the six-connection-per-origin cap applies; when the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the in-flight prefetches held every socket and the navigation the user wanted could not get one. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
+88
View File
@@ -0,0 +1,88 @@
---
type: Concept
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the connection pool that serves the document and the route chunks.
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
tags:
- web
- performance
- nextjs
- static-web-apps
- observability
timestamp: 2026-07-27T18:00:00+02:00
---
# Context
The portal is a Next.js 15 App Router application hosted on Azure Static Web Apps. Every page under
the authenticated shell is a client component behind `ProtectedRoute` that fetches its own data from
the Function App (`autopilotmonitor-api-eu`) after mount. The RSC payload a route returns on
navigation therefore carries no page data — only the shell.
`GlobalSidebar` and `Navbar` render the full navigation on every page. With the App Router's default
prefetch behaviour, each visible `<Link>` triggers a background `GET /<route>?_rsc=<hash>` as soon as
it enters the viewport.
# Mechanism
Three properties combine into a portal-wide freeze:
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
RSC request per visible link — measured at ~10,400 requests over 14 days.
2. **Shared origin over HTTP/1.1.** `portal.autopilotmonitor.com` serves the document, the JS route
chunks *and* the RSC payloads, and it negotiates HTTP/1.1 — not HTTP/2. Browsers cap concurrent
connections per origin at six.
3. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
the Static Web Apps managed runtime queues them.
When the tail hits, the in-flight prefetches hold all six connections. The document request and the
route chunks of the page the user actually wants then have no socket available, and the tab shows a
loading spinner or a blank page until the queue drains. Nothing fails — the requests eventually
return `200`, which is why the failure leaves no error trace anywhere.
# Evidence
Incident of 2026-07-27, 17:25–17:29 CEST. Browser-side dependencies during the freeze:
| Request | Duration | Status |
| --- | --- | --- |
| `/admin/presence?_rsc=…` | 151,461 ms | 200 |
| `/gather-rules?_rsc=…` | 52,283 ms | 200 |
| `/progress?_rsc=…` | 37,878 ms | 200 |
| `/analyze-rules?_rsc=…` | 37,559 ms | 200 |
| `/audit?_rsc=…` | 37,539 ms | 200 |
| Backend API, same window | 1,600–2,200 ms | 200 |
The three ~37.5 s responses complete within 340 ms of each other — a queue draining, not independent
slow requests. The corresponding `browserTimings` row for `/geographic-performance` reports
`networkDuration` = 16 ms against `processingDuration` = 225,095 ms: the network delivered the
document immediately and the browser then waited nearly four minutes for the `load` event.
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
38 over 10 s, 21 over 30 s.
# Diagnosing this class of problem
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
*absence* of requests, never as an error. Browser telemetry lives in a **separate** Application
Insights resource addressed by `AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`, which the MCP
`query_backend_logs` tool does not reach. Query it through
`.claude/commands/scripts/query-appinsights.sh` with that ID exported, and read `dependencies`
(client-side fetches, including RSC prefetches) and `browserTimings` (the network/processing split of
a document load).
# Contract
Navigation `<Link>` elements in `GlobalSidebar` and `Navbar` set `prefetch={false}`. The prefetched
RSC payload buys nothing for these routes, so the only remaining cost of a click is the route's JS
chunk, which the browser caches after first use.
Do not remove the prop to "speed up navigation" without first re-measuring the tail in the web
Application Insights resource — the prefetch is cheap only while the runtime is warm.
# Citations
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
@@ -0,0 +1,209 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow
/// the OS UI language — the parser must extract SSID/signal/radio/channel from the label
/// variants of the languages commonly seen in enrollments, and degrade gracefully (fields
/// absent, never throw) on unrecognized ones.
/// </summary>
public sealed class NetshWifiFallbackTests
{
[Fact]
public void Parse_english_output_extracts_all_fields()
{
const string output = @"
There is 1 interface on the system:
Name : Wi-Fi
Description : Intel(R) Wi-Fi 6 AX201 160MHz
GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6
State : connected
SSID : contoso-corp
AP BSSID : 3c:37:12:53:c8:44
Band : 5 GHz
Channel : 116
Radio type : 802.11ax
Signal : 94%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-corp", r!.Ssid);
Assert.Equal(94, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(116, r.Channel);
}
[Fact]
public void Parse_german_output_extracts_all_fields()
{
const string output = @"
Es ist 1 Schnittstelle auf dem System vorhanden:
Name : WLAN
Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz
Status : Verbunden
SSID : fabrikam-wlan
Kanal : 44
Funktyp : 802.11ac
Signal : 87%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("fabrikam-wlan", r!.Ssid);
Assert.Equal(87, r.SignalPercent);
Assert.Equal("802.11ac", r.RadioType);
Assert.Equal(44, r.Channel);
}
[Fact]
public void Parse_spanish_output_extracts_signal_and_channel()
{
const string output = @"
Nombre : Wi-Fi
SSID : contoso-es
Canal : 6
Tipo de radio : 802.11n
Señal : 72%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-es", r!.Ssid);
Assert.Equal(72, r.SignalPercent);
Assert.Equal("802.11n", r.RadioType);
Assert.Equal(6, r.Channel);
}
[Fact]
public void Parse_chinese_simplified_output_extracts_all_fields()
{
const string output = @"
名称 : WLAN
SSID : contoso-cn
信道 : 149
无线电类型 : 802.11ac
信号 : 68%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-cn", r!.Ssid);
Assert.Equal(68, r.SignalPercent);
Assert.Equal("802.11ac", r.RadioType);
Assert.Equal(149, r.Channel);
}
[Fact]
public void Parse_korean_output_extracts_all_fields()
{
const string output = @"
이름 : Wi-Fi
SSID : contoso-kr
채널 : 36
라디오 종류 : 802.11ax
신호 : 91%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-kr", r!.Ssid);
Assert.Equal(91, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(36, r.Channel);
}
[Fact]
public void Parse_signal_with_space_before_percent_is_parsed()
{
var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n");
Assert.NotNull(r);
Assert.Equal(81, r!.SignalPercent);
}
[Fact]
public void Parse_ap_bssid_does_not_overwrite_ssid()
{
const string output = @"
SSID : contoso-corp
AP BSSID : aa:bb:cc:dd:ee:ff
";
var r = NetshWifiFallback.Parse(output);
Assert.Equal("contoso-corp", r!.Ssid);
}
[Fact]
public void Parse_finnish_output_extracts_all_fields()
{
const string output = @"
Nimi : Wi-Fi
SSID : contoso-fi
Kanava : 100
Radiotyyppi : 802.11ax
Signaali : 79%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-fi", r!.Ssid);
Assert.Equal(79, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(100, r.Channel);
}
[Fact]
public void Parse_indonesian_output_extracts_all_fields()
{
const string output = @"
Nama : Wi-Fi
SSID : contoso-id
Saluran : 11
Jenis radio : 802.11n
Sinyal : 64%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-id", r!.Ssid);
Assert.Equal(64, r.SignalPercent);
Assert.Equal("802.11n", r.RadioType);
Assert.Equal(11, r.Channel);
}
[Fact]
public void Parse_unknown_language_without_ssid_returns_null()
{
// Greek is not in the enrollment-geography label set -> nothing to report.
var r = NetshWifiFallback.Parse(" Κατάσταση : συνδεδεμένο\r\n Σήμα : 90%\r\n");
Assert.Null(r);
}
[Fact]
public void Parse_unknown_language_still_yields_ssid()
{
// SSID is untranslated in all locales — partial result beats none.
var r = NetshWifiFallback.Parse(" SSID : contoso-gr\r\n Σήμα : 90%\r\n");
Assert.NotNull(r);
Assert.Equal("contoso-gr", r!.Ssid);
Assert.Null(r.SignalPercent);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")]
public void Parse_empty_or_error_output_returns_null(string? output)
{
Assert.Null(NetshWifiFallback.Parse(output));
}
}
}
@@ -0,0 +1,98 @@
#nullable enable
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the native-WLAN-API WiFi reader. The WLAN state of the test machine is
/// unknown (CI runners/VMs have no WLAN service), so the live path only pins the
/// fail-soft contract; the pure helpers (SSID decode, PHY-type mapping) are pinned
/// exactly — their output feeds wifi_signal_info event payloads and must stay
/// consistent with the strings netsh historically reported.
/// </summary>
public sealed class WifiInfoReaderTests
{
[Fact]
public void TryGetCurrentConnection_never_throws_and_returns_sane_values()
{
var result = WifiInfoReader.TryGetCurrentConnection();
if (result != null)
{
Assert.True(result.SignalPercent.HasValue, "native API path must always carry signal quality");
Assert.InRange(result.SignalPercent.Value, 0, 100);
if (result.Channel.HasValue)
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
}
}
// ----- DecodeSsid ---------------------------------------------------
[Fact]
public void DecodeSsid_decodes_utf8_bytes()
{
var raw = new byte[32];
var ssid = Encoding.UTF8.GetBytes("Contoso-Büro");
ssid.CopyTo(raw, 0);
Assert.Equal("Contoso-Büro", WifiInfoReader.DecodeSsid(raw, (uint)ssid.Length));
}
[Fact]
public void DecodeSsid_returns_null_for_empty_or_missing()
{
Assert.Null(WifiInfoReader.DecodeSsid(null, 5));
Assert.Null(WifiInfoReader.DecodeSsid(new byte[32], 0));
}
[Fact]
public void DecodeSsid_clamps_length_to_buffer_size()
{
var raw = Encoding.UTF8.GetBytes("abc");
// A corrupt native length beyond the buffer must not throw.
Assert.Equal("abc", WifiInfoReader.DecodeSsid(raw, 200));
}
[Fact]
public void DecodeSsid_uses_only_declared_length()
{
var raw = new byte[32];
Encoding.UTF8.GetBytes("HomeNet-Rest-Ignored").CopyTo(raw, 0);
Assert.Equal("HomeNet", WifiInfoReader.DecodeSsid(raw, 7));
}
// ----- PhyTypeToRadioType -------------------------------------------
// int parameters (cast inside): DOT11_PHY_TYPE is internal and may not appear in a
// public test method signature (CS0051).
[Theory]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ofdm, "802.11a")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_hrdsss, "802.11b")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_erp, "802.11g")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ht, "802.11n")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_vht, "802.11ac")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dmg, "802.11ad")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_he, "802.11ax")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_eht, "802.11be")]
public void PhyTypeToRadioType_maps_known_generations(int phyType, string expected)
{
Assert.Equal(expected, WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
}
[Theory]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_unknown)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_fhss)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dsss)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_irbaseband)]
[InlineData(9999)]
public void PhyTypeToRadioType_returns_null_for_legacy_or_unknown(int phyType)
{
Assert.Null(WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
}
}
}
@@ -1,5 +1,7 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
@@ -70,5 +72,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.Equal(0, bootToStart, 0);
Assert.Equal(10, uptime, 0);
}
// --- DescribeLateStart: outcome-calibrated severity + phrasing (fleet 2026-07-28) ---
private static void Describe(
EnrollmentTerminationOutcome outcome, string oobeState,
out EventSeverity severity, out string message, out string note)
=> ObservationCoverage.DescribeLateStart(
outcome, oobeState, bootToStartSeconds: 34 * 60, uptimeSeconds: 30,
out severity, out message, out note);
[Fact]
public void Failed_outcome_is_a_warning_with_the_hung_script_post_mortem_note()
{
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
out var severity, out var message, out var note);
Assert.Equal(EventSeverity.Warning, severity);
Assert.Contains("low coverage of the enrollment window", message);
Assert.Contains("post-mortem", note);
Assert.Contains("hung ahead of the bootstrap", note);
}
[Fact]
public void TimedOut_outcome_is_a_warning_like_a_failure()
{
Describe(EnrollmentTerminationOutcome.TimedOut, "in_progress",
out var severity, out _, out var note);
Assert.Equal(EventSeverity.Warning, severity);
Assert.Contains("post-mortem", note);
}
[Fact]
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
{
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
out var severity, out _, out var note);
Assert.Equal(EventSeverity.Info, severity);
Assert.Contains("succeeded", note);
Assert.Contains("IME log replay", note);
Assert.DoesNotContain("post-mortem", note);
Assert.DoesNotContain("hung", note);
}
[Fact]
public void Oobe_completed_at_start_phrases_a_pure_post_mortem()
{
Describe(EnrollmentTerminationOutcome.Failed, "completed",
out _, out var message, out _);
Assert.Contains("OOBE was already completed when the agent started", message);
}
[Fact]
public void Oobe_in_progress_at_start_phrases_the_live_tail()
{
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
out _, out var message, out _);
Assert.Contains("OOBE was still in progress at agent start", message);
Assert.Contains("final 30s live", message);
}
[Theory]
[InlineData("not_started")] // flips in after a mid-OOBE reboot — not interpretable
[InlineData("unavailable")] // WinRT contract absent / read failed
[InlineData("unknown_3")] // unexpected enum value
public void Uninterpretable_oobe_states_add_no_claim_to_the_message(string oobeState)
{
Describe(EnrollmentTerminationOutcome.Failed, oobeState,
out _, out var message, out _);
Assert.DoesNotContain("OOBE", message);
Assert.EndsWith("low coverage of the enrollment window.", message);
}
}
}
@@ -0,0 +1,221 @@
using System;
using System.Diagnostics;
using AutopilotMonitor.Agent.V2.Core.Security;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Fallback WiFi reader via <c>netsh wlan show interfaces</c> output parsing, used ONLY
/// when <see cref="WifiInfoReader"/> (native WLAN API) yields nothing on a WiFi-connected
/// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM
/// context on builds where inbox netsh still reports the connection — until that is fully
/// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net.
/// <para>
/// netsh labels follow the OS UI language, so the parser matches the label variants of
/// every language implied by the enrollment geography seen to date (see the comment on
/// the key arrays for the country→language derivation). Unknown-language labels fall
/// through harmlessly — the affected field is simply absent, same as before. "SSID"
/// itself is untranslated in all known locales.
/// </para>
/// </summary>
internal static class NetshWifiFallback
{
private const int NetshTimeoutMs = 5000;
// Label variants per field, by OS UI language. Matched case-insensitively against the
// full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide.
// Language set derived from enrollment geography (get_geographic_metrics, 365d):
// US/GB/AU/CA/IE/SG/IN/ZA/NG/PH en · DE/AT/CH de · BE/NL nl · BE/FR/CH fr · DK da ·
// SE sv · PL pl · TR tr · CN zh-CN · HK zh-TW · ES/MX/PE/PY es · CZ cs · SK sk ·
// IT/CH it · ID id · MY ms · FI fi · LT lt · LV lv · HU hu · BR/PT pt · JP ja ·
// KR ko · TH th · VN vi · RO/MD ro · BG bg · BA/HR hr · AE/SA/EG/JO ar ·
// MN/GE/AZ usually en/ru images → ru.
private static readonly string[] SignalKeys =
{
"Signal", // en, de, fr, sv, da, nb, hr
"Señal", // es
"Sinal", // pt
"Segnale", // it
"Signaal", // nl
"Sygnał", // pl
"Signál", // cs, sk
"Sinyal", // tr, id
"Isyarat", // ms
"Signaali", // fi
"Signalas", // lt
"Signāls", // lv
"Jel", // hu
"Semnal", // ro
"Tín hiệu", // vi
"สัญญาณ", // th
"الإشارة", // ar
"إشارة", // ar (variant without article)
"Сигнал", // ru, bg
"シグナル", // ja
"信号", // zh-CN
"訊號", // zh-TW
"신호", // ko
};
private static readonly string[] RadioTypeKeys =
{
"Radio type", // en
"Funktyp", // de
"Type de radio", // fr
"Tipo de radio", // es
"Tipo de rádio", // pt
"Tipo di radio", // it
"Radiotype", // nl, da
"Radiotyp", // sv
"Typ radia", // pl
"Typ rádia", // cs, sk
"Radyo türü", // tr
"Jenis radio", // id, ms
"Radiotyyppi", // fi
"Radijo tipas", // lt
"Radio tips", // lv
"Rádió típusa", // hu
"Tip radio", // ro
"Vrsta radija", // hr, bs
"Loại radio", // vi
"ชนิดวิทยุ", // th
"نوع الراديو", // ar
"Тип радио", // ru
"Тип на радиото", // bg
"無線の種類", // ja
"无线电类型", // zh-CN
"無線電類型", // zh-TW
"라디오 종류", // ko
"무선 종류", // ko (variant)
};
private static readonly string[] ChannelKeys =
{
"Channel", // en
"Kanal", // de, sv, da, nb, tr, hr
"Canal", // fr, es, pt, ro
"Canale", // it
"Kanaal", // nl
"Kanał", // pl
"Kanál", // cs, sk
"Saluran", // id, ms
"Kanava", // fi
"Kanalas", // lt
"Kanāls", // lv
"Csatorna", // hu
"Kênh", // vi
"ช่องสัญญาณ", // th
"ช่อง", // th (variant)
"القناة", // ar
"قناة", // ar (variant without article)
"Канал", // ru, bg
"チャネル", // ja
"信道", // zh-CN
"通道", // zh-TW / zh-CN (variant)
"채널", // ko
};
/// <summary>
/// Runs <c>netsh wlan show interfaces</c> and parses its output. Returns a
/// <see cref="WifiConnectionInfo"/> with the fields that could be parsed, or null when
/// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized
/// labels). Never throws.
/// </summary>
internal static WifiConnectionInfo TryRead()
{
try
{
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(NetshTimeoutMs))
{
try { process.Kill(); } catch { }
return null;
}
}
return Parse(output);
}
catch
{
return null;
}
}
/// <summary>
/// Pure parser over the netsh output text. Internal for direct unit-testing via
/// InternalsVisibleTo (TryRead itself spawns a process and stays untested).
/// </summary>
internal static WifiConnectionInfo Parse(string output)
{
if (string.IsNullOrEmpty(output))
return null;
string ssid = null, radioType = null;
int? signal = null, channel = null;
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (MatchesAny(key, SignalKeys))
{
// Value formats seen: "94%", "94% " (trailing space), "94 %".
if (int.TryParse(value.TrimEnd('%', ' '), out var sig))
signal = sig;
}
else if (MatchesAny(key, RadioTypeKeys))
{
radioType = value;
}
else if (MatchesAny(key, ChannelKeys))
{
if (int.TryParse(value, out var ch))
channel = ch;
}
}
if (ssid == null && signal == null && radioType == null && channel == null)
return null;
return new WifiConnectionInfo
{
Ssid = ssid,
SignalPercent = signal,
RadioType = radioType,
Channel = channel,
};
}
private static bool MatchesAny(string key, string[] candidates)
{
foreach (var candidate in candidates)
{
if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
}
@@ -0,0 +1,234 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Current WiFi connection details as read from the native WLAN API.
/// </summary>
public sealed class WifiConnectionInfo
{
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
public string Ssid { get; internal set; }
/// <summary>
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
/// </summary>
public int? SignalPercent { get; internal set; }
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
public string RadioType { get; internal set; }
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
public int? Channel { get; internal set; }
}
/// <summary>
/// Fail-soft reader over <see cref="WlanNativeMethods"/>: returns the current WiFi
/// connection or null — never throws, never blocks on a child process. Machines without
/// the WLAN AutoConfig service (VMs, wired-only devices) simply return null.
/// </summary>
public static class WifiInfoReader
{
/// <summary>
/// Reads the current WiFi connection. When <paramref name="preferredInterfaceId"/> is
/// given (the adapter GUID from <c>NetworkInterface.Id</c>), that interface is queried
/// first; otherwise — or when it is not connected — the first connected WLAN interface
/// wins. Returns null when no interface has an active connection.
/// </summary>
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
=> TryGetCurrentConnection(preferredInterfaceId, out _);
/// <summary>
/// Same as <see cref="TryGetCurrentConnection(Guid?)"/> but reports WHY the read
/// yielded nothing via <paramref name="diagnostics"/> (native return codes per step).
/// The WLAN API can fail in ways that are invisible from the outside (e.g. per-process
/// access gating on newer Windows builds) — callers log this at Debug so field issues
/// are explainable from the agent log instead of a silent missing event.
/// </summary>
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId, out string diagnostics)
{
var diag = new StringBuilder();
var clientHandle = IntPtr.Zero;
try
{
var rc = WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle);
if (rc != ERROR_SUCCESS)
{
diagnostics = $"WlanOpenHandle rc={rc}";
return null;
}
var interfaceList = IntPtr.Zero;
try
{
rc = WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList);
if (rc != ERROR_SUCCESS)
{
diagnostics = $"WlanEnumInterfaces rc={rc}";
return null;
}
var count = (uint)Marshal.ReadInt32(interfaceList);
if (count == 0)
{
diagnostics = "no WLAN interfaces";
return null;
}
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
WifiConnectionInfo fallback = null;
for (var i = 0; i < count; i++)
{
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid, diag);
if (connection == null)
continue;
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
{
diagnostics = null;
return connection;
}
if (fallback == null)
fallback = connection;
}
diagnostics = fallback == null ? diag.ToString() : null;
return fallback;
}
finally
{
if (interfaceList != IntPtr.Zero)
WlanFreeMemory(interfaceList);
}
}
catch (Exception ex)
{
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
return null;
}
finally
{
if (clientHandle != IntPtr.Zero)
WlanCloseHandle(clientHandle, IntPtr.Zero);
}
}
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
{
var dataPtr = IntPtr.Zero;
try
{
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero);
if (rc != ERROR_SUCCESS)
{
diag.Append($"[{interfaceGuid:D}] query(current_connection) rc={rc}; ");
return null;
}
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
{
diag.Append($"[{interfaceGuid:D}] state={attributes.isState}; ");
return null;
}
var assoc = attributes.wlanAssociationAttributes;
return new WifiConnectionInfo
{
Ssid = DecodeSsid(assoc.dot11Ssid.ucSSID, assoc.dot11Ssid.uSSIDLength),
SignalPercent = (int)Math.Min(assoc.wlanSignalQuality, 100),
RadioType = PhyTypeToRadioType(assoc.dot11PhyType),
Channel = QueryChannel(clientHandle, interfaceGuid),
};
}
finally
{
if (dataPtr != IntPtr.Zero)
WlanFreeMemory(dataPtr);
}
}
private static int? QueryChannel(IntPtr clientHandle, Guid interfaceGuid)
{
var dataPtr = IntPtr.Zero;
try
{
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
WLAN_INTF_OPCODE.wlan_intf_opcode_channel_number,
IntPtr.Zero, out var dataSize, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
return null;
if (dataSize < sizeof(uint) || dataPtr == IntPtr.Zero)
return null;
return Marshal.ReadInt32(dataPtr);
}
catch
{
return null;
}
finally
{
if (dataPtr != IntPtr.Zero)
WlanFreeMemory(dataPtr);
}
}
/// <summary>
/// Decodes a raw DOT11_SSID (UTF-8 by convention) into a string; null for empty SSIDs.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string DecodeSsid(byte[] raw, uint length)
{
if (raw == null || length == 0)
return null;
var byteCount = (int)Math.Min(length, (uint)raw.Length);
try
{
var ssid = Encoding.UTF8.GetString(raw, 0, byteCount);
return string.IsNullOrEmpty(ssid) ? null : ssid;
}
catch
{
return null;
}
}
/// <summary>
/// Maps a DOT11 PHY type to the same strings netsh historically reported as
/// "Radio type" — keeps event payloads consistent across the netsh→WLAN-API switch.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string PhyTypeToRadioType(DOT11_PHY_TYPE phyType)
{
switch (phyType)
{
case DOT11_PHY_TYPE.dot11_phy_type_ofdm: return "802.11a";
case DOT11_PHY_TYPE.dot11_phy_type_hrdsss: return "802.11b";
case DOT11_PHY_TYPE.dot11_phy_type_erp: return "802.11g";
case DOT11_PHY_TYPE.dot11_phy_type_ht: return "802.11n";
case DOT11_PHY_TYPE.dot11_phy_type_vht: return "802.11ac";
case DOT11_PHY_TYPE.dot11_phy_type_dmg: return "802.11ad";
case DOT11_PHY_TYPE.dot11_phy_type_he: return "802.11ax";
case DOT11_PHY_TYPE.dot11_phy_type_eht: return "802.11be";
default: return null;
}
}
}
}
@@ -0,0 +1,142 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Native WLAN API interop (<c>wlanapi.dll</c>) used to read the current WiFi connection
/// (SSID, signal quality, PHY type, channel) without spawning <c>netsh</c>.
/// <para>
/// The WLAN API is language-neutral — unlike parsing <c>netsh wlan show interfaces</c>
/// output, whose labels follow the OS UI language and silently drop fields on
/// non-English images. On machines without the WLAN AutoConfig service (VMs, servers,
/// wired-only devices) <c>WlanOpenHandle</c>/<c>WlanEnumInterfaces</c> fail with a
/// non-zero error code; callers treat that as "no WiFi" (see <see cref="WifiInfoReader"/>).
/// </para>
/// </summary>
internal static class WlanNativeMethods
{
internal const uint ERROR_SUCCESS = 0;
/// <summary>Client version 2 = Vista and later (the only relevant range).</summary>
internal const uint WLAN_CLIENT_VERSION = 2;
internal enum WLAN_INTF_OPCODE : uint
{
wlan_intf_opcode_current_connection = 7,
wlan_intf_opcode_channel_number = 8,
}
internal enum WLAN_INTERFACE_STATE
{
wlan_interface_state_not_ready = 0,
wlan_interface_state_connected = 1,
wlan_interface_state_ad_hoc_network_formed = 2,
wlan_interface_state_disconnecting = 3,
wlan_interface_state_disconnected = 4,
wlan_interface_state_associating = 5,
wlan_interface_state_discovering = 6,
wlan_interface_state_authenticating = 7,
}
internal enum DOT11_PHY_TYPE : uint
{
dot11_phy_type_unknown = 0,
dot11_phy_type_fhss = 1,
dot11_phy_type_dsss = 2,
dot11_phy_type_irbaseband = 3,
dot11_phy_type_ofdm = 4, // 802.11a
dot11_phy_type_hrdsss = 5, // 802.11b
dot11_phy_type_erp = 6, // 802.11g
dot11_phy_type_ht = 7, // 802.11n
dot11_phy_type_vht = 8, // 802.11ac
dot11_phy_type_dmg = 9, // 802.11ad
dot11_phy_type_he = 10, // 802.11ax
dot11_phy_type_eht = 11, // 802.11be
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WLAN_INTERFACE_INFO
{
public Guid InterfaceGuid;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strInterfaceDescription;
public WLAN_INTERFACE_STATE isState;
}
[StructLayout(LayoutKind.Sequential)]
internal struct DOT11_SSID
{
public uint uSSIDLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] ucSSID;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WLAN_ASSOCIATION_ATTRIBUTES
{
public DOT11_SSID dot11Ssid;
public int dot11BssType;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
public byte[] dot11Bssid;
public DOT11_PHY_TYPE dot11PhyType;
public uint uDot11PhyIndex;
public uint wlanSignalQuality; // 0..100, same semantics as netsh "Signal"
public uint ulRxRate;
public uint ulTxRate;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WLAN_SECURITY_ATTRIBUTES
{
[MarshalAs(UnmanagedType.Bool)]
public bool bSecurityEnabled;
[MarshalAs(UnmanagedType.Bool)]
public bool bOneXEnabled;
public int dot11AuthAlgorithm;
public int dot11CipherAlgorithm;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WLAN_CONNECTION_ATTRIBUTES
{
public WLAN_INTERFACE_STATE isState;
public int wlanConnectionMode;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strProfileName;
public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes;
public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes;
}
[DllImport("wlanapi.dll")]
internal static extern uint WlanOpenHandle(
uint dwClientVersion,
IntPtr pReserved,
out uint pdwNegotiatedVersion,
out IntPtr phClientHandle);
[DllImport("wlanapi.dll")]
internal static extern uint WlanCloseHandle(
IntPtr hClientHandle,
IntPtr pReserved);
[DllImport("wlanapi.dll")]
internal static extern uint WlanEnumInterfaces(
IntPtr hClientHandle,
IntPtr pReserved,
out IntPtr ppInterfaceList);
[DllImport("wlanapi.dll")]
internal static extern uint WlanQueryInterface(
IntPtr hClientHandle,
[In] ref Guid pInterfaceGuid,
WLAN_INTF_OPCODE OpCode,
IntPtr pReserved,
out uint pdwDataSize,
out IntPtr ppData,
IntPtr pWlanOpcodeValueType);
[DllImport("wlanapi.dll")]
internal static extern void WlanFreeMemory(IntPtr pMemory);
}
}
@@ -815,7 +815,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
/// Collects active network interface details (name, type, link speed, MAC) using
/// System.Net.NetworkInformation — instant, no process spawn.
/// If the active interface is WiFi, fires off a separate async task to collect
/// WiFi signal info via netsh (fire-and-forget, never blocks).
/// WiFi signal info via the native WLAN API (fire-and-forget, never blocks).
/// </summary>
private void CollectActiveNetworkInterfaceInfo()
{
@@ -858,7 +858,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// Fire-and-forget WiFi signal collection — separate event, never blocks
if (isWifi)
{
Task.Run(() => CollectWiFiSignalInfo());
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
Task.Run(() => CollectWiFiSignalInfo(nicGuid));
}
}
catch (Exception ex)
@@ -899,78 +900,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
/// <summary>
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
/// never blocks other collection. VMs without WiFi service simply get no event.
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
/// VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo()
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
{
try
{
var psi = new ProcessStartInfo
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
if (wifi == null)
return;
}
if (string.IsNullOrEmpty(output))
return;
var data = new Dictionary<string, object>();
if (wifi.Ssid != null)
data["wifiSsid"] = wifi.Ssid;
if (wifi.SignalPercent.HasValue)
data["wifiSignalPercent"] = wifi.SignalPercent.Value;
if (wifi.RadioType != null)
data["wifiRadioType"] = wifi.RadioType;
if (wifi.Channel.HasValue)
data["wifiChannel"] = wifi.Channel.Value;
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
if (wifi.SignalPercent.HasValue)
message += $", Signal: {wifi.SignalPercent.Value}%";
if (wifi.RadioType != null)
message += $" ({wifi.RadioType})";
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
data["wifiSsid"] = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var signal))
data["wifiSignalPercent"] = signal;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
data["wifiRadioType"] = value;
}
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value, out var channel))
data["wifiChannel"] = channel;
}
}
if (data.Count > 0)
{
var message = data.ContainsKey("wifiSsid")
? $"WiFi: {data["wifiSsid"]}"
: "WiFi signal info";
if (data.ContainsKey("wifiSignalPercent"))
message += $", Signal: {data["wifiSignalPercent"]}%";
if (data.ContainsKey("wifiRadioType"))
message += $" ({data["wifiRadioType"]})";
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
catch (Exception ex)
{
@@ -77,11 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
protected override void Collect()
{
// Full path writes 18 keys: agent_version + process metrics (5: cpu, ws, private,
// threads, handles) + spool stats (4) + network delta (8: requests, failures,
// bytes_up/down, avg_latency, total_up/down/requests). cap=18 → HashHelpers.GetPrime(18)=23
// buckets → no resize on the 18th key (cap=16 would land at 17 buckets and resize).
var data = new Dictionary<string, object>(capacity: 18, StringComparer.Ordinal)
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
{
{ "agent_version", _agentVersion }
};
@@ -164,6 +164,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
data["net_total_requests"] = currentNet.RequestCount;
// Cumulative latency sum: total/requests = session-wide average HTTP
// round-trip. The backend projects that average onto the Session row
// (AvgApiLatencyMs) — cumulative counters make the write idempotent.
data["net_total_latency_ms"] = currentNet.TotalLatencyMs;
}
catch (Exception ex)
{
@@ -8,8 +8,8 @@ using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -37,7 +37,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
private const int DebounceDelayMs = 3000;
private const int ConnectivityCheckDelayMs = 5000;
private const int ConnectivityCheckTimeoutMs = 8000;
private const int WifiCollectTimeoutMs = 3000;
private const string Source = "NetworkChangeDetector";
/// <summary>
@@ -374,7 +373,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Collect WiFi SSID synchronously (needed for before/after comparison)
if (isWifi)
{
var wifiInfo = CollectWiFiInfo();
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
var wifiInfo = CollectWiFiInfo(nicGuid);
snapshot.WifiSsid = wifiInfo.ssid;
snapshot.WifiSignalPercent = wifiInfo.signal;
snapshot.WifiRadioType = wifiInfo.radioType;
@@ -390,73 +390,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
/// Synchronous with timeout — needed before emitting the event.
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
/// Synchronous — needed before emitting the event.
/// </summary>
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
{
string ssid = null;
int? signal = null;
string radioType = null;
try
{
var psi = new ProcessStartInfo
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(WifiCollectTimeoutMs))
{
try { process.Kill(); } catch { }
_logger.Debug("NetworkChangeDetector: netsh timed out");
return (null, null, null);
}
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
}
if (string.IsNullOrEmpty(output))
return (null, null, null);
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var sig))
signal = sig;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
radioType = value;
}
}
if (wifi != null)
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
}
return (ssid, signal, radioType);
return (null, null, null);
}
// -------------------------------------------------------------------
@@ -623,12 +623,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
/// <summary>
/// Emits a one-shot <c>agent_late_start</c> Warning when the agent had low observation
/// Emits a one-shot <c>agent_late_start</c> event when the agent had low observation
/// coverage: it started a long time after device boot AND lived only briefly before this
/// terminal outcome (gate in <see cref="ObservationCoverage"/>). Such a session looks like a
/// normal multi-minute failure in the list (StartedAt is back-dated to the earliest replayed
/// IME-log event) but the agent actually observed only the end-state — so the operator should
/// read the diagnosis as a post-mortem reconstruction, not live evidence. No state mutation.
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
/// enrollments whose bootstrap merely ran late). No state mutation.
/// </summary>
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
{
@@ -642,21 +645,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
out var bootToStartSeconds, out var uptimeSeconds))
return;
ObservationCoverage.DescribeLateStart(
args.Outcome, _oobeStateAtAgentStart, bootToStartSeconds, uptimeSeconds,
out var severity, out var message, out var note);
_logger.Info(
$"EnrollmentTerminationHandler: low observation coverage — agent started " +
$"{bootToStartSeconds / 60.0:F1} min after boot and lived {uptimeSeconds:F0}s; " +
$"emitting agent_late_start.");
$"emitting agent_late_start ({severity}, outcome {args.Outcome}).");
_post.Emit(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = Constants.EventTypes.AgentLateStart,
Severity = EventSeverity.Warning,
Severity = severity,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
$"{uptimeSeconds:F0}s before terminating ({args.Outcome}) — low coverage of the enrollment window.",
Message = message,
Data = new Dictionary<string, object>
{
{ "bootToAgentStartSeconds", Math.Round(bootToStartSeconds, 1) },
@@ -665,7 +671,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
{ "agentStartUtc", _agentStartTimeUtc.ToString("o") },
{ "oobeStateAtAgentStart", _oobeStateAtAgentStart },
{ "outcome", args.Outcome.ToString() },
{ "note", "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, so the agent observed the already-decided end-state rather than the failure window. Treat the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of the bootstrap (see script_timeout_suspected)." },
{ "note", note },
},
ImmediateUpload = true,
});
@@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Runtime.InteropServices;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Termination
{
@@ -76,5 +78,57 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
uptimeSeconds = Math.Max(0, uptime.TotalSeconds);
return bootToStart >= LateStartBootThreshold && uptime <= LowCoverageUptime;
}
/// <summary>
/// Severity + operator-facing phrasing for the <c>agent_late_start</c> event, calibrated
/// by fleet data (2026-07-28): the gate alone is outcome-blind and attached a
/// "post-mortem / check for a hung script" Warning to sessions that in fact succeeded —
/// there the late bootstrap is deployment hygiene, not an alarm. The OOBE state refines
/// the phrasing but must never gate: the canonical hung-script failure terminates on the
/// still-shown ESP screen, i.e. with OOBE reading <c>in_progress</c>.
/// </summary>
/// <param name="outcome">Terminal outcome the enrollment reached.</param>
/// <param name="oobeStateAtAgentStart"><see cref="Monitoring.Interop.OobeStateReader"/>
/// sample taken at THIS process's start. Only <c>completed</c> / <c>in_progress</c> are
/// interpreted; <c>not_started</c> flips in after mid-OOBE reboots and <c>completed</c> is
/// routine for post-desktop restarts, so no claim is phrased from the other values.</param>
/// <param name="bootToStartSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
/// <param name="uptimeSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
internal static void DescribeLateStart(
EnrollmentTerminationOutcome outcome,
string oobeStateAtAgentStart,
double bootToStartSeconds,
double uptimeSeconds,
out EventSeverity severity,
out string message,
out string note)
{
var succeeded = outcome == EnrollmentTerminationOutcome.Succeeded;
severity = succeeded ? EventSeverity.Info : EventSeverity.Warning;
message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
$"{uptimeSeconds:F0}s before terminating ({outcome}) — low coverage of the enrollment window.";
switch (oobeStateAtAgentStart)
{
case "completed":
message += " OOBE was already completed when the agent started — the diagnosis is a " +
"post-mortem reconstruction, not live observation.";
break;
case "in_progress":
message += $" OOBE was still in progress at agent start — the agent did observe the " +
$"final {uptimeSeconds:F0}s live.";
break;
}
note = succeeded
? "The agent's bootstrap (an Intune platform script) ran late in the enrollment, so only the " +
"final stretch was observed live; earlier timeline entries are reconstructed from IME log " +
"replay. The enrollment itself succeeded. If this recurs across sessions, review which " +
"platform/remediation scripts run ahead of the bootstrap (see script_timeout_suspected)."
: "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, " +
"so the agent observed the already-decided end-state rather than the failure window. Treat " +
"the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of " +
"the bootstrap (see script_timeout_suspected).";
}
}
}
@@ -34,6 +34,14 @@ public class AppsProjectionEquivalenceTests
["AppType"] = "Win32",
["AppVersion"] = "2.1.0",
["Status"] = "Failed",
// PR0/PR1 classification columns. The fixture MUST populate every column a Build*
// method reads: a column absent here passes the equivalence assert trivially (both
// sides map to default) — exactly how the missing TerminalState projection slipped
// through and zeroed totalSkipped in production (2026-07-27).
["TerminalState"] = "Skipped",
["AppId"] = "9e8d7c6b-5a49-4832-9182-736451234567",
["EspBlocking"] = true,
["AppIdCollision"] = true,
["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-6)),
["CompletedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-5)),
["DurationSeconds"] = 340,
@@ -83,6 +91,9 @@ public class AppsProjectionEquivalenceTests
Assert.Equal(fromFull.AppName, fromProjected.AppName);
Assert.Equal(fromFull.Status, fromProjected.Status);
// IsSkipTerminalState / skip classification input — see the fixture comment.
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds);
Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes);
@@ -122,6 +133,10 @@ public class AppsProjectionEquivalenceTests
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
Assert.Equal(fromFull.AppId, fromProjected.AppId);
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
}
[Fact]
@@ -0,0 +1,172 @@
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
/// parsing is defensive (only the alert rule name is mandatory).
/// </summary>
public class AzureMonitorAlertWebhookFunctionTests
{
// ── SecretMatches ──────────────────────────────────────────────────────
[Fact]
public void SecretMatches_accepts_equal_secrets()
{
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
}
[Theory]
[InlineData("wrong", "right")]
[InlineData("right-but-longer", "right")]
[InlineData("", "configured")]
[InlineData(null, "configured")]
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
{
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void SecretMatches_fails_closed_when_not_configured(string? configured)
{
// While the app setting is unset, NOTHING matches — not even an empty provided value.
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
}
// ── MapSeverity ────────────────────────────────────────────────────────
[Theory]
[InlineData("Sev0", OpsEventSeverity.Critical)]
[InlineData("Sev1", OpsEventSeverity.Error)]
[InlineData("Sev2", OpsEventSeverity.Warning)]
[InlineData("Sev3", OpsEventSeverity.Info)]
[InlineData("Sev4", OpsEventSeverity.Info)]
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
{
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Sev5")]
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
{
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
}
// ── TryParse ───────────────────────────────────────────────────────────
private const string FullPayload = """
{
"schemaId": "azureMonitorCommonAlertSchema",
"data": {
"essentials": {
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
"alertRule": "Portal RSC prefetch tail",
"severity": "Sev2",
"signalType": "Log",
"monitorCondition": "Fired",
"monitoringService": "Log Alerts V2",
"alertTargetIDs": [
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
],
"description": "RSC prefetches exceeding 10s",
"firedDateTime": "2026-07-27T15:30:00.000Z"
},
"alertContext": {
"condition": {
"windowSize": "PT1H",
"allOf": [
{
"searchQuery": "dependencies | where name contains '_rsc='",
"metricValue": 18.0,
"operator": "GreaterThan",
"threshold": "0"
}
]
}
}
}
}
""";
[Fact]
public void TryParse_extracts_essentials_and_metric_value()
{
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
Assert.NotNull(alert);
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
Assert.Equal("Sev2", alert.Severity);
Assert.Equal("Fired", alert.MonitorCondition);
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
Assert.Equal("Log Alerts V2", alert.MonitoringService);
// No targetResourceName in the payload → resource name segment of the first target ID.
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
Assert.Equal(18.0, alert.MetricValue);
}
[Fact]
public void TryParse_prefers_explicit_target_resource_name()
{
const string payload = """
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
"alertTargetIDs":["/sub/x/other-name"]}}}
""";
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
Assert.Equal("explicit-name", alert!.TargetResource);
}
[Fact]
public void TryParse_minimal_payload_needs_only_the_alert_rule()
{
var alert = AzureMonitorAlertWebhookFunction.TryParse(
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
Assert.NotNull(alert);
Assert.Equal("minimal", alert!.AlertRule);
Assert.Null(alert.Severity);
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
Assert.Null(alert.Description);
Assert.Null(alert.MetricValue);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("{}")] // no data
[InlineData("""{"data":{}}""")] // no essentials
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
public void TryParse_rejects_unusable_payloads(string? body)
{
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
}
[Fact]
public void TryParse_tolerates_malformed_alert_context()
{
// alertContext with unexpected shapes must not break essentials extraction.
const string payload = """
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
""";
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
Assert.NotNull(alert);
Assert.Equal("Sev0", alert!.Severity);
Assert.Null(alert.MetricValue);
}
}
@@ -50,6 +50,8 @@ public class GeoMetricsProjectionEquivalenceTests
["GeoCity"] = "Frankfurt",
["GeoLoc"] = "50.11,8.68",
["IsPreProvisioned"] = false,
["AvgApiLatencyMs"] = 217.4,
["ApiRequestCount"] = 270,
// Noise the geo aggregation never reads.
["SerialNumber"] = "SN-FULL",
["Manufacturer"] = "Contoso",
@@ -76,6 +78,8 @@ public class GeoMetricsProjectionEquivalenceTests
Assert.Equal(fromFull.GeoRegion, fromProjected.GeoRegion);
Assert.Equal(fromFull.GeoCity, fromProjected.GeoCity);
Assert.Equal(fromFull.GeoLoc, fromProjected.GeoLoc);
Assert.Equal(fromFull.AvgApiLatencyMs, fromProjected.AvgApiLatencyMs);
Assert.Equal(fromFull.ApiRequestCount, fromProjected.ApiRequestCount);
// The grouping key must be identical too — it is composed from the Geo* fields.
Assert.Equal(
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the API-latency aggregation of
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/> (2026-07-28): both the
/// per-location and the global figure are REQUEST-WEIGHTED averages —
/// Σ(sessionAvg·requests)/Σ(requests) — not averages of session averages, so a long session
/// with many uploads counts proportionally. Sessions without latency data (agents predating
/// <c>AvgApiLatencyMs</c>) drop out of the weighting entirely.
/// </summary>
public class GeographicMetricsApiLatencyTests
{
private static SessionSummary S(string country, string city, double? avgLatencyMs, int? requestCount)
=> new()
{
TenantId = "00000000-0000-0000-0000-000000000fe0",
SessionId = Guid.NewGuid().ToString(),
Status = SessionStatus.Succeeded,
StartedAt = DateTime.UtcNow.AddHours(-2),
GeoCountry = country,
GeoRegion = country,
GeoCity = city,
GeoLoc = "0,0",
AvgApiLatencyMs = avgLatencyMs,
ApiRequestCount = requestCount,
};
[Fact]
public void LocationLatency_IsRequestWeighted_NotAverageOfAverages()
{
var sessions = new List<SessionSummary>
{
S("ID", "Jakarta", 1000, 300),
S("ID", "Jakarta", 500, 100),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
// (1000·300 + 500·100) / 400 = 875 — an unweighted mean would say 750.
Assert.Equal(875d, loc.AvgApiLatencyMs);
Assert.Equal(2, loc.ApiLatencySessionCount);
}
[Fact]
public void SessionsWithoutLatencyData_DropOutOfWeighting_ButStayInSessionCount()
{
var sessions = new List<SessionSummary>
{
S("DE", "Frankfurt", 200, 100),
S("DE", "Frankfurt", avgLatencyMs: null, requestCount: null), // pre-feature agent
S("DE", "Frankfurt", avgLatencyMs: 0, requestCount: 50), // zero = no signal
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
Assert.Equal(3, loc.SessionCount);
Assert.Equal(1, loc.ApiLatencySessionCount);
Assert.Equal(200d, loc.AvgApiLatencyMs);
}
[Fact]
public void NoLatencyDataAnywhere_YieldsZeroes_NotDivisionByZero()
{
var sessions = new List<SessionSummary> { S("DE", "Frankfurt", null, null) };
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var loc = Assert.Single(result.Locations);
Assert.Equal(0d, loc.AvgApiLatencyMs);
Assert.Equal(0, loc.ApiLatencySessionCount);
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
Assert.Equal(0d, result.GlobalAverages.AvgApiLatencyMs);
}
[Fact]
public void GlobalLatency_WeightsAcrossLocations_AndVsGlobalPctCompares()
{
var sessions = new List<SessionSummary>
{
S("DE", "Frankfurt", 200, 100),
S("ID", "Jakarta", 800, 100),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
// Equal request weights → global = (200 + 800) / 2 = 500.
Assert.Equal(500d, result.GlobalAverages.AvgApiLatencyMs);
var de = result.Locations.Single(l => l.Country == "DE");
var id = result.Locations.Single(l => l.Country == "ID");
Assert.Equal(-60d, de.ApiLatencyVsGlobalPct); // 200 vs 500
Assert.Equal(60d, id.ApiLatencyVsGlobalPct); // 800 vs 500
}
[Fact]
public void LocationWithoutData_KeepsZeroPct_EvenWhenGlobalHasData()
{
var sessions = new List<SessionSummary>
{
S("DE", "Frankfurt", 200, 100),
S("FR", "Paris", null, null),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "country");
var fr = result.Locations.Single(l => l.Country == "FR");
Assert.Equal(0d, fr.AvgApiLatencyMs);
Assert.Equal(0d, fr.ApiLatencyVsGlobalPct); // no data ≠ "0 ms fast"
}
}
@@ -0,0 +1,189 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Guards the cross-tenant redaction on GET metrics/ime-versions.
///
/// The route is <c>MemberRead</c> (EndpointAccessPolicyCatalog) and the archive itself is a
/// single global partition — every tenant member reads the SAME platform-wide rows. The only
/// thing standing between an ordinary tenant user and another tenant's identifiers is the
/// projection in <see cref="GetImeVersionHistoryFunction.BuildResponsePayload"/>. That made it
/// worth pinning: the redaction had no test at all, and a boundary nobody asserts is one a
/// later refactor can open without a single failure.
///
/// The JSON tests are the ones that matter — a property dropped from an anonymous type is only
/// a real redaction once it is also absent from the wire, and the wire is what the customer's
/// MCP client receives. camelCase mirrors Program.cs's PropertyNamingPolicy.
/// </summary>
public class GetImeVersionHistoryRedactionTests
{
private static readonly DateTime First = new(2026, 7, 8, 3, 18, 13, DateTimeKind.Utc);
private static readonly DateTime Last = new(2026, 7, 27, 16, 59, 34, DateTimeKind.Utc);
private static readonly JsonSerializerOptions CamelCase =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// =========================================================================
// Non-global caller (ordinary tenant member) — the redaction
// =========================================================================
[Fact]
public void NonGlobalCaller_DropsForeignTenantAndSessionIds()
{
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
Assert.DoesNotContain("firstSeenTenantId", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The values, not just the property names: a rename of the model property must not let the
/// identifier survive under a different key. Asserting on the GUIDs catches that.
/// </summary>
[Fact]
public void NonGlobalCaller_ForeignIdentifierValuesAppearNowhereInThePayload()
{
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
Assert.DoesNotContain("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("baae0453-6d19-47b5-aa58-8bed5a572fc8", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void NonGlobalCaller_ExposesExactlyTheFourAllowedProperties()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "firstSeenAt", "lastSeenAt", "sessionCount", "version" }, keys);
}
/// <summary>
/// SessionCount stays on purpose — it is a platform-wide total, and the route exists so any
/// tenant member can track Microsoft's IME rollout. Pinned so that "the counts are global"
/// stays a decision someone made rather than something that quietly drifts either way.
/// </summary>
[Fact]
public void NonGlobalCaller_KeepsVersionDatesAndGlobalSessionCount()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
var row = doc.RootElement[0];
Assert.Equal("1.103.101.0", row.GetProperty("version").GetString());
Assert.Equal(First, row.GetProperty("firstSeenAt").GetDateTime());
Assert.Equal(Last, row.GetProperty("lastSeenAt").GetDateTime());
Assert.Equal(3109, row.GetProperty("sessionCount").GetInt32());
}
// =========================================================================
// Global caller (Global Admin or Global Reader) — full archive
// =========================================================================
[Fact]
public void GlobalCaller_KeepsTenantAndSessionIds()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
var row = doc.RootElement[0];
Assert.Equal("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", row.GetProperty("firstSeenTenantId").GetString());
Assert.Equal("baae0453-6d19-47b5-aa58-8bed5a572fc8", row.GetProperty("firstSeenSessionId").GetString());
}
[Fact]
public void GlobalCaller_ExposesTheFullEntry()
{
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
keys);
}
// =========================================================================
// Shape-preserving properties of the projection
// =========================================================================
[Fact]
public void BothScopes_EmitAJsonArray_EvenForASingleRow()
{
foreach (var scope in new[] { true, false })
{
using var doc = JsonDocument.Parse(SerializeFor(scope, MakeEntry()));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
}
}
[Fact]
public void BothScopes_EmptyArchive_SerializesAsEmptyArray()
{
foreach (var scope in new[] { true, false })
{
using var doc = JsonDocument.Parse(SerializeFor(scope));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
}
/// <summary>
/// The repository orders by FirstSeenAt descending; the projection must not reshuffle, or a
/// redacted caller would see a different sequence than a Global Admin for the same archive.
/// </summary>
[Fact]
public void NonGlobalCaller_PreservesRowOrderAndCount()
{
var newest = MakeEntry("1.103.101.0", First);
var older = MakeEntry("1.101.111.0", First.AddDays(-48));
var oldest = MakeEntry("1.99.101.0", First.AddDays(-131));
using var doc = JsonDocument.Parse(SerializeFor(false, newest, older, oldest));
Assert.Equal(3, doc.RootElement.GetArrayLength());
Assert.Equal("1.103.101.0", doc.RootElement[0].GetProperty("version").GetString());
Assert.Equal("1.101.111.0", doc.RootElement[1].GetProperty("version").GetString());
Assert.Equal("1.99.101.0", doc.RootElement[2].GetProperty("version").GetString());
}
/// <summary>
/// BuildResponsePayload returns `object`; the branch that keeps everything hands back the
/// entries themselves. System.Text.Json resolves an `object` declared type via the runtime
/// type, which is what keeps the wire format identical to the pre-extraction inline code —
/// assert it rather than trust it, since a regression here would silently emit "{}" rows.
/// </summary>
[Fact]
public void GlobalPayload_SerializedAsObject_StillEmitsPopulatedRows()
{
var payload = GetImeVersionHistoryFunction.BuildResponsePayload([MakeEntry()], hasGlobalScope: true);
var json = JsonSerializer.Serialize(payload, CamelCase);
Assert.Contains("1.103.101.0", json, StringComparison.Ordinal);
Assert.NotEqual("[{}]", json);
}
// =========================================================================
// Helpers
// =========================================================================
private static string SerializeFor(bool hasGlobalScope, params ImeVersionHistoryEntry[] versions) =>
JsonSerializer.Serialize(
GetImeVersionHistoryFunction.BuildResponsePayload(versions, hasGlobalScope),
CamelCase);
private static ImeVersionHistoryEntry MakeEntry(string version = "1.103.101.0", DateTime? firstSeenAt = null) =>
new()
{
Version = version,
FirstSeenAt = firstSeenAt ?? First,
FirstSeenSessionId = "baae0453-6d19-47b5-aa58-8bed5a572fc8",
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
LastSeenAt = Last,
SessionCount = 3109,
};
}
@@ -0,0 +1,165 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the per-session API-latency projection (2026-07-28): the V2 agent emits cumulative
/// counters <c>net_total_latency_ms</c> / <c>net_total_requests</c> in every
/// <c>agent_metrics_snapshot</c>; ingest derives the session-wide average from the LAST
/// snapshot of the batch and persists it via <c>UpdateSessionNetworkLatencyAsync</c>
/// (last-write-wins on cumulative counters → replay-idempotent).
/// Wire-format note: the ingest body is parsed with Newtonsoft into
/// <c>Dictionary&lt;string, object&gt;</c>, so integer JSON numbers arrive as boxed
/// <c>long</c> — fixtures mirror that, not the agent's in-memory types.
/// </summary>
public class IngestApiLatencyProjectionTests
{
private static EnrollmentEvent Snapshot(object? totalLatencyMs, object? totalRequests)
{
var evt = new EnrollmentEvent
{
EventType = "agent_metrics_snapshot",
Data = new Dictionary<string, object>
{
["agent_cpu_percent"] = 3.62,
["net_requests"] = 50L,
["net_avg_latency_ms"] = 513.0,
},
};
if (totalLatencyMs != null) evt.Data["net_total_latency_ms"] = totalLatencyMs;
if (totalRequests != null) evt.Data["net_total_requests"] = totalRequests;
return evt;
}
[Fact]
public void CumulativeCounters_YieldSessionAverage()
{
var events = new List<EnrollmentEvent> { Snapshot(51_300L, 100L) };
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
Assert.Equal(513.0, avg);
Assert.Equal(100, count);
}
[Fact]
public void AverageIsRounded_ToOneDecimal()
{
var events = new List<EnrollmentEvent> { Snapshot(1_000L, 3L) };
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out _));
Assert.Equal(333.3, avg);
}
[Fact]
public void LastSnapshotOfBatch_Wins()
{
// Cumulative counters grow monotonically — the last snapshot carries the most
// complete session average.
var events = new List<EnrollmentEvent>
{
Snapshot(51_300L, 100L),
new EnrollmentEvent { EventType = "download_progress" },
Snapshot(120_000L, 200L),
};
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
Assert.Equal(600.0, avg);
Assert.Equal(200, count);
}
[Fact]
public void OldAgentSnapshot_WithoutTotalLatencyField_DoesNotMatch()
{
// Agents predating net_total_latency_ms emit net_total_requests only.
var events = new List<EnrollmentEvent> { Snapshot(totalLatencyMs: null, totalRequests: 100L) };
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
[Fact]
public void SnapshotWithLatencyButMissingRequests_DoesNotMatch()
{
var events = new List<EnrollmentEvent> { Snapshot(51_300L, totalRequests: null) };
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
[Fact]
public void ZeroRequests_DoesNotMatch()
{
// No requests yet (first snapshot before the first upload completes) — no division.
var events = new List<EnrollmentEvent> { Snapshot(0L, 0L) };
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
[Fact]
public void MatchingLastSnapshot_SkipsOlderAgentSnapshotAfterIt()
{
// A field-less (old-schema) snapshot later in the batch must not shadow an earlier
// one that carries the counters — the predicate selects the last MATCHING snapshot.
var events = new List<EnrollmentEvent>
{
Snapshot(51_300L, 100L),
Snapshot(totalLatencyMs: null, totalRequests: 150L),
};
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
Assert.Equal(513.0, avg);
Assert.Equal(100, count);
}
[Fact]
public void BatchWithoutSnapshots_DoesNotMatch()
{
var events = new List<EnrollmentEvent>
{
new EnrollmentEvent { EventType = "esp_phase_changed" },
new EnrollmentEvent { EventType = "app_install_started" },
};
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
}
// -------- TryGetDouble coercion ladder (mirrors PlatformMetricsService.GetDouble) --------
[Theory]
[InlineData(513.5)] // double — decimal JSON number
[InlineData(513)] // int — in-memory constructed events (tests, V1 paths)
[InlineData(513L)] // long — Newtonsoft wire form for integer JSON numbers
[InlineData(513f)] // float
public void TryGetDouble_CoercesBoxedNumericPrimitives(object boxed)
{
var data = new Dictionary<string, object> { ["k"] = boxed };
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
Assert.Equal(Convert.ToDouble(boxed), value);
}
[Fact]
public void TryGetDouble_ParsesStrings_InvariantCulture()
{
var data = new Dictionary<string, object> { ["k"] = "513.5" };
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
Assert.Equal(513.5, value);
}
[Theory]
[InlineData("not-a-number")]
[InlineData("")]
public void TryGetDouble_RejectsUnparseableStrings(string raw)
{
var data = new Dictionary<string, object> { ["k"] = raw };
Assert.False(EventIngestProcessor.TryGetDouble(data, "k", out _));
}
[Fact]
public void TryGetDouble_MissingKey_ReturnsFalse()
{
Assert.False(EventIngestProcessor.TryGetDouble(new Dictionary<string, object>(), "k", out _));
}
}
@@ -207,6 +207,34 @@ public class MetricsMathAppPayloadTests
Assert.Equal(0, app.GetProperty("skipped").GetInt32());
}
[Fact]
public void ImplausibleDurations_AreExcludedFromAverages_AndCountedAsUnmeasured()
{
// A duration beyond the agent's max observation lifetime is a back-stamped emission
// artifact (CompletedAt pinned at session end — production: 60 175 s app rows inside a
// 2 697 s session), not a slow install. It must fall out of the averages exactly like
// an unobserved start. Boundary: exactly 6 h is still plausible/measured.
var summaries = new List<AppInstallSummary>
{
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 100 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 200 },
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds }, // boundary: measured
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds + 1 }, // artifact: unmeasured
};
var root = Build(summaries);
Assert.Equal(1, root.GetProperty("totalUnmeasured").GetInt32());
var slowest = root.GetProperty("slowestApps");
Assert.Equal(1, slowest.GetArrayLength());
Assert.Equal(3, slowest[0].GetProperty("measuredInstalls").GetInt32());
// avg over {100, 200, 21600} — the 21601 artifact contributes nothing.
Assert.Equal(7300d, slowest[0].GetProperty("avgDurationSeconds").GetDouble());
Assert.Equal(MetricsMath.MaxPlausibleInstallDurationSeconds, slowest[0].GetProperty("maxDurationSeconds").GetInt32());
}
[Fact]
public void SlowestApps_GateOnMeasuredInstalls_NotOnSucceededCount()
{
@@ -299,7 +299,7 @@ public class TimeAttributionPersistenceAndAggregationTests
{
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetUnknown)),
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.ClockSkewDropped)),
};
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
@@ -312,6 +312,32 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Empty(aggregate.TopBlockingApps);
}
[Fact]
public void Aggregates_BlockingOnlyFlags_StayInSegmentStats()
{
// BlockingSetUnknown/Truncated limit per-app blocking EVIDENCE, not the measured spans.
// Gating on any flag starved the fleet aggregates in production (903/1000 breakdowns
// carried BlockingSetUnknown, 2026-07-27) — blocking-only flagged sessions must count
// as clean; only duration-critical flags exclude.
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
{
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetUnknown, espAppsSeconds: 100)),
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetTruncated, espAppsSeconds: 300)),
(Session(TenantA, T0), Breakdown(
flags: TimeAttributionFlags.BlockingSetUnknown | TimeAttributionFlags.PartialObservation,
espAppsSeconds: 99999)), // duration-critical bit set → still excluded
};
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(2, aggregate.CleanSessionCount);
Assert.Equal(1, aggregate.FlaggedExcludedCount);
// Median of {100, 300} = 100 (nearest-rank, lower value — repo convention).
Assert.Equal(100, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
}
[Fact]
public void Aggregates_MissingBreakdowns_AreCounted_NeverGuessed()
{
@@ -148,6 +148,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
=> _storage.UpdateSessionImeAgentVersionAsync(tenantId, sessionId, version);
public Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
double avgLatencyMs, int requestCount)
=> _storage.UpdateSessionNetworkLatencyAsync(tenantId, sessionId, avgLatencyMs, requestCount);
public Task<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold)
=> _storage.GetSessionsWithEventCountAboveAsync(tenantId, threshold);
@@ -0,0 +1,216 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
/// <summary>
/// Receives Azure Monitor alert notifications (Common Alert Schema) from an Action Group
/// webhook receiver and re-emits them as ops events, so platform alerts flow through the
/// same OpsEventService → OpsAlertDispatchService fan-out (Telegram/Teams/Slack providers +
/// the portal ops-events list) as every internally raised operational event.
///
/// The delivery path is deliberately independent of the portal: Azure Monitor → this
/// function → providers. A portal (SWA) outage cannot suppress these notifications — the
/// action group's e-mail receiver additionally stays as the backend-independent fallback,
/// because a self-hosted channel cannot alert on its own outage.
///
/// Security: anonymous route (Action Group webhook receivers can only call a bare URI — no
/// custom headers), gated by a shared secret in the query string compared in constant time
/// against the OpsAlertWebhookSecret app setting. Fail-closed: while the setting is unset,
/// every request is rejected. Dispatch to providers only happens if an enabled ops alert
/// rule for EventType "AzureMonitorAlert" matches the mapped severity — the event itself is
/// always stored.
/// </summary>
public class AzureMonitorAlertWebhookFunction
{
internal const string SecretSettingName = "OpsAlertWebhookSecret";
// Common Alert Schema payloads for log-search alerts carry the search query, link URLs
// and dimension sets — a few KB in practice. 64 KB is a generous shape gate, not a throttle.
internal const int MaxContentLength = 64 * 1024;
private readonly OpsEventService _opsEventService;
private readonly ILogger<AzureMonitorAlertWebhookFunction> _logger;
public AzureMonitorAlertWebhookFunction(
OpsEventService opsEventService,
ILogger<AzureMonitorAlertWebhookFunction> logger)
{
_opsEventService = opsEventService;
_logger = logger;
}
[Function("AzureMonitorAlertWebhook")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "ops/alert-webhook")] HttpRequestData req)
{
var configuredSecret = Environment.GetEnvironmentVariable(SecretSettingName);
var providedSecret = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty)["secret"];
if (!SecretMatches(providedSecret, configuredSecret))
{
// Deliberately no detail in the response; the warning makes a misconfigured
// (unset) secret operator-visible in the logs.
_logger.LogWarning(
"Azure Monitor alert webhook rejected: {Reason}",
string.IsNullOrEmpty(configuredSecret) ? "secret not configured (fail-closed)" : "secret mismatch");
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
// Bounded read instead of a Body.Length check: with ASP.NET Core integration the
// request body is a forward-only stream whose Length property throws
// NotSupportedException. Reading at most cap+1 chars detects oversize without ever
// needing to know the total size. (Char-based cap ≈ byte cap for a shape gate.)
string body;
using (var reader = new StreamReader(req.Body, Encoding.UTF8))
{
var buffer = new char[MaxContentLength + 1];
var read = await reader.ReadBlockAsync(buffer, 0, buffer.Length);
if (read > MaxContentLength)
{
_logger.LogWarning("Azure Monitor alert webhook rejected: payload exceeds {Cap} char cap", MaxContentLength);
return req.CreateResponse(HttpStatusCode.BadRequest);
}
body = new string(buffer, 0, read);
}
var alert = TryParse(body);
if (alert == null)
{
_logger.LogWarning("Azure Monitor alert webhook rejected: body is not a Common Alert Schema payload");
return req.CreateResponse(HttpStatusCode.BadRequest);
}
await _opsEventService.RecordAzureMonitorAlertAsync(
alert.AlertRule,
MapSeverity(alert.Severity),
alert.MonitorCondition,
alert.Description,
alert.Severity,
alert.MonitoringService,
alert.TargetResource,
alert.MetricValue);
_logger.LogInformation(
"Azure Monitor alert '{AlertRule}' {Condition} ({Severity}) recorded as ops event",
alert.AlertRule, alert.MonitorCondition, alert.Severity);
return req.CreateResponse(HttpStatusCode.OK);
}
/// <summary>
/// Constant-time secret comparison. Fail-closed: an unset/empty configured secret never
/// matches anything, so the endpoint stays dark until the app setting is provisioned.
/// </summary>
internal static bool SecretMatches(string? provided, string? configured)
{
if (string.IsNullOrEmpty(configured) || string.IsNullOrEmpty(provided))
return false;
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(configured));
}
/// <summary>
/// Maps Azure Monitor severities (Sev0 = most severe … Sev4 = verbose) onto the ops event
/// severity scale. Unknown values fall back to Info so a schema drift can never escalate.
/// </summary>
internal static string MapSeverity(string? azureSeverity) => azureSeverity switch
{
"Sev0" => Shared.DataAccess.OpsEventSeverity.Critical,
"Sev1" => Shared.DataAccess.OpsEventSeverity.Error,
"Sev2" => Shared.DataAccess.OpsEventSeverity.Warning,
_ => Shared.DataAccess.OpsEventSeverity.Info,
};
/// <summary>
/// Parsed subset of the Common Alert Schema. Only <see cref="AlertRule"/> is mandatory;
/// everything else is best-effort so a partial payload still produces a usable ops event.
/// </summary>
internal sealed record ParsedAlert(
string AlertRule,
string? Severity,
string MonitorCondition,
string? Description,
string? MonitoringService,
string? TargetResource,
double? MetricValue);
/// <summary>
/// Defensively extracts data.essentials (+ the first condition's metricValue) from a
/// Common Alert Schema payload. Returns null when the body is not JSON or carries no
/// alert rule name — the two states in which no meaningful ops event can be formed.
/// </summary>
internal static ParsedAlert? TryParse(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("essentials", out var essentials))
return null;
var alertRule = GetString(essentials, "alertRule");
if (string.IsNullOrWhiteSpace(alertRule))
return null;
var targetResource = GetString(essentials, "targetResourceName");
if (string.IsNullOrEmpty(targetResource) &&
essentials.TryGetProperty("alertTargetIDs", out var targets) &&
targets.ValueKind == JsonValueKind.Array && targets.GetArrayLength() > 0 &&
targets[0].ValueKind == JsonValueKind.String)
{
// Fall back to the resource name segment of the first target resource ID.
var id = targets[0].GetString() ?? string.Empty;
targetResource = id[(id.LastIndexOf('/') + 1)..];
}
// Log-search alerts carry the observed value under alertContext.condition.allOf[0].
double? metricValue = null;
if (data.TryGetProperty("alertContext", out var ctx) &&
ctx.ValueKind == JsonValueKind.Object &&
ctx.TryGetProperty("condition", out var condition) &&
condition.ValueKind == JsonValueKind.Object &&
condition.TryGetProperty("allOf", out var allOf) &&
allOf.ValueKind == JsonValueKind.Array && allOf.GetArrayLength() > 0 &&
allOf[0].ValueKind == JsonValueKind.Object &&
allOf[0].TryGetProperty("metricValue", out var mv) &&
mv.ValueKind == JsonValueKind.Number)
{
metricValue = mv.GetDouble();
}
return new ParsedAlert(
alertRule!,
GetString(essentials, "severity"),
GetString(essentials, "monitorCondition") ?? "Fired",
GetString(essentials, "description"),
GetString(essentials, "monitoringService"),
targetResource,
metricValue);
}
catch (JsonException)
{
return null;
}
}
private static string? GetString(JsonElement element, string property)
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
}
@@ -153,6 +153,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
.Where(s => appsBySession.ContainsKey(s.SessionId))
.Count(s => appsBySession[s.SessionId].Any(a => a.DoDownloadMode >= 0));
// Agent→backend API latency: weight each session's stored average by its request
// count so the location figure equals Σ(latency·requests)/Σ(requests) — the exact
// whole-population average, not an average of session averages. Sessions from
// agents predating the feature carry no data and drop out.
var latencySessions = group
.Where(s => s.AvgApiLatencyMs is > 0 && s.ApiRequestCount is > 0)
.ToList();
var latencyRequestSum = latencySessions.Sum(s => (double)s.ApiRequestCount!.Value);
var avgApiLatency = latencyRequestSum > 0
? latencySessions.Sum(s => s.AvgApiLatencyMs!.Value * s.ApiRequestCount!.Value) / latencyRequestSum
: 0;
locations.Add(new LocationMetrics
{
LocationKey = group.Key,
@@ -171,6 +183,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
MinutesPerApp = Math.Round(minutesPerApp, 2),
AvgThroughputBytesPerSec = Math.Round(avgThroughput, 0),
TotalDownloadBytes = totalBytes,
AvgApiLatencyMs = Math.Round(avgApiLatency, 0),
ApiLatencySessionCount = latencySessions.Count,
// Delivery Optimization
DoSessionCount = doSessionCount,
AvgDoPercentPeerCaching = Math.Round(doAgg.PercentPeerCaching, 1),
@@ -202,6 +216,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
.Select(l => l.AvgThroughputBytesPerSec).ToList();
var globalAvgThroughput = allThroughputs.Count > 0 ? allThroughputs.Average() : 0;
// Global API latency: request-weighted over ALL geo sessions (not location averages),
// so big sites weigh in proportionally — same Σ(latency·requests)/Σ(requests) math as
// the per-location figure.
var globalLatencySessions = geoSessions
.Where(s => s.AvgApiLatencyMs is > 0 && s.ApiRequestCount is > 0)
.ToList();
var globalLatencyRequestSum = globalLatencySessions.Sum(s => (double)s.ApiRequestCount!.Value);
var globalAvgApiLatency = globalLatencyRequestSum > 0
? globalLatencySessions.Sum(s => s.AvgApiLatencyMs!.Value * s.ApiRequestCount!.Value) / globalLatencyRequestSum
: 0;
// Global DO metrics (weighted by bytes, not simple average)
var globalDoPeers = locations.Sum(l => l.TotalDoBytesFromPeers);
var globalDoHttp = locations.Sum(l => l.TotalDoBytesFromHttp);
@@ -219,6 +244,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
loc.ThroughputVsGlobalPct = globalAvgThroughput > 0
? Math.Round((loc.AvgThroughputBytesPerSec - globalAvgThroughput) / globalAvgThroughput * 100, 1) : 0;
// API latency vs global (positive = slower/farther from the backend region)
loc.ApiLatencyVsGlobalPct = globalAvgApiLatency > 0 && loc.AvgApiLatencyMs > 0
? Math.Round((loc.AvgApiLatencyMs - globalAvgApiLatency) / globalAvgApiLatency * 100, 1) : 0;
// AppLoadScore: normalize minutesPerApp to global median = 100
loc.AppLoadScore = globalMedianMinutesPerApp > 0
? Math.Round(loc.MinutesPerApp / globalMedianMinutesPerApp * 100, 0) : 0;
@@ -243,6 +272,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
AvgMinutesPerApp = Math.Round(globalAvgMinutesPerApp, 2),
AvgThroughputBytesPerSec = Math.Round(globalAvgThroughput, 0),
StdDevDurationMinutes = Math.Round(globalStdDev, 1),
AvgApiLatencyMs = Math.Round(globalAvgApiLatency, 0),
AvgDoPercentPeerCaching = Math.Round(globalDoPct, 1),
TotalDoBytesFromPeers = globalDoPeers,
TotalDoBytesFromHttp = globalDoHttp
@@ -1,6 +1,7 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -34,24 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var versions = await _sessionRepo.GetImeVersionHistoryAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
if (requestCtx.HasGlobalScope)
{
await response.WriteAsJsonAsync(versions);
}
else
{
// Strip sensitive cross-tenant data for non-Global Admins
var redacted = versions.Select(v => new
{
v.Version,
v.FirstSeenAt,
v.LastSeenAt,
v.SessionCount
});
await response.WriteAsJsonAsync(redacted);
}
await response.WriteAsJsonAsync(BuildResponsePayload(versions, requestCtx.HasGlobalScope));
return response;
}
catch (Exception ex)
@@ -62,5 +46,35 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return errorResponse;
}
}
/// <summary>
/// Projects the archive for the caller's scope.
///
/// Extracted from <see cref="Run"/> so the cross-tenant redaction boundary is
/// directly testable: this suite has no HTTP-level harness, so an inline projection
/// is unreachable from a test — and an unguarded redaction is exactly what a later
/// refactor opens silently.
///
/// FirstSeenTenantId and FirstSeenSessionId name ANOTHER tenant's device and are
/// dropped for non-global callers. Version, dates and SessionCount deliberately stay:
/// the archive is a platform-wide view of Microsoft's IME rollout, and the route is
/// MemberRead precisely so any tenant member can read that view.
///
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
/// `object` declared type using the RUNTIME type, so both branches keep the exact
/// wire format they had while the projection was inline.
/// </summary>
public static object BuildResponsePayload(
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
{
if (hasGlobalScope)
{
return versions.ToList();
}
return versions
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount })
.ToList();
}
}
}
@@ -210,6 +210,8 @@ public class SearchSessionsFunction
if (fields.Contains("imeAgentVersion")) dict["imeAgentVersion"] = s.ImeAgentVersion;
if (fields.Contains("geoCountry")) dict["geoCountry"] = s.GeoCountry;
if (fields.Contains("rebootCount")) dict["rebootCount"] = s.RebootCount;
if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs;
if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount;
if (dict.Count == 0)
{
@@ -32,15 +32,29 @@ public static class MetricsMath
public static bool IsSkipTerminalState(AppInstallSummary s)
=> s.TerminalState == "Skipped" || s.TerminalState == "Postponed";
/// <summary>
/// Upper plausibility bound for an observed install duration: 6 h, the agent's default
/// max observation lifetime (AgentMaxLifetimeMinutes = 360) — nothing longer can have
/// been continuously watched. Rows above it are emission artifacts, not slow installs:
/// a batch emission at session end pins CompletedAt far past the real finish (verified
/// in production 2026-07-27: app rows carrying 60 175 s in a session whose own wall
/// clock was 2 697 s; 490 rows &gt; 6 h overall, worst 117 days — dominating the
/// slowest-apps ranking with six-figure averages).
/// </summary>
public const int MaxPlausibleInstallDurationSeconds = 6 * 3600;
/// <summary>
/// True for a succeeded row whose install duration was actually observed. Zero /
/// absent duration on a non-skip succeeded row means the START was never observed
/// (agent attach window — audit finding, §0.5 of the insights spec): the duration is
/// UNKNOWN, not zero — averaging it in would understate real install times (measured
/// −18 % in production). Duration statistics must only read rows where this is true.
/// (agent attach window — audit finding, §0.5 of the insights spec); a duration above
/// <see cref="MaxPlausibleInstallDurationSeconds"/> means the END was never observed
/// (completion back-stamped at session end). Either way the duration is UNKNOWN —
/// averaging zeros understates (measured −18 % in production), averaging back-stamps
/// inflates. Duration statistics must only read rows where this is true; everything
/// else is reported as "unmeasured".
/// </summary>
public static bool HasMeasuredDuration(AppInstallSummary s)
=> s.DurationSeconds > 0;
=> s.DurationSeconds > 0 && s.DurationSeconds <= MaxPlausibleInstallDurationSeconds;
/// <summary>
/// Builds the complete app-metrics response object from a (pre-time-filtered) set of app
@@ -170,6 +170,11 @@ public static class EndpointAccessPolicyCatalog
// ticket is the sole authority — authz happened at mint time (diagnostics/download-ticket,
// MemberRead). See DiagnosticsTicketDownloadFunction.
new("GET", "diagnostics/download", EndpointPolicy.PublicAnonymous),
// Azure Monitor action-group webhook (Common Alert Schema → ops event). Action groups
// cannot send custom headers, so the shared secret travels as ?secret= and is compared
// in constant time against the OpsAlertWebhookSecret app setting (fail-closed while
// unset). See AzureMonitorAlertWebhookFunction.
new("POST", "ops/alert-webhook", EndpointPolicy.PublicAnonymous),
// ── DeviceOrBootstrapAuth ───────────────────────────────────────
new("POST", "agent/register-session", EndpointPolicy.DeviceOrBootstrapAuth),
@@ -143,6 +143,14 @@ namespace AutopilotMonitor.Functions.Services
}
}
if (TryComputeSessionApiLatency(request.Events, out var avgLatencyMs, out var apiRequestCount))
{
_ = _sessionRepo.UpdateSessionNetworkLatencyAsync(
request.TenantId, request.SessionId, avgLatencyMs, apiRequestCount)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget UpdateSessionNetworkLatencyAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
}
var classification = ClassifyEvents(storedEvents);
foreach (var summary in classification.AppInstallUpdates.Values)
@@ -395,6 +403,58 @@ namespace AutopilotMonitor.Functions.Services
};
}
/// <summary>
/// Session-wide API latency projection: the agent emits cumulative counters
/// (net_total_latency_ms / net_total_requests) in every agent_metrics_snapshot, so
/// the LAST snapshot of the batch carries the whole-session average up to now and a
/// plain overwrite is idempotent against replays. Agents predating the field simply
/// never match (net_total_latency_ms absent) — no fallback, fleet turns over
/// per-enrollment. Exposed as internal for unit testing.
/// </summary>
internal static bool TryComputeSessionApiLatency(
List<EnrollmentEvent> events, out double avgLatencyMs, out int requestCount)
{
avgLatencyMs = 0;
requestCount = 0;
var snapshot = events.LastOrDefault(e =>
e.EventType == Shared.Constants.EventTypes.AgentMetricsSnapshot &&
e.Data?.ContainsKey("net_total_latency_ms") == true);
if (snapshot?.Data == null ||
!TryGetDouble(snapshot.Data, "net_total_latency_ms", out var totalLatencyMs) ||
!TryGetDouble(snapshot.Data, "net_total_requests", out var totalRequests) ||
totalRequests <= 0)
{
return false;
}
avgLatencyMs = Math.Round(totalLatencyMs / totalRequests, 1);
requestCount = (int)totalRequests;
return true;
}
/// <summary>
/// Numeric reader for agent event Data values, which arrive as boxed Newtonsoft
/// primitives (integer → long, decimal → double) — same coercion ladder as
/// PlatformMetricsService.GetDouble (the proven parser for these snapshot fields),
/// plus invariant-culture string parsing. Exposed as internal for unit testing.
/// </summary>
internal static bool TryGetDouble(Dictionary<string, object> data, string key, out double value)
{
value = 0;
if (!data.TryGetValue(key, out var raw))
return false;
switch (raw)
{
case double d: value = d; return true;
case int i: value = i; return true;
case long l: value = l; return true;
case float f: value = f; return true;
}
return double.TryParse(raw?.ToString(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out value);
}
/// <summary>
/// Stamps authoritative server-side fields onto all events before storage.
/// TenantId and SessionId always come from the validated request metadata,
@@ -394,8 +394,11 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Pure aggregation core behind <see cref="SweepTimeAttributionAsync"/> — internal static
/// so the bucketing/gating contract is pinned by unit tests. Per (tenant × class × date)
/// bucket plus mirrored "global" rows: only CLEAN breakdowns (QualityFlags == None) form
/// the statistics; flagged and missing ones are counted, never silently dropped (rule 7).
/// bucket plus mirrored "global" rows: only breakdowns without DURATION-critical flags
/// (<see cref="TimeAttributionFlagQuality.DurationCriticalFlags"/>) form the statistics —
/// blocking-set-only flags stay in (their spans are sound; unknown blocking simply
/// contributes no per-app intervals); excluded and missing ones are counted, never
/// silently dropped (rule 7).
/// Rows are written even below the ≥20 UI gate — the UI needs the n (rule 4). Segment
/// stats always carry the five canonical segments + unattributed (a session without a
/// span of a segment contributes 0 — the honest "per enrollment of this class" answer).
@@ -427,7 +430,7 @@ namespace AutopilotMonitor.Functions.Services
foreach (var tenantKey in new[] { session.TenantId, "global" })
{
var key = (tenantKey, date, cls);
if (breakdown.QualityFlags != TimeAttributionFlags.None)
if (TimeAttributionFlagQuality.ExcludesFromFleetStats(breakdown.QualityFlags))
{
Bump(flaggedCounts, key);
// Flagged sessions still materialize the bucket so a day whose sessions
@@ -594,6 +594,24 @@ namespace AutopilotMonitor.Functions.Services
null, "System.SlaEvaluation",
new { tenantsEvaluated, breachesDetected, notificationsSent, durationMs });
// ── Platform (Azure Monitor) ───────────────────────────────────────────
/// <summary>
/// Records an Azure Monitor alert notification delivered via the ops alert webhook
/// (AzureMonitorAlertWebhookFunction). severity is already mapped onto the ops scale;
/// azureSeverity keeps the original Sev0–Sev4 value for the details payload.
/// </summary>
public Task RecordAzureMonitorAlertAsync(string alertRule, string severity, string monitorCondition,
string? description, string? azureSeverity, string? monitoringService, string? targetResource,
double? metricValue)
{
var suffix = string.IsNullOrWhiteSpace(description) ? string.Empty : $": {description}";
return WriteAsync(OpsEventCategory.Platform, "AzureMonitorAlert", severity,
$"Azure Monitor alert '{alertRule}' {monitorCondition}{suffix}",
null, "System.AzureMonitor",
new { alertRule, monitorCondition, azureSeverity, monitoringService, targetResource, metricValue });
}
// ── Core write method ──────────────────────────────────────────────────
private async Task WriteAsync(string category, string eventType, string severity,
@@ -213,6 +213,15 @@ namespace AutopilotMonitor.Functions.Services
var lastSnapshot = snapshots.Last();
// Prefer the exact session average from the cumulative counters (agents ≥ 2026-07-28
// emit net_total_latency_ms); fall back to the unweighted mean of the per-interval
// delta averages for older sessions.
var totalLatencyMs = GetDouble(lastSnapshot, "net_total_latency_ms");
var totalLatencyRequests = GetDouble(lastSnapshot, "net_total_requests");
var sessionAvgLatency = totalLatencyMs > 0 && totalLatencyRequests > 0
? totalLatencyMs / totalLatencyRequests
: (latValues.Count > 0 ? latValues.Average() : 0);
// Resolve agent version: prefer from snapshot, fallback to session
var agentVersion = snapshots
.Select(s => GetString(s, "agent_version"))
@@ -239,7 +248,7 @@ namespace AutopilotMonitor.Functions.Services
AvgWorkingSet = wsValues.Count > 0 ? wsValues.Average() : 0,
MaxWorkingSet = wsValues.Count > 0 ? wsValues.Max() : 0,
AvgPrivateBytes = pbValues.Count > 0 ? pbValues.Average() : 0,
AvgLatency = latValues.Count > 0 ? latValues.Average() : 0,
AvgLatency = sessionAvgLatency,
AvgSpoolDepth = spoolValues.Count > 0 ? spoolValues.Average() : 0,
MaxSpoolDepth = spoolValues.Count > 0 ? spoolValues.Max() : 0,
PeakSpoolDepth = peakValues.Count > 0 ? peakValues.Last() : 0,
@@ -586,7 +586,8 @@ namespace AutopilotMonitor.Functions.Services
internal static readonly string[] GeoMetricsSessionProjection =
{
"PartitionKey", "RowKey", "StartedAt", "CompletedAt", "Status", "DurationSeconds",
"IsPreProvisioned", "ResumedAt", "GeoCountry", "GeoRegion", "GeoCity", "GeoLoc"
"IsPreProvisioned", "ResumedAt", "GeoCountry", "GeoRegion", "GeoCity", "GeoLoc",
"AvgApiLatencyMs", "ApiRequestCount"
};
/// <summary>
@@ -538,12 +538,14 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Columns MetricsMath.BuildAppMetricsPayload consumes (slowest/failing ranking + DO rollup):
/// grouping key, status/duration/bytes, failure code and every counter DoAggregator sums.
/// grouping key, status + terminal state (PR0 skip classification — dropping TerminalState
/// silently zeroed totalSkipped in production, found 2026-07-27), duration/bytes, failure
/// code and every counter DoAggregator sums.
/// internal so AppsProjectionEquivalenceTests derives its keep-set from this array.
/// </summary>
internal static readonly string[] AppMetricsProjection =
{
"PartitionKey", "RowKey", "AppName", "Status", "StartedAt",
"PartitionKey", "RowKey", "AppName", "Status", "TerminalState", "StartedAt",
"DurationSeconds", "DownloadBytes", "FailureCode",
"DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp",
"DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers",
@@ -215,6 +215,16 @@ namespace AutopilotMonitor.Functions.Services
if (stalledAt.HasValue)
indexEntity["StalledAt"] = EnsureUtc(stalledAt.Value);
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges
// these) — must be in this superset or a StartedAt-shift full upsert drops them.
var avgApiLatencyMs = sessionEntity.GetDouble("AvgApiLatencyMs");
if (avgApiLatencyMs.HasValue)
indexEntity["AvgApiLatencyMs"] = avgApiLatencyMs.Value;
var apiRequestCount = sessionEntity.GetInt32("ApiRequestCount");
if (apiRequestCount.HasValue)
indexEntity["ApiRequestCount"] = apiRequestCount.Value;
return indexEntity;
}
@@ -2655,6 +2665,55 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Stores the session-wide average agent→backend HTTP latency (Merge-mode, two fields).
/// Derived from cumulative counters, so a plain last-write-wins overwrite is idempotent
/// against batch replays and monotonically converges to the whole-session average.
/// Same UpdateEntity-not-Upsert rationale as <see cref="UpdateSessionImeAgentVersionAsync"/>:
/// a tombstoned Sessions row must stay tombstoned (404 → no-op).
/// Non-fatal: failures are logged as warnings and do not block ingest.
/// </summary>
public async Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
double avgLatencyMs, int requestCount)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var update = new TableEntity(tenantId, sessionId)
{
["AvgApiLatencyMs"] = avgLatencyMs,
["ApiRequestCount"] = requestCount
};
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
// Mirror to SessionsIndex — the index is a full denormalized mirror and serves the
// session list / GA cross-tenant views where per-geo latency aggregation happens.
var idxRef = await tableClient.GetEntityAsync<TableEntity>(
tenantId, sessionId, select: new[] { "IndexRowKey" });
var indexRowKey = idxRef.Value.GetString("IndexRowKey");
if (!string.IsNullOrEmpty(indexRowKey))
{
await MergeSessionIndexAsync(tenantId, indexRowKey,
new TableEntity(tenantId, indexRowKey)
{
["AvgApiLatencyMs"] = avgLatencyMs,
["ApiRequestCount"] = requestCount
});
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogDebug(
"UpdateSessionNetworkLatency no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)",
tenantId, sessionId);
}
catch (Exception ex)
{
// Non-fatal — don't block ingest
_logger.LogWarning(ex, "Failed to update network latency for session {SessionId}", sessionId);
}
}
// ===== SESSION/EVENT MAPPING HELPERS =====
/// <summary>
@@ -2748,6 +2807,8 @@ namespace AutopilotMonitor.Functions.Services
PendingActionsQueuedAt = SafeGetDateTime(entity, "PendingActionsQueuedAt"),
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
AvgApiLatencyMs = SafeGetDouble(entity, "AvgApiLatencyMs"),
ApiRequestCount = SafeGetInt32(entity, "ApiRequestCount"),
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
// Legacy rows that predate this field surface as null → download path treats
@@ -159,6 +159,37 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Safely reads a Double property from a TableEntity.
/// Returns null instead of throwing when the property has a different type (legacy data).
/// Also accepts Int32/Int64-typed cells — Table Storage stores a whole-number double
/// as Int when written via JSON paths that drop the decimal point.
/// </summary>
private double? SafeGetDouble(TableEntity entity, string key)
{
try
{
return entity.GetDouble(key);
}
catch (InvalidOperationException)
{
if (entity.TryGetValue(key, out var raw))
{
switch (raw)
{
case int i: return i;
case long l: return l;
case string s when double.TryParse(s,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var parsed):
return parsed;
}
}
_logger.LogWarning("Property '{Key}' on entity {PK}/{RK} is not Double and could not be coerced", key, entity.PartitionKey, entity.RowKey);
return null;
}
}
/// <summary>
/// Safely reads a DateTime property from a TableEntity.
/// Returns null instead of throwing when the property has a different type (legacy data).
+14 -1
View File
@@ -70,7 +70,20 @@ ENV NODE_ENV=production
ENV HF_CACHE_DIR=/app/.hf-cache
COPY --from=build /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
# npm is a BUILD-time tool only: the runtime is `node dist/index.js`, the
# HEALTHCHECK is `node -e`, and the server never shells out (no child_process
# anywhere in src/). But the npm that node:22-slim bundles carries its own
# vendored dependency tree under /usr/local/lib/node_modules/npm, and a
# container image scanner reports every CVE in it against this image —
# @sigstore/core, brace-expansion, tar, sigstore, picomatch, ip-address, plus
# npm itself, none of which appear in our package-lock and none of which
# Dependabot can see.
#
# Removing npm clears all of them; upgrading it does not (npm 11.x still
# vendors @sigstore/core 3.1.0, inside the vulnerable <= 3.2.0 range). The rm
# must share this RUN so the deletion lands in the same layer as the install.
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY --from=build /app/dist/ ./dist/
COPY --from=build /app/.hf-cache/ ./.hf-cache/
+11 -8
View File
@@ -9,7 +9,7 @@
"version": "1.5.1",
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"compression": "^1.8.1",
"express": "^5.2.1",
"fuse.js": "^7.5.0",
@@ -25,6 +25,9 @@
"tsx": "^4.23.1",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@emnapi/core": {
@@ -1066,12 +1069,12 @@
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -3772,9 +3775,9 @@
"license": "MIT"
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -20,7 +20,7 @@
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"compression": "^1.8.1",
"express": "^5.2.1",
"fuse.js": "^7.5.0",
@@ -295,7 +295,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
title: 'Geographic Metrics',
description:
'Get geographic distribution of enrollments — where devices are enrolling from, with performance comparisons. ' +
'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, and outlier detection. ' +
'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, outlier detection, and ' +
'avgApiLatencyMs (agent→backend HTTP round-trip, request-weighted — the metric for judging backend-region distance, ' +
'e.g. when evaluating a new backend region; apiLatencyVsGlobalPct compares against the global weighted average). ' +
(ga ? 'Omit tenantId for cross-tenant view (Global Admin). ' : '') +
'Use get_geographic_sessions to drill into a specific location. ' +
'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).',
@@ -144,7 +144,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' +
'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' +
'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' +
'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount.'),
'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' +
'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' +
'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field).'),
deviceProperties: z.record(z.string(), z.string()).optional().describe(
'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' +
'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' +
@@ -45,6 +45,8 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string Tenant = "Tenant";
public const string Agent = "Agent";
public const string Sla = "SLA";
/// <summary>Platform infrastructure alerts relayed from Azure Monitor (ops alert webhook).</summary>
public const string Platform = "Platform";
}
/// <summary>
@@ -107,6 +107,14 @@ namespace AutopilotMonitor.Shared.DataAccess
Task UpdateSessionGeoAsync(string tenantId, string sessionId,
string? country, string? region, string? city, string? loc);
Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
/// <summary>
/// Persists the session-wide average agent→backend HTTP latency derived from the
/// cumulative counters of the latest <c>agent_metrics_snapshot</c>. Last-write-wins
/// (cumulative counters make replays idempotent); fail-soft like the other
/// supplementary-field updaters.
/// </summary>
Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
double avgLatencyMs, int requestCount);
// --- Server→Agent Actions ---
/// <summary>
@@ -54,6 +54,16 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Percentage difference from global avg throughput (positive = faster)</summary>
public double ThroughputVsGlobalPct { get; set; }
/// <summary>
/// Average agent→backend HTTP round-trip (ms) at this location, weighted per session by
/// its request count. 0 when no session here carries latency data (pre-feature agents).
/// </summary>
public double AvgApiLatencyMs { get; set; }
/// <summary>Sessions at this location that carry API-latency data</summary>
public int ApiLatencySessionCount { get; set; }
/// <summary>Percentage difference from global avg API latency (positive = slower/farther)</summary>
public double ApiLatencyVsGlobalPct { get; set; }
public bool IsOutlier { get; set; }
/// <summary>"fast", "slow", or null</summary>
public string? OutlierDirection { get; set; }
@@ -142,6 +152,8 @@ namespace AutopilotMonitor.Shared.Models
GeoRegion = s.GeoRegion,
GeoCity = s.GeoCity,
GeoLoc = s.GeoLoc,
AvgApiLatencyMs = s.AvgApiLatencyMs,
ApiRequestCount = s.ApiRequestCount,
PlatformScriptCount = s.PlatformScriptCount,
RemediationScriptCount = s.RemediationScriptCount,
PendingActionsJson = s.PendingActionsJson,
@@ -173,6 +185,8 @@ namespace AutopilotMonitor.Shared.Models
public double AvgMinutesPerApp { get; set; }
public double AvgThroughputBytesPerSec { get; set; }
public double StdDevDurationMinutes { get; set; }
/// <summary>Global request-weighted average agent→backend API latency (ms); 0 = no data yet</summary>
public double AvgApiLatencyMs { get; set; }
/// <summary>Global weighted average peer caching percentage</summary>
public double AvgDoPercentPeerCaching { get; set; }
/// <summary>Total peer bytes across all locations with DO data</summary>
@@ -64,6 +64,34 @@ namespace AutopilotMonitor.Shared.Models
WhiteGloveAnchorsIncomplete = 16
}
/// <summary>
/// Interpretation helpers for <see cref="TimeAttributionFlags"/>. The duration mask lives
/// here as a const rather than as a composite enum member on purpose: the wire format
/// serializes flag combinations as comma-joined member names (the web splits that string),
/// and a composite member would hijack ToString for combinations that happen to equal it.
/// </summary>
public static class TimeAttributionFlagQuality
{
/// <summary>
/// Flags that make the SEGMENT DURATIONS themselves untrustworthy — dropped anchors,
/// underobserved early phases, best-effort WhiteGlove window boundaries. Only these
/// exclude a breakdown from fleet segment statistics. The blocking-set flags
/// (<see cref="TimeAttributionFlags.BlockingSetUnknown"/> / BlockingSetTruncated) say
/// nothing about the measured spans — they only limit per-app blocking evidence, which
/// is positive-evidence-based anyway (unknown contributes no intervals). Gating fleet
/// stats on ANY flag starved the aggregates in production (2026-07-27: 903/1000
/// breakdowns carried BlockingSetUnknown → CleanSessionCount 12 vs 331 excluded).
/// </summary>
public const TimeAttributionFlags DurationCriticalFlags =
TimeAttributionFlags.ClockSkewDropped |
TimeAttributionFlags.PartialObservation |
TimeAttributionFlags.WhiteGloveAnchorsIncomplete;
/// <summary>True when the breakdown's segment durations are unfit for fleet statistics.</summary>
public static bool ExcludesFromFleetStats(TimeAttributionFlags flags)
=> (flags & DurationCriticalFlags) != TimeAttributionFlags.None;
}
/// <summary>One contiguous attributed span inside an observation window.</summary>
public class TimeAttributionSpan
{
@@ -257,6 +257,24 @@ namespace AutopilotMonitor.Shared.Models
public int EventCount { get; set; }
public int? DurationSeconds { get; set; }
/// <summary>
/// Session-wide average HTTP round-trip latency (ms) of the agent's calls to the
/// backend API, as measured on the device (includes network RTT + TLS + server
/// processing). Projected at ingest from the cumulative counters of the latest
/// <c>agent_metrics_snapshot</c> (<c>net_total_latency_ms / net_total_requests</c>).
/// Null for V1 sessions and sessions from agents that predate the field.
/// Aggregate across sessions weighted by <see cref="ApiRequestCount"/> — e.g. per
/// GeoCountry to compare regional backend reachability.
/// </summary>
public double? AvgApiLatencyMs { get; set; }
/// <summary>
/// Number of HTTP requests behind <see cref="AvgApiLatencyMs"/> (the cumulative
/// <c>net_total_requests</c> at the latest snapshot). Needed as the weight when
/// averaging latency across sessions. Null whenever <see cref="AvgApiLatencyMs"/> is.
/// </summary>
public int? ApiRequestCount { get; set; }
/// <summary>
/// Enrollment type: "v1" (Autopilot Classic/ESP) or "v2" (Windows Device Preparation).
/// Defaults to "v1" for sessions that predate this field.
@@ -110,6 +110,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// feedback_ops_event_types_dual_register.
"AgentEmergencyBreak",
],
Platform: [
// Azure Monitor alerts relayed through POST ops/alert-webhook (Common Alert Schema →
// AzureMonitorAlertWebhookFunction → RecordAzureMonitorAlertAsync). One event type for ALL
// Azure Monitor alert rules — the rule name travels in the message/details. Severity is
// mapped Sev0→Critical, Sev1→Error, Sev2→Warning, Sev3/4→Info; both Fired and Resolved
// notifications carry the same mapped severity, so a rule here delivers recovery pings too.
// Dual-register per memory feedback_ops_event_types_dual_register.
"AzureMonitorAlert",
],
};
const SEVERITIES = ["Info", "Warning", "Error", "Critical"];
@@ -120,6 +129,7 @@ const CATEGORY_COLORS: Record<string, string> = {
Security: "text-orange-700 dark:text-orange-300",
Tenant: "text-teal-700 dark:text-teal-300",
Agent: "text-cyan-700 dark:text-cyan-300",
Platform: "text-sky-700 dark:text-sky-300",
};
interface OpsAlertRulesSectionProps {
@@ -42,9 +42,10 @@ const CATEGORY_STYLES: Record<string, string> = {
Security: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
Tenant: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300",
Agent: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300",
Platform: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
};
const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent"];
const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent", "Platform"];
const PAGE_SIZE = 20;
@@ -30,6 +30,7 @@ const ALL_COLUMNS: ColumnDef[] = [
{ key: "duration", label: "Duration", defaultVisible: true, sortKey: "durationSeconds" },
{ key: "started", label: "Started", defaultVisible: true, sortKey: "startedAt" },
{ key: "country", label: "Country", defaultVisible: false, sortKey: "geoCountry", filterKey: "geoCountry" },
{ key: "apiLatency", label: "API Latency", defaultVisible: false, sortKey: "avgApiLatencyMs" },
{ key: "agentVersion", label: "Agent Version", defaultVisible: false, sortKey: "agentVersion", filterKey: "agentVersion" },
{ key: "osName", label: "OS Name", defaultVisible: false, sortKey: "osName", filterKey: "osName" },
{ key: "osBuild", label: "OS Build", defaultVisible: false, sortKey: "osBuild", filterKey: "osBuild" },
@@ -640,7 +641,7 @@ export function SessionTable({
currentSort={sortColumn}
direction={sortDirection}
onSort={onSort}
className={["eventCount", "duration", "started", "country", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined}
className={["eventCount", "duration", "started", "country", "apiLatency", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined}
filterKey={col.filterKey}
filterValues={col.filterKey ? uniqueValuesByField[col.filterKey] : undefined}
activeFilter={col.filterKey ? columnFilters[col.filterKey] : undefined}
@@ -884,12 +885,16 @@ function SessionCell({
</td>
);
case "started":
case "started": {
// Two-line date/time keeps the column narrow so more columns fit on one page.
const started = new Date(session.startedAt);
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(session.startedAt).toLocaleString()}
<div>{started.toLocaleDateString()}</div>
<div className="text-xs text-gray-400">{started.toLocaleTimeString()}</div>
</td>
);
}
case "country":
return (
@@ -904,6 +909,19 @@ function SessionCell({
</td>
);
case "apiLatency":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
{(session.avgApiLatencyMs ?? 0) > 0 ? (
<span title={`Agent→backend HTTP round-trip${session.apiRequestCount ? ` across ${session.apiRequestCount} requests` : ""}`}>
{Math.round(session.avgApiLatencyMs!)} ms
</span>
) : (
<span className="text-gray-300">—</span>
)}
</td>
);
case "agentVersion": {
const fullVersion = session.agentVersion || "";
const shortVersion = fullVersion.split("+")[0];
@@ -1,6 +1,8 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery";
/**
* Route-level error boundary — catches unhandled exceptions within page
@@ -17,6 +19,15 @@ export default function Error({
}) {
const router = useRouter();
// Stale-bundle chunk failure after a deploy: reload once instead of showing the
// error card (utils/chunkReloadRecovery.ts — the guard prevents reload loops; a
// repeat inside the guard window falls through to the card below).
useEffect(() => {
if (isChunkLoadError(error)) {
tryRecoverFromChunkError("error-boundary");
}
}, [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="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
@@ -31,7 +31,8 @@ export interface DeviceJourneyDailyDto {
export interface DeviceJourneyTotalsDto {
completedJourneys: number;
firstTimeRight: number;
ftrRatePct: number | null;
/** Absent (WhenWritingNull) when no completed journeys — treat null and undefined alike. */
ftrRatePct?: number | null;
excludedSessions: number;
attemptHistogram: DeviceJourneyAttemptBucketDto[];
}
@@ -53,7 +54,12 @@ export interface DeviceJourneyResponseDto {
windowDays: number;
totals: DeviceJourneyTotalsDto;
daily: DeviceJourneyDailyDto[];
repeatDevices: RepeatDeviceDto[] | null;
/**
* Absent (not null) in the aggregated all-tenants view — the worker serializes with
* WhenWritingNull, so the field is omitted entirely. Absent/null = no per-device
* drill-down available; [] = tenant scoped but no repeat devices.
*/
repeatDevices?: RepeatDeviceDto[] | null;
}
/** UI gate for the FTR rate (spec: ≥20 completed journeys). */
@@ -197,7 +203,7 @@ export default function DeviceJourneySection({
title="Devices whose current journey took 2 or more terminal attempts, newest activity first. The failure reason is from the most recent failed attempt.">
Repeat devices
</div>
{repeatDevices === null ? (
{repeatDevices == null ? (
<div className="text-sm text-gray-400 py-4">
Select a tenant to see per-device detail — the aggregated view has no device drill-down.
</div>
@@ -104,16 +104,19 @@ export default function GatherRulesPage() {
// Fetch unrestrictedMode from tenant config (for validation indicators).
// The display flag lives in the member-readable feature-flags endpoint so that
// Operators/Viewers can load this page without 403'ing on the admin-only full config.
// GA-override path keeps using globalConfig.tenant (GA-only endpoint).
// The SAME route serves the GA-override path: MemberRead has a global-scope bypass and
// the route is RouteParam-scoped, so a GA reads the SELECTED tenant's flag. (The former
// override URL /api/global/config/{tenantId} does not exist — the swallowed 404 left the
// badge showing the GA's HOME-tenant mode for every foreign tenant.)
useEffect(() => {
if (!effectiveTenantId) return;
// Reset first so a failed/forbidden fetch never carries the previous tenant's mode over.
setUnrestrictedMode(false);
let stale = false;
const fetchConfig = async () => {
try {
const url = isGlobalOverride
? api.globalConfig.tenant(effectiveTenantId)
: api.config.featureFlags(effectiveTenantId);
const response = await authenticatedFetch(url, getAccessToken);
if (response.ok) {
const response = await authenticatedFetch(api.config.featureFlags(effectiveTenantId), getAccessToken);
if (!stale && response.ok) {
const data = await response.json();
setUnrestrictedMode(data.unrestrictedMode ?? false);
}
@@ -122,7 +125,8 @@ export default function GatherRulesPage() {
}
};
fetchConfig();
}, [effectiveTenantId, isGlobalOverride, getAccessToken]);
return () => { stale = true; };
}, [effectiveTenantId, getAccessToken]);
const handleToggleRule = async (rule: GatherRule) => {
setTogglingRule(rule.ruleId);
@@ -42,6 +42,9 @@ interface LocationMetric {
totalDownloadBytes: number;
durationVsGlobalPct: number;
throughputVsGlobalPct: number;
avgApiLatencyMs: number;
apiLatencySessionCount: number;
apiLatencyVsGlobalPct: number;
isOutlier: boolean;
outlierDirection: string | null;
// Delivery Optimization
@@ -60,6 +63,7 @@ interface GlobalAverages {
avgMinutesPerApp: number;
avgThroughputBytesPerSec: number;
stdDevDurationMinutes: number;
avgApiLatencyMs: number;
avgDoPercentPeerCaching: number;
totalDoBytesFromPeers: number;
totalDoBytesFromHttp: number;
@@ -76,7 +80,7 @@ interface GeographicMetricsResponse {
}
type GroupBy = "city" | "region" | "country";
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgDoPercentPeerCaching";
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgApiLatencyMs" | "avgDoPercentPeerCaching";
type TimeRange = "7d" | "30d" | "90d";
const durationColor = (value: number, globalAvg: number) => {
@@ -96,6 +100,16 @@ const scoreColor = (score: number) => {
return "text-red-600";
};
// Absolute buckets, not relative-to-global: latency encodes physical distance to the backend
// region, and the decision it supports ("open a closer region?") needs absolute thresholds.
const latencyColor = (ms: number) => {
if (ms <= 0) return "text-gray-400";
if (ms < 250) return "bg-green-100 text-green-800";
if (ms < 500) return "bg-yellow-100 text-yellow-800";
if (ms < 800) return "bg-orange-100 text-orange-700";
return "bg-red-100 text-red-800";
};
const formatThroughput = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "—";
if (bytesPerSec >= 1024 * 1024) return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`;
@@ -376,7 +390,7 @@ export default function GeographicPerformancePage() {
{geoMetrics && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="text-sm font-medium text-blue-800 mb-2">Global Averages (Benchmark)</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
<div>
<span className="text-blue-600 font-medium">Avg Duration:</span>{" "}
<span className="text-blue-900">{geoMetrics.globalAverages.avgDurationMinutes} min</span>
@@ -401,6 +415,14 @@ export default function GeographicPerformancePage() {
: "No data"}
</span>
</div>
<div title="Agent→backend HTTP round-trip, request-weighted across sessions with latency data">
<span className="text-blue-600 font-medium">API Latency:</span>{" "}
<span className="text-blue-900">
{geoMetrics.globalAverages.avgApiLatencyMs > 0
? `${Math.round(geoMetrics.globalAverages.avgApiLatencyMs)} ms`
: "No data"}
</span>
</div>
</div>
</div>
)}
@@ -453,6 +475,13 @@ export default function GeographicPerformancePage() {
>
Throughput <SortIcon col="avgThroughputBytesPerSec" />
</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" />
</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")}
@@ -553,6 +582,18 @@ export default function GeographicPerformancePage() {
<td className="px-4 py-3 text-sm text-gray-700">
{formatThroughput(loc.avgThroughputBytesPerSec)}
</td>
<td className="px-4 py-3 text-sm">
{loc.avgApiLatencyMs > 0 ? (
<div title={`${loc.apiLatencySessionCount} session(s) with latency data${loc.apiLatencyVsGlobalPct !== 0 ? ` · ${loc.apiLatencyVsGlobalPct > 0 ? "+" : ""}${loc.apiLatencyVsGlobalPct.toFixed(0)}% vs global` : ""}`}>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${latencyColor(loc.avgApiLatencyMs)}`}>
{Math.round(loc.avgApiLatencyMs)} ms
</span>
<span className="text-xs text-gray-400 ml-1">({loc.apiLatencySessionCount})</span>
</div>
) : (
<span className="text-gray-300" title="No sessions with latency data yet (requires agent ≥ 2.0 with latency reporting)">—</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{loc.doSessionCount > 0 ? (
<div title={
@@ -32,6 +32,10 @@ interface SessionSummary {
failureReason: string | null;
durationSeconds: number | null;
enrollmentType: string;
/** Session-wide average agent→backend HTTP round-trip (ms); absent for pre-feature agents. */
avgApiLatencyMs?: number;
/** Number of HTTP requests behind avgApiLatencyMs. */
apiRequestCount?: number;
// Per-session Delivery Optimization aggregate (added by geographic drilldown endpoint)
hasDoTelemetry?: boolean;
doAppCount?: number;
@@ -170,6 +174,14 @@ function LocationSessionsContent() {
.map((s) => s.durationSeconds!);
return durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length / 60) : 0;
})(),
// Request-weighted API latency across sessions with latency data (matches region aggregate formula)
latencySessions: data.sessions.filter((s) => (s.avgApiLatencyMs ?? 0) > 0 && (s.apiRequestCount ?? 0) > 0).length,
avgApiLatency: (() => {
const withLatency = data.sessions.filter((s) => (s.avgApiLatencyMs ?? 0) > 0 && (s.apiRequestCount ?? 0) > 0);
const totalRequests = withLatency.reduce((a, s) => a + s.apiRequestCount!, 0);
const weightedSum = withLatency.reduce((a, s) => a + s.avgApiLatencyMs! * s.apiRequestCount!, 0);
return totalRequests > 0 ? Math.round(weightedSum / totalRequests) : 0;
})(),
// Weighted peer-caching % across sessions with DO telemetry (matches region aggregate formula)
doSessions: data.sessions.filter((s) => s.hasDoTelemetry).length,
avgDoPercent: (() => {
@@ -235,11 +247,20 @@ function LocationSessionsContent() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Sessions</div>
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="bg-white rounded-lg shadow p-4" title="Agent→backend HTTP round-trip, request-weighted across sessions with latency data">
<div className="text-sm font-medium text-gray-500">API Latency</div>
<div className={`text-2xl font-bold ${stats.avgApiLatency > 0 ? "text-gray-900" : "text-gray-400"}`}>
{stats.avgApiLatency > 0 ? `${stats.avgApiLatency} ms` : "—"}
</div>
<div className="text-xs text-gray-400">
{stats.latencySessions > 0 ? `${stats.latencySessions} session(s) with data` : "requires latency-reporting agent"}
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Succeeded</div>
<div className="text-2xl font-bold text-green-600">{stats.succeeded}</div>
@@ -311,6 +332,9 @@ function LocationSessionsContent() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" title="Agent→backend HTTP round-trip measured on the device">
API Latency
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
DO Peers
</th>
@@ -341,6 +365,15 @@ function LocationSessionsContent() {
<td className="px-4 py-3 text-sm text-gray-600">
{formatDuration(session.durationSeconds)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{(session.avgApiLatencyMs ?? 0) > 0 ? (
<span title={`across ${session.apiRequestCount ?? "?"} requests`}>
{Math.round(session.avgApiLatencyMs!)} ms
</span>
) : (
<span className="text-gray-300" title="No latency data (requires latency-reporting agent)">—</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{session.hasDoTelemetry ? (
<div
@@ -14,6 +14,7 @@ import FeedbackBubble from "../components/FeedbackBubble";
import { SidebarProvider } from "../contexts/SidebarContext";
import { GlobalSidebar } from "../components/GlobalSidebar";
import AppInsightsInit from "../components/AppInsightsInit";
import ChunkReloadRecovery from "../components/ChunkReloadRecovery";
import { SITE_URL } from "@/utils/config";
const inter = Inter({ subsets: ["latin"] });
@@ -126,6 +127,7 @@ export default function RootLayout({
<TenantProvider>
<SidebarProvider>
<AppInsightsInit />
<ChunkReloadRecovery />
<Navbar />
<GlobalSidebar>
{children}
@@ -1,11 +1,11 @@
"use client";
import { useState, useMemo } from "react";
import { EnrollmentEvent } from "@/types";
import { EnrollmentEvent, Session } from "@/types";
import OobeConfigModal from "./OobeConfigModal";
import { compareVersions, stripGitHashSuffix } from "@/utils/bootstrapVersion";
export default function DeviceDetailsCard({ events, latestAgentVersion }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null }) {
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);
@@ -161,8 +161,15 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
const deviceLocation = getEventData("device_location");
const hwSpec = getEventData("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.
const bandwidthEstimate = getEventData("network_bandwidth_estimate");
const metricsSnapshot = getEventData("agent_metrics_snapshot");
const apiLatencyMs = session?.avgApiLatencyMs ?? 0;
const hasConnectivity = apiLatencyMs > 0 || bandwidthEstimate || metricsSnapshot;
const hasData = agentStarted || bootTime || osInfo || networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo ||
autopilotProfile || aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus || deviceLocation || hwSpec;
autopilotProfile || aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus || deviceLocation || hwSpec || hasConnectivity;
if (!hasData) return null;
@@ -293,6 +300,32 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
</DetailSection>
)}
{/* Connectivity: agent→backend measurements collected during the enrollment */}
{hasConnectivity && (
<DetailSection title="Connectivity">
{apiLatencyMs > 0 && (
<DetailRow
label="API Latency (avg)"
value={`${Math.round(apiLatencyMs)} ms${session?.apiRequestCount ? ` (${session.apiRequestCount} requests)` : ""}`}
/>
)}
{bandwidthEstimate?.estimatedWanMbps !== undefined && (
<DetailRow
label="Est. Internet Bandwidth"
value={`~${bandwidthEstimate.estimatedWanMbps} Mbit/s${bandwidthEstimate.confidence ? ` (${bandwidthEstimate.confidence} confidence)` : ""}`}
/>
)}
{bandwidthEstimate?.doThrottleConfigured === true && (
<DetailRow label="DO Throttle Policy" value="Configured (measured rate reflects the throttle)" />
)}
{metricsSnapshot?.net_total_bytes_up !== undefined && (
<DetailRow label="Telemetry Uploaded" value={formatBytesCompact(Number(metricsSnapshot.net_total_bytes_up))} />
)}
{/* Outbound IP is deliberately NOT rendered here — the outbound_ip trace event
stays available for analysis (timeline, MCP). */}
</DetailSection>
)}
{/* Security */}
{(bitLockerStatus || secureBootStatus || tpmStatus) && (
<DetailSection title="Security">
@@ -543,6 +576,13 @@ function DetailSection({ title, children }: { title: string; children: React.Rea
);
}
function formatBytesCompact(bytes: number): string {
if (!bytes || bytes <= 0 || isNaN(bytes)) return "—";
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-xs py-0.5">
@@ -573,7 +573,7 @@ export default function SessionDetailPage() {
)}
{/* Device Details Card (from enrollment tracker events) */}
{!isGatherRulesSession && <div id="section-device-details"><DeviceDetailsCard events={events} latestAgentVersion={latestAgentVersion} /></div>}
{!isGatherRulesSession && <div id="section-device-details"><DeviceDetailsCard events={events} latestAgentVersion={latestAgentVersion} session={session} /></div>}
{/* Phase Timeline */}
{!isGatherRulesSession && session && (
+18 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useTenant } from '../../contexts/TenantContext';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
@@ -121,8 +121,15 @@ export default function SlaPage() {
const scope = useGlobalAdminScope();
const { isGlobalOverride, effectiveTenantId, selectedTenantId, tenants } = scope;
// Latest-wins guard: a tenant switch starts a new fetch while an older one may still be in
// flight; only the most recently started request may write state (raw authenticatedFetch has
// no such guard, unlike useAuthenticatedFetch).
const fetchSeqRef = useRef(0);
const fetchMetrics = useCallback(async (showRefreshing = false) => {
if (!effectiveTenantId) return;
const seq = ++fetchSeqRef.current;
const isCurrent = () => fetchSeqRef.current === seq;
try {
if (showRefreshing) setRefreshing(true);
else setLoading(true);
@@ -135,12 +142,16 @@ export default function SlaPage() {
: api.metrics.sla(effectiveTenantId, months, useFresh);
const response = await authenticatedFetch(url, getAccessToken);
if (!isCurrent()) return;
if (!response.ok) {
addNotification('error', 'Error', `Failed to load SLA metrics: ${response.statusText}`, 'sla-fetch-error');
return;
}
setMetrics(await response.json());
const data = await response.json();
if (!isCurrent()) return;
setMetrics(data);
} catch (err) {
if (!isCurrent()) return;
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
} else {
@@ -152,8 +163,11 @@ export default function SlaPage() {
error: err instanceof Error ? err.message : 'unknown',
});
} finally {
setLoading(false);
setRefreshing(false);
// A superseded request must not clear the newer request's loading/refreshing state.
if (isCurrent()) {
setLoading(false);
setRefreshing(false);
}
}
}, [effectiveTenantId, isGlobalOverride, months, getAccessToken, addNotification]);
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
@@ -99,8 +99,15 @@ export default function UsageMetricsPage() {
const scope = useGlobalAdminScope();
const { isGlobalOverride, effectiveTenantId, selectedTenantId, tenants } = scope;
// Latest-wins guard: a tenant switch starts a new fetch while an older one may still be in
// flight; only the most recently started request may write state (raw authenticatedFetch has
// no such guard, unlike useAuthenticatedFetch).
const fetchSeqRef = useRef(0);
const fetchMetrics = useCallback(async (showRefreshing = false) => {
if (!effectiveTenantId) return;
const seq = ++fetchSeqRef.current;
const isCurrent = () => fetchSeqRef.current === seq;
try {
if (showRefreshing) {
setRefreshing(true);
@@ -114,6 +121,7 @@ export default function UsageMetricsPage() {
: api.metrics.usage(effectiveTenantId);
const response = await authenticatedFetch(url, getAccessToken);
if (!isCurrent()) return;
if (!response.ok) {
addNotification('error', 'Backend Error', `Failed to load usage metrics: ${response.statusText}`, 'usage-metrics-fetch-error');
@@ -121,8 +129,10 @@ export default function UsageMetricsPage() {
}
const data = await response.json();
if (!isCurrent()) return;
setMetrics(data);
} catch (err) {
if (!isCurrent()) return;
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
} else {
@@ -130,8 +140,11 @@ export default function UsageMetricsPage() {
addNotification('error', 'Backend Not Reachable', 'Unable to load usage metrics. Please check your connection.', 'usage-metrics-fetch-error');
}
} finally {
setLoading(false);
setRefreshing(false);
// A superseded request must not clear the newer request's loading/refreshing state.
if (isCurrent()) {
setLoading(false);
setRefreshing(false);
}
}
}, [effectiveTenantId, isGlobalOverride, getAccessToken, addNotification]);
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery";
/**
* Global listener pair for stale-bundle chunk failures (see utils/chunkReloadRecovery.ts).
* Two paths surface them outside any React error boundary:
* - a rejected dynamic import that nothing awaits → unhandledrejection
* - the chunk <script>/<link> tag itself failing to load → a capture-phase error
* event on window whose target is the tag (these don't bubble and carry no Error)
* Renders nothing; mounted once in the root layout.
*/
export default function ChunkReloadRecovery() {
useEffect(() => {
const onRejection = (e: PromiseRejectionEvent) => {
if (isChunkLoadError(e.reason) && tryRecoverFromChunkError("unhandledrejection")) {
e.preventDefault();
}
};
const onError = (e: ErrorEvent | Event) => {
// Resource-load failure: target is the failed tag, no error object attached.
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "SCRIPT" || target.tagName === "LINK")) {
const url =
(target as HTMLScriptElement).src || (target as HTMLLinkElement).href || "";
// Only our own immutable build assets — a blocked third-party script must not reload the app.
if (url.includes("/_next/") && tryRecoverFromChunkError("resource-error")) return;
}
const error = (e as ErrorEvent).error ?? (e as ErrorEvent).message;
if (isChunkLoadError(error)) {
tryRecoverFromChunkError("window-error");
}
};
window.addEventListener("unhandledrejection", onRejection);
window.addEventListener("error", onError, true);
return () => {
window.removeEventListener("unhandledrejection", onRejection);
window.removeEventListener("error", onError, true);
};
}, []);
return null;
}
@@ -312,6 +312,15 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200";
};
// Every nav Link below sets prefetch={false} on purpose. The sidebar renders the whole
// navigation on every page, so the App Router's default viewport prefetch fires one RSC
// request per visible link against the SWA-hosted Next.js runtime on each page load
// (measured: ~10k requests / 14 days). That host speaks HTTP/1.1, so those prefetches
// occupy the browser's 6 connections to the SAME origin that serves the document and the
// route chunks — when the runtime queues them (p95 ~0.9s but a tail reaching 150s+), the
// portal freezes until the queue drains. Every route here is a client component behind
// ProtectedRoute that fetches its own data after mount, so the prefetched RSC payload
// buys nothing. See docs/web/portal-navigation-prefetch.md.
// --- Render a global nav link ---
const renderGlobalItem = (item: NavItem, isGlobal = false) => {
const active = isNavActive(item.href);
@@ -322,6 +331,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
<li key={item.id}>
<Link
href={item.href}
prefetch={false}
onClick={() => setMobileDrawerOpen(false)}
className={`${base} px-2 py-2 justify-center relative group`}
title={item.label}
@@ -339,7 +349,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
return (
<li key={item.id}>
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
<Link href={item.href} prefetch={false} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
{renderIcon(item.icon, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</Link>
@@ -362,7 +372,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
if (pageSectionsMode === "route" && item.href) {
return (
<li key={item.id}>
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-2 py-2 justify-center relative group`} title={item.label}>
<Link href={item.href} prefetch={false} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-2 py-2 justify-center relative group`} title={item.label}>
{inner}
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">{item.label}</span>
</Link>
@@ -384,7 +394,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
if (pageSectionsMode === "route" && item.href) {
return (
<li key={item.id}>
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
<Link href={item.href} prefetch={false} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
{renderIcon(item.icon, "w-4 h-4")}
<span className="truncate">{item.label}</span>
</Link>
@@ -502,6 +512,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
<li key={expandItem.id}>
<Link
href={firstHref}
prefetch={false}
onClick={() => setMobileDrawerOpen(false)}
className={`flex items-center justify-center px-2 py-2 rounded-md text-sm transition-colors relative group ${
itemHasActive
@@ -567,6 +578,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
<li key={sub.id}>
<Link
href={sub.href}
prefetch={false}
onClick={() => setMobileDrawerOpen(false)}
className={`block pl-10 pr-3 py-1 rounded-md text-[13px] transition-colors ${
subActive
@@ -644,6 +656,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
<li key={group.name}>
<Link
href={firstHref ?? "#"}
prefetch={false}
onClick={() => setMobileDrawerOpen(false)}
className={`flex items-center justify-center px-2 py-2 rounded-md text-sm transition-colors relative group ${
groupHasActive
@@ -701,6 +714,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
<li key={item.id}>
<Link
href={item.href}
prefetch={false}
onClick={() => setMobileDrawerOpen(false)}
className={`block pl-10 pr-3 py-1 rounded-md text-[13px] transition-colors ${
active
@@ -159,7 +159,7 @@ export default function Navbar() {
<div className="px-3">
<div className="flex justify-between h-14">
<div className="flex items-center">
<Link href="/progress" className="flex items-center space-x-2.5">
<Link href="/progress" prefetch={false} className="flex items-center space-x-2.5">
<svg className="w-7 h-7" viewBox="0 0 209 191" fill="none">
<path d="M0 180.201L208.401 190.502L188.157 76.2438L5.48363e-06 0L0 180.201Z" fill="#33B161" />
</svg>
@@ -215,7 +215,7 @@ export default function Navbar() {
<div className="flex justify-between h-14">
{/* Logo and Title */}
<div className="flex items-center">
<Link href="/" className="flex items-center space-x-2.5">
<Link href="/" prefetch={false} className="flex items-center space-x-2.5">
<svg className="w-7 h-7" viewBox="0 0 209 191" fill="none">
<path d="M0 180.201L208.401 190.502L188.157 76.2438L5.48363e-06 0L0 180.201Z" fill="#33B161" />
</svg>
@@ -326,6 +326,7 @@ export default function Navbar() {
{tn.href && (
<Link
href={tn.href}
prefetch={false}
onClick={(e) => { e.stopPropagation(); }}
className="text-[10px] text-blue-600 hover:text-blue-800 font-medium underline"
>
@@ -384,6 +385,7 @@ export default function Navbar() {
{notification.href && (
<Link
href={notification.href}
prefetch={false}
onClick={(e) => { e.stopPropagation(); markAsRead(notification.id); }}
className="text-[10px] text-blue-600 hover:text-blue-800 font-medium underline"
>
@@ -518,6 +520,7 @@ export default function Navbar() {
<Link
href="/privacy"
prefetch={false}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => setShowHelp(false)}
>
@@ -529,6 +532,7 @@ export default function Navbar() {
<Link
href="/terms"
prefetch={false}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => setShowHelp(false)}
>
@@ -706,11 +710,11 @@ export default function Navbar() {
<span>Service Announcements</span>
</a>
<div className="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<Link href="/privacy" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<Link href="/privacy" prefetch={false} className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
<span>Privacy Policy</span>
</Link>
<Link href="/terms" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<Link href="/terms" prefetch={false} className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" 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>
<span>Terms of Use</span>
</Link>
@@ -50,6 +50,17 @@ export function useAuthenticatedFetch<T = unknown>(
const optionsRef = useRef(options);
optionsRef.current = options;
// Latest-wins guard: when execute() is called again while a previous request is still in
// flight (e.g. the tenant-scope selector seeds a persisted foreign tenant right after mount,
// firing an own-tenant fetch immediately followed by the override fetch), only the most
// recently started request may write data/error/loading. Without this the LAST-RESOLVED
// response wins and a stale tenant's data can overwrite the current selection's result.
// The stale call still returns its parsed result to its direct awaiter.
const requestSeqRef = useRef(0);
// The loading spinner is owned by the last-started NON-silent request (a later silent
// background refresh must not leave a superseded visible request's spinner stuck on).
const loadingSeqRef = useRef(0);
const clearError = useCallback(() => setError(null), []);
const execute = useCallback(
@@ -58,8 +69,11 @@ export function useAuthenticatedFetch<T = unknown>(
init?: RequestInit,
executeOptions?: ExecuteOptions<T>,
): Promise<T | null> => {
const seq = ++requestSeqRef.current;
const isCurrent = () => requestSeqRef.current === seq;
try {
if (!executeOptions?.silent) {
loadingSeqRef.current = seq;
setLoading(true);
}
setError(null);
@@ -86,9 +100,16 @@ export function useAuthenticatedFetch<T = unknown>(
const result = executeOptions?.transform
? executeOptions.transform(json)
: (json as T);
setData(result);
if (isCurrent()) {
setData(result);
}
return result;
} catch (err) {
// A superseded request's failure is irrelevant to the current view — the newer
// request reports its own outcome. Swallow it entirely (no state, no callbacks).
if (!isCurrent()) {
return null;
}
const opts = optionsRef.current;
if (err instanceof TokenExpiredError) {
if (opts?.onTokenExpired) {
@@ -112,7 +133,9 @@ export function useAuthenticatedFetch<T = unknown>(
setError(message);
return null;
} finally {
if (!executeOptions?.silent) {
// Only the spinner's owner may clear it — a superseded request finishing early
// must not hide the loading state of the one still in flight.
if (!executeOptions?.silent && loadingSeqRef.current === seq) {
setLoading(false);
}
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTenant } from "@/contexts/TenantContext";
import { useAuth } from "@/contexts/AuthContext";
import { useAdminMode } from "@/hooks/useAdminMode";
@@ -86,11 +86,13 @@ export function useGlobalAdminScope(): GlobalAdminScope {
// GA/Reader: seed the selection from the tab-persisted choice, else the user's own tenant (never empty
// in this variant). A persisted aggregated "" resolves locally to the own tenant without clearing storage.
useEffect(() => {
if (isDelegatedScope || !tenantId || selectedTenantId) return;
// Render-time (like the delegated seed below), NOT an effect: an effect-time seed exposes one commit
// where effectiveTenantId is the caller's own tenant, so pages fire an own-tenant fetch that races the
// override fetch — last-resolved wins and the wrong tenant's data can stick until a manual refresh.
if (!isDelegatedScope && tenantId && !selectedTenantId) {
const stored = readTenantScope();
setSelectedRaw(stored ? stored : tenantId);
}, [isDelegatedScope, tenantId, selectedTenantId]);
}
// Delegated: seed from the persisted managed tenant (if still managed) or the first managed tenant once
// the scoped list arrives; re-default if the selection falls outside the managed set. Render-time (converges).
+3 -1
View File
@@ -151,7 +151,9 @@ export const api = {
// ── Global Config (global admin) ──────────────────────────────────────────
globalConfig: {
get: () => `${API_BASE_URL}/api/global/config`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/global/config/${tenantId}`,
// NOTE: there is deliberately no per-tenant variant — /api/global/config/{tenantId} does
// not exist in the backend. A tenant's member-readable flags (incl. unrestrictedMode)
// come from config.featureFlags, which a GA may call cross-tenant (MemberRead bypass).
},
// ── Tenants ───────────────────────────────────────────────────────────────
+64 -52
View File
@@ -17,21 +17,21 @@
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"next": "^15.5.21",
"next": "^15.5.22",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.11",
"@next/bundle-analyzer": "^16.2.12",
"@types/dagre": "^0.7.54",
"@types/node": "^22.20.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^15.5.21",
"eslint-config-next": "^15.5.22",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
@@ -1074,9 +1074,9 @@
"license": "MIT"
},
"node_modules/@next/bundle-analyzer": {
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.11.tgz",
"integrity": "sha512-C5228wy1RC0g7uOSvmQhrZXCuEeLIPureKkqramAkySmqY2Y0yw6K+TyAxXXvtrYe4uehnZs3YMFfJcorBRwpg==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.12.tgz",
"integrity": "sha512-0dYhmCYsTMFYUFaoEUx+VKw/oYP6b3XiGgq47EujXTE2UGgwVWAaur2tbko2pxfUka3WRZ9YWxa3PlSv3luWNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1084,15 +1084,15 @@
}
},
"node_modules/@next/env": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
"integrity": "sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.22.tgz",
"integrity": "sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.21.tgz",
"integrity": "sha512-5MoR9vO3dE1lU3LixogB54xrn7OhMGempTqk1q3WlvbKrT6cNt5mV2mMkX5sghAk0vZF6Nz1HxoTb9YmZv8wRg==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.22.tgz",
"integrity": "sha512-SB8PGBmpAiVmOtMOIfObnI2VA3MN3jUGg0iYTh4MO2efxQIV/IL0U8iYOok/8DldCEKARyCOJJix+Vvgzaic/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1100,9 +1100,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.21.tgz",
"integrity": "sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.22.tgz",
"integrity": "sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==",
"cpu": [
"arm64"
],
@@ -1116,9 +1116,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.21.tgz",
"integrity": "sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.22.tgz",
"integrity": "sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==",
"cpu": [
"x64"
],
@@ -1132,12 +1132,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.21.tgz",
"integrity": "sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.22.tgz",
"integrity": "sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1148,12 +1151,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.21.tgz",
"integrity": "sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.22.tgz",
"integrity": "sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1164,12 +1170,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.21.tgz",
"integrity": "sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.22.tgz",
"integrity": "sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1180,12 +1189,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.21.tgz",
"integrity": "sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.22.tgz",
"integrity": "sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1196,9 +1208,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.21.tgz",
"integrity": "sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.22.tgz",
"integrity": "sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==",
"cpu": [
"arm64"
],
@@ -3909,13 +3921,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.21.tgz",
"integrity": "sha512-W8m8iFZis5SLqrHVXFv3IrRUpUu95VCHcXRMrMWtxpjV7JHfRB847qL2HJS5wIdLER+k17LWGnihBDRkFBRK8A==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.22.tgz",
"integrity": "sha512-HhZsB3dvBsYwQpnymZm/Ps9NVaSYiSzDSXkR02CjyOcQutCuea3cF7NxE8i8Drs2+Dgw95WUzK2CMBIHd5YbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "15.5.21",
"@next/eslint-plugin-next": "15.5.22",
"@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@@ -5932,12 +5944,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.21.tgz",
"integrity": "sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.22.tgz",
"integrity": "sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.21",
"@next/env": "15.5.22",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5950,14 +5962,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.21",
"@next/swc-darwin-x64": "15.5.21",
"@next/swc-linux-arm64-gnu": "15.5.21",
"@next/swc-linux-arm64-musl": "15.5.21",
"@next/swc-linux-x64-gnu": "15.5.21",
"@next/swc-linux-x64-musl": "15.5.21",
"@next/swc-win32-arm64-msvc": "15.5.21",
"@next/swc-win32-x64-msvc": "15.5.21",
"@next/swc-darwin-arm64": "15.5.22",
"@next/swc-darwin-x64": "15.5.22",
"@next/swc-linux-arm64-gnu": "15.5.22",
"@next/swc-linux-arm64-musl": "15.5.22",
"@next/swc-linux-x64-gnu": "15.5.22",
"@next/swc-linux-x64-musl": "15.5.22",
"@next/swc-win32-arm64-msvc": "15.5.22",
"@next/swc-win32-x64-msvc": "15.5.22",
"sharp": "^0.34.3"
},
"peerDependencies": {
@@ -5984,9 +5996,9 @@
}
},
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.21.tgz",
"integrity": "sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==",
"version": "15.5.22",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.22.tgz",
"integrity": "sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==",
"cpu": [
"x64"
],
+3 -3
View File
@@ -24,21 +24,21 @@
"dagre": "^0.8.5",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"next": "^15.5.21",
"next": "^15.5.22",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.11",
"@next/bundle-analyzer": "^16.2.12",
"@types/dagre": "^0.7.54",
"@types/node": "^22.20.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^15.5.21",
"eslint-config-next": "^15.5.22",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
@@ -43,6 +43,10 @@ export interface Session {
geoCountry?: string;
geoRegion?: string;
geoCity?: string;
/** Session-wide average agent→backend HTTP round-trip (ms), measured on the device. Absent for sessions from agents that predate the field. */
avgApiLatencyMs?: number;
/** Number of HTTP requests behind avgApiLatencyMs — the weight when averaging across sessions. */
apiRequestCount?: number;
/**
* Compact JSON snapshot of "last known session state" written by the maintenance
* 5h-timeout sweep when a session graduates to terminal Failed (Hybrid User-Driven
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import {
isChunkLoadError,
shouldAutoReload,
RELOAD_WINDOW_MS,
} from "../chunkReloadRecovery";
function namedError(name: string, message: string): Error {
const err = new Error(message);
err.name = name;
return err;
}
describe("isChunkLoadError", () => {
it("matches webpack ChunkLoadError by name", () => {
expect(isChunkLoadError(namedError("ChunkLoadError", "Loading chunk 4121 failed."))).toBe(true);
});
it("matches the message even when the name is generic", () => {
expect(isChunkLoadError(new Error("Loading chunk app-line-chart failed. (error: https://x/_next/static/chunks/4121.js)"))).toBe(true);
expect(isChunkLoadError(new Error("Loading CSS chunk 22 failed."))).toBe(true);
});
it("matches native dynamic-import failures across browsers", () => {
// Chromium
expect(isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: https://x/_next/y.js"))).toBe(true);
// Firefox
expect(isChunkLoadError(new TypeError("error loading dynamically imported module"))).toBe(true);
// Safari
expect(isChunkLoadError(new TypeError("Importing a module script failed."))).toBe(true);
});
it("accepts plain-string reasons (ErrorEvent.message fallback)", () => {
expect(isChunkLoadError("Uncaught ChunkLoadError: Loading chunk 4121 failed.")).toBe(true);
});
it("rejects unrelated errors and empty values", () => {
expect(isChunkLoadError(new Error("Failed: Internal Server Error"))).toBe(false);
expect(isChunkLoadError(new TypeError("Failed to fetch"))).toBe(false); // plain network error
expect(isChunkLoadError(null)).toBe(false);
expect(isChunkLoadError(undefined)).toBe(false);
expect(isChunkLoadError({ message: "Loading chunk 1 failed" })).toBe(false); // not an Error/string
});
});
describe("shouldAutoReload", () => {
const now = 1_700_000_000_000;
it("allows the first reload (no prior timestamp)", () => {
expect(shouldAutoReload(null, now)).toBe(true);
});
it("blocks a second reload inside the guard window", () => {
expect(shouldAutoReload(now - 1, now)).toBe(false);
expect(shouldAutoReload(now - RELOAD_WINDOW_MS + 1, now)).toBe(false);
});
it("allows again once the window has passed", () => {
expect(shouldAutoReload(now - RELOAD_WINDOW_MS, now)).toBe(true);
});
});
@@ -0,0 +1,77 @@
"use client";
import { trackEvent } from "@/lib/appInsights";
/**
* Recovery for the post-deploy stale-bundle failure mode: a tab still running the
* previous deployment's client bundle asks for a lazily-loaded chunk (next/dynamic
* charts, route chunks) whose content-hashed filename no longer exists after the
* SWA swap. The import rejects (or the script tag 404s) and the user is stuck on
* a "Loading chart…" placeholder until they reload by hand.
*
* The standard remedy is a single automatic reload — the fresh document references
* the new bundle, so the retry succeeds. The reload MUST be bounded: if chunks are
* genuinely unreachable (CDN outage), reloading again would loop. Hence the
* sessionStorage timestamp guard: at most one auto-reload per tab per window; a
* chunk error inside the guard window falls through to the normal error surface.
*/
const STORAGE_KEY = "am-chunk-reload-at";
/** Minimum gap between automatic reloads for one tab. */
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
/** True when the value looks like a failed chunk / dynamic-import load (webpack + browser variants). */
export function isChunkLoadError(reason: unknown): boolean {
if (!reason) return false;
const message =
typeof reason === "string"
? reason
: reason instanceof Error
? `${reason.name}: ${reason.message}`
: "";
return (
/ChunkLoadError/.test(message) ||
/Loading chunk [\w-]+ failed/i.test(message) ||
/Loading CSS chunk/i.test(message) ||
/Failed to fetch dynamically imported module/i.test(message) ||
/error loading dynamically imported module/i.test(message) ||
/Importing a module script failed/i.test(message)
);
}
/** Pure guard decision — exported for tests. Allows a reload only outside the window. */
export function shouldAutoReload(lastReloadAt: number | null, now: number): boolean {
return lastReloadAt === null || now - lastReloadAt >= RELOAD_WINDOW_MS;
}
function readLastReloadAt(): number | null {
try {
const raw = window.sessionStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
/**
* Reload once if allowed. Returns true when a reload was initiated (callers should
* treat the error as handled). Without working sessionStorage there is no loop
* guard, so no auto-reload happens at all — the normal error surface takes over.
*/
export function tryRecoverFromChunkError(source: string): boolean {
if (typeof window === "undefined") return false;
const now = Date.now();
if (!shouldAutoReload(readLastReloadAt(), now)) return false;
try {
window.sessionStorage.setItem(STORAGE_KEY, String(now));
} catch {
return false;
}
trackEvent("ChunkReloadRecovery", { source, path: window.location.pathname });
window.location.reload();
return true;
}
@@ -3,12 +3,12 @@
* Run: node scripts/generate-lastmod.js
*/
export const PAGE_LASTMOD: Record<string, string> = {
"/": "2026-07-21T13:42:03+02:00",
"/about": "2026-07-22T10:31:49+02:00",
"/": "2026-07-23T08:37:42+02:00",
"/about": "2026-07-25T12:43:42+02:00",
"/roadmap": "2026-07-06T18:31:00+02:00",
"/changelog": "2026-07-06T18:12:31+02:00",
"/privacy": "2026-07-21T18:00:18+02:00",
"/terms": "2026-07-21T18:00:18+02:00",
"/privacy": "2026-07-25T12:43:42+02:00",
"/terms": "2026-07-23T08:37:42+02:00",
"/docs": "2026-07-06T18:12:31+02:00",
"/docs/private-preview": "2026-07-06T18:12:31+02:00",
"/docs/overview": "2026-07-06T18:12:31+02:00",