Compare commits

...

24 Commits

Author SHA1 Message Date
Oliver Kieselbach c7ed3ec27d more safe SecureBootCertReader and section plan layout update 2026-07-25 12:48:27 +02:00
Oliver Kieselbach f311e8746b docs(trust): on-demand log collection in customer-facing claims + tracker dedup tests
The "Collect Logs" feature (b382e567) added a second opt-in path into hosted
diagnostics upload — the quick-config dialog on a session — and a new collection
trigger, neither of which the customer-facing pages described. The trust FAQ still
said hosted upload is enabled "in the settings UI", and the dialog itself named
hosted storage without stating that packages leave the tenant boundary, so the
"clearly marked disclosure" promise was not met on that path.

- CollectLogsButton: marked data-boundary disclosure in the quick-config dialog
  (leaves your Azure tenant, per-tenant isolation, blob-scoped write-only token,
  removal per Data Retention Days)
- privacy + about pages: both opt-in paths named; on-demand collection described
  (Admin/Operator of the own tenant, same content as the automatic package,
  delivered with the agent's next check-in, visible on the session timeline)
- docs repo counterpart in trust/security-faq.md (separate commit)

Distress-bell dedup: the TpmPssUnsupported one-shot bell had key-shape tests only.
New HardwareRejectionNotificationTrackerDedupTests pins the 409 dedup branch, the
fail-soft branch, the blank-input short-circuit, the disjoint hardware/tpmpss key
spaces, and the retention sweep — which also prunes tpmpss rows and thereby re-arms
that device's bell, now stated in the interface and implementation docs.

Backend tests 3313/3313, tsc clean, web vitest app/sessions 60/60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:43:42 +02:00
Oliver Kieselbach e0b6ce83cf fix null warnings 2026-07-25 00:17:31 +02:00
Oliver Kieselbach 7f7b590db4 fix(web): mobile readability for rule cards + phase timeline overlap
Rule list cards (analyze rules, gather rules, IME log patterns) used a
single-line flex header where every badge was flex-shrink-0 and only the
title could shrink — on phone widths the title truncated to a few
letters. The header now wraps: on mobile the title drops onto its own
full-width row (order-last + w-full) under the badge row and may wrap;
on >=sm the layout is unchanged (title inline, truncated).

PhaseTimeline squeezed up to 8 flex-1 columns with 48px circles and
whitespace-nowrap labels into the viewport, so circles and label text
overlapped completely on mobile. The timeline now has a minimum width
of 80px per phase inside an overflow-x-auto container (swipe on phone,
no scrollbar on desktop) and labels wrap inside their own column
instead of overlaying neighbours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 00:04:04 +02:00
Oliver Kieselbach 4c2c447a2e fix(web): dark-mode slate/hover-tint overrides + mobile wrap for script executions
Dark mode: globals.css was missing the entire slate family (the
"Incomplete" status filter chip rendered white-on-white) and all light
hover tints outside gray/indigo/cyan (hover:bg-purple-50 in the tenant
suggestion dropdown flashed near-white under light text). Added slate
bg/text/border mappings mirroring gray, plus dark equivalents for every
hover:bg-*-50/100/200 class in use (blue, purple, red, amber, yellow,
green, orange, sky, teal, emerald, slate, gray-200/300).

Mobile: ScriptExecutions rows were single-line flex with flex-shrink-0
right clusters, so badges and status text overlapped/overflowed on
narrow screens. Header counters, cycle card headers and item rows now
flex-wrap with gap spacing instead of space-x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:46:03 +02:00
Oliver Kieselbach 8a794a1b52 fix(rules): cross-tenant GA rule edits no longer misroute to home tenant
A Global Admin editing a gather/analyze rule while viewing a FOREIGN
tenant (override) got a green "saved" banner, but the write landed in
the GA's OWN home tenant: the JWT-scoped rules/{type}/{ruleId} routes
resolve the tenant from the caller's token, and no cross-tenant write
route existed (reads had /api/global/rules/*, writes did not). The
reload re-read the foreign tenant and showed the unchanged rule.

Fix: symmetric cross-tenant WRITE routes mirroring the global reads.

Backend:
- GlobalRulesFunction: PUT/DELETE global/rules/gather/{ruleId} and
  global/rules/analyze/{ruleId}; tenant from ?tenantId= (400 if
  missing), same 1 MB guard + ValidateScopeAndEmitMode as the JWT
  variants, same service calls (toggle-partial/built-in-state/full-edit
  semantics are tenant-parameterized and identical).
- Catalog: 4 entries GlobalAdminOnly + TenantScoping.QueryParam — a
  write tier, so the delegated-read rescue never fires; only a platform
  admin may mutate another tenant's rules. No global CREATE route:
  create stays JWT-only and the portal hides Create in override.

Web:
- api.rules.globalGatherRule/globalAnalyzeRule helpers; both rule pages
  route save/toggle/mark-failed/notify/delete through a
  *RuleWriteUrl(ruleId) switch on isGlobalOverride.
- Analyze template-enable (a create) is blocked in override with a
  clear message instead of silently writing to the wrong tenant.

Tests: CrossTenantAccessTests guards the 4 routes are GA-only +
QueryParam and that a GA reaches the named target tenant. Backend
3295/3295, web vitest 627/627, tsc clean.

Note: the old bug planted orphan copies of foreign rules into the GA's
home tenant on each attempted cross-tenant edit; those are not
auto-cleaned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:21:37 +02:00
Oliver Kieselbach 6c53dc33e7 feat(distress): tenant-visible TpmPssUnsupported devices + one-shot bell
TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops
the client cert on Win11 25H2+, agent can never authenticate) were operator-
only, yet the remediation is tenant-side: TPM firmware update or device
replacement.

- New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial,
  mirroring audit/device-not-registered
- Bell notification "tpm_pss_unsupported" (Admin audience) once per
  (tenant, serial); dedup via the existing rejection tracker table with a
  disjoint "tpmpss|{serial}" row-key space; no webhook by design
- Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist
  section; renders only when a device is affected (no permanent card)

No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:47:06 +02:00
Oliver Kieselbach 801891d57a layout update section plan comparison 2026-07-24 20:30:28 +02:00
Oliver Kieselbach 1f6efca95b fix(backend): stamp DiagnosticsBlobName from on-demand server_action_executed events
Session 8e4cc4ae: two "Collect Logs" round trips completed on the agent (both
ZIPs landed in hosted storage) but the portal never flipped to Download and
the button hung at "Collecting..." — the ServerActionDispatcher confirms an
on-demand upload only via server_action_executed(actionType=request_diagnostics,
blobName=...) and emits NO diagnostics_uploaded (only the terminal upload path
does), so UpdateSessionDiagnosticsBlobAsync never ran.

The ingest classification now treats such an executed event as the upload
confirmation (a real diagnostics_uploaded in the same batch still wins — it
carries destination). Because the executed event has no destination field,
InferDiagnosticsDestination derives "Hosted" from the backend-built
"{tenantId}/{filename}" blob-name shape (CustomerSas blobs are bare
filenames); without it the download route would look in the customer's
container for a hosted blob.

Backend-only — works with the already-deployed agent fleet, no agent release.
Backend suite 3274/3274.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:23:47 +02:00
Oliver Kieselbach 9ec8644be2 feat(settings): operator read-only settings view with real values
Operators previously saw editable settings forms bound to hardcoded defaults
whose Save always 403'd. Now they see the tenant's real configuration
read-only, with no save affordances at all.

Backend:
- GET config/{tenantId}: TenantAdminOrGlobalReader -> MemberRead. Safe because
  the handler redacts by default (CanViewSecrets: only GA or own-tenant Admin
  see secrets); Operators/Viewers receive RedactedCopyForReader().
- POST diagnostics/files: TenantAdminOrGA -> TenantAdminOrOperator (submitting
  logs is a troubleshooting action; Viewer and read-only GlobalReader excluded).
- Tests: policy pins updated; new middleware tests (own-tenant Operator allowed
  + redacted, cross-tenant Operator 403); CanViewSecrets theory for
  Operator/Viewer.

Web:
- TenantConfigContext loads the full config for all settings users (403
  fallback to feature-flags minimal config for deploy-order safety) and
  exposes canEditConfig; saveConfiguration guards on it.
- New ReadOnlyFieldset wraps section content (native fieldset disable, values
  stay copyable); SaveResetBar is not rendered at all for read-only viewers.
  Applied to Contact, SLA Targets, Hardware Whitelist (+ rejection-insights
  toggle), Notifications, Agent Settings, Agent Analyzers, Diagnostics, Data
  Management.
- Hidden entirely from Operators (in-page admin-only card + sidebar sub-item
  gate): Autopilot Validation, Access Management, Offboarding, Unrestricted
  Mode. GlobalReader sidebar parity kept via hasGlobalScope.

Tests: backend 3257/3257, web vitest 627/627, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:40:27 +02:00
Oliver Kieselbach 9acd10e1dc docs(okf): ServerActions & on-demand diagnostics concept doc
Covers the queue-on-session/deliver-on-ingest channel, the
TenantAdminOrOperator tier with in-function per-type re-gating, the portal
Collect Logs flow (quick-config orders rotate_config before
request_diagnostics), sequence-based progress matching, and the hosted-prefix
sweep in the offboarding cascade. Registered in index.md, logged in log.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:15:23 +02:00
Oliver Kieselbach b382e5678e feat(web): "Collect Logs" on-demand diagnostics collection on the session detail page
Queues a request_diagnostics ServerAction (delivered with the agent's next
ingest response) and tracks progress live: server_action_received flips the
button to "Collecting…", the DiagnosticsBlobName SignalR delta completes it
(Download Diagnostics appears), server_action_failed or a 3-min quiet timeout
surface as toasts.

- Button is ALWAYS rendered — disabled states carry tooltips (inactive
  session, cross-tenant view, missing role, diagnostics not configured) as a
  deliberate nudge towards enabling diagnostics upload.
- Tenant Admin on an unconfigured tenant gets a quick-config dialog: switches
  the tenant to Hosted + OnFailure (GET config -> patch two fields -> PUT
  verbatim), then queues rotate_config BEFORE request_diagnostics so the agent
  collects against the fresh config. Operators/Viewers never see the dialog.
- Operator may collect when configured (new TenantAdminOrOperator backend tier).
- useSessionTenantConfig picks up diagnosticsUploadConfigured from the
  member-readable feature-flags endpoint.
- Progress matching is sequence-based (backend-assigned) so agent clock skew
  and at-least-once action redelivery cannot produce false matches.

Pure state logic in collectLogsLogic.ts with vitest matrix (21 tests).
tsc clean; web suite 627/627.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:12:49 +02:00
Oliver Kieselbach 2fee50276e feat(backend): operator-tier session actions + diagnostics groundwork for on-demand log collection
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
  middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
  troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
  rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
  members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
  prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
  blob) so on-demand packages overwritten on the Sessions row by the terminal
  package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
  tenant-shared; operators would clear admin-tier notifications they cannot see.

Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:05:39 +02:00
Oliver Kieselbach 8bb02acc4a fix(web): bump postcss to >=8.5.12 (GHSA-6g55-p6wh-862q, high)
Dependabot alert #74: PostCSS <= 8.5.11 allows arbitrary file read /
information disclosure via an attacker-controlled sourceMappingURL in CSS
comments. Raise the floor (devDependency + override) from ^8.5.10 to
^8.5.12; the lockfile now resolves postcss 8.5.22 (its nanoid dep moves
3.3.11 -> 3.3.16 with it). npm audit reports 0 vulnerabilities.

Next.js pins postcss 8.4.31 transitively, but the existing root override
collapses the tree to a single postcss node, so the bump covers it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:54:57 +02:00
Oliver Kieselbach fd7d3973e7 fix(agent): esp_policy_provider_stalled gains sidecar-missing arm (issue #106)
Customer follow-up corrected the field-case mechanics: TrackingPoliciesCreated=1
WAS set under the foreign ConfigMgr provider - the ESP ignores a foreign
provider's completion values and waits on the Intune-registered "Sidecar"
provider by name (renaming the key unblocks instantly without creating any
tracking entries). The completeness-keyed detector would have missed exactly
the incident it was built for.

- Arm 2: >=1 Setup\Apps provider registered without Sidecar, continuously for
  the 15-min dwell -> reason=sidecar_provider_missing; clock resets when
  Sidecar registers later (legitimate startup ordering); DevicePreparation-only
  snapshots don't activate it
- Arm 1 (continuously-incomplete providers) unchanged -> reason=provider_incomplete;
  sidecar-missing wins when both arms cross in one round (single event)
- Cross-restart fingerprint gains a "sidecarMissing" sentinel; payload adds
  reason + sidecarMissingForMinutes
- Regression test: ConfigMgr complete + Sidecar absent -> fires (previously
  classified healthy); probe/doc headers and OKF doc corrected, web event-type
  description updated

Evidence (research session c7e3db08): Intune's ESP-policy WAP payload always
registers Sidecar (ConfigMgr only via co-management settings policy, per
Niehaus' documented payload); Microsoft's ESP troubleshooting models app
tracking exclusively through the Sidecar subkey.

Agent tests 2043/2043, web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:48:50 +02:00
Oliver Kieselbach fd2b5c2da2 fix(ci): drop invalid SWA input, gate deploy on token via env guard
Azure/static-web-apps-deploy@v1 has no `skip_deploy_on_missing_secrets`
input (confirmed against its action.yml), so passing it did nothing but
emit an "Unexpected input(s)" warning — the intended "skip green on
Dependabot PRs" behaviour was never actually in effect.

Replace it with the documented mechanism: surface the deploy token as a
job-level env var and gate the deploy/close step with `if: env.DEPLOY_TOKEN
!= ''` (secrets cannot be referenced directly in an `if:`). On Dependabot
PRs the token is absent, so the step skips and the job stays green; real
deploys on main and internal PRs still run. Applied to both jobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:38:58 +02:00
Oliver Kieselbach a44e37a7c2 fix(metrics): keep full failure-reason text for fleet-health display
BuildFleetFailureReasons truncated each reason to a 50-char prefix + "..."
and used that as both the grouping key AND the display value. The full text
never reached the UI, so its tooltip/tap-to-expand had nothing to reveal and
near-identical rows were indistinguishable past the cut.

Group by the 50-char prefix as before (variants still collapse into one row),
but carry the longest full reason as the display value. The UI truncates and
expands it on demand, so the complete text is now available end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:25:11 +02:00
Oliver Kieselbach d8a252562d fix(web): pin engines.node to 22.x so SWA/Oryx does not grab Node 24
The prior ">=22" made Oryx build with the highest available Node (24.13),
which the Static Web Apps runtime rejects: "Node version 24.13.0 is not
supported. Please use one of the following versions 18, 20, 22." Pin to
22.x so Oryx selects the SWA-supported major. CI setup-node '22' already
resolves to latest 22.x, so build and deploy now agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:23:17 +02:00
Oliver Kieselbach a5d12ba1d0 chore(ci): unify Node toolchain on 22 across CI + engines pins
Node 20 reached EOL (2026-04-30); Node 22 is the current maintenance LTS
and already the runtime the MCP container ships (node:22-slim). Straighten
the version skew so per-component CI major == deploy runtime == engines floor:

- ci.yml: web, mcp and guardrails setup-node 20 -> 22
- combine-rules.yml: setup-node 20 -> 22
- mcp package.json: add engines.node ">=22" (matches Dockerfile)
- web package.json: add engines.node ">=22" (first explicit pin; SWA/Oryx
  no longer picks a Node version implicitly)

engine-strict stays off (no .npmrc), so the pins are advisory and cannot
hard-break local installs on other Node versions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:15:54 +02:00
Oliver Kieselbach 530c978bf8 fix(web): TruncatedLabel expands to full text, not just two lines
The expanded state used line-clamp-2, which re-truncated long values with an
ellipsis after two lines — unreadable on touch where the hover tooltip is
unavailable. Expand now wraps the entire string over as many lines as needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:11:54 +02:00
Oliver Kieselbach 09898d7ba0 feat(web): consistent truncate-with-tooltip + tap-to-expand across dashboard cards
Add reusable TruncatedLabel component: a single-line label that truncates
with an ellipsis, shows the full text as a native hover tooltip, and on
click/Enter/Space expands to two wrapped lines (touch-friendly). An
interactive={false} mode provides tooltip-only behaviour for use inside
already-clickable rows/links where an inner onClick would hijack navigation.

Apply it consistently to the long, dynamic label columns across:
- fleet-health: failure reasons, model + app ranked lists
- sla: top failing apps, violators failure reason
- geographic-performance: fastest/slowest location cards
- vulnerability exposure panel: CVE sample software
- agent metrics: top exception types
- progress + download/install: app-name labels
- admin software tabs: software names
- session reports (comment/adminNote) + ops events (message): tooltip-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:03:34 +02:00
Oliver Kieselbach 71eda93e8b fix(metrics): app-install failure rate over finished installs only
Second half of the terminal-only rate convention (follows 1cfc32c9, which
converted the enrollment success rates): app-install failureRate divided
by ALL install rows including "InProgress" ones — installs still running,
or orphaned when a session died mid-install — diluting every failure rate
customers see on the Apps dashboard and Fleet Health.

- New MetricsMath.TerminalFailureRatePct (Failed / (Failed + Succeeded),
  0 when nothing finished) shared by both aggregation sites
- MetricsMath.BuildAppMetricsPayload: per-app failureRate terminal-based
  (Fleet Health "Top Failing Apps"; subtitle now "% of N finished")
- AppsAnalyticsHelper: list, analytics summary, versionBreakdown,
  timeSeries and deviceModelBreakdown rates terminal-based; the
  duplicated trend block is deduplicated into ComputeFailureTrend, whose
  rates and >= 5-sample gate now run on finished installs per half; the
  device-model >= 5 sample floor likewise counts finished installs
- Web: InstallsTab "Avg Failure Rate" card computed over summed finished
  installs instead of totalInstalls

Tests: new AppsAnalyticsFailureRateTests (list/trend/analytics/model
cases incl. in-flight dilution and gate regressions) and a terminal-rate
case in MetricsMathAppPayloadTests. Backend 3223/3223, tsc clean,
vitest 602/602.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:01:28 +02:00
Oliver Kieselbach 1cfc32c9aa fix(metrics): success rate over finished enrollments only (geo + fleet health)
Customer feedback: the geographic performance pane showed a poor success
rate for a site mid-rollout because in-progress/pending sessions counted
against the rate. Success rate is an outcome quota — align Geo and Fleet
Health with the SLA convention already used by dashboard stats, usage
snapshots and the fleet rollup: Succeeded / (Succeeded + Failed).
Incomplete (terminal, non-failure) stays excluded.

- Geo: per-location SuccessRate over finished sessions; table badge and
  map tooltip render an em dash instead of a red 0% when nothing has
  finished yet
- Fleet Health: Stats.SuccessRate terminal-based; card shows "X of Y
  finished enrollments" and a neutral em dash state; per-model health
  gets a Failed count (additive wire field) so the client rate excludes
  in-flight devices; TopFailingModels FailureRate now failed/finished
- Tests: FleetHealthPayloadTests flipped to the new convention plus
  zero-finished, model-failed and in-flight-dilution cases; new
  GeographicMetricsSuccessRateTests pin the geo semantics

Backend 3216/3216, web tsc clean, vitest 602/602.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:47:31 +02:00
Oliver Kieselbach 11339c9868 fix(test): de-flake SessionDeletionWorkerTests via condition-gated runs
CI run 30047512186 failed Worker_drops_envelope_missing_required_fields_
without_invoking_handler: RunForAsync starts the BackgroundService poll loop
and waits a fixed 500ms wall-clock window — on a loaded CI runner (xUnit runs
test classes in parallel) the loop may not have processed the message before
the positive assertions (DeleteMessageAsync at least once) run. All ten
positive tests in the file shared that latent race; this run it hit the
malformed-envelope drop test.

Fix mirrors the watchdog event-gate de-flake: new Harness.RunUntilAsync
(condition, 10s ceiling, 25ms poll) runs the worker until the awaited side
effect is observable, then stops. Gates: ops-event recorded (last step of the
poison path, so send+delete+CAS are already done), main-queue delete for the
drop tests, >=3 visibility heartbeats for the heartbeat test. The kill-switch
test keeps the fixed window — a Times.Never expectation has no event to wait
for. OpsEventCalls capture is now lock-guarded so the condition poll can read
it while the worker thread appends.

16/16 worker tests green across 4 consecutive runs; full backend suite
3211/3211.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:03:10 +02:00
112 changed files with 4303 additions and 469 deletions
+3 -3
View File
@@ -63,7 +63,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
cache: npm
cache-dependency-path: src/Web/autopilot-monitor-web/package-lock.json
@@ -86,7 +86,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
cache: npm
cache-dependency-path: src/McpServer/autopilot-monitor-mcp/package-lock.json
@@ -106,7 +106,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
- name: Regenerate
run: node rules/scripts/combine.js
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
- name: Install ajv-cli
run: npm install -g ajv-cli ajv-formats
+11 -3
View File
@@ -21,6 +21,11 @@ jobs:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy Job
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Surface it as an env var so the deploy step can gate on it — secrets
# cannot be referenced directly in an `if:` expression.
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
@@ -28,6 +33,9 @@ jobs:
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
- name: Build And Deploy
id: builddeploy
# Skip (green) instead of failing when the token is missing (Dependabot
# PRs); real deploys on main and internal PRs still have it.
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
@@ -39,9 +47,6 @@ jobs:
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Skip (green) instead of failing the check; real deploys on main still have the token.
skip_deploy_on_missing_secrets: true
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
@@ -54,9 +59,12 @@ jobs:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request Job
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- name: Close Pull Request
id: closepullrequest
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
+55 -29
View File
@@ -1,7 +1,7 @@
---
type: Concept
title: ESP Policy-Provider Stall Detection — the no-timeout Setup/Apps wait and the co-management Sidecar gap
description: Why a registered EnrollmentStatusTracking CSP policy provider that never sets TrackingPoliciesCreated parks the user ESP at "Apps (Identifying)" indefinitely, the pre-provisioning + ConfigMgr field case, and how the always-on esp_policy_provider_stalled tripwire (15-min wall-clock dwell, StartupEventGate cross-restart dedup) surfaces it.
description: Why the ESP's Apps wait is keyed to the Intune-registered "Sidecar" provider by name — a foreign provider (ConfigMgr) with TrackingPoliciesCreated=1 does not satisfy it — plus the classic never-completes stall, and how the always-on two-arm esp_policy_provider_stalled tripwire (reason=provider_incomplete | sidecar_provider_missing, 15-min wall-clock dwell, StartupEventGate cross-restart dedup) surfaces both.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspPolicyProviderProbe.cs
tags:
- agent
@@ -9,7 +9,7 @@ tags:
- enrollment-status-tracking
- co-management
- stall-detection
timestamp: 2026-07-23T00:00:00+02:00
timestamp: 2026-07-24T00:00:00+02:00
---
# ESP Policy-Provider Stall Detection
@@ -42,37 +42,59 @@ exactly these registry paths. The same document states that ESP policy providers
aware of each other** and that **pre-provisioning is unsupported** with Autopilot into
co-management.
## The field case
## The field case (issue #106)
A tenant pre-provisions devices and installs the ConfigMgr client as a blocking app.
Occasionally the user-phase ESP reaches "Apps (Identifying)" and never leaves it — for
hours to days. On those devices `Device\Setup\Apps\PolicyProviders` contains **only a
`ConfigMgr` key (no `Sidecar`)** and `TrackingPoliciesCreated` is never set. Manually
renaming the key to `Sidecar` unblocks the ESP immediately (the IME then finds its
provider node and reports tracking-policies-created). The mechanism is exactly the
documented no-timeout wait; the unsupported pre-provisioning + co-management combination
explains why only some devices are affected. Why the Sidecar registration is missing or
displaced on those devices is not publicly documented — irrelevant for detection, since
the signature is deterministic.
`ConfigMgr` key (no `Sidecar`)** — and, per the customer's follow-up, **`TrackingPoliciesCreated=1`
IS set under it**. The provider is "complete" by the CSP value contract, yet the ESP
still waits. Manually renaming the key to `Sidecar` — which creates no tracking entries,
only satisfies the name — unblocks the ESP instantly.
The naive CSP-doc reading ("any provider with TrackingPoliciesCreated=1 satisfies the
wait") is therefore wrong for this case. What the evidence supports instead: provider
nodes are registered **by the Intune service at enrollment**, and its ESP-policy WAP
payload always registers `Sidecar` (ConfigMgr joins only via a co-management settings
policy — see the payload documented by Niehaus). The ESP waits on the provider it
expects **by name**; a foreign provider occupying the slot does not satisfy it,
regardless of its completion values. On the affected devices the CM client (installed
as a plain Win32 app, no co-management settings policy) evidently displaced the
Sidecar node — consistent with Microsoft's warning that providers "aren't currently
aware of others" and that pre-provisioning is unsupported with co-management. The exact
CloudExperienceHost mechanism is not publicly documented; the registry signature —
setupApps providers present, `Sidecar` absent — is deterministic either way.
## Detection
`EspPolicyProviderProbe` (fail-soft, static, `ScopedOverride` test seam — structural clone
of `EspTrackingInfoProbe`) enumerates all providers in both kinds and scopes.
`EspPolicyProviderStallDetector` evaluates a pure transition function on a 60-second tick
from the always-on `EspPolicyProviderStallHost`:
from the always-on `EspPolicyProviderStallHost` — **two arms**, same 15-minute wall-clock
dwell, one shared event type discriminated by `reason`:
* A provider **continuously incomplete for ≥ 15 minutes** (wall-clock) is stalled. The
dwell clock starts at first incomplete observation and resets on completion or
disappearance. Missing values parse as incomplete (fail-toward-observation).
* **Arm 1, `reason=provider_incomplete`** — a provider **continuously incomplete for
≥ 15 minutes**. The dwell clock starts at first incomplete observation and resets on
completion or disappearance. Missing values parse as incomplete
(fail-toward-observation). Catches e.g. Sidecar itself never reporting.
* **Arm 2, `reason=sidecar_provider_missing`** — **≥ 1 `Setup\Apps` provider registered
but none named `Sidecar`**, continuously for ≥ 15 minutes. Deliberately ignores
provider completeness (the field case had `TrackingPoliciesCreated=1`). Sidecar
registering later — the legitimate startup ordering — clears the condition and resets
the clock. DevicePreparation-only snapshots don't activate it.
* When both arms cross in the same round, ONE event is emitted and
`sidecar_provider_missing` wins as `reason` (it explains the hang regardless of the
foreign provider's state).
* Missing root key / empty provider list = normal early enrollment: clears all dwell
state, never fires.
* One `esp_policy_provider_stalled` **Warning** per newly stalled provider set
(`ImmediateUpload`), one-shot per provider in-process, cross-restart deduped via a
`StartupEventGate` fingerprint over the sorted currently-stalled key set — a restart
into the same stall stays silent, a new provider joining the stall re-reports.
* Payload: `providers[]` (full table: name, scope, kind, raw values, complete flag),
`stalledProviders[]` with `stalledForMinutes`, `sidecarRegistered`, `dwellMinutes`.
state (both arms), never fires.
* One `esp_policy_provider_stalled` **Warning** per newly stalled condition set
(`ImmediateUpload`), one-shot per condition in-process, cross-restart deduped via a
`StartupEventGate` fingerprint over the sorted currently-active fired conditions
(provider keys + a `sidecarMissing` sentinel) — a restart into the same stall stays
silent, a new condition joining re-reports.
* Payload: `reason`, `providers[]` (full table: name, scope, kind, raw values, complete
flag), `stalledProviders[]` with `stalledForMinutes`, `sidecarMissingForMinutes`
(arm 2 only), `sidecarRegistered`, `dwellMinutes`.
* **Purely observational**: emitted via `InformationalEventPost` (dispatch-guard exempt
pass-through) — never touches decision state. No config toggle, no ConfigVersion bump
(precedent: `disk_space_low`).
@@ -93,33 +115,37 @@ resource), so both the detector and the guardrail change ship with the agent rel
## Examples
Customer-case event (abbreviated):
Customer-case event (abbreviated) — note the provider is "complete" by CSP values, the
hang is carried entirely by `reason=sidecar_provider_missing`:
```json
{
"eventType": "esp_policy_provider_stalled",
"severity": "Warning",
"data": {
"reason": "sidecar_provider_missing",
"dwellMinutes": 15,
"sidecarRegistered": false,
"sidecarMissingForMinutes": 15.0,
"providers": [
{ "name": "ConfigMgr", "scope": "device", "kind": "setupApps",
"trackingPoliciesCreated": null, "installationState": null, "complete": false }
"trackingPoliciesCreated": 1, "installationState": null, "complete": true }
],
"stalledProviders": [
{ "name": "ConfigMgr", "scope": "device", "kind": "setupApps", "stalledForMinutes": 15.0 }
]
"stalledProviders": []
}
}
```
`sidecarRegistered=false` while `setupApps` providers exist is the co-management
signature; `sidecarRegistered=true` with a stalled third-party provider generalizes the
same contract violation.
`reason=sidecar_provider_missing` is the co-management signature;
`reason=provider_incomplete` with `sidecarRegistered=true` covers a stalled Sidecar or
third-party provider — the generalized contract violation.
# Citations
* EnrollmentStatusTracking CSP — <https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>
* Windows Autopilot into co-management (requirements, provider-conflict warning, troubleshooting registry paths) — <https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment>
* Troubleshoot the ESP (models app tracking exclusively via the `Sidecar` subkey) — <https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/understand-troubleshoot-esp>
* Niehaus: the Intune ESP-policy WAP payload registering the provider nodes (`Sidecar` always, `ConfigMgr` only with a co-management settings policy) — <https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/>
* Niehaus: ESP tracks arbitrarily named providers when they are properly registered — <https://oofhours.com/2023/09/18/track-anything-using-esp/>
* Agent code: `EspPolicyProviderProbe.cs`, `EspPolicyProviderStallDetector.cs`, `EspPolicyProviderStallHost.cs` (SystemSignals / Orchestration.Hosts)
* Related: [MDM Reboot Coalescing](mdm-reboot-coalescing.md), [Gather-Rule Guardrails](../rules/gather-rule-guardrails.md)
+15
View File
@@ -37,6 +37,21 @@ Fail-soft contract like `OobeStateReader`: never throws; failures map to status
(`not_uefi` = legacy BIOS via `ERROR_INVALID_FUNCTION`, `variable_not_found`,
`privilege_denied`, `error_<code>`).
This is the repo's only privilege-adjustment code, so it also carries the ownership rules
any future one should copy:
* The process token lives in a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR
owns it from the moment `OpenProcessToken` returns — the critical finalizer releases it
even if `Dispose` never runs (async exception before the instance reaches its `using`).
* The restore obligation is recorded immediately after the successful `AdjustTokenPrivileges`,
and only when the privilege was not *already* enabled; nothing that can throw sits between
the adjustment and that assignment. `Dispose` is idempotent, swallows a failed restore, and
releases the handle in a `finally`.
* The privilege is adjusted on the **process** token, so enable → read → restore is a
process-wide side effect and runs under a static gate. Without it a concurrent reader would
see "already enabled", correctly skip its own restore, and then lose the privilege mid-read
when the first reader disposes — a spurious `privilege_denied` on a machine that holds it.
Fields land on the existing `secureboot_status` event (once per agent start,
restart-deduped by the StartupEventGate): `uefiFirmwareReadStatus`, per-cert booleans
(`uefiDbHasWindowsUefiCa2023`, `uefiDbHasWindowsProductionPca2011`,
@@ -0,0 +1,102 @@
---
type: Concept
title: ServerActions & On-Demand Diagnostics Collection
description: The queue-on-session, deliver-on-ingest ServerAction channel (terminate_session / rotate_config / request_diagnostics), the operator-tier authorization split, the portal's Collect Logs flow including the admin quick-config path, and why the offboarding cascade sweeps the hosted diagnostics prefix.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs
tags:
- backend
- agent
- web
- server-actions
- diagnostics
- authorization
timestamp: 2026-07-24T00:00:00+02:00
---
# Schema
**Channel.** A `ServerAction` (`Type`, `Reason`, `RuleId`, `Params`, `QueuedAt`) is queued onto
`Sessions.PendingActionsJson` by server-side writers — the admin endpoint
`POST /api/sessions/{sessionId}/actions`, the RuleEngine, maintenance — and delivered
piggy-backed on the agent's next ingest response. No polling channel exists; delivery latency
equals the agent's telemetry cadence (seconds to ~1 minute during enrollment). Delivery is
at-least-once: the agent's `ServerActionDispatcher` dedups on `Type|RuleId|QueuedAt` and
processes a batch sequentially in queue order. Unknown types are logged and skipped, so new
types roll out server-first. Every action emits `server_action_received` and then
`server_action_executed` or `server_action_failed` into the session timeline.
**Authorization (two layers).** The route sits on `EndpointPolicy.TenantAdminOrOperator`
(tenant Admin, tenant Operator, or Global Admin — no Viewer, no Global Reader: it is a write
tier). Because the route serves multiple action kinds, `QueueSessionActionFunction`
re-gates per type via `IsTypeAllowedForCaller`: Operators may queue only
`request_diagnostics`; `terminate_session` and `rotate_config` require
`RequestContext.IsTenantAdmin || IsGlobalAdmin` (403 otherwise). Any route on this tier with
a multi-kind body MUST follow this same in-function re-gating pattern.
**On-demand collection (`request_diagnostics`).** The dispatcher calls
`DiagnosticsPackageService.CreateAndUploadAsync(enrollmentSucceeded: false, suffix:
"server-requested")` — producing `AgentDiagnostics-{sessionId}-{timestamp}-server-requested.zip`.
Passing `enrollmentSucceeded: false` is what makes on-demand collection work under
`DiagnosticsUploadMode=OnFailure`; only `Off` (or an unconfigured destination) blocks it,
surfacing as `server_action_failed`. The `diagnostics_uploaded` event stamps
`DiagnosticsBlobName` + `Destination` onto the Sessions row (latest wins — an on-demand name
is later replaced by the terminal package), and the SignalR `sessionUpdate` delta carries the
field, so the portal's Download button flips live.
**Portal flow (Collect Logs, session detail header).** The button is always rendered; the
disabled states carry tooltips (inactive session, cross-tenant view, missing role, diagnostics
not configured) as a deliberate nudge. Members below Admin cannot read the tenant config, so
the member-readable feature-flags endpoint exposes `diagnosticsUploadConfigured`
(`mode != Off && (customer SAS present || destination Hosted)`). Progress tracking is
sequence-based (backend-assigned, monotonic), never timestamp-based — agent clock skew and
at-least-once redelivery of older actions cannot produce false matches
(`collectLogsLogic.ts`). Cross-tenant views are gated off because `TenantHelper.GetTenantId`
resolves the tenant strictly from the caller's JWT.
**Admin quick-config.** For an unconfigured tenant, a Tenant Admin's click opens a dialog
that switches the tenant to `Destination=Hosted`, `Mode=OnFailure` (GET full config → patch
exactly those two fields → PUT verbatim), then queues `rotate_config` **before**
`request_diagnostics` — the dispatcher's sequential ordering guarantees the agent refetches
the now-enabled config (the `RemoteConfigMerger` applies diagnostics fields live, remote wins)
before building the package. This works against the already-deployed agent fleet; a
"one-time override" variant would not, because the `mode=Off` check is client-side in
`CreateAndUploadAsync` and the deployed handler reads no params.
**Offboarding cascade.** Latest-wins on `DiagnosticsBlobName` means a session can leave more
hosted blobs behind than the deletion manifest references. The cascade's Hosted branch
therefore sweeps the canonical prefix `{tenantId}/AgentDiagnostics-{sessionId}-`
(`HostedDiagnosticsBlobService.DeleteBySessionPrefixAsync`) plus the manifest-named blob as
belt-and-braces; a non-GUID legacy `SessionId` degrades to the single-name delete instead of
poisoning the cascade. The CustomerSas branch is unchanged (delete only with the `d`
permission; the customer's lifecycle rules stay authoritative).
# Examples
Queue an on-demand collection (Admin or Operator, own tenant):
```
POST /api/sessions/{sessionId}/actions
{ "type": "request_diagnostics", "reason": "On-demand log collection from portal" }
→ 202 { "success": true, "queuedAt": "..." }
```
Operator queueing an admin-only type:
```
POST /api/sessions/{sessionId}/actions
{ "type": "terminate_session" }
→ 403 { "message": "Action type 'terminate_session' requires the Tenant Admin role" }
```
Timeline for a successful round trip: `server_action_received` → `server_action_executed`
(with `blobName`) → `diagnostics_uploaded` → Sessions row + SignalR delta carry the new
`DiagnosticsBlobName`.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs` — allowed types, per-type caller gate.
* `src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs` — `TenantAdminOrOperator` tier definition.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs` — dedup, sequential dispatch, telemetry contract.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — mode gates, archive build, suffix.
* `src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs` — hosted prefix sweep.
* `src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts` — button gating matrix, sequence-based progress evaluation.
+2 -1
View File
@@ -22,7 +22,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait: a registered provider that never sets TrackingPoliciesCreated (co-management "ConfigMgr" without the IME's "Sidecar") parks the user ESP at "Apps (Identifying)" for days; the always-on esp_policy_provider_stalled tripwire and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
@@ -32,6 +32,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Backend
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
# MCP Server
+9 -1
View File
@@ -1,6 +1,14 @@
# Log
## 2026-07-23
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
## 2026-07-24
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
@@ -74,14 +74,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
var previous = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
var missing = EspPolicyProviderStallDetector.EvaluateStallTransition(
EspPolicyProviderSnapshot.Empty, previous, new HashSet<string>(), T0.AddHours(2), Dwell);
EspPolicyProviderSnapshot.Empty, previous, new HashSet<string>(), T0, false, T0.AddHours(2), Dwell);
Assert.Empty(missing.UpdatedFirstSeenUtc);
Assert.Empty(missing.NewlyStalled);
Assert.Null(missing.UpdatedSidecarMissingSinceUtc); // arm 2 resets too
Assert.Null(missing.SidecarMissingStalledForMinutes);
var registeredButEmpty = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(), previous, new HashSet<string>(), T0.AddHours(2), Dwell);
Snapshot(), previous, new HashSet<string>(), T0, false, T0.AddHours(2), Dwell);
Assert.Empty(registeredButEmpty.UpdatedFirstSeenUtc);
Assert.Empty(registeredButEmpty.NewlyStalled);
Assert.Null(registeredButEmpty.UpdatedSidecarMissingSinceUtc);
}
[Fact]
@@ -90,14 +93,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: null));
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), T0, Dwell);
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Equal(T0, round1.UpdatedFirstSeenUtc["setupApps|device|ConfigMgr"]);
Assert.Empty(round1.NewlyStalled);
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(), T0.AddMinutes(14), Dwell);
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(),
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(14), Dwell);
Assert.Equal(T0, round2.UpdatedFirstSeenUtc["setupApps|device|ConfigMgr"]); // first-seen preserved
Assert.Empty(round2.NewlyStalled);
Assert.Null(round2.SidecarMissingStalledForMinutes); // 14 min < dwell on arm 2 as well
}
[Fact]
@@ -107,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
var seen = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, seen, new HashSet<string>(), T0.AddMinutes(15), Dwell);
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(15), Dwell);
var stalled = Assert.Single(eval.NewlyStalled);
Assert.Equal("ConfigMgr", stalled.Provider.Name);
@@ -122,20 +127,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
// Completes mid-dwell → dropped from the map.
var completed = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)), seen, new HashSet<string>(), T0.AddMinutes(10), Dwell);
Snapshot(SetupApps("ConfigMgr", tracking: 1)), seen, new HashSet<string>(), null, false, T0.AddMinutes(10), Dwell);
Assert.Empty(completed.UpdatedFirstSeenUtc);
// Re-appears incomplete much later → dwell restarts from the new now, no stall yet.
var reappeared = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 0)), completed.UpdatedFirstSeenUtc,
new HashSet<string>(), T0.AddHours(3), Dwell);
new HashSet<string>(), null, false, T0.AddHours(3), Dwell);
Assert.Equal(T0.AddHours(3), reappeared.UpdatedFirstSeenUtc[key]);
Assert.Empty(reappeared.NewlyStalled);
// Disappearance behaves the same as completion.
var disappeared = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("Sidecar", tracking: 1)), new Dictionary<string, DateTime> { [key] = T0 },
new HashSet<string>(), T0.AddMinutes(20), Dwell);
new HashSet<string>(), null, false, T0.AddMinutes(20), Dwell);
Assert.False(disappeared.UpdatedFirstSeenUtc.ContainsKey(key));
}
@@ -147,7 +152,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
var fired = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "setupApps|device|ConfigMgr" };
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, seen, fired, T0.AddHours(5), Dwell);
snapshot, seen, fired, null, false, T0.AddHours(5), Dwell);
Assert.Empty(eval.NewlyStalled);
Assert.True(eval.UpdatedFirstSeenUtc.ContainsKey("setupApps|device|ConfigMgr")); // still tracked
@@ -162,7 +167,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
DevicePrep("ConfigMgr", installState: 1));
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), T0, Dwell);
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Equal(3, eval.UpdatedFirstSeenUtc.Count);
Assert.Contains("setupApps|device|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
@@ -179,6 +184,71 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.False(Snapshot(DevicePrep("Sidecar", installState: 3)).SidecarRegistered); // wrong kind
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
}
// ------------------------------------------------- arm 2: sidecar-missing transition
[Fact]
public void Issue106_foreign_provider_complete_but_sidecar_missing_stalls_after_dwell()
{
// The actual field case: ConfigMgr with TrackingPoliciesCreated=1 — "complete" by the
// CSP value contract, arm 1 never fires — yet the ESP waits on the absent Sidecar.
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: 1));
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Empty(round1.NewlyStalled); // arm 1 stays silent...
Assert.Equal(T0, round1.UpdatedSidecarMissingSinceUtc); // ...but arm 2 starts its clock
Assert.Null(round1.SidecarMissingStalledForMinutes);
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(),
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(15), Dwell);
Assert.Empty(round2.NewlyStalled);
Assert.Equal(15.0, round2.SidecarMissingStalledForMinutes!.Value, precision: 3);
}
[Fact]
public void Sidecar_registering_resets_the_sidecar_missing_clock()
{
// ConfigMgr first (legitimate startup ordering) → clock runs...
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Equal(T0, round1.UpdatedSidecarMissingSinceUtc);
// ...Sidecar joins → condition clears, state resets.
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1), SetupApps("Sidecar", tracking: 0)),
round1.UpdatedFirstSeenUtc, new HashSet<string>(),
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(10), Dwell);
Assert.Null(round2.UpdatedSidecarMissingSinceUtc);
Assert.Null(round2.SidecarMissingStalledForMinutes);
// Sidecar gone again much later → clock restarts from the new now, no instant stall.
var round3 = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
round2.UpdatedFirstSeenUtc, new HashSet<string>(),
round2.UpdatedSidecarMissingSinceUtc, false, T0.AddHours(3), Dwell);
Assert.Equal(T0.AddHours(3), round3.UpdatedSidecarMissingSinceUtc);
Assert.Null(round3.SidecarMissingStalledForMinutes);
}
[Fact]
public void Sidecar_missing_one_shot_and_deviceprep_only_scoping()
{
// Already fired → never re-reported, but the clock keeps running.
var latched = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
new Dictionary<string, DateTime>(), new HashSet<string>(), T0, true, T0.AddHours(5), Dwell);
Assert.Null(latched.SidecarMissingStalledForMinutes);
Assert.Equal(T0, latched.UpdatedSidecarMissingSinceUtc);
// DevicePreparation providers alone don't constitute the Setup/Apps wait — arm 2 off.
var devicePrepOnly = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(DevicePrep("ConfigMgr", installState: 1)),
new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Null(devicePrepOnly.UpdatedSidecarMissingSinceUtc);
}
}
/// <summary>
@@ -243,7 +313,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Equal(EspPolicyProviderStallDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
// Both arms cross in the same round (ConfigMgr incomplete AND Sidecar absent) →
// ONE event; sidecar-missing is the dominant reason.
Assert.Equal(EspPolicyProviderStallDetector.ReasonSidecarMissing, data["reason"]);
Assert.Equal(false, data["sidecarRegistered"]);
Assert.Equal(15.0, data["sidecarMissingForMinutes"]);
Assert.Equal(EspPolicyProviderStallDetector.DwellMinutes, data["dwellMinutes"]);
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
var entry = Assert.Single(stalled);
@@ -252,6 +326,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Equal(15.0, entry["stalledForMinutes"]);
}
[Fact]
public void Issue106_configmgr_complete_without_sidecar_still_emits_sidecar_missing()
{
// Regression test for the customer's follow-up on issue #106: TrackingPoliciesCreated=1
// WAS present under ConfigMgr — the provider looks "complete", arm 1 never fires, yet
// the ESP hangs because Sidecar is absent. Arm 2 must catch exactly this.
var configMgrComplete = new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
trackingPoliciesCreated: 1, installationState: null),
});
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => configMgrComplete);
var (sut, sink, clock) = BuildDetector();
sut.Tick();
Assert.Empty(StalledEvents(sink));
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
var evt = Assert.Single(StalledEvents(sink));
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(EspPolicyProviderStallDetector.ReasonSidecarMissing, data["reason"]);
Assert.Equal(false, data["sidecarRegistered"]);
Assert.Equal(15.0, data["sidecarMissingForMinutes"]);
Assert.Empty(Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]));
var providers = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object?>>>(data["providers"]);
Assert.Equal(true, Assert.Single(providers)["complete"]); // the misleading "complete" is visible
}
[Fact]
public void Sidecar_complete_configmgr_incomplete_reports_only_configmgr_with_sidecar_flag()
{
@@ -264,7 +368,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
var evt = Assert.Single(StalledEvents(sink));
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(EspPolicyProviderStallDetector.ReasonProviderIncomplete, data["reason"]); // arm 2 off — Sidecar is there
Assert.Equal(true, data["sidecarRegistered"]);
Assert.False(data.ContainsKey("sidecarMissingForMinutes"));
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
Assert.Equal("ConfigMgr", Assert.Single(stalled)["name"]);
var providers = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object?>>>(data["providers"]);
@@ -161,6 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.False(string.IsNullOrEmpty(snapshot.KekStatus));
}
/// <summary>
/// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so the enable →
/// read → restore sequence is a process-wide side effect. Unserialized, a second
/// reader observes "already enabled", skips its own restore, and then loses the
/// privilege mid-read when the first reader disposes — surfacing as a privilege_denied
/// that disagrees with the other readers. The machine's actual privilege state does
/// not matter here: whatever it is, every reader must report the SAME status.
/// </summary>
[Fact]
public void Read_is_serialized_across_concurrent_callers()
{
const int readers = 8;
var snapshots = new UefiSecureBootCertSnapshot[readers];
var failures = new System.Exception[readers];
var threads = new System.Threading.Thread[readers];
for (var i = 0; i < readers; i++)
{
var index = i;
threads[i] = new System.Threading.Thread(() =>
{
try { snapshots[index] = UefiSecureBootCertReader.Read(); }
catch (System.Exception ex) { failures[index] = ex; }
});
threads[i].Start();
}
foreach (var thread in threads) thread.Join();
Assert.All(failures, Assert.Null);
Assert.All(snapshots, s => Assert.False(string.IsNullOrEmpty(s.DbStatus)));
Assert.All(snapshots, s => Assert.Equal(snapshots[0].DbStatus, s.DbStatus));
Assert.All(snapshots, s => Assert.Equal(snapshots[0].KekStatus, s.KekStatus));
}
private static byte[] Concat(params byte[][] parts)
{
var total = 0;
@@ -35,13 +35,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// (registered during Autopilot-into-co-management, see the troubleshooting registry paths in
/// https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment). Microsoft
/// warns that ESP policy providers are not aware of each other and that pre-provisioning is
/// unsupported with co-management. Field case that motivated this probe: a pre-provisioned
/// device with the ConfigMgr client installed as a blocking app ended up with ONLY
/// <c>ConfigMgr</c> registered under <c>Device\Setup\Apps\PolicyProviders</c> (no
/// <c>Sidecar</c>) and <c>TrackingPoliciesCreated</c> never set — the user ESP hung at
/// "Apps (Identifying)" for hours to days; manually renaming the key to <c>Sidecar</c>
/// unblocked it immediately. <see cref="EspPolicyProviderStallDetector"/> turns this contract
/// into the <c>esp_policy_provider_stalled</c> tripwire.
/// unsupported with co-management. Field case that motivated this probe (issue #106): a
/// pre-provisioned device with the ConfigMgr client installed as a blocking app ended up with
/// ONLY <c>ConfigMgr</c> registered under <c>Device\Setup\Apps\PolicyProviders</c> (no
/// <c>Sidecar</c>) — and although <c>TrackingPoliciesCreated=1</c> WAS set under it, the user
/// ESP hung at "Apps (Identifying)" for hours to days; manually renaming the key to
/// <c>Sidecar</c> unblocked it immediately. So the ESP's Apps wait is keyed to the provider
/// the Intune service registered at enrollment (always <c>Sidecar</c>; ConfigMgr only joins
/// via a co-management settings policy — see the WAP payload documented in
/// https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/), and a
/// foreign provider's completion values do not satisfy it. Detection therefore keys on the
/// provider NAME topology, not only on completeness:
/// <see cref="EspPolicyProviderStallDetector"/> turns this contract into the two-arm
/// <c>esp_policy_provider_stalled</c> tripwire.
/// </para>
/// <para>
/// Fail-soft like <see cref="EspTrackingInfoProbe"/>: any failure is Debug-logged and yields
@@ -13,18 +13,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Turns the ESP policy-provider contract read by <see cref="EspPolicyProviderProbe"/> into
/// the one-shot <c>esp_policy_provider_stalled</c> Warning: a registered provider that stays
/// continuously incomplete for <see cref="DwellMinutes"/> minutes is reported once, with the
/// full provider table in the payload. See the probe's header for the CSP contract, the
/// official sources, and the co-management field case (ConfigMgr registered without Sidecar
/// → user ESP parked at "Apps (Identifying)" with no OS timeout) this tripwire exists for.
/// the one-shot <c>esp_policy_provider_stalled</c> Warning via two independent arms, each
/// with the same <see cref="DwellMinutes"/>-minute continuous-dwell rule and the full
/// provider table in the payload:
/// <list type="bullet">
/// <item><b>Arm 1 — <c>reason=provider_incomplete</c></b>: a registered provider stays
/// continuously incomplete (e.g. Sidecar itself never sets TrackingPoliciesCreated).</item>
/// <item><b>Arm 2 — <c>reason=sidecar_provider_missing</c></b>: at least one
/// <c>Setup\Apps</c> provider is registered but none of them is <c>Sidecar</c> (the IME).
/// This is the actual co-management field-case signature (issue #106): the foreign
/// <c>ConfigMgr</c> provider had <c>TrackingPoliciesCreated=1</c> — i.e. was "complete"
/// by the CSP value contract — yet the user ESP stayed parked at "Apps (Identifying)"
/// until the key was renamed to <c>Sidecar</c>. The ESP's Apps wait is keyed to the
/// Intune-registered provider by NAME, so provider completeness alone must not gate this
/// detection. See the probe's header for sources.</item>
/// </list>
/// <para>
/// Dwell semantics: the dwell clock per provider starts when it is first observed incomplete
/// and RESETS when the provider completes or disappears — only <i>continuously</i> incomplete
/// providers fire. A missing root key / empty provider list is normal early enrollment and
/// clears all dwell state (probe D4). Purely observational: emission goes through
/// <see cref="InformationalEventPost"/> (InformationalEvent pass-through, dispatch-guard
/// exempt), never into decision state.
/// Dwell semantics: arm 1's clock per provider starts when it is first observed incomplete
/// and RESETS when the provider completes or disappears; arm 2's clock starts when the
/// sidecar-missing condition is first observed and RESETS when Sidecar registers (covers the
/// legitimate "ConfigMgr registered before Sidecar" startup ordering) — only
/// <i>continuously</i> bad states fire. A missing root key / empty provider list is normal
/// early enrollment and clears all dwell state (probe D4). Purely observational: emission
/// goes through <see cref="InformationalEventPost"/> (InformationalEvent pass-through,
/// dispatch-guard exempt), never into decision state.
/// </para>
/// <para>
/// Duplicate suppression: in-process one-shot per provider key, plus a
@@ -44,6 +56,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal const string SourceLabel = "EspPolicyProviderStallDetector";
/// <summary>Payload <c>reason</c>: a registered provider stayed continuously incomplete (arm 1).</summary>
internal const string ReasonProviderIncomplete = "provider_incomplete";
/// <summary>Payload <c>reason</c>: Setup\Apps providers registered but Sidecar absent (arm 2 — dominant when both arms fire in one round).</summary>
internal const string ReasonSidecarMissing = "sidecar_provider_missing";
/// <summary>
/// Sentinel token representing the arm-2 condition in the cross-restart fingerprint.
/// Cannot collide with provider keys — those always contain <c>|</c> separators.
/// </summary>
private const string SidecarMissingFingerprintToken = "sidecarMissing";
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
@@ -57,6 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly HashSet<string> _firedKeys =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Arm-2 dwell state: first observation of "setupApps providers without Sidecar"; null = condition not active.</summary>
private DateTime? _sidecarMissingSinceUtc;
/// <summary>Arm-2 in-process one-shot latch (mirror of <see cref="_firedKeys"/>).</summary>
private bool _sidecarMissingFired;
public EspPolicyProviderStallDetector(
string sessionId,
string tenantId,
@@ -79,13 +109,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal void Tick()
{
var snapshot = EspPolicyProviderProbe.Read(_logger);
var eval = EvaluateStallTransition(snapshot, _firstSeenIncompleteUtc, _firedKeys, _clock.UtcNow, _dwell);
var eval = EvaluateStallTransition(
snapshot, _firstSeenIncompleteUtc, _firedKeys,
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell);
_firstSeenIncompleteUtc.Clear();
foreach (var kv in eval.UpdatedFirstSeenUtc)
_firstSeenIncompleteUtc[kv.Key] = kv.Value;
_sidecarMissingSinceUtc = eval.UpdatedSidecarMissingSinceUtc;
if (eval.NewlyStalled.Count > 0)
if (eval.NewlyStalled.Count > 0 || eval.SidecarMissingStalledForMinutes != null)
EmitStalled(snapshot, eval);
}
@@ -94,24 +127,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// registry, timer, or ingress (pattern: <c>PerformanceCollector.EvaluateDiskLowTransition</c>).
/// </summary>
/// <param name="snapshot">Current provider snapshot.</param>
/// <param name="firstSeenIncompleteUtc">Dwell state from the previous round (provider key → first-seen-incomplete).</param>
/// <param name="firstSeenIncompleteUtc">Arm-1 dwell state from the previous round (provider key → first-seen-incomplete).</param>
/// <param name="alreadyFired">Provider keys already reported this run (never re-report).</param>
/// <param name="sidecarMissingSinceUtc">Arm-2 dwell state from the previous round; null = condition was not active.</param>
/// <param name="sidecarMissingAlreadyFired">Arm-2 one-shot latch (never re-report).</param>
/// <param name="nowUtc">Evaluation timestamp.</param>
/// <param name="dwell">Continuous-incompleteness threshold.</param>
/// <param name="dwell">Continuous-condition threshold (shared by both arms).</param>
internal static StallEvaluation EvaluateStallTransition(
EspPolicyProviderSnapshot snapshot,
IReadOnlyDictionary<string, DateTime> firstSeenIncompleteUtc,
ISet<string> alreadyFired,
DateTime? sidecarMissingSinceUtc,
bool sidecarMissingAlreadyFired,
DateTime nowUtc,
TimeSpan dwell)
{
var result = new StallEvaluation();
// Missing root key or no registered providers = normal early enrollment: clear all
// dwell state (a provider must be continuously OBSERVED incomplete to accrue dwell).
// dwell state (a condition must be continuously OBSERVED to accrue dwell).
if (!snapshot.HasData || snapshot.Providers.Count == 0)
return result;
// Arm 1 — a registered provider continuously incomplete.
foreach (var provider in snapshot.Providers)
{
if (provider.IsComplete)
@@ -127,22 +165,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
result.NewlyStalled.Add(new StalledProvider(provider, stalledFor.TotalMinutes));
}
// Arm 2 — Setup\Apps providers registered, but none of them is Sidecar. Deliberately
// ignores provider completeness: the field case (issue #106) had ConfigMgr with
// TrackingPoliciesCreated=1 and the ESP still waited. Sidecar registering later
// (legitimate startup ordering) clears the condition and resets the clock.
if (HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
{
var since = sidecarMissingSinceUtc ?? nowUtc;
result.UpdatedSidecarMissingSinceUtc = since;
var missingFor = nowUtc - since;
if (missingFor >= dwell && !sidecarMissingAlreadyFired)
result.SidecarMissingStalledForMinutes = missingFor.TotalMinutes;
}
return result;
}
private static bool HasSetupAppsProvider(EspPolicyProviderSnapshot snapshot)
=> snapshot.Providers.Any(p => p.Kind == EspPolicyProviderProbe.KindSetupApps);
private void EmitStalled(EspPolicyProviderSnapshot snapshot, StallEvaluation eval)
{
foreach (var stalled in eval.NewlyStalled)
_firedKeys.Add(stalled.Provider.Key);
if (eval.SidecarMissingStalledForMinutes != null)
_sidecarMissingFired = true;
// Cross-restart dedup: fingerprint = sorted set of all fired keys that are still
// incomplete. A restart into the same stall re-accrues dwell and lands on the same
// fingerprint → suppressed; a new provider joining the stall changes it → re-emitted.
// Cross-restart dedup: fingerprint = sorted set of all fired conditions that are
// still active (incomplete providers + the sidecar-missing sentinel). A restart into
// the same stall re-accrues dwell and lands on the same fingerprint → suppressed; a
// new condition joining the stall changes it → re-emitted.
var currentlyStalledKeys = snapshot.Providers
.Where(p => !p.IsComplete && _firedKeys.Contains(p.Key))
.Select(p => p.Key)
.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
.ToList();
if (_sidecarMissingFired && HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
currentlyStalledKeys.Add(SidecarMissingFingerprintToken);
currentlyStalledKeys.Sort(StringComparer.OrdinalIgnoreCase);
var fingerprint = StartupEventGate.HashString(string.Join(";", currentlyStalledKeys));
if (_startupGate != null &&
!_startupGate.ShouldEmit(Constants.EventTypes.EspPolicyProviderStalled, fingerprint))
@@ -153,11 +213,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return;
}
// Sidecar-missing is the dominant reason when both arms fire in one round: it
// explains the ESP hang regardless of the foreign providers' completeness.
var reason = eval.SidecarMissingStalledForMinutes != null
? ReasonSidecarMissing
: ReasonProviderIncomplete;
var messageParts = new List<string>();
if (eval.SidecarMissingStalledForMinutes != null)
{
var registeredNames = string.Join(", ", snapshot.Providers
.Where(p => p.Kind == EspPolicyProviderProbe.KindSetupApps)
.Select(p => $"{p.Name} ({p.Scope})"));
messageParts.Add(
$"expected IME provider '{EspPolicyProviderProbe.SidecarProviderName}' not registered for " +
$">= {DwellMinutes} min while Setup/Apps providers exist: {registeredNames}");
}
var stalledNames = string.Join(", ",
eval.NewlyStalled.Select(s => $"{s.Provider.Name} ({s.Provider.Scope}/{s.Provider.Kind})"));
if (eval.NewlyStalled.Count > 0)
messageParts.Add($"provider(s) incomplete for >= {DwellMinutes} min: {stalledNames}");
var data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["reason"] = reason,
["dwellMinutes"] = DwellMinutes,
["sidecarRegistered"] = snapshot.SidecarRegistered,
["providers"] = snapshot.Providers.Select(p => new Dictionary<string, object?>(StringComparer.Ordinal)
@@ -177,6 +256,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
["stalledForMinutes"] = Math.Round(s.StalledForMinutes, 1),
}).ToList(),
};
if (eval.SidecarMissingStalledForMinutes != null)
data["sidecarMissingForMinutes"] = Math.Round(eval.SidecarMissingStalledForMinutes.Value, 1);
var message =
"ESP Setup/Apps tracking stalled: " + string.Join("; ", messageParts) +
" — the ESP waits on its app-tracking providers without any timeout";
_post.Emit(new EnrollmentEvent
{
@@ -188,27 +273,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Source = SourceLabel,
Phase = EnrollmentPhase.Unknown,
ImmediateUpload = true,
Message = $"ESP policy provider(s) incomplete for >= {DwellMinutes} min: {stalledNames} " +
$"(sidecarRegistered={snapshot.SidecarRegistered}) — the ESP waits on " +
"Setup/Apps providers without any timeout",
Message = message,
Data = data
});
_startupGate?.MarkEmitted(Constants.EventTypes.EspPolicyProviderStalled);
_logger?.Warning(
$"EspPolicyProviderStallDetector: {stalledNames} incomplete for >= {DwellMinutes} min " +
$"(sidecarRegistered={snapshot.SidecarRegistered}).");
$"EspPolicyProviderStallDetector: {string.Join("; ", messageParts)} " +
$"(reason={reason}, sidecarRegistered={snapshot.SidecarRegistered}).");
}
/// <summary>Result of one <see cref="EvaluateStallTransition"/> round.</summary>
internal sealed class StallEvaluation
{
/// <summary>Next round's dwell state — only currently-incomplete providers survive.</summary>
/// <summary>Next round's arm-1 dwell state — only currently-incomplete providers survive.</summary>
public Dictionary<string, DateTime> UpdatedFirstSeenUtc { get; } =
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
/// <summary>Providers that crossed the dwell threshold this round and were not yet reported.</summary>
/// <summary>Providers that crossed the dwell threshold this round and were not yet reported (arm 1).</summary>
public List<StalledProvider> NewlyStalled { get; } = new List<StalledProvider>();
/// <summary>Next round's arm-2 dwell state; null = sidecar-missing condition not active (reset).</summary>
public DateTime? UpdatedSidecarMissingSinceUtc { get; set; }
/// <summary>Non-null when the sidecar-missing condition crossed the dwell threshold this round and was not yet reported (arm 2).</summary>
public double? SidecarMissingStalledForMinutes { get; set; }
}
internal readonly struct StalledProvider
@@ -1,5 +1,6 @@
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
@@ -56,6 +57,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
private const int InitialBufferSize = 64 * 1024;
private const int RetryBufferSize = 256 * 1024;
// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so enable → read →
// restore is a process-wide side effect and must run as one atomic sequence. Without
// this gate a second reader would observe "already enabled", correctly skip its own
// restore, and then have the privilege disabled underneath it when the first reader
// finishes — a spurious privilege_denied on a machine that actually holds it.
private static readonly object FirmwareReadGate = new object();
/// <summary>
/// Reads db and KEK and evaluates the certificate presence flags. Never throws;
/// per-variable failures surface as <see cref="UefiSecureBootCertSnapshot.DbStatus"/> /
@@ -67,6 +75,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
var snapshot = new UefiSecureBootCertSnapshot();
try
{
// The critical section covers the whole privileged window, not just the
// adjustment — see FirmwareReadGate. Only synchronous kernel calls and byte
// scans run inside it, so it cannot block on anything else the agent does.
lock (FirmwareReadGate)
using (var privilege = SystemEnvironmentPrivilege.TryEnable())
{
if (!privilege.Enabled)
@@ -168,23 +180,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// of the instance and restores the previous privilege state on dispose. SYSTEM holds
/// the privilege (default-disabled); without elevation <see cref="Enabled"/> stays
/// false and the caller reports <see cref="StatusPrivilegeDenied"/>.
/// <para>
/// <b>Crash-safety.</b> This is the repo's only privilege-adjustment code, so the
/// ownership rules are spelled out: the token is held in a <see cref="SafeHandle"/>
/// assigned by the P/Invoke marshaler, so the CLR owns it from the moment
/// <c>OpenProcessToken</c> returns — it is released by the critical finalizer even if
/// this instance never reaches the caller's <c>using</c> (async exception between the
/// call and the assignment). <see cref="Dispose"/> is idempotent, never throws, and
/// releases the handle in a <c>finally</c> so a failed restore cannot leak it. The
/// restore obligation is recorded immediately after the successful adjustment, so no
/// throw in between can strand the process with the privilege left enabled.
/// </para>
/// </summary>
private sealed class SystemEnvironmentPrivilege : IDisposable
{
public bool Enabled { get; private set; }
private IntPtr _token = IntPtr.Zero;
private SafeTokenHandle _token;
private NativeMethods.TOKEN_PRIVILEGES _previousState;
private bool _restoreNeeded;
private bool _disposed;
public static SystemEnvironmentPrivilege TryEnable()
{
var instance = new SystemEnvironmentPrivilege();
try
{
SafeTokenHandle token;
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
NativeMethods.TOKEN_ADJUST_PRIVILEGES | NativeMethods.TOKEN_QUERY, out instance._token))
NativeMethods.TOKEN_ADJUST_PRIVILEGES | NativeMethods.TOKEN_QUERY, out token))
{
// Failed open still yields a (closed/invalid) SafeHandle instance.
if (token != null) token.Dispose();
return instance;
}
instance._token = token;
NativeMethods.LUID luid;
if (!NativeMethods.LookupPrivilegeValueW(null, "SeSystemEnvironmentPrivilege", out luid))
@@ -197,52 +228,80 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
Attributes = NativeMethods.SE_PRIVILEGE_ENABLED
};
NativeMethods.TOKEN_PRIVILEGES previousState;
uint returnLength;
if (!NativeMethods.AdjustTokenPrivileges(instance._token, false, ref newState,
if (!NativeMethods.AdjustTokenPrivileges(token, false, ref newState,
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
out instance._previousState, out returnLength))
out previousState, out returnLength))
return instance;
// AdjustTokenPrivileges returns TRUE even when the privilege is not held —
// the real verdict is ERROR_NOT_ALL_ASSIGNED in the last error.
// the real verdict is ERROR_NOT_ALL_ASSIGNED in the last error. Nothing was
// adjusted in that case, so there is nothing to restore.
if (Marshal.GetLastWin32Error() == ErrorNotAllAssigned)
return instance;
// Restore only when the privilege was not already enabled before.
instance._restoreNeeded = instance._previousState.PrivilegeCount > 0
&& (instance._previousState.Attributes & NativeMethods.SE_PRIVILEGE_ENABLED) == 0;
// The token is now modified: record the restore obligation before anything
// else can throw. Restore only when the privilege was not already enabled.
instance._previousState = previousState;
instance._restoreNeeded = previousState.PrivilegeCount > 0
&& (previousState.Attributes & NativeMethods.SE_PRIVILEGE_ENABLED) == 0;
instance.Enabled = true;
}
catch
{
// Fail-soft: Enabled stays false.
// Fail-soft: Enabled stays false. Whatever was acquired before the failure
// is already tracked on the instance and released by Dispose.
}
return instance;
}
public void Dispose()
{
if (_token != IntPtr.Zero)
if (_disposed) return;
_disposed = true;
Enabled = false;
var token = _token;
_token = null;
if (token == null) return;
try
{
try
if (_restoreNeeded)
{
if (_restoreNeeded)
{
NativeMethods.TOKEN_PRIVILEGES ignored;
uint returnLength;
NativeMethods.AdjustTokenPrivileges(_token, false, ref _previousState,
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
out ignored, out returnLength);
}
_restoreNeeded = false;
NativeMethods.TOKEN_PRIVILEGES ignored;
uint returnLength;
NativeMethods.AdjustTokenPrivileges(token, false, ref _previousState,
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
out ignored, out returnLength);
}
catch
{
// Best effort — the process token privilege state is not load-bearing
// for anything else the agent does.
}
NativeMethods.CloseHandle(_token);
_token = IntPtr.Zero;
}
catch
{
// Best effort — the process token privilege state is not load-bearing for
// anything else the agent does, and Dispose must never throw.
}
finally
{
token.Dispose();
}
}
}
/// <summary>
/// Owns a process-token handle. <c>ReleaseHandle</c> runs on the finalizer thread when
/// <see cref="SystemEnvironmentPrivilege.Dispose"/> is skipped, so it must not throw —
/// a single <c>CloseHandle</c> P/Invoke satisfies that.
/// </summary>
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeTokenHandle() : base(true) { }
protected override bool ReleaseHandle()
{
return NativeMethods.CloseHandle(handle);
}
}
@@ -278,7 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
@@ -286,7 +345,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool AdjustTokenPrivileges(IntPtr tokenHandle, bool disableAllPrivileges,
public static extern bool AdjustTokenPrivileges(SafeTokenHandle tokenHandle, bool disableAllPrivileges,
ref TOKEN_PRIVILEGES newState, uint bufferLength, out TOKEN_PRIVILEGES previousState, out uint returnLength);
[DllImport("kernel32.dll", SetLastError = true)]
@@ -37,8 +37,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <see cref="StallProbeCollectorAdapter"/>. Owns its 60-s idle-check timer (the
/// collector itself has no timer — it's a pure probe invoked from outside).</item>
/// <item><b>EspPolicyProviderStallHost</b> — always-on 60-s wall-clock tick for the
/// <c>esp_policy_provider_stalled</c> tripwire (registered EnrollmentStatusTracking CSP
/// provider continuously incomplete ≥ 15 min — the no-timeout ESP wait).</item>
/// two-arm <c>esp_policy_provider_stalled</c> tripwire (EnrollmentStatusTracking CSP
/// provider continuously incomplete ≥ 15 min, OR Setup\Apps providers registered
/// without the expected <c>Sidecar</c>/IME provider — the no-timeout ESP wait).</item>
/// </list>
/// Optional peripheral hosts (driven by <see cref="CollectorConfiguration"/> toggles):
/// <list type="bullet">
@@ -307,11 +308,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
// disk_space_low): a registered EnrollmentStatusTracking CSP provider that never
// completes (e.g. co-management "ConfigMgr" without "Sidecar" on a pre-provisioned
// device) parks the user ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock
// dwell, deliberately outside the StallProbe gate: the idle clock there resets on any
// session activity, and the stall must accrue regardless of it.
// disk_space_low): a provider that never completes, or co-management "ConfigMgr"
// registered without the expected "Sidecar"/IME provider (issue #106 — even with
// TrackingPoliciesCreated=1 the ESP ignores foreign provider names), parks the user
// ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock dwell, deliberately
// outside the StallProbe gate: the idle clock there resets on any session activity,
// and the stall must accrue regardless of it.
hosts.Add(new EspPolicyProviderStallHost(
sessionId: sessionId,
tenantId: tenantId,
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Apps;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the terminal-only failure-rate convention on the Apps dashboard aggregations
/// (<see cref="AppsAnalyticsHelper"/>): every failureRate is Failed / (Failed + Succeeded) —
/// finished installs only. "InProgress" rows (still installing, or orphaned by a session that
/// died mid-install) sit in the install counts but never dilute a rate, matching the enrollment
/// success-rate convention. Responses are anonymous projections, so tests assert on the
/// JSON wire shape like the sibling suites.
/// </summary>
public class AppsAnalyticsFailureRateTests
{
private const string TenantId = "00000000-0000-0000-0000-000000000fa1";
private static AppInstallSummary A(
string status,
string appName = "Contoso App",
DateTime? startedAt = null,
string sessionId = "s-1",
string appVersion = "")
=> new()
{
TenantId = TenantId,
SessionId = sessionId,
AppName = appName,
AppVersion = appVersion,
Status = status,
StartedAt = startedAt ?? DateTime.UtcNow.AddHours(-2),
DurationSeconds = status == "Succeeded" ? 30 : 0,
};
// ── /apps/list ──────────────────────────────────────────────────────────
[Fact]
public void List_FailureRate_IsOverFinishedInstalls_InProgressExcluded()
{
var summaries = new List<AppInstallSummary>
{
A("Failed"),
A("Succeeded"),
A("InProgress"),
A("InProgress"),
};
var root = JsonSerializer.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
Assert.Equal(4, app.GetProperty("totalInstalls").GetInt32());
// 1 / (1 + 1) = 50% (over finished), NOT 1 / 4 = 25% (over all installs).
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
}
[Fact]
public void List_FailureRate_IsZero_WhenNothingFinishedYet()
{
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
var root = JsonSerializer.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
}
[Fact]
public void List_Trend_UsesFinishedRates_AndGatesOnFinishedSamples()
{
var now = DateTime.UtcNow;
var firstHalf = now.AddDays(-20); // days: 30 → midpoint at now - 15d
var secondHalf = now.AddDays(-5);
var summaries = new List<AppInstallSummary>();
// First half: 1 failed / 5 finished = 20%.
summaries.Add(A("Failed", startedAt: firstHalf));
for (var i = 0; i < 4; i++) summaries.Add(A("Succeeded", startedAt: firstHalf));
// Second half: 3 failed / 5 finished = 60% — plus 10 in-flight installs that
// previously dragged the half's rate down to 20% and masked the regression.
for (var i = 0; i < 3; i++) summaries.Add(A("Failed", startedAt: secondHalf));
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
var root = JsonSerializer.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
Assert.Equal("worsening", app.GetProperty("trend").GetString());
Assert.Equal(40d, app.GetProperty("trendDelta").GetDouble());
}
[Fact]
public void List_Trend_StaysStable_WhenAHalfLacksFiveFinishedInstalls()
{
var now = DateTime.UtcNow;
var summaries = new List<AppInstallSummary>();
// First half: 5 finished. Second half: 4 finished + many in-flight — the old
// install-count gate would have passed; the finished-sample gate must not.
for (var i = 0; i < 5; i++) summaries.Add(A("Succeeded", startedAt: now.AddDays(-20)));
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
var root = JsonSerializer.SerializeToElement(
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
var app = root.GetProperty("apps")[0];
Assert.Equal("stable", app.GetProperty("trend").GetString());
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
}
// ── /apps/{appName}/analytics ───────────────────────────────────────────
private static async Task<JsonElement> BuildAnalyticsAsync(
List<AppInstallSummary> summaries, ISessionRepository? sessionRepo = null)
{
if (sessionRepo == null)
{
var mock = new Mock<ISessionRepository>();
mock.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((SessionSummary?)null);
sessionRepo = mock.Object;
}
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, sessionRepo, "Contoso App", days: 30);
return JsonSerializer.SerializeToElement(result);
}
[Fact]
public async Task Analytics_SummaryVersionAndTimeSeriesRates_AreOverFinishedInstalls()
{
var summaries = new List<AppInstallSummary>
{
A("Failed", appVersion: "1.0"),
A("Succeeded", appVersion: "1.0"),
A("InProgress", appVersion: "1.0"),
A("InProgress", appVersion: "1.0"),
};
var root = await BuildAnalyticsAsync(summaries);
// Summary: 1 / (1 + 1) = 50%, NOT 1 / 4 = 25%.
var summary = root.GetProperty("summary");
Assert.Equal(4, summary.GetProperty("totalInstalls").GetInt32());
Assert.Equal(50d, summary.GetProperty("failureRate").GetDouble());
var version = root.GetProperty("versionBreakdown")[0];
Assert.Equal(4, version.GetProperty("installs").GetInt32());
Assert.Equal(50d, version.GetProperty("failureRate").GetDouble());
// All four installs land in today's bucket → same terminal-only rate there.
var buckets = root.GetProperty("timeSeries").EnumerateArray().ToList();
var todayBucket = buckets.Single(b => b.GetProperty("installs").GetInt32() > 0);
Assert.Equal(50d, todayBucket.GetProperty("failureRate").GetDouble());
}
[Fact]
public async Task Analytics_DeviceModelBreakdown_RatesAndSampleFloor_UseFinishedInstalls()
{
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(r => r.GetSessionAsync(TenantId, It.IsAny<string>()))
.ReturnsAsync((string _, string sessionId) => new SessionSummary
{
TenantId = TenantId,
SessionId = sessionId,
Manufacturer = "Contoso",
Model = sessionId.StartsWith("s-a") ? "ModelA" : "ModelB",
});
var summaries = new List<AppInstallSummary>();
// ModelA: 5 finished (1 failed → 20%) + 3 in-flight → ranked, rate undiluted.
summaries.Add(A("Failed", sessionId: "s-a-0"));
for (var i = 1; i <= 4; i++) summaries.Add(A("Succeeded", sessionId: $"s-a-{i}"));
for (var i = 5; i <= 7; i++) summaries.Add(A("InProgress", sessionId: $"s-a-{i}"));
// ModelB: 4 finished + 6 in-flight — 10 installs, but below the 5-finished floor.
for (var i = 0; i <= 3; i++) summaries.Add(A("Failed", sessionId: $"s-b-{i}"));
for (var i = 4; i <= 9; i++) summaries.Add(A("InProgress", sessionId: $"s-b-{i}"));
var root = await BuildAnalyticsAsync(summaries, sessionRepo.Object);
var breakdown = root.GetProperty("deviceModelBreakdown");
var model = Assert.Single(breakdown.EnumerateArray());
Assert.Equal("ModelA", model.GetProperty("model").GetString());
Assert.Equal(8, model.GetProperty("installs").GetInt32());
Assert.Equal(20d, model.GetProperty("failureRate").GetDouble());
}
}
@@ -183,4 +183,13 @@ public class ConfigRedactionTests
public void CanViewSecrets_PureDelegatedReader_False()
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
new RequestContext { IsDelegatedReader = true, TenantId = TenantA, TargetTenantId = TenantB }));
[Theory]
[InlineData(Constants.TenantRoles.Operator)]
[InlineData(Constants.TenantRoles.Viewer)]
public void CanViewSecrets_OwnTenantOperatorOrViewer_False(string role)
// Member-tier read-only Settings view (config GET is MemberRead): an own-tenant
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
}
@@ -324,4 +324,42 @@ public class CrossTenantAccessTests
Assert.NotNull(entry);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
// ── Cross-tenant rule WRITES: global/rules/{gather,analyze}/{ruleId} ──
// Bug (2026-07-24): a Global Admin editing a foreign tenant's rule via the JWT-scoped
// rules/{type}/{ruleId} route silently upserted into the GA's OWN tenant (200 → false "saved");
// the reload re-read the foreign tenant and showed the unchanged rule. Fix: dedicated cross-tenant
// write routes. They MUST be GlobalAdminOnly (a write tier → no delegated-read rescue, so a
// tenant-admin / delegated caller can never cross into another tenant's rules) + QueryParam (the
// tenant is named by ?tenantId=, not the {ruleId} segment). A downgrade to a tenant tier, or a slip
// to TenantScoping.None (which would ignore ?tenantId= and re-introduce the home-tenant misroute),
// is caught here.
[Theory]
[InlineData("PUT", "global/rules/gather/{ruleId}")]
[InlineData("DELETE", "global/rules/gather/{ruleId}")]
[InlineData("PUT", "global/rules/analyze/{ruleId}")]
[InlineData("DELETE", "global/rules/analyze/{ruleId}")]
public void GlobalRuleWriteRoutes_AreGlobalAdminOnly_QueryParam(string httpMethod, string routeTemplate)
{
var entry = EndpointAccessPolicyCatalog.Entries
.FirstOrDefault(e => e.HttpMethod == httpMethod.ToUpperInvariant() && e.RouteTemplate == routeTemplate);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry!.Policy);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
[Theory]
[InlineData("PUT", "/api/global/rules/gather/rule-1")]
[InlineData("DELETE", "/api/global/rules/gather/rule-1")]
[InlineData("PUT", "/api/global/rules/analyze/rule-1")]
[InlineData("DELETE", "/api/global/rules/analyze/rule-1")]
public void GlobalRuleWrite_CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: true, queryTenantId: TenantB);
Assert.False(isBlocked, $"Global Admin should reach the target tenant on the cross-tenant write route: {httpMethod} {path}");
Assert.Equal(TenantB, targetTenantId);
}
}
@@ -154,6 +154,80 @@ public class DiagnosticsBlobCascadeDeleterTests
Assert.Equal(0, deleter.HostedDeleteCalls);
}
// ── REAL DeleteAsync, Hosted branch — prefix sweep + belt-and-braces name delete ──
// The RecordingDeleter above replaces DeleteAsync wholesale, so these run the REAL
// base implementation against a fake storage service to pin the sweep behaviour.
private const string SessionGuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Fact]
public async Task RealDeleteAsync_Hosted_SweepsSessionPrefix_AndDeletesManifestBlob()
{
var storage = new RecordingHostedStorage();
var deleter = RealDeleter(storage);
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
manifest.SessionId = SessionGuid;
var outcome = await deleter.DeleteAsync(manifest);
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
Assert.Equal((TenantA, SessionGuid), storage.SweptSession);
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
}
[Fact]
public async Task RealDeleteAsync_Hosted_NonGuidSessionId_SkipsSweep_StillDeletesManifestBlob()
{
// Legacy manifests may carry a malformed/empty SessionId — the sweep must degrade
// to the single-blob delete, never throw into the cascade poison path.
var storage = new RecordingHostedStorage();
var deleter = RealDeleter(storage);
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
manifest.SessionId = "not-a-guid";
var outcome = await deleter.DeleteAsync(manifest);
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
Assert.Null(storage.SweptSession);
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
}
private static DiagnosticsBlobCascadeDeleter RealDeleter(RecordingHostedStorage storage)
// TenantConfigurationService is only consulted on the CustomerSas branch; the
// Hosted-branch tests never reach it, so null keeps the harness Azure-free.
=> new(
storage,
tenantConfig: null!,
Microsoft.Extensions.Logging.Abstractions.NullLogger<DiagnosticsBlobCascadeDeleter>.Instance);
private sealed class RecordingHostedStorage : HostedDiagnosticsBlobService
{
public (string TenantId, string SessionId)? SweptSession { get; private set; }
public string? DeletedBlobPath { get; private set; }
public RecordingHostedStorage()
: base(
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
Microsoft.Extensions.Logging.Abstractions.NullLogger<HostedDiagnosticsBlobService>.Instance,
usesManagedIdentity: false)
{
}
public override Task<int> DeleteBySessionPrefixAsync(
string tenantId, string sessionId, CancellationToken cancellationToken = default)
{
SweptSession = (tenantId, sessionId);
return Task.FromResult(1);
}
public override Task<bool> DeleteIfExistsAsync(
string blobPath, CancellationToken cancellationToken = default)
{
DeletedBlobPath = blobPath;
return Task.FromResult(true);
}
}
// ── BuildBlobUrl helper (CustomerSas URL construction) ─────────────────────────
[Fact]
@@ -160,9 +160,9 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.NotNull(getConfig);
Assert.NotNull(putConfig);
// GET config read is admin-tier read (own-tenant Admin / GA / read-only GlobalReader);
// PUT config write stays Admin/GA only.
Assert.Equal(EndpointPolicy.TenantAdminOrGlobalReader, getConfig.Policy);
// GET config read is member-tier (Operators/Viewers get the redacted read-only Settings
// view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only.
Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
@@ -338,7 +338,7 @@ public class EndpointPolicyCatalogCompletenessTests
[InlineData("POST", "/api/agent/telemetry", true)] // DeviceOrBootstrapAuth
[InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route)
[InlineData("GET", "/api/sessions", false)] // MemberRead
[InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // TenantAdminOrGlobalReader
[InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // MemberRead
[InlineData("GET", "/api/global/sessions", false)] // GlobalReadOrAdmin
[InlineData("GET", "/api/nonexistent", false)] // unregistered → fail-closed
public void SkipsJwtValidation_MatchesPolicyTier(string method, string path, bool expectedExempt)
@@ -10,9 +10,10 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Covers the server-side Fleet Health aggregation in
/// <see cref="MetricsMath.BuildFleetHealthPayload"/>, the single source of truth shared by the
/// tenant (metrics/fleet-health) and global (global/metrics/fleet-health) functions. These assert
/// the semantics ported verbatim from the previous client-side aggregation — notably success rate
/// over ALL sessions and average duration over non-in-progress sessions (failures included).
/// tenant (metrics/fleet-health) and global (global/metrics/fleet-health) functions. Success and
/// failure rates follow the SLA convention — over finished enrollments (Succeeded + Failed) only,
/// so in-flight sessions never dilute them. Average duration stays over non-in-progress sessions
/// that carry a duration (failures included).
/// </summary>
public class FleetHealthPayloadTests
{
@@ -55,7 +56,7 @@ public class FleetHealthPayloadTests
}
[Fact]
public void SuccessRate_IsOverAllSessions_NotJustTerminal()
public void SuccessRate_IsOverFinishedSessions_InFlightAndIncompleteExcluded()
{
var sessions = new List<SessionSummary>
{
@@ -63,16 +64,34 @@ public class FleetHealthPayloadTests
S(SessionStatus.Succeeded),
S(SessionStatus.Failed),
S(SessionStatus.InProgress),
S(SessionStatus.Pending),
S(SessionStatus.Incomplete), // terminal but non-failure — stays out of the rate
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
Assert.Equal(4, payload.Stats.Total);
Assert.Equal(6, payload.Stats.Total);
Assert.Equal(2, payload.Stats.Succeeded);
Assert.Equal(1, payload.Stats.Failed);
Assert.Equal(1, payload.Stats.InProgress);
// 2 / 4 = 50% (over total), NOT 2 / 3 = 66.7% (over terminal).
Assert.Equal(50d, payload.Stats.SuccessRate);
Assert.Equal(1, payload.Stats.Incomplete);
// 2 / (2 + 1) = 66.7% (over finished), NOT 2 / 6 = 33.3% (over total).
Assert.Equal(66.7d, payload.Stats.SuccessRate);
}
[Fact]
public void SuccessRate_IsZero_WhenNothingFinishedYet()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.InProgress),
S(SessionStatus.Pending),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
// No finished enrollments → no rate; clients render "—" off Succeeded + Failed == 0.
Assert.Equal(0d, payload.Stats.SuccessRate);
}
[Fact]
@@ -111,6 +130,27 @@ public class FleetHealthPayloadTests
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Contoso Model1");
}
[Fact]
public void ModelHealth_CountsFailedSeparately_InFlightInTotalOnly()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded, model: "X"),
S(SessionStatus.Failed, model: "X"),
S(SessionStatus.InProgress, model: "X"),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
var m = Assert.Single(payload.ModelHealth);
// Total keeps the full device count; the client derives the rate from
// Succeeded / (Succeeded + Failed), so the in-flight session must land
// in Total but in neither outcome bucket.
Assert.Equal(3, m.Total);
Assert.Equal(1, m.Succeeded);
Assert.Equal(1, m.Failed);
}
[Fact]
public void ModelKey_FallsBackToUnknown_WhenManufacturerAndModelBlank()
{
@@ -147,10 +187,13 @@ public class FleetHealthPayloadTests
{
var sessions = new List<SessionSummary>
{
// Model A: 1 failed of 2 → 50%
// Model A: 1 failed / 2 finished → 50%; the 2 in-flight sessions widen Total
// but must not dilute the rate (over-total it would read 25%).
S(SessionStatus.Failed, model: "A"),
S(SessionStatus.Succeeded, model: "A"),
// Model B: 2 failed of 2 → 100%
S(SessionStatus.InProgress, model: "A"),
S(SessionStatus.Pending, model: "A"),
// Model B: 2 failed of 2 finished → 100%
S(SessionStatus.Failed, model: "B"),
S(SessionStatus.Failed, model: "B"),
// Model C: no failures → excluded entirely
@@ -164,35 +207,37 @@ public class FleetHealthPayloadTests
Assert.Equal(2, payload.TopFailingModels[0].Failed);
Assert.Equal(100, payload.TopFailingModels[0].FailureRate);
Assert.Equal("Contoso A", payload.TopFailingModels[1].Model);
Assert.Equal(4, payload.TopFailingModels[1].Total);
Assert.Equal(50, payload.TopFailingModels[1].FailureRate);
Assert.DoesNotContain(payload.TopFailingModels, m => m.Model == "Contoso C");
}
[Fact]
public void FailureReasons_TruncateLongReasons_AndTakeTopFive()
public void FailureReasons_GroupByPrefix_KeepFullestText_AndTakeTopFive()
{
var sessions = new List<SessionSummary>();
// 6 distinct reasons with descending frequency 6..1 → only top 5 kept.
for (int freq = 6; freq >= 1; freq--)
// 5 distinct short reasons with descending frequency 5..1.
for (int freq = 5; freq >= 1; freq--)
for (int i = 0; i < freq; i++)
sessions.Add(S(SessionStatus.Failed, failureReason: $"Reason {freq}"));
// A null reason folds into "Unknown".
sessions.Add(S(SessionStatus.Failed, failureReason: null));
// An over-long reason is collapsed to a 50-char prefix + ellipsis.
var longReason = new string('x', 60);
sessions.Add(S(SessionStatus.Failed, failureReason: longReason));
// Two variants that share the first 50 characters collapse into one group;
// the longest variant is kept for display so the UI can show the full text.
var prefix = new string('x', 50);
var shortVariant = prefix + " short";
var longVariant = prefix + " a much longer tail that must survive intact";
for (int i = 0; i < 6; i++)
sessions.Add(S(SessionStatus.Failed, failureReason: shortVariant));
sessions.Add(S(SessionStatus.Failed, failureReason: longVariant));
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
Assert.Equal(5, payload.FailureReasons.Count);
Assert.Equal("Reason 6", payload.FailureReasons[0].Reason);
Assert.Equal(6, payload.FailureReasons[0].Count);
// The collapsed long reason exists in the tally (50 chars + "...").
var collapsed = new string('x', 50) + "...";
Assert.True(
sessions.Count(s => s.FailureReason == longReason) == 1,
"sanity: exactly one long-reason session");
Assert.Equal(53, collapsed.Length);
// The grouped cluster (6 + 1 = 7) outranks every "Reason N".
Assert.Equal(7, payload.FailureReasons[0].Count);
// The full, longest text survives end-to-end — no 50-char truncation.
Assert.Equal(longVariant, payload.FailureReasons[0].Reason);
Assert.DoesNotContain("...", payload.FailureReasons[0].Reason);
}
[Fact]
@@ -0,0 +1,99 @@
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 success-rate semantics of the geographic aggregation
/// (<see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/>): the rate is an outcome
/// quota over finished enrollments (Succeeded + Failed) following the SLA convention. In-flight
/// sessions (InProgress/Pending/Stalled/AwaitingUser) and Incomplete (terminal, non-failure) sit
/// in SessionCount but never dilute the rate — the customer-visible symptom this pins against was
/// a site mid-rollout showing a poor rate despite zero actual failures.
/// </summary>
public class GeographicMetricsSuccessRateTests
{
private static SessionSummary S(SessionStatus status, string city = "Frankfurt")
=> new()
{
TenantId = "00000000-0000-0000-0000-000000000fe0",
SessionId = Guid.NewGuid().ToString(),
Status = status,
StartedAt = DateTime.UtcNow.AddHours(-2),
GeoCountry = "DE",
GeoRegion = "Hessen",
GeoCity = city,
GeoLoc = "50.11,8.68",
};
[Fact]
public void SuccessRate_IsOverFinishedSessions_InFlightAndIncompleteExcluded()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded),
S(SessionStatus.Succeeded),
S(SessionStatus.Succeeded),
S(SessionStatus.Failed),
S(SessionStatus.InProgress),
S(SessionStatus.Pending),
S(SessionStatus.AwaitingUser),
S(SessionStatus.Incomplete), // terminal but non-failure — stays out of the rate
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "city");
var loc = Assert.Single(result.Locations);
Assert.Equal(8, loc.SessionCount);
Assert.Equal(3, loc.Succeeded);
Assert.Equal(1, loc.Failed);
// 3 / (3 + 1) = 75% (over finished), NOT 3 / 8 = 37.5% (over all sessions).
Assert.Equal(75d, loc.SuccessRate);
}
[Fact]
public void SuccessRate_IsZero_WhenNothingFinishedYet()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.InProgress),
S(SessionStatus.Pending),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "city");
var loc = Assert.Single(result.Locations);
// No finished enrollments → no rate; the frontend renders "—" off Succeeded + Failed == 0.
Assert.Equal(0d, loc.SuccessRate);
Assert.Equal(0, loc.Succeeded + loc.Failed);
Assert.Equal(2, loc.SessionCount);
}
[Fact]
public void SuccessRate_IsPerLocation_NotGlobal()
{
var sessions = new List<SessionSummary>
{
// Frankfurt: 1 / 1 finished → 100% despite the in-flight session.
S(SessionStatus.Succeeded, city: "Frankfurt"),
S(SessionStatus.InProgress, city: "Frankfurt"),
// Berlin: 1 / 2 finished → 50%.
S(SessionStatus.Succeeded, city: "Berlin"),
S(SessionStatus.Failed, city: "Berlin"),
};
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
sessions, new List<AppInstallSummary>(), "city");
var frankfurt = result.Locations.Single(l => l.City == "Frankfurt");
var berlin = result.Locations.Single(l => l.City == "Berlin");
Assert.Equal(100d, frankfurt.SuccessRate);
Assert.Equal(50d, berlin.SuccessRate);
}
}
@@ -35,6 +35,7 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.Equal(new[]
{
"bootstrapTokenEnabled",
"diagnosticsUploadConfigured",
"edition",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
@@ -114,6 +115,35 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
}
// ── diagnosticsUploadConfigured (drives the session-detail "Collect Logs" button) ──
[Theory]
// mode Off (or unset) → never configured, regardless of destination
[InlineData(null, null, null, false)]
[InlineData("Off", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", false)]
[InlineData("Off", null, "Hosted", false)]
// mode active + Hosted destination → configured without any SAS
[InlineData("OnFailure", null, "Hosted", true)]
[InlineData("Always", null, "Hosted", true)]
// mode active + CustomerSas → configured only when a SAS URL is present
[InlineData("OnFailure", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", true)]
[InlineData("OnFailure", null, "CustomerSas", false)]
[InlineData("Always", "", null, false)]
public void Payload_DiagnosticsUploadConfigured_ModeAndDestinationMatrix(
string? mode, string? sasUrl, string? destination, bool expected)
{
// Null-forgiving: the model declares these non-nullable with defaults, but table
// round-trips can produce null at runtime — the payload must tolerate that shape.
var element = Serialize(new TenantConfiguration
{
DiagnosticsUploadMode = mode!,
DiagnosticsBlobSasUrl = sasUrl!,
DiagnosticsUploadDestination = destination!,
});
Assert.Equal(expected, element.GetProperty("diagnosticsUploadConfigured").GetBoolean());
}
// ── Edition / trial surface ─────────────────────────────────────────────
[Fact]
@@ -0,0 +1,194 @@
using AutopilotMonitor.Functions.Functions.Reports;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the TPM-PSS-unsupported aggregation logic extracted from
/// GetTpmPssUnsupportedFunction. All tests target the pure static BuildAggregatedResult
/// method — no HTTP or DI needed. Aggregation is by serial number (the TPM incompatibility
/// is a per-device condition, not a per-model one — firmware levels differ within a model).
/// </summary>
public class GetTpmPssUnsupportedFunctionTests
{
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
// =========================================================================
// Empty / no matching reports
// =========================================================================
[Fact]
public void BuildAggregatedResult_EmptyList_ReturnsEmpty()
{
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult([]);
Assert.Empty(aggregated);
Assert.Equal(0, totalRawReports);
}
[Fact]
public void BuildAggregatedResult_FiltersTpmPssUnsupportedOnly()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0),
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN2", T0.AddMinutes(1)),
MakeEntry("AuthCertificateRejected", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
Assert.Equal(1, totalRawReports);
}
// =========================================================================
// Aggregation by serial number
// =========================================================================
[Fact]
public void BuildAggregatedResult_AggregatesBySerialCaseInsensitive()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "S4SQ8685", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "s4sq8685", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " S4SQ8685 ", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
Assert.Equal(3, totalRawReports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
}
[Fact]
public void BuildAggregatedResult_DistinctSerials_ProduceDistinctRows()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN2", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "Microsoft", "Surface Book", "SN3", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
Assert.Equal(3, totalRawReports);
}
[Fact]
public void BuildAggregatedResult_UsesMostRecentManufacturerModel()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "OldMfr", "OldModel", "SN1", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0.AddMinutes(5)),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal("Lenovo", (string)item.manufacturer);
Assert.Equal("ThinkCentre M720q", (string)item.model);
Assert.Equal("SN1", (string)item.serialNumber);
}
// =========================================================================
// Timestamps
// =========================================================================
[Fact]
public void BuildAggregatedResult_FirstSeenLastSeen_Correct()
{
var t1 = T0;
var t2 = T0.AddHours(1);
var t3 = T0.AddHours(2);
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t2),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t1),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t3),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(t1, (DateTime)item.firstSeen);
Assert.Equal(t3, (DateTime)item.lastSeen);
}
[Fact]
public void BuildAggregatedResult_OrderedByLastSeenDescending()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkPad X13", "SN2", T0.AddHours(2)),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720s", "SN3", T0.AddHours(1)),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
var first = ToDynamic(aggregated[0]);
var second = ToDynamic(aggregated[1]);
var third = ToDynamic(aggregated[2]);
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
}
// =========================================================================
// Null / empty serial number
// =========================================================================
[Fact]
public void BuildAggregatedResult_NullOrEmptySerials_GroupedIntoSingleBucket()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", null, T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " ", T0.AddMinutes(2)),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
}
// =========================================================================
// Helpers
// =========================================================================
private static DistressReportEntry MakeEntry(
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
new()
{
TenantId = "test-tenant",
ErrorType = errorType,
Manufacturer = manufacturer,
Model = model,
SerialNumber = serial,
IngestedAt = ingestedAt,
AgentTimestamp = ingestedAt,
};
private static dynamic ToDynamic(object obj)
{
var type = obj.GetType();
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Behavioural coverage for <see cref="TableHardwareRejectionNotificationTracker"/> — the dedup
/// store behind the two distress-driven bell notifications (hardware rejection per model,
/// TPM-PSS incompatibility per serial). <see cref="HardwareRejectionNotificationTrackerKeyTests"/>
/// pins the RowKey shape; this file pins what the tracker DOES with those keys.
///
/// CORRECTNESS GUARD: the "one bell ever" promise rests entirely on the 409 branch of
/// <c>AddEntityAsync</c> — a rewrite to Upsert or a get-then-add would silently start ringing
/// the bell on every distress report (a device with an incompatible TPM retries forever, so
/// "every report" means a notification storm, not a second bell).
///
/// The retention sweep is covered here too because <c>DeleteOlderThanAsync</c> prunes BOTH key
/// spaces from the shared table: pruning a <c>tpmpss|</c> row re-arms that device's bell.
/// </summary>
public class HardwareRejectionNotificationTrackerDedupTests
{
private const string TenantId = "77777777-7777-7777-7777-777777777777";
private const string OtherTenantId = "88888888-8888-8888-8888-888888888888";
private const string Serial = "S4SQ8685";
// ── TPM PSS: first bell, then never again ──────────────────────────────────────
[Fact]
public async Task TpmPss_FirstReport_RegistersRowAndReturnsTrue()
{
var harness = new Harness();
var isFirst = await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial);
Assert.True(isFirst);
var entity = Assert.Single(harness.Store.Values);
Assert.Equal(TenantId.ToLowerInvariant(), entity.PartitionKey);
Assert.Equal("tpmpss|s4sq8685", entity.RowKey);
// Serialization guard: the row must carry the fields the cleanup sweep and any future
// operator query rely on — losing FirstNotifiedAt would make the row immortal.
Assert.Equal(TenantId, entity.GetString("TenantId"));
Assert.Equal(Serial, entity.GetString("SerialNumber"));
Assert.NotNull(entity.GetDateTime("FirstNotifiedAt"));
}
[Fact]
public async Task TpmPss_SecondReport_SameSerial_ReturnsFalse()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.Single(harness.Store);
}
[Theory]
[InlineData("s4sq8685")]
[InlineData(" S4SQ8685 ")]
[InlineData("S4Sq8685")]
public async Task TpmPss_SecondReport_CasingOrWhitespaceVariant_StillDeduped(string variant)
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, variant));
}
[Fact]
public async Task TpmPss_DifferentSerial_OrDifferentTenant_RingsAgain()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, "OTHER123"));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(OtherTenantId, Serial));
Assert.Equal(3, harness.Store.Count);
}
[Theory]
[InlineData("", Serial)]
[InlineData(" ", Serial)]
[InlineData(TenantId, "")]
[InlineData(TenantId, " ")]
public async Task TpmPss_BlankInput_ReturnsFalse_WithoutTouchingTheTable(string tenantId, string serial)
{
var harness = new Harness();
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(tenantId, serial));
Assert.Empty(harness.Store);
harness.Table.Verify(
c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task TpmPss_UnexpectedTableFailure_ReturnsFalse()
{
// Fail-soft in the SUPPRESSING direction: the write may or may not have landed, so the
// caller must not fire a bell it might fire again on the next report.
var harness = new Harness { AddFailure = new RequestFailedException(503, "ServerBusy") };
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
}
// ── Hardware rejection: same contract, disjoint key space ──────────────────────
[Fact]
public async Task HardwareRejection_SecondReport_SameModel_ReturnsFalse()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1"));
Assert.False(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "LENOVO", " thinkpad x1 "));
Assert.Single(harness.Store);
}
[Fact]
public async Task HardwareRejection_AndTpmPss_DoNotDedupEachOtherInTheSharedTable()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", Serial));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.Equal(2, harness.Store.Count);
}
// ── Retention sweep ───────────────────────────────────────────────────────────
[Fact]
public async Task DeleteOlderThanAsync_PrunesTpmPssRows_AndReArmsThatDevicesBell()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
harness.AgeRows(TimeSpan.FromDays(31));
var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30));
Assert.Equal(1, deleted);
Assert.Empty(harness.Store);
// A device whose TPM was never fixed keeps reporting; after the retention window it is
// worth one more bell rather than permanent silence.
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
}
[Fact]
public async Task DeleteOlderThanAsync_KeepsRowsInsideTheWindow_DedupSurvives()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1"));
var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30));
Assert.Equal(0, deleted);
Assert.Equal(2, harness.Store.Count);
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.False(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1"));
}
[Fact]
public async Task DeleteOlderThanAsync_FiltersOnFirstNotifiedAt_NotTimestamp()
{
// Rows are insert-once, so FirstNotifiedAt is the creation time. Timestamp would look
// equivalent but is rewritten by a table restore/copy, which would silently re-arm every
// bell in the tenant at once.
var harness = new Harness();
var cutoff = new DateTime(2026, 7, 25, 12, 0, 0, DateTimeKind.Utc);
await harness.Sut.DeleteOlderThanAsync(cutoff);
var filter = Assert.Single(harness.QueryFilters);
Assert.Equal("FirstNotifiedAt lt datetime'2026-07-25T12:00:00Z'", filter);
}
[Fact]
public async Task DeleteOlderThanAsync_ContinuesAfterASingleRowFailure()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, "OTHER123"));
harness.AgeRows(TimeSpan.FromDays(31));
harness.FailDeleteForRowKeys.Add("tpmpss|s4sq8685");
var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30));
Assert.Equal(1, deleted);
Assert.Equal("tpmpss|s4sq8685", Assert.Single(harness.Store.Values).RowKey);
}
[Fact]
public async Task DeleteOlderThanAsync_QueryFailure_ReturnsZero()
{
var harness = new Harness { QueryFailure = new RequestFailedException(500, "Boom") };
Assert.Equal(0, await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)));
}
// ── Harness ───────────────────────────────────────────────────────────────────
private sealed class Harness
{
private static readonly Regex CutoffFilter =
new(@"^FirstNotifiedAt lt datetime'(?<cutoff>[^']+)'$", RegexOptions.Compiled);
public TableHardwareRejectionNotificationTracker Sut { get; }
public Mock<TableClient> Table { get; }
public Dictionary<(string Pk, string Rk), TableEntity> Store { get; } = new();
public List<string> QueryFilters { get; } = new();
public HashSet<string> FailDeleteForRowKeys { get; } = new();
/// <summary>Non-409 failure injected into AddEntityAsync (fail-soft path).</summary>
public Exception? AddFailure { get; init; }
/// <summary>Failure injected into QueryAsync enumeration (sweep fail-soft path).</summary>
public Exception? QueryFailure { get; init; }
public Harness()
{
Table = new Mock<TableClient>();
Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
if (AddFailure != null) throw AddFailure;
if (Store.ContainsKey((e.PartitionKey, e.RowKey)))
throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
Store[(e.PartitionKey, e.RowKey)] = e;
return Task.FromResult(new Mock<Response>().Object);
});
Table.Setup(c => c.QueryAsync<TableEntity>(
It.IsAny<string>(),
It.IsAny<int?>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.Returns<string, int?, IEnumerable<string>, CancellationToken>((filter, _, _, _) =>
{
if (QueryFailure != null) throw QueryFailure;
QueryFilters.Add(filter);
var rows = Store.Values.Where(e => MatchesCutoff(filter, e)).ToList();
return AsyncPageable<TableEntity>.FromPages(new[]
{
Page<TableEntity>.FromValues(rows, null, new Mock<Response>().Object),
});
});
Table.Setup(c => c.DeleteEntityAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<ETag>(),
It.IsAny<CancellationToken>()))
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
{
if (FailDeleteForRowKeys.Contains(rk))
throw new RequestFailedException(500, "DeleteFailed");
Store.Remove((pk, rk));
return Task.FromResult(new Mock<Response>().Object);
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableHardwareRejectionNotificationTracker(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
NullLogger<TableHardwareRejectionNotificationTracker>.Instance);
}
/// <summary>Backdates every stored row so a retention sweep sees it as expired.</summary>
public void AgeRows(TimeSpan age)
{
foreach (var entity in Store.Values)
entity["FirstNotifiedAt"] = DateTime.UtcNow - age;
}
/// <summary>
/// Minimal server-side emulation of the OData filter the tracker builds. An unrecognised
/// filter matches nothing, so a rewrite that no longer selects expired rows fails loudly
/// here instead of leaving dead rows in production.
/// </summary>
private static bool MatchesCutoff(string filter, TableEntity entity)
{
var match = CutoffFilter.Match(filter ?? string.Empty);
if (!match.Success) return false;
var cutoff = DateTime.Parse(
match.Groups["cutoff"].Value,
null,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal);
var firstNotified = entity.GetDateTime("FirstNotifiedAt");
return firstNotified.HasValue && firstNotified.Value.ToUniversalTime() < cutoff;
}
}
}
@@ -59,4 +59,35 @@ public class HardwareRejectionNotificationTrackerKeyTests
var key = TableHardwareRejectionNotificationTracker.BuildRowKey("", "");
Assert.Equal("|", key);
}
// =========================================================================
// TPM PSS row keys (same table, "tpmpss|" prefix — dedup per serial)
// =========================================================================
[Fact]
public void BuildTpmPssRowKey_LowercasesTrimsAndPrefixes()
{
var key = TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(" S4SQ8685 ");
Assert.Equal("tpmpss|s4sq8685", key);
}
[Theory]
[InlineData("S4SQ8685", "s4sq8685")]
[InlineData("s4Sq8685", "S4SQ8685")]
public void BuildTpmPssRowKey_IsCaseInsensitive(string serialA, string serialB)
{
Assert.Equal(
TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(serialA),
TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(serialB));
}
[Fact]
public void BuildTpmPssRowKey_DoesNotCollideWithHardwareKeyOfSameText()
{
// A hardware key is "{mfr}|{model}"; the TPM key's fixed "tpmpss|" prefix keeps the
// two key spaces disjoint within the shared table.
var hardwareKey = TableHardwareRejectionNotificationTracker.BuildRowKey("Lenovo", "S4SQ8685");
var tpmKey = TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey("S4SQ8685");
Assert.NotEqual(hardwareKey, tpmKey);
}
}
@@ -4,9 +4,12 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Diagnostics;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -172,6 +175,119 @@ public class HostedDiagnosticsBlobServiceTests
Assert.StartsWith(TenantB + "/", b.BlobPath);
}
// ── DeleteBySessionPrefixAsync — cascade sweep for multi-package sessions ──────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Theory]
[InlineData("not-a-guid", SessionA)]
[InlineData(TenantA, "not-a-guid")]
public async Task DeleteBySessionPrefix_RejectsNonGuidInputs(string tenantId, string sessionId)
{
var svc = new PrefixDeleteFake(containerExists: true);
await Assert.ThrowsAsync<ArgumentException>(
() => svc.DeleteBySessionPrefixAsync(tenantId, sessionId));
}
[Fact]
public async Task DeleteBySessionPrefix_MissingContainer_IsNoOp()
{
var svc = new PrefixDeleteFake(containerExists: false);
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(0, deleted);
Assert.Null(svc.CapturedPrefix);
}
[Fact]
public async Task DeleteBySessionPrefix_UsesSessionScopedPrefix_AndDeletesEveryMatch()
{
// Two packages for the session: an on-demand (server-requested) one and the terminal
// one. The sweep must issue a delete for BOTH even though the Sessions row (and thus
// the deletion manifest) only references the last.
var svc = new PrefixDeleteFake(
containerExists: true,
blobNames: new[]
{
$"{TenantA}/AgentDiagnostics-{SessionA}-20260724T100000-server-requested.zip",
$"{TenantA}/AgentDiagnostics-{SessionA}-20260724T113000.zip",
});
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(2, deleted);
Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix);
Assert.Equal(2, svc.DeletedBlobNames.Count);
Assert.All(svc.DeletedBlobNames, n => Assert.StartsWith(svc.CapturedPrefix!, n));
}
[Fact]
public async Task DeleteBySessionPrefix_NoMatches_ReturnsZero()
{
var svc = new PrefixDeleteFake(containerExists: true, blobNames: Array.Empty<string>());
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(0, deleted);
Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix);
}
/// <summary>
/// Overrides <c>GetContainerClient</c> with a Moq'd <see cref="BlobContainerClient"/> whose
/// enumeration returns a canned page for the CAPTURED prefix. Prefix filtering itself is
/// the Azure SDK's contract — these tests pin what prefix we ask for and that every
/// returned item is deleted.
/// </summary>
private sealed class PrefixDeleteFake : HostedDiagnosticsBlobService
{
private readonly Mock<BlobContainerClient> _container;
public string? CapturedPrefix { get; private set; }
public List<string> DeletedBlobNames { get; } = new();
public PrefixDeleteFake(bool containerExists, string[]? blobNames = null)
: base(
new BlobServiceClient("UseDevelopmentStorage=true"),
NullLogger<HostedDiagnosticsBlobService>.Instance,
usesManagedIdentity: false)
{
_container = new Mock<BlobContainerClient>();
_container
.Setup(c => c.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(containerExists, Mock.Of<Response>()));
_container
.Setup(c => c.GetBlobsAsync(
It.IsAny<BlobTraits>(), It.IsAny<BlobStates>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns((BlobTraits _, BlobStates _, string? prefix, CancellationToken _) =>
{
CapturedPrefix = prefix;
var items = (blobNames ?? Array.Empty<string>())
.Select(n => BlobsModelFactory.BlobItem(name: n))
.ToList();
return AsyncPageable<BlobItem>.FromPages(new[]
{
Page<BlobItem>.FromValues(items, continuationToken: null, Mock.Of<Response>()),
});
});
_container
.Setup(c => c.GetBlobClient(It.IsAny<string>()))
.Returns((string name) =>
{
var blob = new Mock<BlobClient>();
blob
.Setup(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
DeletedBlobNames.Add(name);
return Response.FromValue(true, Mock.Of<Response>());
});
return blob.Object;
});
}
protected override BlobContainerClient GetContainerClient() => _container.Object;
}
// ── Constants ───────────────────────────────────────────────────────────────────
[Fact]
@@ -0,0 +1,128 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Session 8e4cc4ae (2026-07-24): two on-demand ("Collect Logs") uploads landed in hosted
/// storage, but the portal never flipped to Download — the agent's ServerActionDispatcher
/// confirms the upload via <c>server_action_executed(actionType=request_diagnostics,
/// blobName=…)</c> and emits NO <c>diagnostics_uploaded</c> (only the terminal path does),
/// so the Sessions row was never stamped. These tests pin the classification predicate and
/// the destination inference that close that gap backend-side (no agent release needed).
/// </summary>
public class IngestOnDemandDiagnosticsTests
{
private const string TenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359";
private const string HostedBlob =
TenantId + "/AgentDiagnostics-8e4cc8ae-1111-2222-3333-444455556666-20260724170902-server-requested.zip";
private static EnrollmentEvent Evt(
string eventType = "server_action_executed",
string? actionType = "request_diagnostics",
string? blobName = HostedBlob)
{
var data = new Dictionary<string, object>();
if (actionType != null) data["actionType"] = actionType;
if (blobName != null) data["blobName"] = blobName;
return new EnrollmentEvent { EventType = eventType, Data = data };
}
// -------- IsOnDemandDiagnosticsUploadConfirmation --------
[Fact]
public void ExecutedRequestDiagnosticsWithBlobName_IsConfirmation()
{
Assert.True(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt()));
}
[Fact]
public void ActionTypeIsCaseInsensitive()
{
Assert.True(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(
Evt(actionType: "Request_Diagnostics")));
}
[Theory]
[InlineData("server_action_received")]
[InlineData("server_action_failed")]
[InlineData("diagnostics_uploaded")]
public void OtherEventTypes_AreNotConfirmations(string eventType)
{
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt(eventType: eventType)));
}
[Theory]
[InlineData("rotate_config")]
[InlineData("terminate_session")]
[InlineData(null)]
public void OtherActionTypes_AreNotConfirmations(string? actionType)
{
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt(actionType: actionType)));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void MissingOrEmptyBlobName_IsNotAConfirmation(string? blobName)
{
// A failed upload never reaches server_action_executed, but an executed event without
// blobName (defensive) must not wipe/stamp the row either.
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt(blobName: blobName)));
}
[Fact]
public void NullEventOrData_IsNotAConfirmation()
{
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(null));
// Data is non-nullable on the model but the JSON deserializer can still hand us null
// for a payload without "data" — null! pins the guard the predicate actually has.
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(
new EnrollmentEvent { EventType = "server_action_executed", Data = null! }));
}
// -------- InferDiagnosticsDestination --------
[Fact]
public void ExplicitDestination_AlwaysWins()
{
Assert.Equal("CustomerSas",
EventIngestProcessor.InferDiagnosticsDestination("CustomerSas", HostedBlob, TenantId));
}
[Fact]
public void TenantPrefixedBlobName_InfersHosted()
{
Assert.Equal("Hosted",
EventIngestProcessor.InferDiagnosticsDestination(null, HostedBlob, TenantId));
}
[Fact]
public void TenantPrefixMatch_IsCaseInsensitive()
{
Assert.Equal("Hosted",
EventIngestProcessor.InferDiagnosticsDestination(null, HostedBlob.ToUpperInvariant(), TenantId));
}
[Fact]
public void BareFilename_InfersNothing()
{
// CustomerSas uploads persist the bare zip filename — repo must leave the
// destination column unchanged (read-time default CustomerSas).
Assert.Null(EventIngestProcessor.InferDiagnosticsDestination(
null, "AgentDiagnostics-x-20260724.zip", TenantId));
}
[Fact]
public void ForeignTenantPrefix_InfersNothing()
{
Assert.Null(EventIngestProcessor.InferDiagnosticsDestination(
null, "99999999-9999-9999-9999-999999999999/x.zip", TenantId));
}
[Fact]
public void NullBlobName_InfersNothing()
{
Assert.Null(EventIngestProcessor.InferDiagnosticsDestination(null, null, TenantId));
}
}
@@ -119,6 +119,24 @@ public class MetricsMathAppPayloadTests
Assert.Equal(2, topCode.GetProperty("count").GetInt32());
}
[Fact]
public void FailureRate_IsOverFinishedInstalls_InProgressExcluded()
{
var summaries = new List<AppInstallSummary>
{
new() { AppName = "App A", Status = "Failed" },
new() { AppName = "App A", Status = "Succeeded", DurationSeconds = 10 },
new() { AppName = "App A", Status = "InProgress" },
new() { AppName = "App A", Status = "InProgress" },
};
var app = Build(summaries).GetProperty("topFailingApps")[0];
Assert.Equal(4, app.GetProperty("totalInstalls").GetInt32());
// 1 / (1 + 1) = 50% (over finished), NOT 1 / 4 = 25% (over all installs).
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
}
[Fact]
public void SlowestApps_DropsAppsBelowSampleFloor()
{
@@ -1,4 +1,5 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -205,8 +206,101 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(403, result.StatusCode);
}
// ── config GET (TenantAdminOrGlobalReader): own-tenant admin view vs reader view ──
// GetTenantConfigurationFunction redacts only when IsGlobalReader && !(IsTenantAdmin && Target==Tenant).
// ── TenantAdminOrOperator (POST sessions/{id}/actions — "Collect Logs") ────────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private static void AsTenantMemberRole(Harness h, string tenantId, string upn, string role) =>
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember { Upn = upn, TenantId = tenantId, Role = role, IsEnabled = true });
[Fact]
public async Task SessionActions_TenantOperator_IsAllowed_WithOperatorRoleOnContext()
{
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
// The function's per-type gate depends on these exact flags: an Operator must arrive
// with IsTenantAdmin=false + UserRole=Operator so terminate_session/rotate_config 403.
Assert.False(result.Context!.IsTenantAdmin);
Assert.Equal(Constants.TenantRoles.Operator, result.Context!.UserRole);
}
[Fact]
public async Task SessionActions_TenantAdmin_IsAllowed_WithAdminFlag()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsTenantAdmin);
}
[Fact]
public async Task SessionActions_TenantViewer_IsForbidden()
{
const string upn = "viewer@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Viewer);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task SessionActions_NonMember_IsForbidden()
{
var h = BuildHarness(); // defaults: no membership, no global role
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, "outsider@contoso.com"));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task SessionActions_GlobalAdmin_IsAllowed()
{
const string upn = "ga@platform.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsGlobalAdmin);
}
[Fact]
public async Task SessionActions_PureGlobalReader_IsForbidden_WriteTier()
{
const string upn = "reader@platform.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── config GET (MemberRead): own-tenant admin view vs redacted reader/member view ──
// GetTenantConfigurationFunction redacts unless GA or own-tenant Admin (CanViewSecrets).
// These lock in the RequestContext that drives that decision.
[Fact]
@@ -243,6 +337,38 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(TenantB, rc.TargetTenantId);
}
[Fact]
public async Task ConfigGet_OwnTenantOperator_IsAllowed_RedactedMemberView()
{
// Read-only Settings view for troubleshooting staff: an own-tenant Operator is admitted
// (MemberRead) but arrives with IsTenantAdmin=false, so CanViewSecrets ⇒ redacted copy.
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
var rc = result.Context!;
Assert.False(rc.IsTenantAdmin);
Assert.False(GetTenantConfigurationFunction.CanViewSecrets(rc));
}
[Fact]
public async Task ConfigGet_CrossTenantOperator_IsForbidden()
{
// Membership is resolved against the caller's JWT tenant — an Operator of A naming
// tenant B in the route is a cross-tenant read and must stay 403.
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── Cross-tenant read: GlobalReader crosses tenants (HasGlobalScope gate) ───────
[Fact]
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -11,6 +12,35 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class ServerActionQueueTests
{
// ── Per-type caller gate (route tier is TenantAdminOrOperator; the function must
// re-gate admin-only types for Operators) ─────────────────────────────────────
[Theory]
// Operator (neither admin flag): only request_diagnostics
[InlineData(ServerActionTypes.RequestDiagnostics, false, false, true)]
[InlineData(ServerActionTypes.TerminateSession, false, false, false)]
[InlineData(ServerActionTypes.RotateConfig, false, false, false)]
// Tenant Admin: everything
[InlineData(ServerActionTypes.TerminateSession, true, false, true)]
[InlineData(ServerActionTypes.RotateConfig, true, false, true)]
[InlineData(ServerActionTypes.RequestDiagnostics, true, false, true)]
// Global Admin: everything
[InlineData(ServerActionTypes.TerminateSession, false, true, true)]
[InlineData(ServerActionTypes.RotateConfig, false, true, true)]
public void IsTypeAllowedForCaller_Matrix(
string actionType, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
{
Assert.Equal(expected,
QueueSessionActionFunction.IsTypeAllowedForCaller(actionType, isTenantAdmin, isGlobalAdmin));
}
[Fact]
public void IsTypeAllowedForCaller_IsCaseInsensitive_ForOperator()
{
Assert.True(QueueSessionActionFunction.IsTypeAllowedForCaller(
"Request_Diagnostics", isTenantAdmin: false, isGlobalAdmin: false));
}
[Fact]
public void DeserializePendingActions_NullOrEmpty_ReturnsEmpty()
{
@@ -62,7 +62,7 @@ public class SessionDeletionWorkerTests
};
harness.EnqueueMessage(JsonConvert.SerializeObject(envelope), dequeueCount: QueuePollingWorkerBase.DefaultMaxDequeueCount + 1);
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
// Poison queue received the body and main queue deleted the original.
harness.PoisonQueue.Verify(q => q.SendMessageAsync(
@@ -121,7 +121,7 @@ public class SessionDeletionWorkerTests
},
"etag-mock"));
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
Assert.Contains($"\"observedResidualCount\":{capObservation}", poisoned.RawDetails);
@@ -168,7 +168,7 @@ public class SessionDeletionWorkerTests
},
"etag-mock"));
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
Assert.Contains("\"observedResidualCount\":3", poisoned.RawDetails);
@@ -307,7 +307,7 @@ public class SessionDeletionWorkerTests
},
"etag-mock"));
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
Assert.True(poisoned.RawDetails.Length < 4096,
@@ -357,7 +357,7 @@ public class SessionDeletionWorkerTests
return Task.FromResult(Response.FromValue(receipt, new Mock<Response>().Object));
});
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
var casIdx = callOrder.IndexOf("cas-running-to-poisoned");
var poisonIdx = callOrder.IndexOf("poison-queue-send");
@@ -402,7 +402,7 @@ public class SessionDeletionWorkerTests
CurrentState = SessionDeletionState.Poisoned,
});
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
// Both CAS attempts fired, and the poison-queue send still completed.
harness.StorageMock.Verify(s => s.CasSetSessionDeletionStateAsync(
@@ -444,7 +444,7 @@ public class SessionDeletionWorkerTests
CurrentState = "Unknown",
});
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.OpsEventRecorded());
harness.PoisonQueue.Verify(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
@@ -457,7 +457,7 @@ public class SessionDeletionWorkerTests
var harness = new Harness();
harness.EnqueueMessage("{ \"this is not valid JSON for an envelope: garbage", dequeueCount: 1);
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
harness.HandlerMock.Verify(h => h.HandleAsync(
It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()),
@@ -491,7 +491,7 @@ public class SessionDeletionWorkerTests
await Task.Delay(TimeSpan.FromMilliseconds(350), ct);
});
await harness.RunForAsync(TimeSpan.FromMilliseconds(800));
await harness.RunUntilAsync(() => harness.HeartbeatCount() >= 3);
// Heartbeat called at least 3× while the handler was busy (350ms / 50ms ≈ 7, minus a few
// due to scheduling jitter). One call is enough to prove the heartbeat task is wired.
@@ -512,7 +512,7 @@ public class SessionDeletionWorkerTests
};
harness.EnqueueMessage(JsonConvert.SerializeObject(envelope), dequeueCount: 1);
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
harness.HandlerMock.Verify(h => h.HandleAsync(
It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()),
@@ -663,7 +663,9 @@ public class SessionDeletionWorkerTests
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Returns<OpsEventEntry>(e =>
{
OpsEventCalls.Add(CapturedOpsEvent.From(e));
// Locked so RunUntilAsync's condition poll can read Count from the test
// thread while the worker thread appends.
lock (OpsEventCalls) OpsEventCalls.Add(CapturedOpsEvent.From(e));
return Task.CompletedTask;
});
var alertDispatch = new OpsAlertDispatchService(
@@ -721,6 +723,41 @@ public class SessionDeletionWorkerTests
try { await Sut.StopAsync(CancellationToken.None); }
catch (OperationCanceledException) { /* expected */ }
}
/// <summary>
/// De-flake (CI run 30047512186): <see cref="RunForAsync"/>'s fixed wall-clock window
/// raced the worker's background poll loop on loaded CI runners — positive assertions
/// (delete issued, ops event recorded) could fire before the loop ever processed the
/// message. Runs the worker until <paramref name="condition"/> observes the awaited
/// side effect (polled every 25 ms); the ceiling only bounds a genuinely broken worker,
/// the asserts after this call remain the source of truth. Same pattern as the watchdog
/// event-gate de-flake. Only for POSITIVE expectations — Times.Never-style tests keep
/// the fixed window (there is no event to wait for).
/// </summary>
public async Task RunUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
using var cts = new CancellationTokenSource();
try { await Sut.StartAsync(cts.Token); }
catch (OperationCanceledException) { /* expected */ }
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(25);
cts.Cancel();
try { await Sut.StopAsync(CancellationToken.None); }
catch (OperationCanceledException) { /* expected */ }
}
// Condition probes for RunUntilAsync. Moq's Invocations list snapshots under its
// internal lock, so polling it from the test thread while the worker invokes the
// mock is safe. OpsEventCalls is guarded by its own lock (see the repo callback).
public bool MainQueueDeleted() =>
MainQueue.Invocations.Any(i => i.Method.Name == nameof(QueueClient.DeleteMessageAsync));
public int HeartbeatCount() =>
MainQueue.Invocations.Count(i => i.Method.Name == nameof(QueueClient.UpdateMessageAsync));
public bool OpsEventRecorded()
{
lock (OpsEventCalls) return OpsEventCalls.Count > 0;
}
}
private sealed record AuditEntry(
@@ -13,6 +13,7 @@ public class TenantNotificationAudienceCatalogTests
{
[Theory]
[InlineData("hardware_rejection", NotificationAudience.Admin)]
[InlineData("tpm_pss_unsupported", NotificationAudience.Admin)]
[InlineData("sla_breach", NotificationAudience.Member)]
[InlineData("sla_consecutive_failures", NotificationAudience.Member)]
[InlineData("sla_resolved", NotificationAudience.Member)]
@@ -9,7 +9,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table Storage implementation of <see cref="IHardwareRejectionNotificationTracker"/>.
/// PartitionKey = tenantId (lowercased), RowKey = "{manufacturer-lower}|{model-lower}" (trimmed).
/// PartitionKey = tenantId (lowercased). RowKey encodes the dedup subject:
/// - hardware rejection: "{manufacturer-lower}|{model-lower}" (trimmed)
/// - TPM PSS unsupported: "tpmpss|{serial-lower}" (trimmed; the literal "tpmpss" prefix
/// cannot collide with a hardware key because no real manufacturer is named "tpmpss")
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
/// </summary>
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
@@ -68,14 +71,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(serialNumber))
return false;
var partitionKey = tenantId.ToLowerInvariant();
var rowKey = BuildTpmPssRowKey(serialNumber);
var entity = new TableEntity(partitionKey, rowKey)
{
["TenantId"] = tenantId,
["SerialNumber"] = serialNumber,
["FirstNotifiedAt"] = DateTime.UtcNow
};
try
{
await _table.AddEntityAsync(entity);
_logger.LogInformation(
"TpmPssUnsupported tracker registered: tenant={TenantId} sn={SerialNumber}",
tenantId, serialNumber);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Already notified for this (tenant, serial) — no second bell.
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"TpmPssUnsupported tracker failed: tenant={TenantId} sn={SerialNumber}",
tenantId, serialNumber);
// On unexpected failure, return false so we do not double-fire if the row was actually written.
return false;
}
}
public async Task<int> DeleteOlderThanAsync(DateTime cutoffUtc)
{
try
{
// Rows are insert-once (AddEntityAsync, never updated), so FirstNotifiedAt == creation time.
// Pruning here resets the lifetime dedup for a given model: if the same model is rejected
// again after the cutoff, the bell fires once more — acceptable, since the portal only
// surfaces recent rejections anyway.
// Pruning here resets the lifetime dedup for BOTH key spaces in this table: a hardware
// model rejected again after the cutoff rings once more, and so does a TPM-PSS device
// that reports again ("tpmpss|{serial}" rows are pruned by the same sweep). Acceptable
// in both cases — the portal only surfaces recent rejections anyway, and a device whose
// TPM was never fixed is worth surfacing again after a month of silence.
var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'";
var query = _table.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "RowKey" });
@@ -111,5 +154,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
var mdl = (model ?? string.Empty).Trim().ToLowerInvariant();
return $"{mfr}|{mdl}";
}
internal static string BuildTpmPssRowKey(string serialNumber)
{
var sn = (serialNumber ?? string.Empty).Trim().ToLowerInvariant();
return $"tpmpss|{sn}";
}
}
}
@@ -1,5 +1,6 @@
using System.Collections.Specialized;
using System.Threading;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -146,24 +147,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var succeeded = g.Count(s => s.Status == "Succeeded");
var failed = g.Count(s => s.Status == "Failed");
var completed = g.Where(s => s.Status == "Succeeded").ToList();
var failureRate = total > 0 ? Math.Round((double)failed / total * 100, 1) : 0;
var failureRate = MetricsMath.TerminalFailureRatePct(failed, succeeded);
// Trend: compare failure rate in first half vs second half of window.
// Only emit non-stable trend if BOTH halves have >= 5 installs (too noisy otherwise).
var firstHalf = g.Where(s => s.StartedAt < midpoint).ToList();
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
double? trendDelta = null;
string trend = "stable";
if (firstHalf.Count >= 5 && secondHalf.Count >= 5)
{
var fhRate = (double)firstHalf.Count(s => s.Status == "Failed") / firstHalf.Count * 100;
var shRate = (double)secondHalf.Count(s => s.Status == "Failed") / secondHalf.Count * 100;
var delta = Math.Round(shRate - fhRate, 1);
trendDelta = delta;
if (delta < -1) trend = "improving";
else if (delta > 1) trend = "worsening";
}
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
return new
{
@@ -275,7 +263,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var succeeded = summaries.Count(s => s.Status == "Succeeded");
var failed = summaries.Count(s => s.Status == "Failed");
var completed = summaries.Where(s => s.Status == "Succeeded").ToList();
var failureRate = Math.Round((double)failed / total * 100, 1);
var failureRate = MetricsMath.TerminalFailureRatePct(failed, succeeded);
var avgDurationSeconds = completed.Count > 0 ? Math.Round(completed.Average(s => s.DurationSeconds), 0) : 0;
var p95DurationSeconds = Percentile(completed.Select(s => s.DurationSeconds).ToList(), 0.95);
var avgDownloadBytes = completed.Count > 0 ? (long)completed.Average(s => s.DownloadBytes) : 0;
@@ -283,16 +271,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// Trend (same rule as list endpoint).
var firstHalf = summaries.Where(s => s.StartedAt < midpoint).ToList();
var secondHalf = summaries.Where(s => s.StartedAt >= midpoint).ToList();
double? trendDelta = null;
string trend = "stable";
if (firstHalf.Count >= 5 && secondHalf.Count >= 5)
{
var fhRate = (double)firstHalf.Count(s => s.Status == "Failed") / firstHalf.Count * 100;
var shRate = (double)secondHalf.Count(s => s.Status == "Failed") / secondHalf.Count * 100;
trendDelta = Math.Round(shRate - fhRate, 1);
if (trendDelta < -1) trend = "improving";
else if (trendDelta > 1) trend = "worsening";
}
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
var flakinessScore = total > 0
? Math.Round((double)summaries.Count(s => s.AttemptNumber > 1) / total, 3)
@@ -308,12 +287,13 @@ namespace AutopilotMonitor.Functions.Functions.Apps
{
var vTotal = g.Count();
var vFailed = g.Count(s => s.Status == "Failed");
var vSucceeded = g.Count(s => s.Status == "Succeeded");
return new
{
appVersion = g.Key,
installs = vTotal,
failed = vFailed,
failureRate = vTotal > 0 ? Math.Round((double)vFailed / vTotal * 100, 1) : 0
failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded)
};
})
.OrderByDescending(v => v.installs)
@@ -359,12 +339,16 @@ namespace AutopilotMonitor.Functions.Functions.Apps
Model = sessionLookup[$"{s.TenantId}|{s.SessionId}"].Model ?? "Unknown"
})
.GroupBy(x => new { x.Manufacturer, x.Model })
.Where(g => g.Count() >= 5)
// Sample floor on FINISHED installs — the displayed metric is the terminal-only
// failure rate, so a model with many in-flight installs but few outcomes is still
// too noisy to rank.
.Where(g => g.Count(x => x.Summary.Status == "Failed" || x.Summary.Status == "Succeeded") >= 5)
.Select(g =>
{
var modelTotal = g.Count();
var modelFailed = g.Count(x => x.Summary.Status == "Failed");
var modelFailureRate = Math.Round((double)modelFailed / modelTotal * 100, 1);
var modelSucceeded = g.Count(x => x.Summary.Status == "Succeeded");
var modelFailureRate = MetricsMath.TerminalFailureRatePct(modelFailed, modelSucceeded);
var lift = failureRate > 0
? Math.Round(modelFailureRate / failureRate, 2)
: 0;
@@ -496,6 +480,30 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// ── Helpers ─────────────────────────────────────────────────────────
/// <summary>
/// Failure-rate trend between the two halves of the window, shared by the list and
/// analytics endpoints. Rates follow the terminal-only convention
/// (<see cref="MetricsMath.TerminalFailureRatePct"/>), and a non-stable trend requires
/// at least 5 FINISHED installs in BOTH halves — too noisy otherwise, and a half made
/// up of in-flight installs has no outcome to trend on.
/// </summary>
private static (string Trend, double? TrendDelta) ComputeFailureTrend(
List<AppInstallSummary> firstHalf, List<AppInstallSummary> secondHalf)
{
var fhFailed = firstHalf.Count(s => s.Status == "Failed");
var fhSucceeded = firstHalf.Count(s => s.Status == "Succeeded");
var shFailed = secondHalf.Count(s => s.Status == "Failed");
var shSucceeded = secondHalf.Count(s => s.Status == "Succeeded");
if (fhFailed + fhSucceeded < 5 || shFailed + shSucceeded < 5)
return ("stable", null);
var delta = Math.Round(
MetricsMath.TerminalFailureRatePct(shFailed, shSucceeded)
- MetricsMath.TerminalFailureRatePct(fhFailed, fhSucceeded), 1);
var trend = delta < -1 ? "improving" : delta > 1 ? "worsening" : "stable";
return (trend, delta);
}
private static List<object> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
{
var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date;
@@ -531,7 +539,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
installs = bTotal,
succeeded = bSucceeded,
failed = bFailed,
failureRate = bTotal > 0 ? Math.Round((double)bFailed / bTotal * 100, 1) : 0,
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bSucceeded),
avgDurationSeconds = bCompleted.Count > 0 ? Math.Round(bCompleted.Average(s => s.DurationSeconds), 0) : 0
};
})
@@ -29,7 +29,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware —
// Operators/Viewers are admitted for a READ-ONLY view (secrets redacted below).
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
@@ -39,8 +40,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Only a caller with WRITE authority over THIS target tenant sees the full config including
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
// UI). Everyone else admitted to this read tier — a cross-tenant Global Reader, or a
// delegated reader viewing one of their managed tenants — gets the secrets redacted (returns
// UI). Everyone else admitted to this read tier — an own-tenant Operator/Viewer, a
// cross-tenant Global Reader, or a delegated reader viewing one of their managed
// tenants — gets the secrets redacted (returns
// a copy; the cached instance is never mutated). Redact-by-default: only Global Admin (any
// tenant) or the tenant's OWN admin clears secrets. The own-tenant check (target == JWT
// tenant) is what keeps a delegated reader — whose target is a DIFFERENT tenant — redacted
@@ -66,8 +68,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// <summary>
/// Redact-by-default secret clearance for the config GET. Only a caller with WRITE authority over
/// THIS target tenant sees the unredacted secrets (SAS URLs, webhook URLs, custom headers): a Global
/// Admin (any tenant), or the tenant's OWN admin. Everyone else admitted to this read tier — a
/// cross-tenant Global Reader, or a delegated reader viewing one of their managed tenants — is
/// Admin (any tenant), or the tenant's OWN admin. Everyone else admitted to this read tier — an
/// own-tenant Operator/Viewer (read-only Settings view), a cross-tenant Global Reader, or a
/// delegated reader viewing one of their managed tenants — is
/// redacted. The own-tenant check (target == JWT tenant) is what keeps a delegated reader redacted
/// even when they are an admin of their own home tenant, since their target is a DIFFERENT tenant.
/// Extracted as a pure static so the security decision is unit-testable without an HttpRequestData mock.
@@ -76,6 +76,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
return new
{
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
diagnosticsUploadConfigured =
!string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice,
@@ -269,6 +269,36 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
}
// In-app bell notification on first-ever TPM-PSS incompatibility per (tenant, serial).
// Same lifetime-dedup pattern as the hardware-rejection bell above. No webhook —
// volume is tiny and the remediation (TPM firmware update / device replacement)
// is not time-critical. Reports without a serial number cannot be deduped and
// fire no bell; they still appear in the tenant insights panel.
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
&& !string.IsNullOrEmpty(serialNumber))
{
try
{
var isFirst = await _hardwareBellTracker
.TryRegisterFirstTpmPssNotificationAsync(tenantId, serialNumber);
if (isFirst)
{
var deviceLabel = string.Join(" ",
new[] { manufacturer, model }.Where(s => !string.IsNullOrEmpty(s)));
await _tenantNotificationService.CreateNotificationAsync(
tenantId,
type: "tpm_pss_unsupported",
title: "Device with incompatible TPM detected",
message: $"Device {serialNumber}{(deviceLabel.Length > 0 ? $" ({deviceLabel})" : "")} reported a TPM that cannot perform RSA-PSS signing — the agent on this device cannot authenticate. A TPM firmware update or device replacement is required.",
href: "/settings/tenant/hardware-whitelist");
}
}
catch (Exception bellEx)
{
_logger.LogWarning(bellEx, "TpmPssUnsupported bell notification failed for tenant {TenantId}", tenantId);
}
}
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
@@ -106,6 +106,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var first = group.First();
var succeeded = group.Where(s => s.Status == SessionStatus.Succeeded).ToList();
var failed = group.Where(s => s.Status == SessionStatus.Failed).ToList();
// Success rate is an outcome quota over finished enrollments only
// (Succeeded + Failed), matching the SLA convention. In-flight sessions
// (InProgress/Pending/Stalled/AwaitingUser) and Incomplete (terminal,
// non-failure) must not dilute it — a site mid-rollout would otherwise
// show a poor rate despite zero actual failures. 0 finished → 0; the
// frontend renders "—" off Succeeded+Failed instead of a 0% badge.
var finished = succeeded.Count + failed.Count;
// Duration stats from succeeded sessions with valid duration
var durations = succeeded
@@ -156,7 +163,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
SessionCount = group.Count(),
Succeeded = succeeded.Count,
Failed = failed.Count,
SuccessRate = group.Count() > 0 ? Math.Round((double)succeeded.Count / group.Count() * 100, 1) : 0,
SuccessRate = finished > 0 ? Math.Round((double)succeeded.Count / finished * 100, 1) : 0,
AvgDurationMinutes = Math.Round(avgDuration, 1),
MedianDurationMinutes = Math.Round(medianDuration, 1),
P95DurationMinutes = Math.Round(p95Duration, 1),
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Tenant-scoped endpoint: returns devices whose TPM-backed client certificate cannot
/// perform RSA-PSS signing (Schannel silently drops the cert from TLS client-auth on
/// Windows 11 25H2+, so the agent can never authenticate), aggregated by serial number.
/// Data comes from distress reports with ErrorType == "TpmPssUnsupported".
///
/// Informational only — the remediation is a TPM firmware update or device replacement,
/// not a portal action. The 14-day horizon is enforced by the distress-report retention
/// cleanup, not by this function.
/// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
/// </summary>
public class GetTpmPssUnsupportedFunction
{
private readonly ILogger<GetTpmPssUnsupportedFunction> _logger;
private readonly IDistressReportRepository _repository;
public GetTpmPssUnsupportedFunction(
ILogger<GetTpmPssUnsupportedFunction> logger,
IDistressReportRepository repository)
{
_logger = logger;
_repository = repository;
}
[Function("GetTpmPssUnsupported")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "audit/tpm-pss-unsupported")] HttpRequestData req)
{
try
{
string tenantId = TenantHelper.GetTenantId(req);
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
return await req.OkAsync(new
{
success = true,
aggregated,
totalRawReports,
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot perform RSA-PSS signing, so their agent cannot authenticate to the backend. Serial number, manufacturer, and model values are self-reported by devices."
});
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "Get TPM PSS unsupported devices");
}
}
/// <summary>
/// Filters distress reports to TpmPssUnsupported and aggregates by serial number.
/// Reports with no serial number are grouped into a single "unknown serial" bucket.
/// Extracted as public static for testability.
/// </summary>
public static (List<object> aggregated, int totalRawReports) BuildAggregatedResult(
List<DistressReportEntry> reports)
{
var tpmReports = reports
.Where(r => string.Equals(r.ErrorType, "TpmPssUnsupported", StringComparison.OrdinalIgnoreCase))
.ToList();
var aggregated = tpmReports
.GroupBy(r => (r.SerialNumber ?? "").Trim().ToLowerInvariant())
.Select(g =>
{
var mostRecent = g.OrderByDescending(r => r.IngestedAt).First();
return new
{
serialNumber = mostRecent.SerialNumber ?? "",
manufacturer = mostRecent.Manufacturer ?? "",
model = mostRecent.Model ?? "",
attemptCount = g.Count(),
firstSeen = g.Min(r => r.IngestedAt),
lastSeen = g.Max(r => r.IngestedAt)
};
})
.OrderByDescending(a => a.lastSeen)
.ToList<object>();
return (aggregated, tpmReports.Count);
}
}
}
@@ -10,8 +10,8 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Tenant-admin entry point for submitting diagnostic files (logs, JSON state files,
/// screenshots) without a session context. Lands in the same SessionReports table +
/// Tenant-admin/-operator entry point for submitting diagnostic files (logs, JSON state
/// files, screenshots) without a session context. Lands in the same SessionReports table +
/// session-reports blob container as session reports, distinguished by
/// <see cref="SessionReportMetadata.ReportType"/> = "diagFiles".
/// </summary>
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
// Authentication + TenantAdminOrOperator authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var tenantId = requestCtx.TenantId;
var userIdentifier = requestCtx.UserPrincipalName;
@@ -1,14 +1,22 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// Global Admin endpoints for viewing rules across tenants.
/// Reads tenantId from query string (not JWT) so global admins can inspect any tenant.
/// Global Admin endpoints for viewing AND mutating rules across tenants.
/// Reads tenantId from the ?tenantId= query string (not JWT) so a Global Admin can inspect
/// or edit any tenant. The write routes exist because the JWT-scoped GatherRulesFunction /
/// AnalyzeRulesFunction resolve the tenant from the caller's own token: a GA editing a foreign
/// tenant via those routes would silently upsert into its HOME tenant (200 → false "saved"),
/// while the reload re-reads the foreign tenant and shows the unchanged rule. These routes are
/// GlobalAdminOnly + TenantScoping.QueryParam so cross-tenant write stays platform-admin-only
/// (no tenant-admin / delegated rescue — the delegated grant applies to READ tiers only).
/// </summary>
public class GlobalRulesFunction
{
@@ -77,5 +85,163 @@ namespace AutopilotMonitor.Functions.Functions.Rules
await response.WriteAsJsonAsync(new { success = true, rules });
return response;
}
/// <summary>
/// PUT /api/global/rules/gather/{ruleId}?tenantId=X - Update a gather rule in any tenant (Global Admin only).
/// Cross-tenant counterpart of GatherRulesFunction.UpdateRule; the tenant comes from the query, not the JWT.
/// </summary>
[Function("UpdateGlobalGatherRule")]
public async Task<HttpResponseData> UpdateGatherRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/rules/gather/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
var (rule, bodyError) = await ReadRuleBodyAsync<GatherRule>(req);
if (bodyError != null)
return await BadRequestAsync(req, bodyError);
rule!.RuleId = ruleId;
var scopeError = GatherRulesFunction.ValidateScopeAndEmitMode(rule);
if (scopeError != null)
return await BadRequestAsync(req, scopeError);
_logger.LogInformation("Global admin updating gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _gatherRuleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
/// <summary>
/// DELETE /api/global/rules/gather/{ruleId}?tenantId=X - Delete a gather rule in any tenant (Global Admin only).
/// </summary>
[Function("DeleteGlobalGatherRule")]
public async Task<HttpResponseData> DeleteGatherRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/rules/gather/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
// Load the rule to determine its type (built-in/community vs. custom).
var rules = await _gatherRuleService.GetAllRulesForTenantAsync(tenantId);
var rule = rules.FirstOrDefault(r => r.RuleId == ruleId);
if (rule == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Rule not found" });
return notFound;
}
_logger.LogInformation("Global admin deleting gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _gatherRuleService.DeleteRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule deleted" : "Failed to delete rule" });
return response;
}
/// <summary>
/// PUT /api/global/rules/analyze/{ruleId}?tenantId=X - Update an analyze rule in any tenant (Global Admin only).
/// Cross-tenant counterpart of AnalyzeRulesFunction.UpdateRule.
/// </summary>
[Function("UpdateGlobalAnalyzeRule")]
public async Task<HttpResponseData> UpdateAnalyzeRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/rules/analyze/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
var (rule, bodyError) = await ReadRuleBodyAsync<AnalyzeRule>(req);
if (bodyError != null)
return await BadRequestAsync(req, bodyError);
rule!.RuleId = ruleId;
_logger.LogInformation("Global admin updating analyze rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _analyzeRuleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
/// <summary>
/// DELETE /api/global/rules/analyze/{ruleId}?tenantId=X - Delete an analyze rule in any tenant (Global Admin only).
/// </summary>
[Function("DeleteGlobalAnalyzeRule")]
public async Task<HttpResponseData> DeleteAnalyzeRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/rules/analyze/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
// Load the rule to determine its type (built-in/community vs. custom).
var rules = await _analyzeRuleService.GetAllRulesForTenantAsync(tenantId);
var rule = rules.FirstOrDefault(r => r.RuleId == ruleId);
if (rule == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Rule not found" });
return notFound;
}
_logger.LogInformation("Global admin deleting analyze rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _analyzeRuleService.DeleteRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule deleted" : "Failed to delete rule" });
return response;
}
/// <summary>
/// Resolves the target tenant from the ?tenantId= query string (the write routes are
/// TenantScoping.QueryParam). Sets <paramref name="error"/> when the parameter is missing.
/// </summary>
private static string ResolveTenantId(HttpRequestData req, out string? error)
{
var tenantId = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "").Get("tenantId");
error = string.IsNullOrEmpty(tenantId) ? "tenantId query parameter is required" : null;
return tenantId ?? string.Empty;
}
/// <summary>
/// Reads and deserializes a rule body with the same 1 MB guard the JWT-scoped functions apply.
/// Returns (rule, null) on success or (null, errorMessage) for a 400.
/// </summary>
private static async Task<(T? rule, string? error)> ReadRuleBodyAsync<T>(HttpRequestData req) where T : class
{
if (req.Headers.TryGetValues("Content-Length", out var clValues)
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
&& contentLength > 1_048_576) // 1 MB limit
{
return (null, "Request body too large");
}
var body = await new StreamReader(req.Body).ReadToEndAsync();
var rule = JsonConvert.DeserializeObject<T>(body);
return rule == null ? (null, "Invalid rule data") : (rule, null);
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message });
return badRequest;
}
}
}
@@ -32,6 +32,28 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
ServerActionTypes.RequestDiagnostics
};
/// <summary>
/// Action types an Operator may queue. The route is TenantAdminOrOperator so troubleshooting
/// staff can trigger an on-demand diagnostics collection; everything with config or lifecycle
/// impact (terminate_session, rotate_config) stays Admin/GA-only via <see cref="IsTypeAllowedForCaller"/>.
/// </summary>
private static readonly HashSet<string> OperatorAllowedTypes = new(StringComparer.OrdinalIgnoreCase)
{
ServerActionTypes.RequestDiagnostics
};
/// <summary>
/// Type-level authorization on top of the route policy: Admins and Global Admins may queue
/// every allowed type, Operators only the types in <see cref="OperatorAllowedTypes"/>.
/// Internal-static so tests can pin the matrix without an HTTP host.
/// </summary>
internal static bool IsTypeAllowedForCaller(string actionType, bool isTenantAdmin, bool isGlobalAdmin)
{
if (isTenantAdmin || isGlobalAdmin)
return true;
return OperatorAllowedTypes.Contains(actionType);
}
public QueueSessionActionFunction(
ILogger<QueueSessionActionFunction> logger,
ISessionRepository sessionRepo,
@@ -53,9 +75,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; per-type re-gating for Operators happens below.
var tenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
string body;
using (var reader = new StreamReader(req.Body))
@@ -78,6 +102,20 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
if (!AllowedTypes.Contains(action.Type))
return await BadRequestAsync(req, $"Unknown action type '{action.Type}'. Allowed: {string.Join(", ", AllowedTypes)}");
if (!IsTypeAllowedForCaller(action.Type, requestCtx.IsTenantAdmin, requestCtx.IsGlobalAdmin))
{
_logger.LogWarning(
"Operator {User} denied queueing admin-only action '{Type}' for session {SessionId}",
userIdentifier, action.Type, sessionId);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = $"Action type '{action.Type}' requires the Tenant Admin role"
});
return forbidden;
}
// Stamp server-side fields — never trust client timestamps, and never let the caller
// forge a RuleId (that's reserved for the RuleEngine).
action.QueuedAt = DateTime.UtcNow;
@@ -7,6 +7,19 @@ namespace AutopilotMonitor.Functions.Helpers;
/// </summary>
public static class MetricsMath
{
/// <summary>
/// App-install failure rate over finished installs only: Failed / (Failed + Succeeded), one
/// decimal, 0 when nothing finished. The same outcome-quota convention as the enrollment
/// success rate — "InProgress" rows (still installing, or orphaned by a session that died
/// mid-install) never dilute the rate. Shared by the app-metrics payload and the Apps
/// dashboard aggregations so the definition can't drift between panes.
/// </summary>
public static double TerminalFailureRatePct(int failed, int succeeded)
{
var finished = failed + succeeded;
return finished > 0 ? Math.Round((double)failed / finished * 100, 1) : 0;
}
/// <summary>
/// Builds the complete app-metrics response object from a (pre-time-filtered) set of app
/// install summaries. Single source of truth for both the tenant (<c>metrics/app</c>) and
@@ -40,7 +53,7 @@ public static class MetricsMath
totalInstalls = total,
succeeded = completed.Count,
failed = failed.Count,
failureRate = total > 0 ? Math.Round((double)failed.Count / total * 100, 1) : 0,
failureRate = TerminalFailureRatePct(failed.Count, completed.Count),
avgDurationSeconds = completed.Count > 0 ? Math.Round(completed.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = completed.Count > 0 ? completed.Max(s => s.DurationSeconds) : 0,
avgDownloadBytes = completed.Count > 0 ? (long)completed.Average(s => s.DownloadBytes) : 0,
@@ -93,9 +106,10 @@ public static class MetricsMath
/// Single source of truth for the tenant (<c>metrics/fleet-health</c>) and global
/// (<c>global/metrics/fleet-health</c>) functions. Replaces the previous client-side path that
/// drained up to 200k raw sessions into the browser and ran these aggregations on the main
/// thread. Semantics are preserved verbatim from that client code, notably: success rate is
/// over ALL sessions (not just terminal ones), and average duration counts every
/// non-in-progress session that carries a positive duration (failures included).
/// thread. Success rate follows the SLA convention: Succeeded / (Succeeded + Failed) — finished
/// enrollments only, so in-flight sessions and Incomplete (terminal, non-failure) never dilute
/// it. Average duration counts every non-in-progress session that carries a positive duration
/// (failures included).
/// </summary>
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
{
@@ -121,6 +135,7 @@ public static class MetricsMath
}
int total = sessions.Count;
int finished = succeeded + failed;
var stats = new FleetHealthStats
{
Total = total,
@@ -128,7 +143,7 @@ public static class MetricsMath
Failed = failed,
InProgress = inProgress,
Incomplete = incomplete,
SuccessRate = total > 0 ? Math.Round((double)succeeded / total * 100, 1) : 0,
SuccessRate = finished > 0 ? Math.Round((double)succeeded / finished * 100, 1) : 0,
AvgDurationMinutes = completedWithDurationCount > 0
? (int)Math.Round((double)completedDurationSeconds / completedWithDurationCount / 60.0, MidpointRounding.AwayFromZero)
: 0,
@@ -179,19 +194,29 @@ public static class MetricsMath
private static List<FleetFailureReason> BuildFleetFailureReasons(IReadOnlyList<SessionSummary> sessions)
{
var reasons = new Dictionary<string, int>();
// Group near-identical messages by a 50-char prefix so variants collapse
// into one row, but keep the longest full reason as the display value. The
// UI truncates and expands it on demand, so the complete text must survive.
var groups = new Dictionary<string, (string Display, int Count)>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Failed) continue;
var reason = string.IsNullOrEmpty(s.FailureReason) ? "Unknown" : s.FailureReason;
// Collapse very long reasons to a 50-char prefix so near-identical messages group.
if (reason.Length > 50) reason = reason.Substring(0, 50) + "...";
reasons[reason] = reasons.GetValueOrDefault(reason) + 1;
var key = reason.Length > 50 ? reason.Substring(0, 50) : reason;
if (groups.TryGetValue(key, out var g))
{
var display = reason.Length > g.Display.Length ? reason : g.Display;
groups[key] = (display, g.Count + 1);
}
else
{
groups[key] = (reason, 1);
}
}
return reasons
.OrderByDescending(kv => kv.Value)
return groups
.OrderByDescending(kv => kv.Value.Count)
.Take(5)
.Select(kv => new FleetFailureReason { Reason = kv.Key, Count = kv.Value })
.Select(kv => new FleetFailureReason { Reason = kv.Value.Display, Count = kv.Value.Count })
.ToList();
}
@@ -208,6 +233,7 @@ public static class MetricsMath
}
m.Total++;
if (s.Status == SessionStatus.Succeeded) m.Succeeded++;
else if (s.Status == SessionStatus.Failed) m.Failed++;
}
return models.Values
.OrderByDescending(m => m.Total)
@@ -240,15 +266,18 @@ public static class MetricsMath
private static List<FleetFailingModel> BuildFleetTopFailingModels(IReadOnlyList<SessionSummary> sessions)
{
var acc = new Dictionary<string, (int Failed, int Total)>();
var acc = new Dictionary<string, (int Failed, int Succeeded, int Total)>();
foreach (var s in sessions)
{
var key = FleetModelKey(s);
acc.TryGetValue(key, out var cur);
cur.Total++;
if (s.Status == SessionStatus.Failed) cur.Failed++;
else if (s.Status == SessionStatus.Succeeded) cur.Succeeded++;
acc[key] = cur;
}
// FailureRate over finished enrollments only (mirror of the success-rate convention);
// the Where(Failed > 0) guard also keeps the denominator non-zero.
return acc
.Where(kv => kv.Value.Failed > 0)
.Select(kv => new FleetFailingModel
@@ -256,7 +285,8 @@ public static class MetricsMath
Model = kv.Key,
Failed = kv.Value.Failed,
Total = kv.Value.Total,
FailureRate = (int)Math.Round((double)kv.Value.Failed / kv.Value.Total * 100, MidpointRounding.AwayFromZero),
FailureRate = (int)Math.Round(
(double)kv.Value.Failed / (kv.Value.Failed + kv.Value.Succeeded) * 100, MidpointRounding.AwayFromZero),
})
.OrderByDescending(m => m.Failed)
.Take(5)
@@ -336,6 +336,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
case EndpointPolicy.TenantAdminOrGlobalReader:
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrOperator:
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGA:
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
@@ -469,6 +472,28 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGA");
}
/// <summary>
/// Operational write tier: own-tenant Admin or Operator (no Viewer), or Global Admin.
/// The admitted member's role flows onto RequestContext.UserRole so a function that
/// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin.
/// The read-only Global Reader is deliberately NOT admitted — this is a write tier.
/// </summary>
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator");
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA");
}
private async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
@@ -55,11 +55,22 @@ public enum EndpointPolicy
/// </summary>
MemberRead,
/// <summary>
/// Own-tenant Admin or Operator (no Viewer), OR Global Admin. Tenant-scoped OPERATIONAL write
/// access: actions a troubleshooting operator may take on a session/device without holding
/// config-level admin power. Routes on this tier whose function accepts multiple operation
/// kinds MUST re-gate the admin-only kinds in the function on
/// <c>RequestContext.IsTenantAdmin || IsGlobalAdmin</c> (see QueueSessionActionFunction).
/// </summary>
TenantAdminOrOperator,
/// <summary>
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
/// READ access (e.g. GET config/{tenantId}). Like <see cref="TenantAdminOrGA"/> but additionally
/// admits the read-only Global Reader — used only on GET routes so the reader gets visibility
/// without write power, while tenant Operators/Viewers still cannot read admin-tier config.
/// READ access (e.g. GET tenants/{tenantId}/graph-permissions/status). Like
/// <see cref="TenantAdminOrGA"/> but additionally admits the read-only Global Reader — used only
/// on GET routes so the reader gets visibility without write power, while tenant
/// Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead:
/// its handler redacts secrets for every non-admin caller, so member-tier read is safe there.)
/// </summary>
TenantAdminOrGlobalReader,
@@ -233,12 +244,18 @@ public static class EndpointAccessPolicyCatalog
new("GET", "audit/logs", EndpointPolicy.MemberRead),
new("GET", "audit/hardware-rejected", EndpointPolicy.MemberRead),
new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead),
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "rules/gather", EndpointPolicy.MemberRead),
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
new("GET", "config/{tenantId}", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
// Viewer, read-only Global Reader, delegated reader) receives RedactedCopyForReader()
// (see GetTenantConfigurationFunction.CanViewSecrets). Writes stay TenantAdminOrGA below.
new("GET", "config/{tenantId}", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/feature-flags", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
// ── TenantAdminOrGA ─────────────────────────────────────────────
@@ -254,9 +271,15 @@ public static class EndpointAccessPolicyCatalog
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrGA),
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
// the portal); the function re-gates the admin-only action types (terminate_session,
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA),
new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrGA),
// Operator tier: submitting diagnostic files to the Autopilot Monitor team is a
// troubleshooting action, not a config change — a support-driven Operator may use it.
// Viewer and the read-only Global Reader stay excluded (the tier admits no Viewer/Reader).
new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrOperator),
new("DELETE", "sessions/{sessionId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
@@ -388,6 +411,15 @@ public static class EndpointAccessPolicyCatalog
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Cross-tenant rule WRITES (edit/toggle/delete an existing rule in a foreign tenant). GlobalAdminOnly
// — a write tier, so the delegated-read rescue never fires: only a platform admin may mutate another
// tenant's rules. QueryParam because the tenant is named by ?tenantId= (the {ruleId} segment is not a
// tenant). No global CREATE route: creating rules in a foreign tenant stays disabled (the portal hides
// Create in the cross-tenant override), so the JWT-scoped POST routes remain the only create path.
new("PUT", "global/rules/gather/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("DELETE", "global/rules/gather/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("PUT", "global/rules/analyze/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("DELETE", "global/rules/analyze/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("GET", "global/devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "devices/block", EndpointPolicy.GlobalAdminOnly),
@@ -75,10 +75,23 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
if (destination == DestinationHosted)
{
// Prefix sweep first: the session may have produced several packages (on-demand
// server-requested uploads plus the terminal one) while the manifest carries only
// the last row-referenced name. Guarded by TryParse because a malformed legacy
// manifest must degrade to the single-blob delete, not poison the cascade.
var swept = 0;
if (Guid.TryParse(manifest.SessionId, out _))
{
swept = await _hostedDiagnostics.DeleteBySessionPrefixAsync(
manifest.TenantId, manifest.SessionId, cancellationToken);
}
// Belt-and-braces: the manifest-named blob covers any shape outside the canonical
// {tenantId}/AgentDiagnostics-{sessionId}- layout. Idempotent (404 = no-op).
await _hostedDiagnostics.DeleteIfExistsAsync(manifest.DiagnosticsBlobName, cancellationToken);
_logger.LogInformation(
"Cascade deleted hosted diagnostics blob tenant={TenantId} blob={Blob}",
manifest.TenantId, manifest.DiagnosticsBlobName);
"Cascade deleted hosted diagnostics blobs tenant={TenantId} blob={Blob} sweptByPrefix={Swept}",
manifest.TenantId, manifest.DiagnosticsBlobName, swept);
return DiagnosticsBlobDeleteOutcome.HostedDeleted;
}
@@ -165,6 +165,40 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
return response.Value;
}
/// <summary>
/// Deletes every hosted diagnostics blob belonging to one session
/// (prefix <c>{tenantId}/AgentDiagnostics-{sessionId}-</c>). A session can leave more
/// than one blob behind — an on-demand ("Collect Logs" / server-requested) upload is
/// later overwritten on the Sessions row by the terminal package, so deleting only the
/// row-referenced name would strand the earlier blobs at offboarding.
/// Returns the number of blobs deleted. Idempotent; a missing container is a no-op.
/// </summary>
public virtual async Task<int> DeleteBySessionPrefixAsync(
string tenantId, string sessionId, CancellationToken cancellationToken = default)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
var containerClient = GetContainerClient();
if (!await containerClient.ExistsAsync(cancellationToken))
return 0;
// Trailing dash bounds the match to this exact session id (GUIDs are fixed-length,
// but the explicit separator keeps the invariant obvious and layout-proof).
var prefix = $"{tenantId}/AgentDiagnostics-{sessionId}-";
var deleted = 0;
await foreach (var item in containerClient.GetBlobsAsync(
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
{
var response = await containerClient.GetBlobClient(item.Name)
.DeleteIfExistsAsync(cancellationToken: cancellationToken);
if (response.Value)
deleted++;
}
return deleted;
}
/// <summary>
/// Enumerates blobs under a single tenant's prefix (<c>{tenantId}/</c>).
/// Used by ad-hoc operator scripts and (later) any orphan-scan job.
@@ -44,6 +44,19 @@ namespace AutopilotMonitor.Functions.Services
case "diagnostics_uploaded":
classification.DiagnosticsUploadedEvent = evt;
break;
case "server_action_executed":
// The on-demand ("Collect Logs") path confirms its upload via
// server_action_executed carrying blobName — the agent's dispatcher does
// NOT emit diagnostics_uploaded there (only the terminal path does).
// Treat it as the upload confirmation so the Sessions row gets stamped
// and the portal's Download button flips. Null-guard: a real
// diagnostics_uploaded in the same batch wins (it carries destination).
if (classification.DiagnosticsUploadedEvent == null
&& IsOnDemandDiagnosticsUploadConfirmation(evt))
{
classification.DiagnosticsUploadedEvent = evt;
}
break;
case "whiteglove_complete":
classification.WhiteGloveEvent = evt;
break;
@@ -116,6 +129,43 @@ namespace AutopilotMonitor.Functions.Services
/// reasons (decision_terminal, ctrl_c, process_exit, unhandled_exception, ...) either
/// follow a real terminal event or imply nothing terminal.
/// </summary>
/// <summary>
/// True for a <c>server_action_executed</c> event that confirms an on-demand
/// ("Collect Logs") diagnostics upload: <c>actionType=request_diagnostics</c> with a
/// non-empty <c>blobName</c>. The agent's ServerActionDispatcher emits no
/// <c>diagnostics_uploaded</c> on that path — this event IS the upload confirmation
/// and must stamp the Sessions row (session 8e4cc4ae: two successful uploads landed
/// in hosted storage but the portal never flipped because the row stayed empty).
/// </summary>
internal static bool IsOnDemandDiagnosticsUploadConfirmation(EnrollmentEvent? evt)
{
if (evt?.Data == null || !string.Equals(evt.EventType, "server_action_executed", StringComparison.Ordinal))
return false;
var actionType = evt.Data.ContainsKey("actionType") ? evt.Data["actionType"]?.ToString() : null;
if (!string.Equals(actionType, "request_diagnostics", StringComparison.OrdinalIgnoreCase))
return false;
var blobName = evt.Data.ContainsKey("blobName") ? evt.Data["blobName"]?.ToString() : null;
return !string.IsNullOrEmpty(blobName);
}
/// <summary>
/// Destination fallback for upload confirmations that carry no <c>destination</c>
/// (the on-demand server_action_executed path, and legacy agents). Only the Hosted
/// path persists the backend-built <c>{tenantId}/{filename}</c> shape
/// (HostedDiagnosticsBlobService.BuildBlobPath); CustomerSas blobs are bare filenames.
/// Returns the explicit destination when present, "Hosted" for a tenant-prefixed blob
/// name, otherwise null (repo leaves the column unchanged; read-time default is
/// CustomerSas).
/// </summary>
internal static string? InferDiagnosticsDestination(string? destination, string? blobName, string tenantId)
{
if (!string.IsNullOrEmpty(destination))
return destination;
if (blobName?.StartsWith(tenantId + "/", StringComparison.OrdinalIgnoreCase) == true)
return "Hosted";
return null;
}
internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt)
{
if (evt == null || !string.Equals(evt.EventType, "agent_shutting_down", StringComparison.Ordinal))
@@ -300,6 +300,10 @@ namespace AutopilotMonitor.Functions.Services
var destination = data?.ContainsKey("destination") == true
? data["destination"]?.ToString()
: null;
// server_action_executed (on-demand collection) carries no destination at all —
// infer Hosted from the tenant-prefixed blob-name shape. Without this the
// download route would look in the customer's container for a hosted blob.
destination = InferDiagnosticsDestination(destination, blobName, request.TenantId);
if (!string.IsNullOrEmpty(blobName))
{
await _sessionRepo.UpdateSessionDiagnosticsBlobAsync(
@@ -26,6 +26,7 @@ public static class TenantNotificationAudienceCatalog
private static readonly Dictionary<string, NotificationAudience> _map = new(System.StringComparer.OrdinalIgnoreCase)
{
["hardware_rejection"] = NotificationAudience.Admin,
["tpm_pss_unsupported"] = NotificationAudience.Admin,
["sla_breach"] = NotificationAudience.Member,
["sla_consecutive_failures"] = NotificationAudience.Member,
["sla_resolved"] = NotificationAudience.Member,
@@ -3,6 +3,9 @@
"version": "1.5.1",
"description": "MCP server for Autopilot-Monitor — enables AI agents to query enrollment sessions, device properties, events and vulnerabilities",
"type": "module",
"engines": {
"node": ">=22"
},
"bin": {
"autopilot-monitor-mcp": "./dist/index.js"
},
@@ -4,23 +4,37 @@ using System.Threading.Tasks;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Tracks which (tenant, manufacturer, model) combinations have already triggered a bell
/// notification. Used by ReportDistressFunction to fire a tenant-admin notification at most
/// once per model per tenant (lifetime dedup).
/// First-notification dedup store for distress-driven bell notifications. Used by
/// ReportDistressFunction to fire each tenant-admin notification at most once per subject
/// (lifetime dedup): hardware rejections per (tenant, manufacturer, model) and TPM PSS
/// incompatibilities per (tenant, serial number).
/// </summary>
public interface IHardwareRejectionNotificationTracker
{
/// <summary>
/// Atomically records a first-time notification for the given (tenantId, manufacturer, model).
/// Atomically records a first-time hardware-rejection notification for the given
/// (tenantId, manufacturer, model).
/// Returns true if this is the first time (caller should fire the notification),
/// false if an entry already exists (caller should skip).
/// Manufacturer and model are matched case-insensitively after trim.
/// </summary>
Task<bool> TryRegisterFirstNotificationAsync(string tenantId, string manufacturer, string model);
/// <summary>
/// Atomically records a first-time TPM-PSS-unsupported notification for the given
/// (tenantId, serialNumber). Same semantics as
/// <see cref="TryRegisterFirstNotificationAsync"/>; the serial number is matched
/// case-insensitively after trim.
/// </summary>
Task<bool> TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber);
/// <summary>
/// Retention cleanup: deletes tracker rows whose FirstNotifiedAt is older than
/// <paramref name="cutoffUtc"/>. Pruning re-arms the dedup for that model. Returns rows deleted.
/// <paramref name="cutoffUtc"/>. Both key spaces live in one table, so pruning re-arms the
/// dedup for BOTH subjects: a hardware model rejected again after the cutoff rings once more,
/// and so does a TPM-PSS-incompatible device that reports again. That is intended — a bell
/// that can never ring a second time would go silent on a device the tenant never fixed.
/// Returns rows deleted.
/// </summary>
Task<int> DeleteOlderThanAsync(DateTime cutoffUtc);
}
@@ -36,7 +36,8 @@ namespace AutopilotMonitor.Shared.Models
public int InProgress { get; set; }
/// <summary>Terminal, non-failure sessions (timeout reclassification). Surfaced as its own count; not a failure.</summary>
public int Incomplete { get; set; }
/// <summary>Succeeded / total * 100 (one decimal). Note: over all sessions, not just terminal.</summary>
/// <summary>Succeeded / (Succeeded + Failed) * 100 (one decimal) — finished enrollments only,
/// matching the SLA convention. 0 when nothing has finished yet (clients render "—").</summary>
public double SuccessRate { get; set; }
/// <summary>Average duration in minutes over non-in-progress sessions that carry a duration.</summary>
public int AvgDurationMinutes { get; set; }
@@ -58,8 +59,11 @@ namespace AutopilotMonitor.Shared.Models
public sealed class FleetModelHealth
{
public string Model { get; set; } = default!;
/// <summary>All sessions on this model in the window, including in-flight ones.</summary>
public int Total { get; set; }
public int Succeeded { get; set; }
/// <summary>Clients derive the per-model rate as Succeeded / (Succeeded + Failed).</summary>
public int Failed { get; set; }
}
public sealed class FleetSlowModel
@@ -73,7 +77,9 @@ namespace AutopilotMonitor.Shared.Models
{
public string Model { get; set; } = default!;
public int Failed { get; set; }
/// <summary>All sessions on this model in the window, including in-flight ones.</summary>
public int Total { get; set; }
/// <summary>Failed / (Failed + Succeeded) * 100, rounded — finished enrollments only.</summary>
public int FailureRate { get; set; }
}
}
@@ -27,9 +27,9 @@ const FEATURES = [
{
title: "Diagnostics Collection",
description:
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle at the end of an enrollment — then download it from the session view without touching the device.",
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle — automatically at the end of an enrollment, or on demand while one is still running — then download it from the session view without touching the device.",
color: "green",
bullets: ["Configurable upload: off, always, or on failure", "Agent, IME & device information bundle", "Configurable additional log paths"],
bullets: ["Configurable upload: off, always, or on failure", "On-demand collection from a running enrollment", "Agent, IME & device information bundle", "Configurable additional log paths"],
},
{
title: "Detailed Event Timeline",
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { trackEvent } from "@/lib/appInsights";
import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes";
@@ -211,8 +212,8 @@ export function IgnoredSoftwareTab({
return (
<tr key={key} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 truncate">
{entry.softwareName}
<td className="px-3 py-3">
<TruncatedLabel text={entry.softwareName} className="block text-sm text-gray-900 dark:text-gray-100" />
</td>
<td className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 truncate">
{entry.publisher || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import TruncatedLabel from "@/components/TruncatedLabel";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { extractContinuation } from "@/lib/paginationLink";
import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers";
@@ -445,8 +446,8 @@ export function OpsEventsSection({
<td className="px-3 py-2.5 text-sm font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap">
{evt.eventType}
</td>
<td className="px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[300px] truncate">
{evt.message}
<td className="px-3 py-2.5 max-w-[300px]">
<TruncatedLabel interactive={false} text={evt.message ?? ""} className="block text-sm text-gray-700 dark:text-gray-300" />
</td>
<td className="px-3 py-2.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{evt.tenantId ? (evt.tenantId.length > 8 ? `${evt.tenantId.slice(0, 8)}...` : evt.tenantId) : <span className="text-gray-300 dark:text-gray-600">-</span>}
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { extractContinuation } from "@/lib/paginationLink";
import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
@@ -355,8 +356,10 @@ export function SessionReportsSection({
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{r.submittedBy}
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 truncate max-w-xs">
{r.comment || <span className="text-gray-400 italic">no comment</span>}
<td className="px-4 py-3 max-w-xs">
{r.comment
? <TruncatedLabel interactive={false} text={r.comment} className="block text-sm text-gray-700 dark:text-gray-300" />
: <span className="text-sm text-gray-400 italic">no comment</span>}
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{r.adminNote ? (
@@ -364,7 +367,7 @@ export function SessionReportsSection({
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
<span className="truncate max-w-[140px]">{r.adminNote}</span>
<TruncatedLabel interactive={false} text={r.adminNote} className="max-w-[140px]" />
</div>
) : (
<span className="text-gray-300 dark:text-gray-600">—</span>
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import TruncatedLabel from "@/components/TruncatedLabel";
import { trackEvent } from "@/lib/appInsights";
import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes";
@@ -564,9 +565,7 @@ export function UnmappedSoftwareTab({
/>
)}
</div>
<div className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 flex-1 min-w-0 truncate">
{entry.softwareName}
</div>
<TruncatedLabel text={entry.softwareName} className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 flex-1" />
<div className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 truncate" style={{ width: "18%", flexShrink: 0 }}>
{entry.publisher || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
</div>
@@ -3,6 +3,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import TruncatedLabel from '@/components/TruncatedLabel';
import { useAuth } from '../../../../contexts/AuthContext';
import { useNotifications } from '../../../../contexts/NotificationContext';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -705,7 +706,7 @@ export function SectionAgentMetrics() {
<p className="text-xs text-gray-500 mb-1">Top exceptions:</p>
{crashRate.topExceptions.map((e) => (
<div key={e.exceptionType} className="flex justify-between text-xs">
<span className="text-red-600 font-mono truncate mr-2">{e.exceptionType}</span>
<TruncatedLabel text={e.exceptionType} className="text-red-600 font-mono mr-2" />
<span className="text-gray-500">{e.count}x</span>
</div>
))}
@@ -80,7 +80,9 @@ export default function AnalyzeRuleCard({
>
{/* Collapsed Header */}
<div className="p-4 cursor-pointer select-none" onClick={() => { if (isEditing) return; onToggle(); }}>
<div className="flex items-center space-x-4">
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
≥sm: single line with the title in the middle, exactly as before. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{isTemplateVariant ? (
<span className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-amber-100 text-amber-600" title="Template — enabling creates a custom rule copy">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-3m-9-4h6m-6 4h6m2-11l4 4m0 0l-4 4m4-4H9" /></svg>
@@ -127,8 +129,8 @@ export default function AnalyzeRuleCard({
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
</span>
<span className="text-xs font-mono text-gray-400 flex-shrink-0 hidden sm:inline">{rule.ruleId}</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">{rule.title}</h3>
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">{rule.title}</h3>
</div>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${catColor.bg} ${catColor.text} flex-shrink-0`}>
{rule.category.charAt(0).toUpperCase() + rule.category.slice(1)}
@@ -159,7 +161,7 @@ export default function AnalyzeRuleCard({
{hitRate}% hit rate
</span>
)}
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
@@ -89,6 +89,14 @@ export default function AnalyzeRulesPage() {
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/analyze/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
// (200 → false "saved"), so edit/toggle/notify/delete MUST target the global route with ?tenantId=.
const analyzeRuleWriteUrl = (ruleId: string) =>
isGlobalOverride
? api.rules.globalAnalyzeRule(ruleId, effectiveTenantId)
: api.rules.analyzeRule(ruleId);
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
const url = isGlobalOverride
@@ -173,15 +181,22 @@ export default function AnalyzeRulesPage() {
// Toggle rule enabled/disabled
const handleToggleRule = async (rule: AnalyzeRule) => {
// Intercept: if this is a template rule being enabled, open the config modal instead
// Intercept: if this is a template rule being enabled, open the config modal instead.
// Enabling a template CREATES a new custom rule — a create, which is disabled in the
// cross-tenant override (like the hidden Create button), so it would misroute to the GA's
// own tenant. Block it with a clear message rather than silently writing to the wrong tenant.
if ((rule.templateVariables?.length ?? 0) > 0 && !rule.enabled) {
if (isGlobalOverride) {
showError("Creating a rule from a template isn't available while viewing another tenant. Switch to that tenant to add rules.");
return;
}
setConfigureTemplateRule(rule);
return;
}
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -211,7 +226,7 @@ export default function AnalyzeRulesPage() {
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -235,7 +250,7 @@ export default function AnalyzeRulesPage() {
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -263,7 +278,7 @@ export default function AnalyzeRulesPage() {
setDeletingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{ method: "DELETE" }
);
if (result !== null) {
@@ -359,7 +374,7 @@ export default function AnalyzeRulesPage() {
};
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -170,11 +170,14 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
if (!data) return { totalApps: 0, totalInstalls: 0, avgFailureRate: 0 };
const apps = data.apps ?? [];
const totalFailed = apps.reduce((acc, a) => acc + a.failed, 0);
const totalInstalls = data.totalInstalls;
// Terminal-only convention: rate over finished installs (succeeded + failed), so
// in-flight/orphaned InProgress rows don't dilute it. Matches the per-app failureRate.
const totalSucceeded = apps.reduce((acc, a) => acc + a.succeeded, 0);
const totalFinished = totalFailed + totalSucceeded;
return {
totalApps: data.totalApps,
totalInstalls,
avgFailureRate: totalInstalls > 0 ? Math.round((totalFailed / totalInstalls) * 1000) / 10 : 0,
totalInstalls: data.totalInstalls,
avgFailureRate: totalFinished > 0 ? Math.round((totalFailed / totalFinished) * 1000) / 10 : 0,
};
}, [data]);
@@ -16,6 +16,7 @@ export interface FleetHealthStats {
inProgress: number;
/** Terminal, non-failure (timeout reclassification). Surfaced separately; not counted as a failure. */
incomplete: number;
/** Succeeded / (succeeded + failed) — finished enrollments only. 0 when nothing finished yet. */
successRate: number;
avgDurationMinutes: number;
}
@@ -30,8 +31,10 @@ export interface FleetFailureReason {
}
export interface FleetModelHealth {
model: string;
/** All sessions on this model in the window, including in-flight ones. */
total: number;
succeeded: number;
failed: number;
}
export interface FleetSlowModel {
model: string;
@@ -10,6 +10,7 @@ import { useNotifications } from "../../contexts/NotificationContext";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import FleetStatCard from "./components/FleetStatCard";
import TruncatedLabel from "../../components/TruncatedLabel";
import { useFleetHealth } from "./hooks/useFleetHealth";
import { useAggregatedAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
@@ -129,6 +130,9 @@ export default function FleetHealthPage() {
successRate: s?.successRate ?? 0,
avgDuration: s?.avgDurationMinutes ?? 0,
};
// Success rate is an outcome quota over finished enrollments (succeeded + failed);
// with none finished there is no rate to show — the card renders "—" instead of 0%.
const finishedCount = stats.succeeded + stats.failed;
const modelHealth = data?.modelHealth ?? [];
const slowestModels = data?.slowestModels ?? [];
@@ -238,10 +242,16 @@ export default function FleetHealthPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-5 mb-8">
<FleetStatCard
title="Success Rate"
value={`${stats.successRate.toFixed(1)}%`}
subtitle={`${stats.succeeded} of ${stats.total} enrollments`}
value={finishedCount > 0 ? `${stats.successRate.toFixed(1)}%` : "—"}
subtitle={
finishedCount > 0
? `${stats.succeeded} of ${finishedCount} finished enrollments`
: "No finished enrollments yet"
}
color={
stats.successRate >= 95
finishedCount === 0
? "slate"
: stats.successRate >= 95
? "green"
: stats.successRate >= 80
? "yellow"
@@ -395,9 +405,10 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{fr.reason}
</span>
<TruncatedLabel
text={fr.reason}
className="text-sm text-gray-700 pr-2"
/>
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
({fr.count})
</span>
@@ -435,9 +446,10 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{m.model}
</span>
<TruncatedLabel
text={m.model}
className="text-sm text-gray-700 pr-2"
/>
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
{m.avgMinutes} min
</span>
@@ -470,9 +482,10 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{m.model}
</span>
<TruncatedLabel
text={m.model}
className="text-sm text-gray-700 pr-2"
/>
<span className="text-sm font-medium text-red-600 flex-shrink-0">
{m.failed} failed
</span>
@@ -536,9 +549,10 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{app.appName}
</span>
<TruncatedLabel
text={app.appName}
className="text-sm text-gray-700 pr-2"
/>
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
{avgLabel} avg
</span>
@@ -586,9 +600,10 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{app.appName}
</span>
<TruncatedLabel
text={app.appName}
className="text-sm text-gray-700 pr-2"
/>
<span className="text-sm font-medium text-red-600 flex-shrink-0">
{app.failed} failed
</span>
@@ -601,7 +616,7 @@ export default function FleetHealthPage() {
/>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{app.failureRate}% of {app.totalInstalls}
{app.failureRate}% of {app.succeeded + app.failed} finished
</span>
</div>
{app.topFailureCodes.length > 0 && (
@@ -631,10 +646,13 @@ export default function FleetHealthPage() {
) : (
<div className="space-y-3">
{modelHealth.map((m) => {
// Rate over finished enrollments only; devices still enrolling don't count
// against the model. No finished enrollments → no rate ("—", neutral bar).
const finished = m.succeeded + m.failed;
const successRate =
m.total > 0
? Math.round((m.succeeded / m.total) * 100)
: 0;
finished > 0
? Math.round((m.succeeded / finished) * 100)
: null;
return (
<Link
key={m.model}
@@ -645,19 +663,21 @@ export default function FleetHealthPage() {
<div className="flex items-baseline justify-between mb-1">
<span className="text-sm text-gray-700 group-hover:text-blue-600 break-words leading-snug">{m.model}</span>
<span className="ml-3 flex-shrink-0 text-sm font-medium text-gray-900">
{successRate}% <span className="text-xs font-normal text-gray-400">({m.total} devices)</span>
{successRate !== null ? `${successRate}%` : "—"} <span className="text-xs font-normal text-gray-400">({m.total} devices)</span>
</span>
</div>
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
successRate >= 95
successRate === null
? "bg-gray-300"
: successRate >= 95
? "bg-green-500"
: successRate >= 80
? "bg-yellow-500"
: "bg-red-500"
}`}
style={{ width: `${successRate}%` }}
style={{ width: `${successRate ?? 0}%` }}
/>
</div>
</Link>
@@ -82,7 +82,9 @@ export function GatherRuleCard({
onExpand();
}}
>
<div className="flex items-center space-x-4">
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
≥sm: single line with the title in the middle, exactly as before. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* Enable/Disable Toggle */}
{readOnly ? (
<span
@@ -119,8 +121,8 @@ export function GatherRuleCard({
</span>
{/* Title */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">
{rule.title}
</h3>
</div>
@@ -150,7 +152,7 @@ export function GatherRuleCard({
{/* Expand/Collapse Arrow */}
<svg
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
@@ -59,7 +59,7 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
{ value: "stall_probe_check", label: "stall_probe_check", category: "stall",
description: "Trace-level heartbeat from a stall probe — proves the logic ran (default: only the 15 min probe)." },
{ value: "esp_policy_provider_stalled", label: "esp_policy_provider_stalled", category: "stall",
description: "A registered ESP policy provider (EnrollmentStatusTracking CSP) stayed incomplete for 15+ minutes — e.g. co-management leaves only ConfigMgr registered without the Intune Sidecar provider, which parks the user ESP at 'Apps (Identifying)' with no OS timeout. One-shot Warning; payload carries the full provider table, per-provider state, and sidecarRegistered." },
description: "The ESP's app-tracking wait (EnrollmentStatusTracking CSP) made no progress for 15+ minutes: a registered policy provider stayed incomplete (reason=provider_incomplete), or Setup/Apps providers exist without the expected Intune 'Sidecar' provider (reason=sidecar_provider_missing) — the co-management signature where only ConfigMgr is registered (even with TrackingPoliciesCreated=1) and the user ESP parks at 'Apps (Identifying)' with no OS timeout. One-shot Warning; payload carries reason, the full provider table, per-provider state, and sidecarRegistered." },
{ value: "agent_late_start", label: "agent_late_start", category: "stall",
description: "Low observation coverage: the agent started long after device boot (≥10 min) AND lived only briefly (≤5 min) before a terminal outcome — it arrived after the enrollment had already decided, so its diagnosis is a post-mortem of the end-state, not a live observation. Carries bootToAgentStartSeconds, agentUptimeSeconds and outcome. Often paired with script_timeout_suspected (a script that hung ahead of the agent bootstrap)." },
@@ -75,6 +75,14 @@ export default function GatherRulesPage() {
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/gather/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
// (200 → false "saved"), so edit/toggle/delete MUST target the global route that carries ?tenantId=.
const gatherRuleWriteUrl = (ruleId: string) =>
isGlobalOverride
? api.rules.globalGatherRule(ruleId, effectiveTenantId)
: api.rules.gatherRule(ruleId, effectiveTenantId);
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
const url = isGlobalOverride
@@ -119,7 +127,7 @@ export default function GatherRulesPage() {
const handleToggleRule = async (rule: GatherRule) => {
setTogglingRule(rule.ruleId);
const result = await mutate(
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
gatherRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -142,7 +150,7 @@ export default function GatherRulesPage() {
setDeletingRule(rule.ruleId);
const result = await mutate(
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
gatherRuleWriteUrl(rule.ruleId),
{ method: "DELETE" }
);
if (result !== null) {
@@ -323,7 +331,7 @@ export default function GatherRulesPage() {
};
const result = await mutate(
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
gatherRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -11,8 +11,11 @@ interface LocationMetric {
city: string;
loc: string;
sessionCount: number;
succeeded: number;
failed: number;
avgDurationMinutes: number;
appLoadScore: number;
/** Over finished enrollments (succeeded + failed) only; 0 when nothing finished yet. */
successRate: number;
avgThroughputBytesPerSec: number;
isOutlier: boolean;
@@ -160,7 +163,10 @@ export default function GeoMap({ locations, globalAvgDuration, selectedLocation,
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
<div className="text-xs">
<div className="font-bold text-sm mb-1">{loc.locationKey}</div>
<div><strong>Sessions:</strong> {loc.sessionCount} ({loc.successRate}% success)</div>
<div>
<strong>Sessions:</strong> {loc.sessionCount}
{loc.succeeded + loc.failed > 0 ? ` (${loc.successRate}% success)` : " (none finished yet)"}
</div>
<div><strong>Avg Duration:</strong> {Math.round(loc.avgDurationMinutes)} min</div>
{loc.appLoadScore > 0 && (
<div><strong>App-Load-Score:</strong> {Math.round(loc.appLoadScore)}</div>
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import TruncatedLabel from "@/components/TruncatedLabel";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
@@ -323,18 +324,14 @@ export default function GeographicPerformancePage() {
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Fastest Location</div>
<div className="text-lg font-bold text-green-600 truncate" title={stats.fastest?.locationKey}>
{stats.fastest?.locationKey || "—"}
</div>
<TruncatedLabel text={stats.fastest?.locationKey || "—"} className="block text-lg font-bold text-green-600" />
<div className="text-xs text-gray-400">
{stats.fastest ? `${Math.round(stats.fastest.avgDurationMinutes)} min avg` : "No data"}
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Slowest Location</div>
<div className="text-lg font-bold text-red-600 truncate" title={stats.slowest?.locationKey}>
{stats.slowest?.locationKey || "—"}
</div>
<TruncatedLabel text={stats.slowest?.locationKey || "—"} className="block text-lg font-bold text-red-600" />
<div className="text-xs text-gray-400">
{stats.slowest ? `${Math.round(stats.slowest.avgDurationMinutes)} min avg` : "No data"}
</div>
@@ -507,17 +504,28 @@ export default function GeographicPerformancePage() {
</td>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{loc.sessionCount}</td>
<td className="px-4 py-3 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
loc.successRate >= 90
? "bg-green-100 text-green-800"
: loc.successRate >= 70
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{loc.successRate}%
</span>
{/* Rate is over finished enrollments (succeeded + failed) only; a
location where everything is still in flight has no rate yet. */}
{loc.succeeded + loc.failed > 0 ? (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
loc.successRate >= 90
? "bg-green-100 text-green-800"
: loc.successRate >= 70
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{loc.successRate}%
</span>
) : (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500"
title="No finished enrollments yet"
>
—
</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<span
@@ -124,6 +124,12 @@ body {
.dark .hover\:bg-gray-100:hover {
background-color: #334155 !important;
}
.dark .hover\:bg-gray-200:hover {
background-color: #334155 !important;
}
.dark .hover\:bg-gray-300:hover {
background-color: #475569 !important;
}
.dark .hover\:bg-indigo-100:hover {
background-color: #312e81 !important;
}
@@ -133,6 +139,60 @@ body {
.dark .hover\:bg-cyan-50:hover {
background-color: #0c2a3d !important;
}
/* Light hover tints (50/100/200) flash near-white in dark mode without an
override — map each family onto its dark -50 tone (one step lighter for
the 100/200 variants so hovering a -50 base still gives feedback). */
.dark .hover\:bg-blue-50:hover {
background-color: #1e3a5f !important;
}
.dark .hover\:bg-blue-100:hover,
.dark .hover\:bg-blue-200:hover {
background-color: #26497a !important;
}
.dark .hover\:bg-purple-50:hover {
background-color: #2d1b4e !important;
}
.dark .hover\:bg-purple-100:hover,
.dark .hover\:bg-purple-200:hover {
background-color: #3b2565 !important;
}
.dark .hover\:bg-red-50:hover {
background-color: #3b1c1c !important;
}
.dark .hover\:bg-red-100:hover,
.dark .hover\:bg-red-200:hover {
background-color: #4a2424 !important;
}
.dark .hover\:bg-amber-50:hover,
.dark .hover\:bg-yellow-50:hover {
background-color: #3d2e0a !important;
}
.dark .hover\:bg-amber-100:hover,
.dark .hover\:bg-amber-200:hover,
.dark .hover\:bg-yellow-100:hover {
background-color: #4d3a0e !important;
}
.dark .hover\:bg-green-100:hover,
.dark .hover\:bg-green-200:hover {
background-color: #1a4436 !important;
}
.dark .hover\:bg-orange-100:hover,
.dark .hover\:bg-orange-200:hover {
background-color: #4d300d !important;
}
.dark .hover\:bg-sky-100:hover {
background-color: #10394f !important;
}
.dark .hover\:bg-teal-50:hover {
background-color: #0d2e2e !important;
}
.dark .hover\:bg-emerald-100:hover {
background-color: #124430 !important;
}
.dark .hover\:bg-slate-50:hover,
.dark .hover\:bg-slate-100:hover {
background-color: #334155 !important;
}
/* -- Input fields -- */
.dark input,
@@ -233,6 +293,35 @@ body {
background-color: #3d2e0a !important;
}
/* -- Slate family (neutral chips like the "Incomplete" status filter) — mirrors gray -- */
.dark .bg-slate-50 {
background-color: #0f172a !important;
}
.dark .bg-slate-100 {
background-color: #1e293b !important;
}
.dark .text-slate-900 {
color: #f1f5f9 !important;
}
.dark .text-slate-700 {
color: #cbd5e1 !important;
}
.dark .text-slate-600 {
color: #94a3b8 !important;
}
.dark .text-slate-500 {
color: #64748b !important;
}
.dark .text-slate-400 {
color: #64748b !important;
}
.dark .border-slate-200 {
border-color: #334155 !important;
}
.dark .border-slate-300 {
border-color: #475569 !important;
}
/* -- Gradient backgrounds (card headers) -- */
.dark [class*="from-purple-50"],
.dark [class*="from-indigo-50"],
@@ -60,7 +60,9 @@ export default function ImePatternCard({
onToggle();
}}
>
<div className="flex items-center space-x-4">
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
≥sm: single line with the title in the middle, exactly as before. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* Enable/Disable Toggle */}
{canEdit ? (
<button
@@ -101,8 +103,8 @@ export default function ImePatternCard({
{pattern.patternId}
</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">
{pattern.description || pattern.patternId}
</h3>
</div>
@@ -120,7 +122,7 @@ export default function ImePatternCard({
</span>
<svg
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
@@ -148,8 +148,17 @@ export default function PrivacyPage() {
<p className="text-gray-700">
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
exists as an alternative but is opt-in only and requires an explicit administrator action behind a clearly
marked disclosure that data leaves your tenant. It is never enabled silently.
exists as an alternative but is opt-in only and requires an explicit administrator action — in Settings, or
in the one-step dialog offered on a session — always behind a clearly marked disclosure that data leaves your
tenant. It is never enabled silently.
</p>
<p className="text-gray-700">
Once upload is configured, an <strong>Admin or Operator in your own tenant</strong> can also request a
package on demand from an enrollment that is still running, instead of waiting for it to finish. It collects
exactly what the automatic package collects — the configured paths, nothing wider — the device only acts on
the request when the agent next checks in, and both the request and its outcome appear as events on that
session&apos;s timeline. Viewers and Progress-Portal members cannot trigger it, and it does nothing on a
tenant that has no upload destination configured.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
@@ -3,6 +3,7 @@
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import TruncatedLabel from "@/components/TruncatedLabel";
import { useSignalR } from "../../contexts/SignalRContext";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useProgressSearch } from "./hooks/useProgressSearch";
@@ -374,13 +375,16 @@ export default function ProgressPortalPage() {
{isCurrent && (step.id === 3 || step.id === 5) && (
<div className="flex items-center space-x-1.5 mt-0.5">
<div className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse flex-shrink-0" />
<span className="text-xs text-blue-500 truncate">
{currentDownload?.active && currentDownload.appName
? `Downloading ${currentDownload.appName}...`
: currentInstall?.active && currentInstall.appName
? `Installing ${currentInstall.appName}...`
: "Processing..."}
</span>
<TruncatedLabel
className="text-xs text-blue-500"
text={
currentDownload?.active && currentDownload.appName
? `Downloading ${currentDownload.appName}...`
: currentInstall?.active && currentInstall.appName
? `Installing ${currentInstall.appName}...`
: "Processing..."
}
/>
</div>
)}
</div>
@@ -398,9 +402,7 @@ export default function ProgressPortalPage() {
<div>
<p className="text-xs text-blue-500 mb-1 font-medium">Downloading</p>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-blue-700 font-medium truncate pr-2">
{currentDownload.appName}
</span>
<TruncatedLabel text={currentDownload.appName} className="text-sm text-blue-700 font-medium pr-2" />
{currentDownload.downloadRateBps > 0 && (
<span className="text-xs text-blue-500 flex-shrink-0">
{currentDownload.downloadRateBps >= 1024 * 1024
@@ -443,9 +445,7 @@ export default function ProgressPortalPage() {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1.5 min-w-0">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse flex-shrink-0" />
<span className="text-sm text-blue-700 font-medium truncate">
{currentInstall.appName}
</span>
<TruncatedLabel text={currentInstall.appName} className="text-sm text-blue-700 font-medium" />
</div>
{installElapsedMs != null && installElapsedMs > 0 && (
<span className="text-xs text-blue-600 font-medium tabular-nums flex-shrink-0 ml-2">
@@ -0,0 +1,320 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { EnrollmentEvent } from "@/types";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { NotificationType } from "@/contexts/NotificationContext";
import {
CollectPhase,
COLLECT_TIMEOUT_MS,
evaluateCollectProgress,
resolveCollectButtonState,
} from "./collectLogsLogic";
interface CollectLogsButtonProps {
sessionId: string;
effectiveTenantId?: string;
sessionStatus?: string;
/** Live from the session object (SignalR sessionUpdate delta) — a change means the package landed. */
diagnosticsBlobName?: string;
isCrossTenantView: boolean;
isTenantAdmin: boolean;
isOperator: boolean;
diagnosticsConfigured: boolean;
/** Called after a successful quick-config so the page-level flag flips without a refetch. */
onDiagnosticsConfigured: () => void;
events: EnrollmentEvent[];
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
addNotification: (type: NotificationType, title: string, message: string, key?: string) => void;
}
/**
* "Collect Logs" — queues a `request_diagnostics` ServerAction that the agent picks up with
* its next ingest response, then tracks progress via the live event stream
* (server_action_received/_executed/_failed) and the DiagnosticsBlobName session delta.
*
* Always rendered, even when disabled: the visible-but-disabled button (with a tooltip
* explaining what is missing) is the deliberate nudge towards configuring diagnostics upload.
* Tenant Admins on an unconfigured tenant get the quick-config dialog instead (enable
* Hosted + OnFailure, then collect immediately) — that dialog IS the explicit opt-in.
*/
export default function CollectLogsButton({
sessionId,
effectiveTenantId,
sessionStatus,
diagnosticsBlobName,
isCrossTenantView,
isTenantAdmin,
isOperator,
diagnosticsConfigured,
onDiagnosticsConfigured,
events,
getAccessToken,
addNotification,
}: CollectLogsButtonProps) {
const [phase, setPhase] = useState<CollectPhase>("idle");
const [showQuickConfig, setShowQuickConfig] = useState(false);
const [quickConfigBusy, setQuickConfigBusy] = useState(false);
const baselineSequenceRef = useRef(0);
const baselineBlobRef = useRef<string | undefined>(undefined);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonState = resolveCollectButtonState({
sessionStatus,
isCrossTenantView,
isTenantAdmin,
isOperator,
diagnosticsConfigured,
phase,
});
const notifyError = useCallback((err: unknown, fallback: string) => {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
addNotification("error", "Collect Logs", fallback, "collect-logs");
}
}, [addNotification]);
const queueAction = useCallback(async (type: string, reason: string): Promise<boolean> => {
const res = await authenticatedFetch(
api.sessions.queueAction(sessionId, effectiveTenantId),
getAccessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, reason }),
},
);
return res.ok;
}, [sessionId, effectiveTenantId, getAccessToken]);
const startCollect = useCallback(async () => {
setPhase("working");
// Sequence baseline: only server_action events NEWER than the click count as feedback
// for THIS request (at-least-once redelivery of older actions must not match).
baselineSequenceRef.current = events.reduce((max, e) => Math.max(max, e.sequence ?? 0), 0);
baselineBlobRef.current = diagnosticsBlobName;
try {
const ok = await queueAction("request_diagnostics", "On-demand log collection from portal");
if (!ok) {
setPhase("idle");
addNotification("error", "Collect Logs", "Failed to queue the log collection request.", "collect-logs");
return;
}
setPhase("queued");
addNotification("info", "Collect Logs",
"Request queued — the agent picks it up with its next check-in and uploads a diagnostics package.",
"collect-logs");
} catch (err) {
setPhase("idle");
notifyError(err, "Failed to queue the log collection request.");
}
}, [events, diagnosticsBlobName, queueAction, addNotification, notifyError]);
const handleClick = () => {
if (!buttonState.enabled) return;
if (buttonState.opensQuickConfig) {
setShowQuickConfig(true);
} else {
void startCollect();
}
};
// Quick-config (Admin only): read the full tenant config, flip ONLY the two diagnostics
// fields, write it back verbatim, then rotate_config BEFORE request_diagnostics so the
// agent refetches the now-enabled config before building the package.
const handleQuickConfig = async () => {
if (!effectiveTenantId) return;
setQuickConfigBusy(true);
try {
const getRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken);
if (!getRes.ok) throw new Error(`Failed to load tenant configuration (${getRes.status})`);
const config = await getRes.json();
const updated = {
...config,
diagnosticsUploadDestination: "Hosted",
// OnFailure is the least invasive mode that unlocks on-demand collection (the
// server-requested path always uploads); keep a stricter existing mode untouched.
diagnosticsUploadMode:
!config.diagnosticsUploadMode || config.diagnosticsUploadMode === "Off"
? "OnFailure"
: config.diagnosticsUploadMode,
};
const putRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
});
if (!putRes.ok) throw new Error(`Failed to save tenant configuration (${putRes.status})`);
onDiagnosticsConfigured();
setShowQuickConfig(false);
// Order matters: the agent processes queued actions sequentially, so rotate_config
// must be first for the collection to run against the new config.
const rotated = await queueAction("rotate_config", "Enable diagnostics upload (quick config from portal)");
if (!rotated) {
addNotification("warning", "Collect Logs",
"Configuration saved, but queueing the config refresh failed — try Collect Logs again.",
"collect-logs");
return;
}
await startCollect();
} catch (err) {
notifyError(err, err instanceof Error ? err.message : "Quick configuration failed.");
} finally {
setQuickConfigBusy(false);
}
};
// Progress watcher: SignalR keeps `events` and the session delta fresh; sequence-based
// matching (see collectLogsLogic) makes this immune to agent clock skew.
useEffect(() => {
const result = evaluateCollectProgress({
phase,
baselineSequence: baselineSequenceRef.current,
baselineBlobName: baselineBlobRef.current,
currentBlobName: diagnosticsBlobName,
events,
});
if (result.kind === "done") {
setPhase("idle");
addNotification("success", "Collect Logs",
"Diagnostics package uploaded — it is now available via Download Diagnostics.",
"collect-logs");
} else if (result.kind === "failed") {
setPhase("idle");
addNotification("error", "Collect Logs", `The agent could not upload the package: ${result.reason}`, "collect-logs");
} else if (result.kind === "collecting" && phase === "queued") {
setPhase("collecting");
}
}, [events, diagnosticsBlobName, phase, addNotification]);
// Give up after a quiet timeout — the agent may have exited. The package still appears
// via SignalR if it reconnects later; the button just returns to idle for a retry.
useEffect(() => {
if (phase === "queued" || phase === "collecting") {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setPhase("idle");
addNotification("warning", "Collect Logs",
"No response from the agent yet — it may no longer be running. The package will still appear if the agent checks in later.",
"collect-logs");
}, COLLECT_TIMEOUT_MS);
}
} else if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [phase, addNotification]);
return (
<>
<button
onClick={handleClick}
disabled={!buttonState.enabled}
title={buttonState.tooltip}
className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm ${
buttonState.enabled
? "bg-sky-600 text-white hover:bg-sky-700"
: "bg-white border border-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
{phase !== "idle" ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 17v-2a2 2 0 012-2h2a2 2 0 012 2v2m-8 4h10a2 2 0 002-2V9a2 2 0 00-2-2h-3.586a1 1 0 01-.707-.293l-1.414-1.414A1 1 0 0012.586 5H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
{buttonState.busyLabel ?? "Collect Logs"}
</button>
{showQuickConfig && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={() => !quickConfigBusy && setShowQuickConfig(false)}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-sky-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Enable Diagnostics Upload</h3>
</div>
<div className="mb-6 space-y-2">
<p className="text-sm text-gray-700">
Diagnostics upload is not configured for this tenant, so the agent has nowhere to send the logs.
</p>
<p className="text-sm text-gray-700">
<span className="font-semibold">Enable &amp; Collect</span> switches the upload destination to
{" "}<span className="font-semibold">hosted storage</span> (managed by AutopilotMonitor) with mode
{" "}<span className="font-semibold">On failure</span>, then collects the logs from this device right away.
</p>
{/* Data-boundary disclosure. This dialog is the SECOND opt-in path into hosted
upload (the first is Settings → Diagnostics Package), and the privacy page and
trust FAQ both promise that hosted upload is only ever enabled behind a clearly
marked "data leaves your tenant" disclosure — this block is that promise here. */}
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3 space-y-1">
<p className="text-sm text-sky-900">
<span className="font-semibold">Uploaded packages leave your own Azure tenant boundary.</span>{" "}
They are stored in the AutopilotMonitor backend&apos;s Azure Storage (Germany West Central),
isolated per tenant, and each upload uses a fresh blob-scoped, write-only token.
</p>
<p className="text-xs text-sky-800">
Packages are removed automatically according to your <span className="font-semibold">Data Retention Days</span> setting.
</p>
</div>
<p className="text-sm text-gray-600">
You can change the destination (e.g. to your own Azure Blob Storage) or turn uploads off again at any
time under Settings → Diagnostics Package, which also lists exactly which paths are collected.
</p>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowQuickConfig(false)}
disabled={quickConfigBusy}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => void handleQuickConfig()}
disabled={quickConfigBusy}
className="px-4 py-2 bg-sky-600 text-white rounded-md hover:bg-sky-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{quickConfigBusy && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
Enable &amp; Collect
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
@@ -390,7 +390,9 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : status === 'skipped' ? '⊘' : phase.id + 1}
</div>
<div className="mt-3 text-center">
<div className={`text-xs font-medium whitespace-nowrap ${status === 'skipped' ? 'text-gray-400' : 'text-gray-700'}`}>
{/* No whitespace-nowrap: long names ("Device Preparation") wrap inside their own
column instead of overlapping the neighbours when columns get narrow. */}
<div className={`text-xs font-medium break-words px-0.5 ${status === 'skipped' ? 'text-gray-400' : 'text-gray-700'}`}>
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
@@ -420,8 +422,14 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
);
};
// Mobile: below ~80px per phase the 48px circles and their labels physically overlap,
// so give the timeline a minimum width and let it scroll horizontally instead of
// squeezing. On desktop the container is wider than the minimum → no scrollbar.
const minTimelineWidth = phases.length * 80 + (isWhiteGloveV1 && splitIndex > 0 ? 32 : 0);
return (
<div className="w-full py-4">
<div className="w-full py-4 overflow-x-auto">
<div style={{ minWidth: `${minTimelineWidth}px` }}>
{/* Group labels for WhiteGlove sessions */}
{isWhiteGloveV1 && splitIndex > 0 && (
<div className="flex w-full mb-3">
@@ -454,6 +462,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
evaluateCollectProgress,
resolveCollectButtonState,
ACTIVE_SESSION_STATUSES,
CollectWatchEvent,
} from "../collectLogsLogic";
// The Collect Logs button is ALWAYS rendered; these tests pin which combination of
// session state / role / tenant configuration enables it, when a click opens the
// quick-config dialog instead, and how live events + the DiagnosticsBlobName delta
// drive the progress state. Pure logic — no DOM.
const baseInput = {
sessionStatus: "InProgress",
isCrossTenantView: false,
isTenantAdmin: false,
isOperator: false,
diagnosticsConfigured: true,
phase: "idle" as const,
};
describe("resolveCollectButtonState", () => {
it("enables direct collect for an Operator when diagnostics is configured", () => {
const s = resolveCollectButtonState({ ...baseInput, isOperator: true });
expect(s.enabled).toBe(true);
expect(s.opensQuickConfig).toBe(false);
});
it("enables direct collect for a Tenant Admin when diagnostics is configured", () => {
const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true });
expect(s.enabled).toBe(true);
expect(s.opensQuickConfig).toBe(false);
});
it("routes an Admin on an UNCONFIGURED tenant into the quick-config dialog", () => {
const s = resolveCollectButtonState({
...baseInput, isTenantAdmin: true, diagnosticsConfigured: false,
});
expect(s.enabled).toBe(true);
expect(s.opensQuickConfig).toBe(true);
});
it("disables (with the nudge tooltip) for an Operator on an unconfigured tenant", () => {
const s = resolveCollectButtonState({
...baseInput, isOperator: true, diagnosticsConfigured: false,
});
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/Tenant Admin can enable/);
});
it("disables for a Viewer even when configured", () => {
const s = resolveCollectButtonState({ ...baseInput });
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/Operator or Admin/);
});
it.each(["Succeeded", "Failed", "Incomplete", undefined, null])(
"disables for non-active session status %s (agent gone)",
(status) => {
const s = resolveCollectButtonState({
...baseInput, isTenantAdmin: true, sessionStatus: status as string | null | undefined,
});
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/no longer active/);
},
);
it.each(ACTIVE_SESSION_STATUSES)("treats %s as an active status", (status) => {
const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, sessionStatus: status });
expect(s.enabled).toBe(true);
});
it("disables in cross-tenant views (actions endpoint is JWT-tenant-bound)", () => {
const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, isCrossTenantView: true });
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/cross-tenant/);
});
it("shows a busy label and disables while a collection is pending", () => {
const queued = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, phase: "queued" });
expect(queued.enabled).toBe(false);
expect(queued.busyLabel).toMatch(/agent/i);
const collecting = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, phase: "collecting" });
expect(collecting.busyLabel).toMatch(/Collecting/);
});
});
describe("evaluateCollectProgress", () => {
const ev = (eventType: string, sequence: number, data?: Record<string, unknown>): CollectWatchEvent =>
({ eventType, sequence, data });
const baseWatch = {
phase: "queued" as const,
baselineSequence: 100,
baselineBlobName: undefined as string | undefined,
currentBlobName: undefined as string | undefined,
events: [] as CollectWatchEvent[],
};
it("is inert while idle", () => {
expect(evaluateCollectProgress({ ...baseWatch, phase: "idle" }).kind).toBe("none");
});
it("reports done when the session's blob name changes — even without visible events", () => {
const r = evaluateCollectProgress({
...baseWatch,
currentBlobName: "t/AgentDiagnostics-s-20260724T100000-server-requested.zip",
});
expect(r).toEqual({ kind: "done", blobName: "t/AgentDiagnostics-s-20260724T100000-server-requested.zip" });
});
it("reports done when an EXISTING blob name is replaced by a new one", () => {
const r = evaluateCollectProgress({
...baseWatch,
baselineBlobName: "t/old.zip",
currentBlobName: "t/new-server-requested.zip",
});
expect(r.kind).toBe("done");
});
it("does NOT report done while the blob name is unchanged from the baseline", () => {
const r = evaluateCollectProgress({
...baseWatch,
baselineBlobName: "t/old.zip",
currentBlobName: "t/old.zip",
});
expect(r.kind).toBe("none");
});
it("moves to collecting on server_action_received for request_diagnostics after the baseline", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_received", 101, { actionType: "request_diagnostics" })],
});
expect(r.kind).toBe("collecting");
});
it("ignores server_action events at or before the baseline (at-least-once redelivery)", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [
ev("server_action_received", 100, { actionType: "request_diagnostics" }),
ev("server_action_failed", 99, { actionType: "request_diagnostics", failureReason: "old" }),
],
});
expect(r.kind).toBe("none");
});
it("ignores server_action events for OTHER action types", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_received", 101, { actionType: "rotate_config" })],
});
expect(r.kind).toBe("none");
});
it("reports failed with the agent's failureReason", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_failed", 102, { actionType: "request_diagnostics", failureReason: "url_host_rejected" })],
});
expect(r).toEqual({ kind: "failed", reason: "url_host_rejected" });
});
it("falls back to a generic reason when failureReason is missing", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_failed", 102, { actionType: "request_diagnostics" })],
});
expect(r.kind).toBe("failed");
});
it("prefers done over a stale failure when the blob eventually landed", () => {
const r = evaluateCollectProgress({
...baseWatch,
currentBlobName: "t/new.zip",
events: [ev("server_action_failed", 102, { actionType: "request_diagnostics", failureReason: "transient" })],
});
expect(r.kind).toBe("done");
});
});
@@ -0,0 +1,144 @@
// Pure state helpers for the session-detail "Collect Logs" button. No React/JSX so
// vitest can pin the role/config gating matrix and the progress evaluation without
// rendering (same pattern as diagnosticsSasExpiry.ts).
export type CollectPhase = "idle" | "working" | "queued" | "collecting";
/** Session statuses in which the agent is (potentially) still alive and can receive actions. */
export const ACTIVE_SESSION_STATUSES = ["InProgress", "Pending", "Stalled"];
/** Give up waiting for agent feedback after this long; the package still appears later if the agent reconnects. */
export const COLLECT_TIMEOUT_MS = 3 * 60_000;
export interface CollectButtonInput {
sessionStatus?: string | null;
/** Cross-tenant (fleet drill-in) views cannot queue actions: the backend resolves the tenant strictly from the caller's JWT. */
isCrossTenantView: boolean;
isTenantAdmin: boolean;
isOperator: boolean;
diagnosticsConfigured: boolean;
phase: CollectPhase;
}
export interface CollectButtonState {
enabled: boolean;
/** When true, the click opens the quick-config dialog instead of queueing directly (Admin on an unconfigured tenant). */
opensQuickConfig: boolean;
/** Tooltip for the disabled states — the button stays visible so users learn the capability exists. */
tooltip?: string;
/** Progress label replacing the idle caption while a collection is running. */
busyLabel?: string;
}
export function resolveCollectButtonState(input: CollectButtonInput): CollectButtonState {
if (input.phase !== "idle") {
return {
enabled: false,
opensQuickConfig: false,
busyLabel:
input.phase === "collecting" ? "Collecting…"
: input.phase === "queued" ? "Waiting for agent…"
: "Requesting…",
};
}
const isActive = !!input.sessionStatus && ACTIVE_SESSION_STATUSES.includes(input.sessionStatus);
if (!isActive) {
return {
enabled: false,
opensQuickConfig: false,
tooltip: "The agent is no longer active for this session — logs can only be collected while an enrollment is running.",
};
}
if (input.isCrossTenantView) {
return {
enabled: false,
opensQuickConfig: false,
tooltip: "Not available in the cross-tenant view.",
};
}
if (!input.diagnosticsConfigured) {
if (input.isTenantAdmin) {
// Admin path: the click opens the quick-config dialog (enable hosted upload, then collect).
return { enabled: true, opensQuickConfig: true };
}
return {
enabled: false,
opensQuickConfig: false,
tooltip: "Diagnostics upload is not configured for this tenant — a Tenant Admin can enable it in Settings.",
};
}
if (input.isTenantAdmin || input.isOperator) {
return { enabled: true, opensQuickConfig: false };
}
return {
enabled: false,
opensQuickConfig: false,
tooltip: "Requires the Operator or Admin role.",
};
}
// ── Progress evaluation ────────────────────────────────────────────────────────────
/** Minimal event shape needed for progress tracking (subset of EnrollmentEvent). */
export interface CollectWatchEvent {
eventType: string;
sequence: number;
data?: Record<string, unknown>;
}
export interface CollectWatchInput {
phase: CollectPhase;
/** Highest event sequence at the moment the collection was requested — only newer events count. */
baselineSequence: number;
/** DiagnosticsBlobName at request time; ANY change means a fresh package landed. */
baselineBlobName?: string;
currentBlobName?: string;
events: CollectWatchEvent[];
}
export type CollectWatchResult =
| { kind: "none" }
| { kind: "collecting" }
| { kind: "done"; blobName: string }
| { kind: "failed"; reason: string };
/**
* Evaluates the live event stream + session delta for progress on a pending collection.
* Sequence-based (backend-assigned, monotonic) rather than timestamp-based so agent
* clock skew cannot hide or resurrect matches.
*/
export function evaluateCollectProgress(input: CollectWatchInput): CollectWatchResult {
if (input.phase !== "queued" && input.phase !== "collecting") return { kind: "none" };
// Ultimate goal first: the Sessions row carries a new package name (delivered via the
// SignalR sessionUpdate delta) — the Download button is live regardless of which
// server_action events made it into the visible stream.
if (input.currentBlobName && input.currentBlobName !== input.baselineBlobName) {
return { kind: "done", blobName: input.currentBlobName };
}
const relevant = input.events.filter(
(e) => (e.sequence ?? 0) > input.baselineSequence
&& e.data?.["actionType"] === "request_diagnostics",
);
const failed = relevant.find((e) => e.eventType === "server_action_failed");
if (failed) {
const reason = typeof failed.data?.["failureReason"] === "string" && failed.data["failureReason"]
? (failed.data["failureReason"] as string)
: "diagnostics upload failed";
return { kind: "failed", reason };
}
const started = relevant.some(
(e) => e.eventType === "server_action_received" || e.eventType === "server_action_executed",
);
if (started) return { kind: "collecting" };
return { kind: "none" };
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
@@ -8,6 +8,10 @@ interface UseSessionTenantConfigReturn {
showScriptOutput: boolean;
enableSoftwareInventoryAnalyzer: boolean;
enableIntegrityBypassAnalyzer: boolean;
/** Whether an on-demand diagnostics upload can succeed right now (drives the Collect Logs button). */
diagnosticsUploadConfigured: boolean;
/** Optimistic flip after a successful quick-config from the session page — avoids a refetch round-trip. */
markDiagnosticsConfigured: () => void;
}
/**
@@ -22,13 +26,14 @@ export function useSessionTenantConfig(
const [showScriptOutput, setShowScriptOutput] = useState(true);
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true);
const [diagnosticsUploadConfigured, setDiagnosticsUploadConfigured] = useState(false);
useEffect(() => {
if (!sessionTenantId) return;
let cancelled = false;
(async () => {
try {
// These three flags are exposed via the member-readable feature-flags endpoint so that
// These flags are exposed via the member-readable feature-flags endpoint so that
// Operators and Viewers can load session details without 403'ing on the admin-only
// full /api/config/{tenantId} response.
const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken);
@@ -38,10 +43,19 @@ export function useSessionTenantConfig(
setShowScriptOutput(cfg.showScriptOutput ?? true);
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(cfg.enableIntegrityBypassAnalyzer ?? true);
setDiagnosticsUploadConfigured(cfg.diagnosticsUploadConfigured ?? false);
} catch { /* non-fatal */ }
})();
return () => { cancelled = true; };
}, [sessionTenantId, getAccessToken]);
return { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer };
const markDiagnosticsConfigured = useCallback(() => setDiagnosticsUploadConfigured(true), []);
return {
showScriptOutput,
enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer,
diagnosticsUploadConfigured,
markDiagnosticsConfigured,
};
}
@@ -32,6 +32,7 @@ import VulnerabilityReportSection from "./components/VulnerabilityReportSection"
import IntegrityBypassSection from "./components/IntegrityBypassSection";
import AdminOverrideModal from "./components/AdminOverrideModal";
import ReportSessionModal from "./components/ReportSessionModal";
import CollectLogsButton from "./components/CollectLogsButton";
import { usePageSections } from "../../../hooks/usePageSections";
import { PageSectionItem } from "../../../contexts/SidebarContext";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons";
@@ -403,6 +404,23 @@ export default function SessionDetailPage() {
</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Always rendered (also disabled): the visible button is the nudge towards
configuring diagnostics upload. Admin capability includes a GA viewing a
session in their own tenant; cross-tenant is gated inside the component. */}
<CollectLogsButton
sessionId={sessionId}
effectiveTenantId={sessionTenantId || tenantId}
sessionStatus={session?.status}
diagnosticsBlobName={session?.diagnosticsBlobName}
isCrossTenantView={isCrossTenantView}
isTenantAdmin={!!user?.isTenantAdmin || !!user?.isGlobalAdmin}
isOperator={user?.role === 'Operator'}
diagnosticsConfigured={tenantConfig.diagnosticsUploadConfigured}
onDiagnosticsConfigured={tenantConfig.markDiagnosticsConfigured}
events={events}
getAccessToken={getAccessToken}
addNotification={addNotification}
/>
{session?.diagnosticsBlobName && (
<button
onClick={async () => {
@@ -95,6 +95,13 @@ interface TenantConfigContextValue {
// Core
config: TenantConfiguration | null;
loading: boolean;
/**
* True when the current user may CHANGE tenant configuration (Tenant Admin or Global Admin).
* Operators reach the settings area read-only: sections must render no Save/Reset bar and
* disable their inputs when this is false. Mirrors the backend write gate (PUT config is
* TenantAdminOrGA), so hiding the affordances is UX — the server enforces regardless.
*/
canEditConfig: boolean;
savingSection: string | null;
error: string | null;
setError: (e: string | null) => void;
@@ -320,6 +327,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const { getAccessToken, user, logout } = useAuth();
const { addNotification } = useNotifications();
// Operators reach the settings area read-only — only a Tenant Admin or Global Admin may
// change configuration. Drives section rendering (no Save/Reset bar, disabled inputs)
// and the defensive guard in saveConfiguration; the backend enforces the same gate.
const canEditConfig = user?.isTenantAdmin === true || user?.isGlobalAdmin === true;
// --- State (mirrors old page.tsx lines 35-112) ---
const [config, setConfig] = useState<TenantConfiguration | null>(null);
const [admins, setAdmins] = useState<TenantAdmin[]>([]);
@@ -429,7 +441,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
useEffect(() => {
if (!tenantId) return;
// Operators (non-admin) only need feature flags, not the full config
// Operators reach the settings area read-only (no Save affordances); admins may edit.
const isAdminOrGA = user?.isTenantAdmin || user?.isGlobalAdmin;
const fetchConfiguration = async () => {
@@ -437,33 +449,34 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setLoading(true);
setError(null);
if (!isAdminOrGA) {
// Operator with canManageBootstrapTokens — load only feature flags
const response = await authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load feature flags: ${response.statusText}`);
}
const flags = await response.json();
// Create a minimal config object with just the feature flag
setConfig({ bootstrapTokenEnabled: flags.bootstrapTokenEnabled } as TenantConfiguration);
setEditionInfo(parseEditionInfo(flags));
return;
}
// Admin path: full config + feature flags in parallel — the edition surface is
// resolved SERVER-side (trial expiry math), so it comes from flags, not the raw config.
// Everyone entitled to the settings area loads the full config + feature flags in
// parallel — the edition surface is resolved SERVER-side (trial expiry math), so it
// comes from flags, not the raw config. Non-admins (Operators) receive the
// server-REDACTED copy: the backend clears secrets for every caller without write
// authority over the target tenant, so showing it read-only is safe.
const [response, flagsResponse] = await Promise.all([
authenticatedFetch(api.config.tenant(tenantId), getAccessToken),
authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken),
]);
let flags: unknown = null;
if (flagsResponse.ok) {
try {
setEditionInfo(parseEditionInfo(await flagsResponse.json()));
flags = await flagsResponse.json();
setEditionInfo(parseEditionInfo(flags));
} catch { /* fail-closed: keep Community default */ }
}
if (!response.ok) {
// Deploy-order safety net: a backend that still gates the config GET admin-tier
// 403s an Operator. Fall back to the feature-flags minimal view (pre-change
// behavior for non-admins) instead of surfacing a load error.
if (response.status === 403 && !isAdminOrGA && flags && typeof flags === "object") {
setConfig({
bootstrapTokenEnabled: (flags as { bootstrapTokenEnabled?: boolean }).bootstrapTokenEnabled,
} as TenantConfiguration);
return;
}
throw new Error(`Failed to load configuration: ${response.statusText}`);
}
@@ -652,7 +665,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
if (!tenantId || !config) return false;
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PUT regardless).
if (!tenantId || !config || !canEditConfig) return false;
try {
setSavingSection(sectionName);
@@ -758,7 +773,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setSavingSection(null);
}
}, [
tenantId, config, getAccessToken, addNotification,
tenantId, config, canEditConfig, getAccessToken, addNotification,
manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
@@ -1494,7 +1509,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
return (
<TenantConfigContext.Provider value={{
config, loading, savingSection,
config, loading, canEditConfig, savingSection,
error, setError, successMessage, setSuccessMessage,
// Edition / trial
@@ -6,6 +6,7 @@ import AgentAnalyzersSection from "../../components/AgentAnalyzersSection";
export function SectionAgentAnalyzers() {
const {
canEditConfig,
enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer,
localAdminAllowedAccounts, setLocalAdminAllowedAccounts,
newAllowedAccount, setNewAllowedAccount,
@@ -41,6 +42,7 @@ export function SectionAgentAnalyzers() {
onSave={handleSaveAgentAnalyzers}
onReset={handleResetAgentAnalyzers}
saving={savingSection === "agentAnalyzers"}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,6 +6,7 @@ import AgentSettingsSection from "../../components/AgentSettingsSection";
export function SectionAgentSettings() {
const {
canEditConfig,
enablePerformanceCollector, setEnablePerformanceCollector,
performanceCollectorInterval, setPerformanceCollectorInterval,
helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds,
@@ -65,6 +66,7 @@ export function SectionAgentSettings() {
onSave={handleSaveAgentSettings}
onReset={handleResetAgentSettings}
saving={savingSection === "agentSettings"}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,6 +6,7 @@ import DiagnosticsSection from "../../components/DiagnosticsSection";
export function SectionDiagnostics() {
const {
canEditConfig,
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
diagnosticsUploadMode, setDiagnosticsUploadMode,
diagnosticsUploadDestination, setDiagnosticsUploadDestination,
@@ -39,6 +40,7 @@ export function SectionDiagnostics() {
onSave={handleSaveDiagnostics}
onReset={handleResetDiagnostics}
saving={savingSection === "diagnostics"}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,12 +6,22 @@ import UnrestrictedModeSection from "../../components/UnrestrictedModeSection";
export function SectionUnrestrictedMode() {
const {
config,
canEditConfig, config,
unrestrictedMode, setUnrestrictedMode,
handleSaveUnrestrictedMode,
savingSection,
} = useTenantConfig();
// Security-sensitive toggle — Operators never see it (with the unified config fetch
// they would otherwise see the GA-gate flag and get the section rendered).
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
if (!config?.unrestrictedModeEnabled) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
@@ -1,6 +1,7 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface AgentAnalyzersSectionProps {
enableLocalAdminAnalyzer: boolean;
@@ -22,6 +23,8 @@ interface AgentAnalyzersSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): analyzers visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
const BUILTIN_ACCOUNTS = [
@@ -58,6 +61,7 @@ export default function AgentAnalyzersSection({
onSave,
onReset,
saving,
readOnly = false,
}: AgentAnalyzersSectionProps) {
const trimmed = newAllowedAccount.trim();
const isDuplicate =
@@ -89,6 +93,8 @@ export default function AgentAnalyzersSection({
</div>
</div>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
{/* ── Local Admin Analyzer ─────────────────────────── */}
<div>
@@ -337,8 +343,11 @@ export default function AgentAnalyzersSection({
</div>
</div>
</div>
</ReadOnlyFieldset>
{/* Save / Reset */}
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -1,6 +1,7 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
@@ -38,6 +39,8 @@ interface AgentSettingsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function AgentSettingsSection({
@@ -76,6 +79,7 @@ export default function AgentSettingsSection({
onSave,
onReset,
saving,
readOnly = false,
}: AgentSettingsSectionProps) {
return (
<>
@@ -93,6 +97,8 @@ export default function AgentSettingsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
@@ -291,8 +297,10 @@ export default function AgentSettingsSection({
</div>
</>
)}
</div>
</ReadOnlyFieldset>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
@@ -310,6 +318,8 @@ export default function AgentSettingsSection({
</div>
</div>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
@@ -370,6 +380,8 @@ export default function AgentSettingsSection({
</div>
</div>
</div>
</ReadOnlyFieldset>
</div>
</div>
</>
@@ -1,6 +1,7 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface DataManagementSectionProps {
dataRetentionDays: number;
@@ -13,6 +14,8 @@ interface DataManagementSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): values visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function DataManagementSection({
@@ -25,6 +28,7 @@ export default function DataManagementSection({
onSave,
onReset,
saving,
readOnly = false,
}: DataManagementSectionProps) {
const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > retentionCapDays;
const isRetentionDisabled = isOverridden && !isGlobalAdmin;
@@ -43,6 +47,8 @@ export default function DataManagementSection({
</div>
</div>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
{/* Data Retention Days */}
<div>
<label className="block">
@@ -106,7 +112,10 @@ export default function DataManagementSection({
</label>
</div>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -5,6 +5,7 @@ import { DiagnosticsLogPath } from "../types";
import { validateDiagnosticsPath } from "@/utils/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { parseSasExpiry } from "./diagnosticsSasExpiry";
// Re-export for callers that still import from the section. Implementation lives
@@ -30,6 +31,8 @@ interface DiagnosticsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function DiagnosticsSection({
@@ -50,6 +53,7 @@ export default function DiagnosticsSection({
onSave,
onReset,
saving,
readOnly = false,
}: DiagnosticsSectionProps) {
const isHosted = diagnosticsUploadDestination === "Hosted";
const [newDiagSubfolders, setNewDiagSubfolders] = useState(false);
@@ -77,6 +81,8 @@ export default function DiagnosticsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Destination selector — explicit admin choice between customer-controlled SAS
(default, data stays in customer's storage) and hosted (opt-in, data leaves
@@ -364,7 +370,10 @@ export default function DiagnosticsSection({
</div>
</div>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -28,6 +28,8 @@ interface HardwareRejectionInsightsProps {
webhookNotifyOnHardwareRejection: boolean;
onToggleNotification: (value: boolean) => void;
hasWebhook: boolean;
/** Read-only viewer (Operator): the notify toggle is disabled (insights stay browsable). */
readOnly?: boolean;
}
function formatDate(iso: string): string {
@@ -47,13 +49,16 @@ function WebhookToggle({
checked,
onChange,
hasWebhook,
disabled = false,
}: {
checked: boolean;
onChange: (value: boolean) => void;
hasWebhook: boolean;
disabled?: boolean;
}) {
const interactive = hasWebhook && !disabled;
return (
<label className={`flex items-center justify-between p-3 rounded-lg border border-gray-200 transition-colors ${hasWebhook ? "cursor-pointer hover:bg-gray-50" : "opacity-60 cursor-not-allowed"}`}>
<label className={`flex items-center justify-between p-3 rounded-lg border border-gray-200 transition-colors ${interactive ? "cursor-pointer hover:bg-gray-50" : "opacity-60 cursor-not-allowed"}`}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
@@ -72,7 +77,7 @@ function WebhookToggle({
type="checkbox"
checked={checked && hasWebhook}
onChange={(e) => onChange(e.target.checked)}
disabled={!hasWebhook}
disabled={!interactive}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:bg-blue-600 peer-disabled:opacity-50 transition-colors" />
@@ -89,6 +94,7 @@ export default function HardwareRejectionInsights({
webhookNotifyOnHardwareRejection,
onToggleNotification,
hasWebhook,
readOnly = false,
}: HardwareRejectionInsightsProps) {
const [data, setData] = useState<HardwareRejectedResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -189,6 +195,7 @@ export default function HardwareRejectionInsights({
checked={webhookNotifyOnHardwareRejection}
onChange={onToggleNotification}
hasWebhook={hasWebhook}
disabled={readOnly}
/>
</div>
@@ -2,6 +2,7 @@
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
@@ -11,6 +12,8 @@ interface HardwareWhitelistSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): inputs disabled, no Save/Reset bar. */
readOnly?: boolean;
}
function parseList(csv: string): string[] {
@@ -130,6 +133,7 @@ export default function HardwareWhitelistSection({
onSave,
onReset,
saving,
readOnly = false,
}: HardwareWhitelistSectionProps) {
const manufacturers = parseList(manufacturerWhitelist);
const models = parseList(modelWhitelist);
@@ -153,6 +157,8 @@ export default function HardwareWhitelistSection({
</div>
</div>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-5">
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<p className="text-sm text-blue-900">
@@ -190,7 +196,10 @@ export default function HardwareWhitelistSection({
setItems={(items) => setModelWhitelist(joinList(items))}
/>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
interface NotificationsSectionProps {
@@ -13,6 +14,8 @@ interface NotificationsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): channels visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
const GENERIC_PROVIDER = 20;
@@ -330,6 +333,7 @@ export default function NotificationsSection({
onSave,
onReset,
saving,
readOnly = false,
}: NotificationsSectionProps) {
const addChannel = () => {
if (channels.length >= MAX_NOTIFICATION_CHANNELS) return;
@@ -363,6 +367,8 @@ export default function NotificationsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{channels.length === 0 && (
<p className="text-sm text-gray-500">
No channels configured yet. Add a channel to receive enrollment, SLA, or rule notifications.
@@ -392,8 +398,10 @@ export default function NotificationsSection({
</svg>
Add channel{channels.length >= MAX_NOTIFICATION_CHANNELS ? ` (max ${MAX_NOTIFICATION_CHANNELS})` : ""}
</button>
</div>
</ReadOnlyFieldset>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -0,0 +1,31 @@
"use client";
/**
* Wraps a settings section's interactive content for read-only viewers (Operators).
* The native fieldset `disabled` attribute inertly disables every nested input, select,
* textarea and button while values stay visible and copyable; a small notice explains why.
* Sections must ADDITIONALLY not render their SaveResetBar when read-only — a read-only
* viewer gets no save affordance at all, not a disabled one.
*/
export default function ReadOnlyFieldset({
readOnly,
notice = true,
children,
}: {
readOnly: boolean;
/** Set false on follow-up cards of the same page so the notice appears only once. */
notice?: boolean;
children: React.ReactNode;
}) {
if (!readOnly) return <>{children}</>;
return (
<fieldset disabled className="min-w-0">
{notice && (
<p className="mb-4 rounded-md bg-gray-50 border border-gray-200 px-3 py-2 text-sm text-gray-500">
Read-only view — configuration changes require a tenant administrator.
</p>
)}
{children}
</fieldset>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
interface AggregatedTpmPssUnsupported {
serialNumber: string;
manufacturer: string;
model: string;
attemptCount: number;
firstSeen: string;
lastSeen: string;
}
interface TpmPssUnsupportedResponse {
success: boolean;
aggregated: AggregatedTpmPssUnsupported[];
totalRawReports: number;
dataQualityNotice: string;
}
interface TpmIncompatibleDevicesInsightsProps {
getAccessToken: () => Promise<string | null>;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
/**
* Devices whose TPM cannot perform RSA-PSS signing — Schannel silently drops their client
* certificate from TLS client-auth (Windows 11 25H2+ prefers PSS), so the agent can never
* authenticate. Remediation is a TPM firmware update or device replacement.
*
* Unlike the sibling insights panels this one renders nothing while loading, on error, and
* when no device is affected: it is a rare condition and a third permanent card in the
* section would be noise for the common all-clear case.
*/
export default function TpmIncompatibleDevicesInsights({
getAccessToken,
}: TpmIncompatibleDevicesInsightsProps) {
const [data, setData] = useState<TpmPssUnsupportedResponse | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await authenticatedFetch(api.distress.tpmPssUnsupported(), getAccessToken);
if (!res.ok) return;
const json: TpmPssUnsupportedResponse = await res.json();
setData(json);
} catch {
// Silent degrade — the panel is a secondary insight; the section stays usable without it.
}
}, [getAccessToken]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!data || data.aggregated.length === 0) {
return null;
}
return (
<div className="bg-white rounded-lg shadow mt-6">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<h3 className="text-lg font-semibold text-gray-900">
Devices with Incompatible TPM
<span className="ml-2 text-xs font-normal text-gray-500">Last 14 days</span>
</h3>
<p className="text-sm text-gray-500 mt-0.5">
These devices have a TPM that cannot perform RSA-PSS signing, so the agent cannot
authenticate and no enrollment data is reported
</p>
</div>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Unverified
</span>
</div>
</div>
{/* Explanation + remediation */}
<div className="px-6 pt-4 space-y-3">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
Windows 11 25H2 and later prefer RSA-PSS for TLS client authentication. On these devices
the TPM-backed Intune certificate cannot sign with RSA-PSS (common on older TPM firmware),
so Windows silently withholds the certificate and every connection attempt is rejected.
Remediation: update the TPM firmware via the vendor&apos;s tooling, or replace the device.
This data comes from pre-authentication distress signals; serial number, manufacturer,
and model values are self-reported and unverified.
</p>
</div>
</div>
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th className="pb-2 pr-4">Serial Number</th>
<th className="pb-2 pr-4">Manufacturer</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Attempts</th>
<th className="pb-2 pr-4">First Seen</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.aggregated.map((row, idx) => (
<tr key={`${row.serialNumber}|${idx}`}>
<td className="py-3 pr-4 font-mono text-gray-900">{row.serialNumber || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.manufacturer || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.model || "–"}</td>
<td className="py-3 pr-4 text-right font-mono text-gray-900">{row.attemptCount}</td>
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.firstSeen)}</td>
<td className="py-3 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-400 mt-3">
{data.totalRawReports} total report{data.totalRawReports !== 1 ? "s" : ""} across {data.aggregated.length} device{data.aggregated.length !== 1 ? "s" : ""}
</p>
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import DataManagementSection from "../../components/DataManagementSection";
export function SectionDataManagement() {
const {
canEditConfig,
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
user, editionInfo,
@@ -26,6 +27,7 @@ export function SectionDataManagement() {
onSave={handleSaveDataManagement}
onReset={handleResetDataManagement}
saving={savingSection === "dataManagement"}
readOnly={!canEditConfig}
/>
</>
);
@@ -8,6 +8,7 @@ import OffboardingSection from "../../components/OffboardingSection";
export function SectionOffboarding() {
const {
canEditConfig,
showOffboardDialog, setShowOffboardDialog,
offboardConfirmText, setOffboardConfirmText,
offboarding, offboardError, setOffboardError,
@@ -17,6 +18,15 @@ export function SectionOffboarding() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
// Offboarding deletes the whole tenant — Operators never see the danger zone.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
return (
<>
<TenantNotifications />
@@ -6,6 +6,7 @@ import AdminManagementSection from "../../components/AdminManagementSection";
export function SectionAccessManagement() {
const {
canEditConfig,
admins, loadingAdmins,
newAdminEmail, setNewAdminEmail,
newMemberRole, setNewMemberRole,
@@ -17,6 +18,17 @@ export function SectionAccessManagement() {
handleToggleTenantAdmin, handleUpdatePermissions,
} = useTenantConfig();
// Operators do not manage members, and the member list itself is admin-tier data
// (the backend never serves it to them) — hide the section instead of rendering
// an empty table with dead controls.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
return (
<>
<TenantNotifications />
@@ -8,6 +8,7 @@ import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesI
export function SectionAutopilotValidation() {
const {
canEditConfig,
validateAutopilotDevice, setValidateAutopilotDevice,
validateCorporateIdentifier, setValidateCorporateIdentifier,
validateDeviceAssociation,
@@ -17,6 +18,16 @@ export function SectionAutopilotValidation() {
} = useTenantConfig();
const { user, getAccessToken } = useAuth();
// Validation gates + the Entra admin-consent flow are tenant-admin territory —
// Operators do not see this section at all.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
// DevPrep "Device association" is in Microsoft Private Preview — surface only to Global Admins
// until it ships GA. Backend always rejects writes from non-GA callers regardless of UI.
// TODO(devprep-followup): add vitest DOM-render test asserting toggle is hidden for
@@ -2,9 +2,11 @@
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
export function SectionContact() {
const {
canEditConfig,
contactEmail, setContactEmail,
handleSaveContact, handleResetContact,
savingSection,
@@ -28,6 +30,7 @@ export function SectionContact() {
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={!canEditConfig}>
<div>
<label htmlFor="contactEmail" className="block text-sm font-medium text-gray-700">
Contact email address
@@ -37,13 +40,14 @@ export function SectionContact() {
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="it-operations@contoso.com"
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder={canEditConfig ? "it-operations@contoso.com" : "Not configured"}
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 disabled:bg-gray-50 disabled:text-gray-600"
/>
{looksInvalid && (
<p className="mt-1 text-sm text-amber-600">That does not look like an email address.</p>
)}
</div>
</ReadOnlyFieldset>
<div className="rounded-md bg-blue-50 border border-blue-100 p-4">
<p className="text-sm text-blue-900">
@@ -57,12 +61,14 @@ export function SectionContact() {
</p>
</div>
<SaveResetBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid}
/>
{canEditConfig && (
<SaveResetBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid}
/>
)}
</div>
</div>
);
@@ -6,6 +6,7 @@ import { useTenantConfig } from "../../TenantConfigContext";
import { TenantNotifications } from "../../TenantNotifications";
import HardwareWhitelistSection from "../../components/HardwareWhitelistSection";
import HardwareRejectionInsights from "../../components/HardwareRejectionInsights";
import TpmIncompatibleDevicesInsights from "../../components/TpmIncompatibleDevicesInsights";
function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
@@ -18,6 +19,7 @@ function joinList(items: string[]): string {
export function SectionHardwareWhitelist() {
const { getAccessToken } = useAuth();
const {
canEditConfig,
manufacturerWhitelist, setManufacturerWhitelist,
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection,
@@ -53,15 +55,18 @@ export function SectionHardwareWhitelist() {
onSave={handleSaveHardwareWhitelist}
onReset={handleResetHardwareWhitelist}
saving={savingSection === "hardwareWhitelist"}
readOnly={!canEditConfig}
/>
<HardwareRejectionInsights
getAccessToken={getAccessToken}
onAddManufacturer={handleAddManufacturer}
onAddModel={handleAddModel}
onAddManufacturer={canEditConfig ? handleAddManufacturer : undefined}
onAddModel={canEditConfig ? handleAddModel : undefined}
webhookNotifyOnHardwareRejection={webhookNotifyOnHardwareRejection}
onToggleNotification={setWebhookNotifyOnHardwareRejection}
hasWebhook={hasWebhook}
readOnly={!canEditConfig}
/>
<TpmIncompatibleDevicesInsights getAccessToken={getAccessToken} />
</>
);
}
@@ -6,6 +6,7 @@ import NotificationsSection from "../../components/NotificationsSection";
export function SectionNotifications() {
const {
canEditConfig,
notificationChannels, setNotificationChannels,
handleTestChannel, testingChannelId, testChannelResult,
handleSaveNotifications, handleResetNotifications,
@@ -24,6 +25,7 @@ export function SectionNotifications() {
onSave={handleSaveNotifications}
onReset={handleResetNotifications}
saving={savingSection === "notifications"}
readOnly={!canEditConfig}
/>
</>
);

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