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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-27 08:38:17 +02:00
parent 1e6de47433
commit 88da317bf4
14 changed files with 1229 additions and 0 deletions
+122
View File
@@ -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).
+1
View File
@@ -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.
+4
View File
@@ -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.
@@ -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<DeviceSessionRef>(), "s-1", T0));
}
// ── fleet response helpers (PR5) ────────────────────────────────────────
[Fact]
public void SumAggregates_SumsAdditiveCounts_AndMergesHistograms()
{
var daily = new List<DeviceJourneyDailyAggregate>
{
new()
{
CompletedJourneyCount = 2, FirstTimeRightCount = 1, ExcludedSessionCount = 1,
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
{
new() { Attempts = 1, JourneyCount = 1 },
new() { Attempts = 2, JourneyCount = 1 },
},
},
new()
{
CompletedJourneyCount = 1, FirstTimeRightCount = 1, ExcludedSessionCount = 0,
AttemptHistogram = new List<DeviceJourneyAttemptBucket> { 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<DeviceJourneyDailyAggregate>
{
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<DeviceHistory>
{
// 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<DeviceHistory>();
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");
}
}
@@ -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
{
/// <summary>
/// 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 (<see cref="DeviceJourneyCalculator"/>); no consumer re-derives
/// them. Durations in chain refs are the sessions' authoritative DurationSeconds verbatim.
/// </summary>
public class GetDeviceHistoryFunction
{
private readonly ILogger<GetDeviceHistoryFunction> _logger;
private readonly IMetricsRepository _metricsRepo;
private readonly ISessionRepository _sessionRepo;
public GetDeviceHistoryFunction(
ILogger<GetDeviceHistoryFunction> logger,
IMetricsRepository metricsRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_metricsRepo = metricsRepo;
_sessionRepo = sessionRepo;
}
/// <summary>
/// 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.
/// </summary>
[Function("GetDeviceHistory")]
public async Task<HttpResponseData> 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;
}
}
}
/// <summary>
/// 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).
/// </summary>
public class GetDeviceJourneyMetricsFunction
{
private readonly ILogger<GetDeviceJourneyMetricsFunction> _logger;
private readonly IMetricsRepository _metricsRepo;
private readonly ISessionRepository _sessionRepo;
public GetDeviceJourneyMetricsFunction(
ILogger<GetDeviceJourneyMetricsFunction> logger,
IMetricsRepository metricsRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_metricsRepo = metricsRepo;
_sessionRepo = sessionRepo;
}
[Function("GetDeviceJourneyMetrics")]
public async Task<HttpResponseData> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
public class GetGlobalDeviceJourneyMetricsFunction
{
private readonly ILogger<GetGlobalDeviceJourneyMetricsFunction> _logger;
private readonly IMetricsRepository _metricsRepo;
private readonly ISessionRepository _sessionRepo;
public GetGlobalDeviceJourneyMetricsFunction(
ILogger<GetGlobalDeviceJourneyMetricsFunction> logger,
IMetricsRepository metricsRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_metricsRepo = metricsRepo;
_sessionRepo = sessionRepo;
}
[Function("GetGlobalDeviceJourneyMetrics")]
public async Task<HttpResponseData> 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;
}
}
}
/// <summary>Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse).</summary>
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<object> 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<object>? repeatDevices = null;
if (includeRepeatDevices)
{
var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition);
var candidates = SelectRepeatDevices(histories, today.AddDays(-days));
repeatDevices = new List<object>(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,
};
}
/// <summary>Window totals (serialized camelCase by the worker's global serializer, like the model rows).</summary>
internal sealed class DeviceJourneyWindowTotals
{
public int CompletedJourneys { get; init; }
public int FirstTimeRight { get; init; }
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
public double? FtrRatePct { get; init; }
public int ExcludedSessions { get; init; }
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; init; } = new();
}
/// <summary>
/// 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.
/// </summary>
internal static DeviceJourneyWindowTotals SumAggregates(IReadOnlyList<DeviceJourneyDailyAggregate> 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; }
}
/// <summary>
/// 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
/// <see cref="MaxRepeatDevices"/>. internal static: pinned by unit tests.
/// </summary>
internal static List<RepeatDeviceCandidate> SelectRepeatDevices(
IReadOnlyList<DeviceHistory> histories, DateTime windowStartUtc)
{
var candidates = new List<RepeatDeviceCandidate>();
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();
}
}
}
@@ -203,4 +203,42 @@ public static class DeviceJourneyCalculator
if (journeys.Count == 0) return (0, 0);
return (journeys.Count, journeys[journeys.Count - 1].Attempts.Count);
}
/// <summary>
/// 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).
/// </summary>
public static int? ComputeAttemptNumber(
IReadOnlyList<DeviceSessionRef> 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
}
}
@@ -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),
@@ -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',
@@ -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).
@@ -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<string, { completed: number; ftr: number }>();
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 (
<div className="bg-white shadow rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-1">
First-time-right
<span
className="ml-2 text-xs font-normal text-gray-400"
title="Share of completed device journeys that succeeded on the first attempt. A journey groups a device's terminal enrollment attempts (by serial number) until the first success; open journeys (no success yet) and placeholder serials are excluded and disclosed. A pre-provisioning (WhiteGlove) enrollment is ONE attempt."
>
last {data.windowDays} days · wipe-and-retry visibility
</span>
</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-4">
{/* FTR rate + weekly trend */}
<div>
{gated ? (
<div>
<div className="text-3xl font-semibold text-gray-400">—</div>
<div className="text-sm text-gray-400 mt-1">
insufficient data (n={totals.completedJourneys}, needs {MIN_JOURNEYS_FOR_RATE} completed journeys)
</div>
</div>
) : (
<div>
<div className="text-3xl font-semibold text-gray-900">
{totals.ftrRatePct != null ? `${totals.ftrRatePct.toFixed(1)}%` : "—"}
</div>
<div className="text-sm text-gray-500 mt-1">
{totals.firstTimeRight} of {totals.completedJourneys} completed journeys succeeded first try
</div>
</div>
)}
{totals.excludedSessions > 0 && (
<div className="text-xs text-gray-400 mt-1">
{totals.excludedSessions} session{totals.excludedSessions === 1 ? "" : "s"} excluded (placeholder serials)
</div>
)}
{weekly.filter((w) => w.completed > 0).length >= 2 && (
<div className="mt-4">
<div className="text-xs text-gray-400 mb-1.5">Weekly trend</div>
<div className="flex items-end gap-1 h-16">
{weekly.map((w) => (
<div key={w.week} className="flex-1 flex flex-col items-center justify-end h-full group relative">
{w.ratePct != null && (
<div className="absolute -top-7 bg-gray-900 text-white text-xs px-2 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10">
wk of {w.week}: {w.ratePct}% (n={w.completed})
</div>
)}
<div
className={`w-full rounded-t ${w.ratePct != null ? "bg-blue-500" : "bg-gray-100"}`}
style={{ height: `${w.ratePct ?? 0}%`, minHeight: w.ratePct != null ? "3px" : "1px" }}
/>
</div>
))}
</div>
<div className="text-[10px] text-gray-400 mt-1">
first-time-right % per week (bar height = rate)
</div>
</div>
)}
</div>
{/* Attempt histogram */}
<div>
<div className="text-xs text-gray-400 mb-1.5">Attempts until success</div>
{totals.attemptHistogram.length === 0 ? (
<div className="text-sm text-gray-400 py-4">No completed journeys in this window.</div>
) : (
<div className="space-y-2">
{totals.attemptHistogram.map((b) => (
<div key={b.attempts} className="flex items-center space-x-3">
<span className="text-xs text-gray-500 w-16 flex-shrink-0">
{b.attempts} attempt{b.attempts === 1 ? "" : "s"}
</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${b.attempts === 1 ? "bg-green-500" : "bg-amber-400"}`}
style={{ width: `${(b.journeyCount / maxHistogramCount) * 100}%`, minWidth: "2px" }}
/>
</div>
<span className="text-xs text-gray-500 w-8 text-right flex-shrink-0">{b.journeyCount}</span>
</div>
))}
</div>
)}
</div>
{/* Repeat devices */}
<div>
<div className="text-xs text-gray-400 mb-1.5"
title="Devices whose current journey took 2 or more terminal attempts, newest activity first. The failure reason is from the most recent failed attempt.">
Repeat devices
</div>
{repeatDevices === null ? (
<div className="text-sm text-gray-400 py-4">
Select a tenant to see per-device detail — the aggregated view has no device drill-down.
</div>
) : repeatDevices.length === 0 ? (
<div className="text-sm text-gray-400 py-4">No devices needed more than one attempt in this window.</div>
) : (
<div className="space-y-2">
{repeatDevices.map((d) => (
<div key={d.serialNumber} className="text-sm">
<div className="flex items-center justify-between gap-2">
<Link
href={sessionHref(d.lastSessionId)}
className="text-gray-700 hover:text-blue-600 font-mono text-xs truncate"
title={`Open the latest enrollment of ${d.serialNumber}`}
>
{d.serialNumber}
</Link>
<span className="text-xs font-medium text-amber-700 flex-shrink-0">
{d.attempts} attempts
</span>
</div>
<div className="text-xs text-gray-400 flex items-center gap-2">
<TruncatedLabel text={d.model || d.manufacturer || "unknown model"} className="max-w-[160px]" />
<span className={d.lastStatus === "Succeeded" ? "text-green-600" : d.lastStatus === "Failed" ? "text-red-500" : "text-gray-400"}>
last: {d.lastStatus}
</span>
</div>
{d.lastFailureReason && (
<div className="text-xs text-gray-400">
<TruncatedLabel text={d.lastFailureReason} className="max-w-full" />
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -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<AppMetricsResponse | null>(null);
const [timeAttribution, setTimeAttribution] = useState<TimeAttributionResponseDto | null>(null);
const [deviceJourneys, setDeviceJourneys] = useState<DeviceJourneyResponseDto | null>(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 */}
<TimeAttributionSection data={timeAttribution} />
{/* First-time-right (F2) — journey-based wipe-and-retry visibility */}
<DeviceJourneySection
data={deviceJourneys}
linkTenantId={isGlobalAdmin && selectedTenantId ? selectedTenantId : undefined}
/>
{/* Health by Device Model */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
@@ -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<string, string> = {
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<string | null>;
}) {
const [data, setData] = useState<DeviceHistoryResponse | null>(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 (
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-6">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 text-sm text-amber-900">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
{attemptNumber != null ? (
<>
<span className="font-medium">Attempt {attemptNumber}</span> for this device
</>
) : (
<span className="font-medium">This device has enrolled before</span>
)}
<span className="text-amber-700">
{" "}· {priorSessions.length} previous enrollment{priorSessions.length === 1 ? "" : "s"} recorded
</span>
</span>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="text-sm text-amber-800 hover:text-amber-900 underline underline-offset-2"
>
{expanded ? "Hide history" : "View history"}
</button>
</div>
{expanded && (
<div className="mt-3 border-t border-amber-200 pt-2">
<div className="text-xs text-amber-700 mb-2" title="The 20 most recent terminal enrollments of this device (by serial number). Open sessions are not attempts. Durations are the sessions' recorded enrollment durations (pre-provisioning pause excluded).">
Terminal enrollments of {data?.history?.serialNumber || serialNumber}
{data?.history?.model ? ` · ${data.history.model}` : ""}
</div>
<div className="space-y-1">
{[...chain].reverse().map((r) => {
const isCurrent = r.sessionId === sessionId;
const started = new Date(r.startedAt);
return (
<div
key={r.sessionId}
className={`flex items-center flex-wrap gap-x-3 gap-y-1 text-sm rounded px-2 py-1 ${isCurrent ? "bg-amber-100/70" : ""}`}
>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_PILL[r.status] ?? "bg-gray-100 text-gray-600"}`}>
{r.status}
</span>
<span className="text-gray-700">{started.toLocaleString()}</span>
<span className="text-gray-500">{formatDuration(r.durationSeconds)}</span>
{r.isPreProvisioned && (
<span className="text-xs text-violet-700 bg-violet-50 px-1.5 py-0.5 rounded">Pre-provisioning</span>
)}
{r.enrollmentType === "v2" && (
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">Device Preparation</span>
)}
{r.adminMarked && (
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded"
title="An administrator set this session's final status manually">admin-marked</span>
)}
{isCurrent ? (
<span className="text-xs text-amber-800">this session</span>
) : (
<Link href={sessionHref(r.sessionId)} className="text-xs text-blue-600 hover:text-blue-800 underline underline-offset-2">
open
</Link>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -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() {
</div>
)}
{/* 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 && (
<DeviceHistoryBanner
sessionId={sessionId}
serialNumber={session.serialNumber}
effectiveTenantId={sessionTenantId || tenantIdOverride || undefined}
linkTenantId={tenantIdOverride}
getAccessToken={getAccessToken}
/>
)}
{/* Device Details Card (from enrollment tracker events) */}
{!isGatherRulesSession && <div id="section-device-details"><DeviceDetailsCard events={events} latestAgentVersion={latestAgentVersion} /></div>}
+11
View File
@@ -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) =>