From 88da317bf448555e78e056170c605a13bb699ebb Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 27 Jul 2026 08:38:17 +0200 Subject: [PATCH] feat(insights): F2 surfaces - device-history banner, fleet FTR panel, API routes, MCP tool (PR5) Three read routes over the PR4 data: metrics/device-history (MemberRead + tenant query param - one route serves the web banner AND MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner fallback; history:null is a normal outcome), metrics/device-journeys and global/metrics/device-journeys (days selector honored because FTR counts are additive - window rate = ratio of summed daily rows; the tenant-less global variant serves the "global" partition and discloses repeatDevices:null). Attempt numbers are server-computed (DeviceJourneyCalculator.ComputeAttemptNumber: chain position within the journey; live sessions via a virtual non-successful attempt so the redeploy and 30d-gap rules apply) - no consumer re-derives journey semantics. Repeat-devices violator list: last journey >=2 attempts with newest terminal ref in-window, attempts desc then recency, cap 10, failure reason from the newest failed attempt (bounded point-reads). Web: DeviceHistoryBanner on session detail ("Attempt N for this device - View history", expandable terminal chain, durations strictly the authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the Fleet Health "First-time-right" section (rate gated at >=20 completed journeys with disclosed n, junk-serial exclusion note, Monday-UTC weekly trend, attempt histogram, repeat devices) following the 7/30/90 selector. MCP: get_device_history (serial mode / fleet FTR mode, role-aware routing, all roles - GA_FULL privilege snapshot extended deliberately). Docs: OKF backend/device-journeys.md + index/log. Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries and live sessions, additive sums, days clamping, repeat-device gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed. Co-Authored-By: Claude Fable 5 --- docs/backend/device-journeys.md | 122 ++++++ docs/index.md | 1 + docs/log.md | 4 + .../DeviceJourneyAndFtrTests.cs | 168 +++++++++ .../Metrics/GetDeviceJourneyFunctions.cs | 351 ++++++++++++++++++ .../Helpers/DeviceJourneyCalculator.cs | 38 ++ .../Security/EndpointAccessPolicyCatalog.cs | 5 + .../src/__tests__/tools-catalog-order.test.ts | 4 + .../autopilot-monitor-mcp/src/tools/admin.ts | 55 +++ .../components/DeviceJourneySection.tsx | 241 ++++++++++++ .../app/fleet-health/page.tsx | 30 ++ .../components/DeviceHistoryBanner.tsx | 185 +++++++++ .../app/sessions/[sessionId]/page.tsx | 14 + src/Web/autopilot-monitor-web/lib/api.ts | 11 + 14 files changed, 1229 insertions(+) create mode 100644 docs/backend/device-journeys.md create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs create mode 100644 src/Web/autopilot-monitor-web/app/fleet-health/components/DeviceJourneySection.tsx create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceHistoryBanner.tsx diff --git a/docs/backend/device-journeys.md b/docs/backend/device-journeys.md new file mode 100644 index 00000000..956f0923 --- /dev/null +++ b/docs/backend/device-journeys.md @@ -0,0 +1,122 @@ +--- +type: Concept +title: Device Journeys & First-Time-Right (F2) +description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them. +resource: src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs +tags: + - backend + - metrics + - device-history + - first-time-right + - insights +timestamp: 2026-07-27T12:00:00+02:00 +--- + +# Device Journeys & First-Time-Right (F2) + +Answers "how many devices enrolled right on the first try?" and "show me every attempt +this device made". Session success rate hides wipe-and-retry loops — every retry that +eventually succeeds counts as a success; First-Time-Right (FTR) exposes the real pain. +All grouping semantics live server-side in `DeviceJourneyCalculator` (JourneyVersion 1); +no consumer re-derives them. + +# Schema + +## Device key + +`(TenantId, normalized serial)` — normalization is trim + lower-case. Junk/placeholder +serials are excluded entirely (never a chain, never in FTR, disclosed as a per-day +exclusion count): `System Serial Number`, `To Be Filled By O.E.M.`, `Default string`, +`0`, `None`, `INVALID`, `Unknown` (the agent's WMI-failure sentinel), empty, or shorter +than 4 characters. A motherboard swap changes the serial and therefore the device key; +re-enrollment under a different tenant is a separate key by design. + +## Journey + +Terminal sessions of a device key ordered by StartedAt. The terminal set is +**Succeeded / Failed / Incomplete** — `Pending` (a sealed WhiteGlove part 1), +`AwaitingUser`, `Stalled` and `InProgress` are OPEN sessions and never attempts. + +* A journey **ends** with the first terminal success; the next session starts a new + journey (redeployment). +* A gap of **more than 30 days** since the previous attempt's terminal moment + (`CompletedAt`, fallback StartedAt) starts a new journey even without a success + (device shelved/repurposed). Constant, not a setting — production gaps: median 4.9 h, + 92 % < 7 d. +* WhiteGlove part 1 + part 2 share ONE session row, so a completed WG enrollment is ONE + attempt; a WG device still waiting for its user session leaves the journey open — + never a failed attempt. +* **Attempt count** = terminal sessions in the journey (`Failed` and `Incomplete` are + non-successful attempts). +* **FTR rate** = completed journeys with attempt count 1 ÷ all completed journeys. + Open and gap-abandoned journeys never enter numerator or denominator. + +Durations shown for attempts are the sessions' authoritative `DurationSeconds` verbatim +(WhiteGlove pause excluded; `Incomplete` deliberately has none) — never +`CompletedAt − StartedAt`, which is later in ~25 % of terminal sessions. + +## Persistence & compute + +* `DeviceHistories` (PK = TenantId, RK = percent-encoded normalized serial): the chain of + the device's terminal session refs `{sessionId, startedAt, completedAt, status, + enrollmentType, isPreProvisioned, durationSeconds, adminMarked}` capped at the 20 most + recent, plus derived `CurrentJourneyAttempts` (the LAST journey's attempts — the + banner's "Attempt N"), `JourneyCount` (chain-scoped) and `JourneyVersion`. Written + inline at the session-terminal single-writer seam + (`TableSessionRepository.UpdateSessionStatusAsync`, all three terminal statuses, + DeletionState-guarded) so the session banner is fresh; healed by the maintenance sweep. + Deliberately NOT part of the per-session deletion manifest — the row aggregates many + sessions. Wiped on tenant offboarding. +* Sweep (`SweepDeviceJourneysAsync`, 30-day rolling window, 2h tick): (1) drops chain + refs of deleted sessions **tombstone-driven** — every session deletion (cascade and + retention both run through the cascade worker) leaves a `SessionTombstones` marker for + ~7 days, far longer than the sweep cadence, and even an expired-but-unpruned marker is + valid deletion evidence; a chain left empty deletes its row. (2) Merges the window's + terminal sessions into their chains (backfill, inline-miss healing, re-terminal + reclassifications). (3) Recomputes the daily FTR aggregates idempotently. +* `DeviceJourneyAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}`): per day — + completed journeys, first-time-right count, attempt histogram, junk-serial exclusion + count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing + success session (the platform's StartedAt-date convention; late terminals converge via + the sweep). All counts are **additive**, so a window rate is the ratio of summed daily + rows — there is no rolling-window row (unlike the median-based time-attribution + aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate + on purpose (the UI needs the n for "insufficient data (n=…)"). + +# Surfaces + +* `GET api/metrics/device-history?serialNumber=&sessionId=` (MemberRead + tenant query + param — the same route serves the web banner and MCP; a Global Admin passes `tenantId`) + — the chain plus, with `sessionId`, that session's server-computed attempt number + (live sessions get their would-be position via a virtual non-successful attempt, so the + redeploy and gap rules apply). `history: null` = no recorded history. +* `GET api/metrics/device-journeys?days=` (MemberRead) / + `GET api/global/metrics/device-journeys?days=&tenantId=` (GlobalReadOrAdmin) — + `{ windowDays, totals, daily, repeatDevices }`. The days selector is honored because + counts are additive. Without `tenantId` the global variant serves the cross-tenant + aggregate rows and `repeatDevices: null` — a per-device list would require scanning + every tenant's partition. +* Repeat devices (violator list, capped 10): devices whose LAST journey took ≥2 attempts + and whose newest terminal session falls inside the window, ordered by attempts then + recency; the failure reason comes from the newest failed attempt (bounded point-reads). +* Web: "Attempt N for this device · View history" banner on session detail (expandable + chain); "First-time-right" section on Fleet Health (rate + weekly trend + attempt + histogram + repeat devices). MCP: `get_device_history` (serial mode / fleet FTR mode). + +# Examples + +A device fails ESP twice and succeeds on the third try within one week: one journey, +three attempts, completed — counts once in the FTR denominator, not in the numerator, +and the histogram's bucket `3` gains one. The same device redeployed a month later and +succeeding immediately starts journey 2 with attempt count 1 — first-time-right. A +device with `serialNumber = "Unknown"` (WMI failure) never gets a chain; its terminal +sessions surface only as that day's disclosed exclusion count. + +# Citations + +* `src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs` — device key, junk list, grouping, attempt numbers (JourneyVersion 1). +* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceHistory.cs` — persistence + inline seam entry. +* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.DeviceJourneys.cs` — sweep, tombstone pruning, daily aggregation. +* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs` — read surfaces. +* `src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs` — table-driven grouping/aggregation/attempt-number tests. +* `tasks/insights-expansion-spec.md` — F2 specification and source-data audit (§0.5). diff --git a/docs/index.md b/docs/index.md index 926a2b66..ac4d6ea9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. +* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces. * [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains. * [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. diff --git a/docs/log.md b/docs/log.md index f3c8894b..06db72f8 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-07-27 + +* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode). + ## 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. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs index 0aadf4df..16757ed4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Functions.Functions.Metrics; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; @@ -628,4 +629,171 @@ public class DeviceJourneyAndFtrTests Assert.Equal(2, tenantRow.CompletedJourneyCount); Assert.Equal(2, tenantRow.FirstTimeRightCount); } + + // ── attempt number (session banner, PR5) ──────────────────────────────── + + [Fact] + public void ComputeAttemptNumber_TerminalSessionInChain_ReturnsPositionWithinItsJourney() + { + var chain = new[] + { + Ref("s-1", SessionStatus.Failed, T0), + Ref("s-2", SessionStatus.Succeeded, T0.AddHours(2)), + Ref("s-3", SessionStatus.Failed, T0.AddDays(5)), // redeployment → new journey + }; + + Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-1", T0)); + Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddHours(2))); + Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", T0.AddDays(5))); + } + + [Fact] + public void ComputeAttemptNumber_LiveSession_ContinuesTheOpenJourney() + { + var chain = new[] { Ref("s-1", SessionStatus.Failed, T0) }; + // s-2 is still InProgress — not a chain ref; the virtual-attempt rule places it. + Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(1))); + } + + [Fact] + public void ComputeAttemptNumber_LiveSession_AfterCompletedJourney_IsAttemptOne() + { + var chain = new[] { Ref("s-1", SessionStatus.Succeeded, T0) }; + Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(2))); + } + + [Fact] + public void ComputeAttemptNumber_LiveSession_AfterThirtyDayGap_IsAttemptOne() + { + var prevEnd = T0.AddHours(1); + var chain = new[] { Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd) }; + Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", prevEnd.AddDays(31))); + // Inside the gap boundary the open journey continues instead. + Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", prevEnd.AddDays(29))); + } + + [Fact] + public void ComputeAttemptNumber_EmptyChain_YieldsNull_NoGuessedPosition() + { + Assert.Null(DeviceJourneyCalculator.ComputeAttemptNumber( + Array.Empty(), "s-1", T0)); + } + + // ── fleet response helpers (PR5) ──────────────────────────────────────── + + [Fact] + public void SumAggregates_SumsAdditiveCounts_AndMergesHistograms() + { + var daily = new List + { + new() + { + CompletedJourneyCount = 2, FirstTimeRightCount = 1, ExcludedSessionCount = 1, + AttemptHistogram = new List + { + new() { Attempts = 1, JourneyCount = 1 }, + new() { Attempts = 2, JourneyCount = 1 }, + }, + }, + new() + { + CompletedJourneyCount = 1, FirstTimeRightCount = 1, ExcludedSessionCount = 0, + AttemptHistogram = new List { new() { Attempts = 1, JourneyCount = 1 } }, + }, + }; + + var totals = DeviceJourneyMetricsResponse.SumAggregates(daily); + + Assert.Equal(3, totals.CompletedJourneys); + Assert.Equal(2, totals.FirstTimeRight); + Assert.Equal(66.7, totals.FtrRatePct); + Assert.Equal(1, totals.ExcludedSessions); + Assert.Equal(2, totals.AttemptHistogram.Count); + Assert.Equal(2, totals.AttemptHistogram.Single(b => b.Attempts == 1).JourneyCount); + Assert.Equal(1, totals.AttemptHistogram.Single(b => b.Attempts == 2).JourneyCount); + } + + [Fact] + public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero() + { + var totals = DeviceJourneyMetricsResponse.SumAggregates(new List + { + new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 }, + }); + + Assert.Equal(0, totals.CompletedJourneys); + Assert.Null(totals.FtrRatePct); + Assert.Equal(3, totals.ExcludedSessions); + } + + [Theory] + [InlineData(null, 30)] // default window + [InlineData("garbage", 30)] + [InlineData("7", 7)] + [InlineData("0", 1)] // clamped low + [InlineData("999", 180)] // clamped to aggregate retention + public void ClampDays_DefaultsAndClamps(string? raw, int expected) + { + Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw)); + } + + private static DeviceHistory RepeaterHistory( + string serial, int attempts, params DeviceSessionRef[] chain) + => new() + { + TenantId = TenantA, + SerialKey = serial, + SerialNumber = serial.ToUpperInvariant(), + Chain = chain.ToList(), + CurrentJourneyAttempts = attempts, + JourneyCount = 1, + }; + + [Fact] + public void SelectRepeatDevices_FiltersGate_WindowsOnNewestRef_AndPicksNewestFailure() + { + var histories = new List + { + // Single attempt — not a repeat device. + RepeaterHistory("clean-1", attempts: 1, Ref("c1", SessionStatus.Succeeded, T0)), + // Repeat device, active in window: newest failed ref carries the reason lookup. + RepeaterHistory("hot-1", attempts: 3, + Ref("h1", SessionStatus.Failed, T0.AddDays(-2)), + Ref("h2", SessionStatus.Failed, T0.AddDays(-1)), + Ref("h3", SessionStatus.Incomplete, T0)), + // Repeat device, but its pain is OLDER than the window — current lists only. + RepeaterHistory("old-1", attempts: 4, + Ref("o1", SessionStatus.Failed, T0.AddDays(-40))), + // Lower attempt count → sorts after hot-1. + RepeaterHistory("warm-1", attempts: 2, + Ref("w1", SessionStatus.Failed, T0.AddDays(-3)), + Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))), + }; + + var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30)); + + Assert.Equal(2, selected.Count); + Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc + Assert.Equal("h3", selected[0].Newest.SessionId); + Assert.Equal("h2", selected[0].NewestFailed!.SessionId); // last FAILED, not last ref + Assert.Equal("warm-1", selected[1].History.SerialKey); + } + + [Fact] + public void SelectRepeatDevices_CapsAtTen_ByAttemptsThenRecency() + { + var histories = new List(); + for (var i = 0; i < 12; i++) + { + histories.Add(RepeaterHistory($"dev-{i:D2}", attempts: 2 + i, + Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i)))); + } + + var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30)); + + Assert.Equal(10, selected.Count); + Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first + Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-00"); + Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-01"); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs new file mode 100644 index 00000000..bd2e2c52 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs @@ -0,0 +1,351 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Metrics +{ + /// + /// F2 device-history / First-Time-Right read surfaces (insights spec §F2 "Surfaces", PR5). + /// All endpoints serve rows the terminal seam + maintenance sweep maintain — the journey + /// semantics live server-side (); no consumer re-derives + /// them. Durations in chain refs are the sessions' authoritative DurationSeconds verbatim. + /// + public class GetDeviceHistoryFunction + { + private readonly ILogger _logger; + private readonly IMetricsRepository _metricsRepo; + private readonly ISessionRepository _sessionRepo; + + public GetDeviceHistoryFunction( + ILogger logger, + IMetricsRepository metricsRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _metricsRepo = metricsRepo; + _sessionRepo = sessionRepo; + } + + /// + /// One device's enrollment history by serial number. history=null is a NORMAL outcome + /// (unknown device, junk/placeholder serial, or every ref pruned) — the banner simply + /// stays hidden. With ?sessionId= the response additionally carries that session's + /// attempt number within its journey (server-computed; works for live sessions too via + /// the virtual-attempt rule). Serves the session-detail banner and the MCP tool; a + /// Global Admin passes ?tenantId= for cross-tenant reads (TenantScoping.QueryParam), + /// with the sessionId-based owner fallback mirroring GetSessionTimeAttribution. + /// + [Function("GetDeviceHistory")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "metrics/device-history")] HttpRequestData req) + { + try + { + // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware; + // cross-tenant access via TargetTenantId (TenantScoping.QueryParam). + var requestCtx = req.GetRequestContext(); + var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var rawSerial = query["serialNumber"]; + var sessionId = query["sessionId"]; + + if (string.IsNullOrWhiteSpace(rawSerial)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "serialNumber is required" }); + return badRequest; + } + + // Junk/placeholder serials have no device identity — by design they never have a + // chain (disclosed in the fleet aggregate instead), so null here is honest. + var serialKey = DeviceJourneyCalculator.NormalizeSerial(rawSerial); + var effectiveTenantId = requestCtx.TargetTenantId; + var history = serialKey == null + ? null + : await _metricsRepo.GetDeviceHistoryAsync(effectiveTenantId, serialKey); + + // Global-scope fallback (mirrors GetSessionTimeAttribution): a GA/operator opening + // a session without an explicit tenantId query param reads their home partition — + // resolve the owning tenant via the session and retry once. + if (history == null && serialKey != null && requestCtx.HasGlobalScope && !string.IsNullOrEmpty(sessionId)) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId!); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase)) + { + effectiveTenantId = resolvedTenantId; + history = await _metricsRepo.GetDeviceHistoryAsync(effectiveTenantId, serialKey); + } + } + + // Attempt number for the requesting session — fail-soft: a missing session or an + // empty basis yields null, never a guessed position. + int? attemptNumber = null; + if (history != null && !string.IsNullOrEmpty(sessionId)) + { + var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId!); + if (session != null) + attemptNumber = DeviceJourneyCalculator.ComputeAttemptNumber(history.Chain, sessionId!, session.StartedAt); + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { success = true, history, attemptNumber }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching device history"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + } + + /// + /// Tenant fleet view: daily First-Time-Right rows of the requested window plus their sums, + /// the merged attempt histogram, and the "repeat devices" violator list. FTR counts are + /// additive, so unlike the median-based time-attribution panel this endpoint honors the + /// page's days selector honestly (window rate = sum over daily rows). + /// + public class GetDeviceJourneyMetricsFunction + { + private readonly ILogger _logger; + private readonly IMetricsRepository _metricsRepo; + private readonly ISessionRepository _sessionRepo; + + public GetDeviceJourneyMetricsFunction( + ILogger logger, + IMetricsRepository metricsRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _metricsRepo = metricsRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetDeviceJourneyMetrics")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "metrics/device-journeys")] HttpRequestData req) + { + try + { + // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware + var tenantId = TenantHelper.GetTenantId(req); + var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync( + _metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true)); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching device journey metrics"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + } + + /// + /// Global-admin variant: tenantId filters to one tenant (full response incl. repeat + /// devices); absent = the cross-tenant "global" aggregate rows, WITHOUT a repeat-devices + /// list — that would require scanning every tenant's DeviceHistories partition, and the + /// aggregated view has no per-device drill anyway. repeatDevices=null discloses the gap. + /// + public class GetGlobalDeviceJourneyMetricsFunction + { + private readonly ILogger _logger; + private readonly IMetricsRepository _metricsRepo; + private readonly ISessionRepository _sessionRepo; + + public GetGlobalDeviceJourneyMetricsFunction( + ILogger logger, + IMetricsRepository metricsRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _metricsRepo = metricsRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetGlobalDeviceJourneyMetrics")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/device-journeys")] HttpRequestData req) + { + try + { + // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware + var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var tenantIdFilter = query["tenantId"]; + var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]); + var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!; + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync( + _metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global")); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching global device journey metrics"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + } + + /// Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse). + internal static class DeviceJourneyMetricsResponse + { + internal const int DefaultWindowDays = 30; + internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist + internal const int MaxRepeatDevices = 10; + + internal static int ClampDays(string? raw) + { + if (!int.TryParse(raw, out var days)) return DefaultWindowDays; + return Math.Clamp(days, 1, MaxWindowDays); + } + + internal static async Task BuildAsync( + IMetricsRepository metricsRepo, ISessionRepository sessionRepo, + string partition, int days, bool includeRepeatDevices) + { + var today = DateTime.UtcNow.Date; + var daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, today.AddDays(-days), today); + var totals = SumAggregates(daily); + + List? repeatDevices = null; + if (includeRepeatDevices) + { + var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition); + var candidates = SelectRepeatDevices(histories, today.AddDays(-days)); + repeatDevices = new List(candidates.Count); + foreach (var candidate in candidates) + { + // Failure reason of the newest failed attempt — a bounded point-read per top + // row (≤10), fail-soft: an unreadable/deleted session just leaves it empty. + var lastFailureReason = string.Empty; + if (candidate.NewestFailed != null) + { + try + { + var failedSession = await sessionRepo.GetSessionAsync(partition, candidate.NewestFailed.SessionId); + lastFailureReason = failedSession?.FailureReason ?? string.Empty; + } + catch + { + // fail-soft — the row renders without a reason + } + } + repeatDevices.Add(new + { + serialNumber = candidate.History.SerialNumber, + manufacturer = candidate.History.Manufacturer, + model = candidate.History.Model, + attempts = candidate.History.CurrentJourneyAttempts, + journeyCount = candidate.History.JourneyCount, + lastStatus = candidate.Newest.Status, + lastSessionId = candidate.Newest.SessionId, + lastStartedAt = candidate.Newest.StartedAt, + lastFailureReason, + }); + } + } + + return new + { + success = true, + windowDays = days, + totals, + daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), + repeatDevices, + }; + } + + /// Window totals (serialized camelCase by the worker's global serializer, like the model rows). + internal sealed class DeviceJourneyWindowTotals + { + public int CompletedJourneys { get; init; } + public int FirstTimeRight { get; init; } + + /// Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1). + public double? FtrRatePct { get; init; } + + public int ExcludedSessions { get; init; } + public List AttemptHistogram { get; init; } = new(); + } + + /// + /// Window sums over the daily rows — honest because every field is an additive count + /// (rule: the FTR window rate IS the ratio of summed counts). internal static: pinned + /// by unit tests. + /// + internal static DeviceJourneyWindowTotals SumAggregates(IReadOnlyList daily) + { + var completed = daily.Sum(d => d.CompletedJourneyCount); + var firstTimeRight = daily.Sum(d => d.FirstTimeRightCount); + return new DeviceJourneyWindowTotals + { + CompletedJourneys = completed, + FirstTimeRight = firstTimeRight, + FtrRatePct = completed > 0 ? Math.Round(firstTimeRight * 100.0 / completed, 1) : null, + ExcludedSessions = daily.Sum(d => d.ExcludedSessionCount), + AttemptHistogram = daily + .SelectMany(d => d.AttemptHistogram) + .GroupBy(b => b.Attempts) + .Select(g => new DeviceJourneyAttemptBucket { Attempts = g.Key, JourneyCount = g.Sum(b => b.JourneyCount) }) + .OrderBy(b => b.Attempts) + .ToList(), + }; + } + + internal sealed class RepeatDeviceCandidate + { + public DeviceHistory History { get; init; } = null!; + public DeviceSessionRef Newest { get; init; } = null!; + public DeviceSessionRef? NewestFailed { get; init; } + } + + /// + /// Violator selection (spec: "repeat devices" table): devices whose LAST journey took + /// ≥2 attempts and whose newest terminal session falls inside the window — current pain, + /// not ancient history (a device that retried long ago but enrolls cleanly today does + /// not belong on a violator list). Ordered by attempts desc, then recency; capped at + /// . internal static: pinned by unit tests. + /// + internal static List SelectRepeatDevices( + IReadOnlyList histories, DateTime windowStartUtc) + { + var candidates = new List(); + foreach (var history in histories) + { + if (history.CurrentJourneyAttempts < 2 || history.Chain.Count == 0) + continue; + var newest = history.Chain[history.Chain.Count - 1]; + if (newest.StartedAt < windowStartUtc) + continue; + candidates.Add(new RepeatDeviceCandidate + { + History = history, + Newest = newest, + NewestFailed = history.Chain.LastOrDefault(r => + string.Equals(r.Status, nameof(SessionStatus.Failed), StringComparison.OrdinalIgnoreCase)), + }); + } + return candidates + .OrderByDescending(c => c.History.CurrentJourneyAttempts) + .ThenByDescending(c => c.Newest.StartedAt) + .Take(MaxRepeatDevices) + .ToList(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs index 52113e00..493ac696 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs @@ -203,4 +203,42 @@ public static class DeviceJourneyCalculator if (journeys.Count == 0) return (0, 0); return (journeys.Count, journeys[journeys.Count - 1].Attempts.Count); } + + /// + /// Attempt number of one session within its journey — the "Attempt N for this device" the + /// session-detail banner renders, computed server-side so no consumer re-derives journey + /// semantics. Terminal sessions sit in the chain and take their real position. A LIVE + /// (non-terminal) session is not a chain ref yet, so its position is computed by inserting + /// a virtual non-successful attempt at the session's StartedAt — the redeploy rule (a prior + /// completed journey starts a new one) and the 30-day gap rule then place it exactly like + /// the real ref would land once terminal. Null when the chain gives no basis (empty). + /// + public static int? ComputeAttemptNumber( + IReadOnlyList chain, string sessionId, DateTime sessionStartedAt) + { + var effectiveChain = chain; + if (!chain.Any(r => string.Equals(r.SessionId, sessionId, StringComparison.Ordinal))) + { + if (chain.Count == 0) return null; + // Virtual ref: status is a NON-successful terminal placeholder purely for position + // math — it never persists and never marks the journey completed. + var virtualRef = new DeviceSessionRef + { + SessionId = sessionId, + StartedAt = sessionStartedAt, + Status = nameof(SessionStatus.Failed), + }; + effectiveChain = MergeChain(chain, new[] { virtualRef }); + } + + foreach (var journey in GroupJourneys(effectiveChain)) + { + for (var i = 0; i < journey.Attempts.Count; i++) + { + if (string.Equals(journey.Attempts[i].SessionId, sessionId, StringComparison.Ordinal)) + return i + 1; + } + } + return null; // session fell off the 20-cap when merged — no honest position claim + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index cb9a4b29..7ee7f970 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -231,6 +231,10 @@ public static class EndpointAccessPolicyCatalog new("GET", "metrics/app", EndpointPolicy.MemberRead), new("GET", "metrics/fleet-health", EndpointPolicy.MemberRead), new("GET", "metrics/time-attribution", EndpointPolicy.MemberRead), + // F2 device history: QueryParam scoping so the session-detail banner works cross-tenant + // (GA/delegated drill-in) over the SAME route MCP uses — no global duplicate needed. + new("GET", "metrics/device-history", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "metrics/device-journeys", EndpointPolicy.MemberRead), new("GET", "apps/list", EndpointPolicy.MemberRead), new("GET", "apps/{appName}/analytics", EndpointPolicy.MemberRead), new("GET", "apps/{appName}/sessions", EndpointPolicy.MemberRead), @@ -397,6 +401,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/metrics/app", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/metrics/time-attribution", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), + new("GET", "global/metrics/device-journeys", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam), diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index 20f909fc..a6b7ac08 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -111,6 +111,10 @@ describe('role catalog snapshot — privilege-leak guard', () => { 'get_api_usage', 'get_app_install_metrics', 'get_audit_logs', + // Available to every role: backed by MemberRead endpoints with TenantScoping.QueryParam / + // role-aware routing to the GlobalReadOrAdmin fleet variant — same placement as + // get_time_attribution (F2 counterpart). + 'get_device_history', 'get_geographic_metrics', 'get_geographic_sessions', 'get_ime_version_history', diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 2738a01f..369d86b5 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -1202,6 +1202,61 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }) ); + // Tool: get_device_history — F2 device history / First-Time-Right (insights spec §F2). + // Two modes over rows the terminal seam + maintenance sweep maintain (journey semantics are + // server-side; nothing is re-derived here): a per-device terminal-enrollment chain by serial + // number, or the fleet FTR rollup. Registered for ALL callers with role-aware routing — + // same placement as get_time_attribution. + server.registerTool( + 'get_device_history', + { + title: 'Device Enrollment History & First-Time-Right', + description: + 'Answer "how often did this device retry?" and "how many devices enrolled right on the first try?". ' + + 'Two modes: pass serialNumber for one device\'s enrollment history — the chain of its terminal ' + + 'sessions (Succeeded/Failed/Incomplete; capped at the 20 most recent) with per-attempt status, ' + + 'timestamps, the session\'s authoritative durationSeconds (WhiteGlove pause excluded; Incomplete has ' + + 'none), plus derived journey counts (a journey = attempts until the first success; a >30-day gap ' + + 'starts a new journey). history=null means no recorded history (unknown device, placeholder serial). ' + + 'Omit serialNumber for the fleet First-Time-Right rollup: daily completed-journey and ' + + 'first-time-right counts (additive — window rate = ratio of sums), attempt histogram, junk-serial ' + + 'exclusion disclosure, and the repeat-devices list (devices whose current journey took ≥2 attempts). ' + + 'Open journeys (device not successfully enrolled yet, incl. WhiteGlove awaiting its user session) ' + + 'never count toward FTR. ' + + (ga + ? 'Fleet mode: omit tenantId for the cross-tenant aggregate (no repeat-devices list there), or pass ' + + 'tenantId to scope to one tenant. Device mode: pass tenantId for cross-tenant serial lookups. ' + : ''), + inputSchema: { + serialNumber: z.string().min(1).max(128).optional() + .describe('Device serial number (trimmed, case-insensitive). Omit for the fleet FTR rollup.'), + days: z.coerce.number().int().min(1).max(180).optional() + .describe('Fleet mode window in days (default 30, max 180 — aggregate retention).'), + tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, + 'Device mode: the device\'s tenant (needed cross-tenant). Fleet mode: filter to one tenant; omit for the cross-tenant aggregate.', + 'Optional; ignored — data is scoped to your tenant.')), + }, + annotations: READ_ONLY, + }, + async (args) => withToolTelemetry('get_device_history', async () => { + try { + const { serialNumber, days, tenantId: rawTenantId } = args; + const tenantId = enforceDelegatedTenant(rawTenantId); + if (serialNumber) { + // Member route with TenantScoping.QueryParam — the same route serves every role; + // a GA/delegated caller passes tenantId for cross-tenant reads. + const data = await apiFetch(`/api/metrics/device-history${buildQuery({ serialNumber, tenantId })}`); + return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); + } + const path = pickGlobalOrTenantPath('/api/global/metrics/device-journeys', '/api/metrics/device-journeys', tenantId); + const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`); + return toolResultText(data, MAX_RESULT_SIZE_CHARS.adminStream); + } catch (error: unknown) { + return toolError('get_device_history', args, error); + } + }) + ); + // Tool: get_software_inventory — installed-software catalog from the SoftwareInventory table. // Registered for ALL callers, but role-aware: a non-GA caller only ever sees their own tenant's // inventory (no tenantId / no cross-tenant scope in the schema — nothing to leak or tempt with). diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/components/DeviceJourneySection.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/components/DeviceJourneySection.tsx new file mode 100644 index 00000000..5f6bfaa9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/fleet-health/components/DeviceJourneySection.tsx @@ -0,0 +1,241 @@ +"use client"; + +import React, { useMemo } from "react"; +import Link from "next/link"; +import TruncatedLabel from "../../../components/TruncatedLabel"; + +/** + * F2 First-Time-Right section (insights spec §F2 "Surfaces"): FTR rate + weekly trend + + * attempt histogram + "repeat devices" violator table. All counts come from the daily + * FTR aggregate rows, which are ADDITIVE — window and weekly rates are ratios of summed + * counts (server sums the window; weeks are grouped here from the same rows). Below the + * ≥20-completed-journeys gate the rate renders as "insufficient data (n=…)" (truthfulness + * rule 4); junk-serial exclusions are disclosed; open journeys never count. + */ + +export interface DeviceJourneyAttemptBucketDto { + attempts: number; + journeyCount: number; +} + +export interface DeviceJourneyDailyDto { + tenantId: string; + date: string; + journeyVersion: number; + completedJourneyCount: number; + firstTimeRightCount: number; + attemptHistogram: DeviceJourneyAttemptBucketDto[]; + excludedSessionCount: number; +} + +export interface DeviceJourneyTotalsDto { + completedJourneys: number; + firstTimeRight: number; + ftrRatePct: number | null; + excludedSessions: number; + attemptHistogram: DeviceJourneyAttemptBucketDto[]; +} + +export interface RepeatDeviceDto { + serialNumber: string; + manufacturer: string; + model: string; + attempts: number; + journeyCount: number; + lastStatus: string; + lastSessionId: string; + lastStartedAt: string; + lastFailureReason: string; +} + +export interface DeviceJourneyResponseDto { + success: boolean; + windowDays: number; + totals: DeviceJourneyTotalsDto; + daily: DeviceJourneyDailyDto[]; + repeatDevices: RepeatDeviceDto[] | null; +} + +/** UI gate for the FTR rate (spec: ≥20 completed journeys). */ +const MIN_JOURNEYS_FOR_RATE = 20; + +/** Monday-based week key (UTC) so weekly buckets are stable across locales. */ +function weekStart(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00Z"); + const day = d.getUTCDay(); // 0 = Sunday + const diff = day === 0 ? 6 : day - 1; + d.setUTCDate(d.getUTCDate() - diff); + return d.toISOString().slice(0, 10); +} + +export default function DeviceJourneySection({ + data, + linkTenantId, +}: { + data: DeviceJourneyResponseDto | null; + /** Carried into session links so a GA scoped to one tenant lands in that tenant. */ + linkTenantId?: string; +}) { + const weekly = useMemo(() => { + const byWeek = new Map(); + for (const row of data?.daily ?? []) { + const key = weekStart(row.date); + const bucket = byWeek.get(key) ?? { completed: 0, ftr: 0 }; + bucket.completed += row.completedJourneyCount; + bucket.ftr += row.firstTimeRightCount; + byWeek.set(key, bucket); + } + return [...byWeek.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([week, b]) => ({ + week, + completed: b.completed, + ratePct: b.completed > 0 ? Math.round((b.ftr / b.completed) * 100) : null, + })); + }, [data]); + + if (!data || !data.success) return null; + const totals = data.totals; + const gated = totals.completedJourneys < MIN_JOURNEYS_FOR_RATE; + const maxHistogramCount = Math.max(1, ...totals.attemptHistogram.map((b) => b.journeyCount)); + const repeatDevices = data.repeatDevices; + + const sessionHref = (id: string) => + `/sessions/${id}${linkTenantId ? `?tenantId=${encodeURIComponent(linkTenantId)}` : ""}`; + + return ( +
+

+ First-time-right + + last {data.windowDays} days · wipe-and-retry visibility + +

+ +
+ {/* FTR rate + weekly trend */} +
+ {gated ? ( +
+
—
+
+ insufficient data (n={totals.completedJourneys}, needs {MIN_JOURNEYS_FOR_RATE} completed journeys) +
+
+ ) : ( +
+
+ {totals.ftrRatePct != null ? `${totals.ftrRatePct.toFixed(1)}%` : "—"} +
+
+ {totals.firstTimeRight} of {totals.completedJourneys} completed journeys succeeded first try +
+
+ )} + {totals.excludedSessions > 0 && ( +
+ {totals.excludedSessions} session{totals.excludedSessions === 1 ? "" : "s"} excluded (placeholder serials) +
+ )} + + {weekly.filter((w) => w.completed > 0).length >= 2 && ( +
+
Weekly trend
+
+ {weekly.map((w) => ( +
+ {w.ratePct != null && ( +
+ wk of {w.week}: {w.ratePct}% (n={w.completed}) +
+ )} +
+
+ ))} +
+
+ first-time-right % per week (bar height = rate) +
+
+ )} +
+ + {/* Attempt histogram */} +
+
Attempts until success
+ {totals.attemptHistogram.length === 0 ? ( +
No completed journeys in this window.
+ ) : ( +
+ {totals.attemptHistogram.map((b) => ( +
+ + {b.attempts} attempt{b.attempts === 1 ? "" : "s"} + +
+
+
+ {b.journeyCount} +
+ ))} +
+ )} +
+ + {/* Repeat devices */} +
+
+ Repeat devices +
+ {repeatDevices === null ? ( +
+ Select a tenant to see per-device detail — the aggregated view has no device drill-down. +
+ ) : repeatDevices.length === 0 ? ( +
No devices needed more than one attempt in this window.
+ ) : ( +
+ {repeatDevices.map((d) => ( +
+
+ + {d.serialNumber} + + + {d.attempts} attempts + +
+
+ + + last: {d.lastStatus} + +
+ {d.lastFailureReason && ( +
+ +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index ac2d2401..3c34068e 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -11,6 +11,7 @@ import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import FleetStatCard from "./components/FleetStatCard"; import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection"; +import DeviceJourneySection, { DeviceJourneyResponseDto } from "./components/DeviceJourneySection"; import TruncatedLabel from "../../components/TruncatedLabel"; import { useFleetHealth } from "./hooks/useFleetHealth"; import { useAggregatedAdminScope } from "@/hooks"; @@ -40,6 +41,7 @@ interface AppMetricsResponse { export default function FleetHealthPage() { const [appMetrics, setAppMetrics] = useState(null); const [timeAttribution, setTimeAttribution] = useState(null); + const [deviceJourneys, setDeviceJourneys] = useState(null); const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("7d"); const hasJoinedGroup = useRef(false); @@ -101,6 +103,28 @@ export default function FleetHealthPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeInitialized, scopeKey]); + // F2 first-time-right: daily FTR counts are additive, so this DOES follow the 7/30/90d + // selector (window rate = ratio of summed daily rows). Fail-soft like the sections above. + useEffect(() => { + if (!scopeInitialized) return; + let cancelled = false; + (async () => { + try { + const endpoint = routeGlobal + ? api.metrics.globalDeviceJourneys(days, selectedTenantId || undefined) + : api.metrics.deviceJourneys(days); + const response = await authenticatedFetch(endpoint, getAccessToken); + if (!response.ok) return; + const json = await response.json(); + if (!cancelled) setDeviceJourneys(json); + } catch { + // fail-soft: section stays hidden + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopeInitialized, scopeKey, days]); + // Join this tenant's SignalR group so its session events reach the client; the // useFleetHealth hook listens for newSession/newevents to trigger a debounced // refetch. No group in GA-aggregated mode (effectiveTenantId empty). @@ -668,6 +692,12 @@ export default function FleetHealthPage() { {/* Time attribution (F1) — sweep-maintained rolling 30d rollup per enrollment class */} + {/* First-time-right (F2) — journey-based wipe-and-retry visibility */} + + {/* Health by Device Model */}

diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceHistoryBanner.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceHistoryBanner.tsx new file mode 100644 index 00000000..e3b18d06 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceHistoryBanner.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { api } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; + +/** + * F2 device-history banner (insights spec §F2 "Surfaces"): shown when the device behind this + * session has PRIOR terminal sessions — "Attempt N for this device · View history". The attempt + * number is SERVER-computed (journey semantics live in the backend calculator; live sessions get + * their would-be position via the virtual-attempt rule). The expandable list renders the chain + * refs verbatim: durations are the sessions' authoritative DurationSeconds (never recomputed + * from timestamps; Incomplete honestly has none). Fetch is fail-soft — no history, no banner. + */ + +export interface DeviceSessionRefDto { + sessionId: string; + startedAt: string; + completedAt: string | null; + status: string; + enrollmentType: string; + isPreProvisioned: boolean; + durationSeconds: number | null; + adminMarked: boolean; +} + +export interface DeviceHistoryDto { + tenantId: string; + serialKey: string; + serialNumber: string; + manufacturer: string; + model: string; + chain: DeviceSessionRefDto[]; + currentJourneyAttempts: number; + journeyCount: number; + journeyVersion: number; +} + +interface DeviceHistoryResponse { + success: boolean; + history: DeviceHistoryDto | null; + attemptNumber: number | null; +} + +function formatDuration(seconds: number | null): string { + if (seconds === null || seconds === undefined) return "—"; + const s = Math.max(0, Math.round(seconds)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.round(s / 60)} min`; + return `${Math.floor(s / 3600)}h ${Math.round((s % 3600) / 60)}m`; +} + +const STATUS_PILL: Record = { + Succeeded: "bg-green-100 text-green-800", + Failed: "bg-red-100 text-red-800", + Incomplete: "bg-slate-100 text-slate-600", +}; + +export default function DeviceHistoryBanner({ + sessionId, + serialNumber, + effectiveTenantId, + linkTenantId, + getAccessToken, +}: { + sessionId: string; + serialNumber: string | undefined; + /** Tenant used for the API read (resolved session tenant / GA override); undefined = own tenant. */ + effectiveTenantId?: string; + /** Carried into session links so a GA/MSP drill-in stays in the viewed tenant. */ + linkTenantId?: string; + getAccessToken: () => Promise; +}) { + const [data, setData] = useState(null); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (!sessionId || !serialNumber) { + setData(null); + return; + } + let cancelled = false; + (async () => { + try { + const response = await authenticatedFetch( + api.metrics.deviceHistory(serialNumber, sessionId, effectiveTenantId), + getAccessToken + ); + if (!response.ok) return; + const json = (await response.json()) as DeviceHistoryResponse; + if (!cancelled) setData(json); + } catch { + // fail-soft: no banner + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, serialNumber, effectiveTenantId]); + + const chain = data?.history?.chain ?? []; + const priorSessions = chain.filter((r) => r.sessionId !== sessionId); + if (priorSessions.length === 0) return null; + + const attemptNumber = data?.attemptNumber; + const sessionHref = (id: string) => + `/sessions/${id}${linkTenantId ? `?tenantId=${encodeURIComponent(linkTenantId)}` : ""}`; + + return ( +
+
+
+ + + + + {attemptNumber != null ? ( + <> + Attempt {attemptNumber} for this device + + ) : ( + This device has enrolled before + )} + + {" "}· {priorSessions.length} previous enrollment{priorSessions.length === 1 ? "" : "s"} recorded + + +
+ +
+ + {expanded && ( +
+
+ Terminal enrollments of {data?.history?.serialNumber || serialNumber} + {data?.history?.model ? ` · ${data.history.model}` : ""} +
+
+ {[...chain].reverse().map((r) => { + const isCurrent = r.sessionId === sessionId; + const started = new Date(r.startedAt); + return ( +
+ + {r.status} + + {started.toLocaleString()} + {formatDuration(r.durationSeconds)} + {r.isPreProvisioned && ( + Pre-provisioning + )} + {r.enrollmentType === "v2" && ( + Device Preparation + )} + {r.adminMarked && ( + admin-marked + )} + {isCurrent ? ( + this session + ) : ( + + open + + )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 99e713da..08254504 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -25,6 +25,7 @@ import { useSessionDerivedData } from "./hooks/useSessionDerivedData"; import { useSessionTenantConfig } from "./hooks/useSessionTenantConfig"; import SessionInfoCard from "./components/SessionInfoCard"; +import DeviceHistoryBanner from "./components/DeviceHistoryBanner"; import PhaseTimeline from "./components/PhaseTimeline"; import TimeAttributionLane, { SessionTimeBreakdownDto } from "./components/TimeAttributionLane"; import EventTimeline from "./components/EventTimeline"; @@ -557,6 +558,19 @@ export default function SessionDetailPage() {

)} + {/* F2 device-history banner: "Attempt N for this device" when the device (by serial) + has prior terminal enrollments — expandable chain with links. Fail-soft; hidden + when the device has no history row (first enrollment, junk serial). */} + {!isGatherRulesSession && session?.serialNumber && ( + + )} + {/* Device Details Card (from enrollment tracker events) */} {!isGatherRulesSession &&
} diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 71eaffb1..2f0ba2bc 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -241,6 +241,17 @@ export const api = { `${API_BASE_URL}/api/metrics/time-attribution`, globalTimeAttribution: (tenantId?: string) => `${API_BASE_URL}/api/global/metrics/time-attribution${qs({ tenantId })}`, + // F2 device history: one device's terminal-session chain by serial; with sessionId the + // response carries that session's server-computed attempt number (no client re-derivation). + // tenantId enables the GA/delegated cross-tenant read (TenantScoping.QueryParam). + deviceHistory: (serialNumber: string, sessionId?: string, tenantId?: string) => + `${API_BASE_URL}/api/metrics/device-history${qs({ serialNumber, sessionId, tenantId })}`, + // F2 First-Time-Right rollups: daily counts are additive, so unlike time attribution the + // window follows the page's days selector honestly (window rate = sum of daily rows). + deviceJourneys: (days: number) => + `${API_BASE_URL}/api/metrics/device-journeys${qs({ days: String(days) })}`, + globalDeviceJourneys: (days: number, tenantId?: string) => + `${API_BASE_URL}/api/global/metrics/device-journeys${qs({ days: String(days), tenantId })}`, geographic: (tenantId: string, days: number, groupBy: string) => `${API_BASE_URL}/api/metrics/geographic${qs({ tenantId, days: String(days), groupBy })}`, globalGeographic: (days: number, groupBy: string, tenantId?: string) =>