mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+351
@@ -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">
|
||||
|
||||
+185
@@ -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>}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user