feat: usage metrics split enrollments by type, join and deployment mode (D-361)

Usage Metrics (tenant page and the platform section) shows the window's
sessions in three groups that each sum to the session count:
Autopilot v1 or Device Preparation, Entra or hybrid joined, and the
Autopilot deployment mode (user-driven, self-deploying, pre-provisioned).
Type and mode follow the fleet enrollment class; user-driven no longer
counts the IsUserDriven flag, which every registration sets. The counts
come from the session rows on each request, so every retained session is
covered without a counter.

- DeploymentTypeMetrics gains the new counts and shares; the usage
  projection reads EnrollmentType, IsSelfDeployingProfile, IsHybridJoin
- platform and tenant compute share one builder per block
- Platform Usage takes ?days= like the other analytics pages
- the usage pages share their blocks (components/UsageMetricsBlocks.tsx);
  enrollment class labels and the time-attribution segments each live in
  one place, the duplication baseline shrinks by seven clones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-14 18:19:48 +02:00
parent e046571350
commit 17fc23f8cb
17 changed files with 756 additions and 656 deletions
@@ -0,0 +1,113 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the deployment-type breakdown of the usage metrics: three exclusive groups that each sum to
/// the window's session count, with the fleet enrollment-class precedence (pre-provisioned →
/// device preparation → self-deploying → user-driven), and the same result on the platform and the
/// tenant path.
/// </summary>
public class UsageMetricsDeploymentTypeTests
{
private static SessionSummary Session(string id, string enrollmentType = "v1", bool isUserDriven = true,
bool isPreProvisioned = false, bool isSelfDeployingProfile = false, bool isHybridJoin = false) => new()
{
SessionId = id,
TenantId = "00000000-0000-0000-0000-000000000abc",
StartedAt = DateTime.UtcNow.AddHours(-1),
Status = SessionStatus.Succeeded,
EnrollmentType = enrollmentType,
IsUserDriven = isUserDriven,
IsPreProvisioned = isPreProvisioned,
IsSelfDeployingProfile = isSelfDeployingProfile,
IsHybridJoin = isHybridJoin,
};
// The agent registers every session with IsUserDriven = true (WhiteGlove Part 1 alone flips it to
// false), so the fixtures carry the flag the way production rows do: it must not decide anything.
private static List<SessionSummary> MixedWindow() => new()
{
Session("user-driven"),
Session("user-driven-hybrid", isHybridJoin: true),
Session("self-deploying", isSelfDeployingProfile: true),
Session("whiteglove-part2-hybrid", isPreProvisioned: true, isHybridJoin: true),
Session("whiteglove-part1", isUserDriven: false, isPreProvisioned: true),
Session("self-deploying-preprovisioned", isPreProvisioned: true, isSelfDeployingProfile: true),
Session("device-preparation", enrollmentType: "v2"),
};
[Fact]
public void Groups_are_exclusive_and_follow_the_enrollment_class_precedence()
{
var m = UsageMetricsService.BuildDeploymentTypeMetrics(MixedWindow());
Assert.Equal(6, m.AutopilotV1);
Assert.Equal(1, m.DevicePreparation);
Assert.Equal(2, m.UserDriven);
Assert.Equal(1, m.SelfDeploying);
Assert.Equal(3, m.WhiteGlove);
Assert.Equal(2, m.HybridJoin);
Assert.Equal(5, m.EntraJoin);
Assert.Equal(85.7, m.AutopilotV1Percentage);
Assert.Equal(14.3, m.DevicePreparationPercentage);
Assert.Equal(28.6, m.UserDrivenPercentage);
Assert.Equal(14.3, m.SelfDeployingPercentage);
Assert.Equal(42.9, m.WhiteGlovePercentage);
Assert.Equal(28.6, m.HybridJoinPercentage);
Assert.Equal(71.4, m.EntraJoinPercentage);
}
[Fact]
public void Each_group_sums_to_the_session_count()
{
var sessions = MixedWindow();
var m = UsageMetricsService.BuildDeploymentTypeMetrics(sessions);
Assert.Equal(sessions.Count, m.AutopilotV1 + m.DevicePreparation);
Assert.Equal(sessions.Count, m.EntraJoin + m.HybridJoin);
Assert.Equal(m.AutopilotV1, m.UserDriven + m.SelfDeploying + m.WhiteGlove);
}
[Fact]
public void Empty_window_yields_zeros_without_NaN()
{
var m = UsageMetricsService.BuildDeploymentTypeMetrics(new List<SessionSummary>());
Assert.Equal(0, m.AutopilotV1);
Assert.Equal(0, m.EntraJoin);
Assert.Equal(0, m.UserDriven);
Assert.Equal(0, m.AutopilotV1Percentage);
Assert.Equal(0, m.EntraJoinPercentage);
Assert.Equal(0, m.WhiteGlovePercentage);
}
[Fact]
public async Task Platform_and_tenant_paths_report_the_same_breakdown()
{
var sessions = MixedWindow();
var maintenanceRepo = new Mock<IMaintenanceRepository>();
maintenanceRepo
.Setup(r => r.GetUsageWindowSessionsAsync(It.IsAny<DateTime>(), It.IsAny<DateTime>(), It.IsAny<string?>()))
.ReturnsAsync(sessions);
var metricsRepo = new Mock<IMetricsRepository>();
metricsRepo.Setup(r => r.GetTenantStatsAsync(It.IsAny<string>())).ReturnsAsync((TenantStats?)null);
metricsRepo.Setup(r => r.GetUserActivityMetricsAsync(It.IsAny<string>())).ReturnsAsync(new UserActivityMetrics());
metricsRepo.Setup(r => r.GetAllUserActivityMetricsAsync()).ReturnsAsync(new UserActivityMetrics());
metricsRepo.Setup(r => r.GetAppInstallRefsAsync(It.IsAny<DateTime>(), It.IsAny<string?>())).ReturnsAsync(new List<SessionAppRef>());
metricsRepo.Setup(r => r.GetPlatformStatsAsync()).ReturnsAsync((PlatformStats?)null);
var service = new UsageMetricsService(metricsRepo.Object, maintenanceRepo.Object, NullLogger<UsageMetricsService>.Instance);
var platform = await service.ComputeUsageMetricsInternalAsync(90);
var tenant = await service.ComputeTenantUsageMetricsInternalAsync("00000000-0000-0000-0000-000000000abc", 90);
var expected = UsageMetricsService.BuildDeploymentTypeMetrics(sessions);
Assert.Equivalent(expected, platform.DeploymentTypes, strict: true);
Assert.Equivalent(expected, tenant.DeploymentTypes, strict: true);
}
}
@@ -30,7 +30,8 @@ public class UsageMetricsProjectionEquivalenceTests
private static TableEntity FullRow(
string sessionId, string status, DateTime startedAt,
int? durationSeconds, DateTime? completedAt,
bool isPreProvisioned, DateTime? resumedAt)
bool isPreProvisioned, DateTime? resumedAt,
string enrollmentType = "v1", bool isHybridJoin = false, bool isSelfDeployingProfile = false)
{
var e = new TableEntity(TenantId, sessionId)
{
@@ -40,6 +41,9 @@ public class UsageMetricsProjectionEquivalenceTests
["Model"] = "Model-X",
["IsUserDriven"] = true,
["IsPreProvisioned"] = isPreProvisioned,
["EnrollmentType"] = enrollmentType,
["IsHybridJoin"] = isHybridJoin,
["IsSelfDeployingProfile"] = isSelfDeployingProfile,
["PlatformScriptCount"] = 4,
["RemediationScriptCount"] = 2,
// Representative noise the usage compute never reads — present on a real wide row,
@@ -84,8 +88,10 @@ public class UsageMetricsProjectionEquivalenceTests
Assert.Equal(fromFull.Status, fromProjected.Status);
Assert.Equal(fromFull.Manufacturer, fromProjected.Manufacturer);
Assert.Equal(fromFull.Model, fromProjected.Model);
Assert.Equal(fromFull.IsUserDriven, fromProjected.IsUserDriven);
Assert.Equal(fromFull.IsPreProvisioned, fromProjected.IsPreProvisioned);
Assert.Equal(fromFull.EnrollmentType, fromProjected.EnrollmentType);
Assert.Equal(fromFull.IsHybridJoin, fromProjected.IsHybridJoin);
Assert.Equal(fromFull.IsSelfDeployingProfile, fromProjected.IsSelfDeployingProfile);
Assert.Equal(fromFull.PlatformScriptCount, fromProjected.PlatformScriptCount);
Assert.Equal(fromFull.RemediationScriptCount, fromProjected.RemediationScriptCount);
// Wall-clock duration branches call UtcNow inside the mapper; two map calls microseconds
@@ -137,4 +143,20 @@ public class UsageMetricsProjectionEquivalenceTests
AssertUsageFieldsEqual(Sut.MapToSessionSummary(full), Sut.MapToSessionSummary(Project(full)));
}
[Theory]
[InlineData("v2", false, false)]
[InlineData("v1", true, false)]
[InlineData("v1", false, true)]
public void Deployment_type_columns_survive_projection(string enrollmentType, bool isHybridJoin, bool isSelfDeployingProfile)
{
// Each case sets one breakdown input away from its mapper default ("v1" / false), so dropping
// that column from the projection maps the projected row back to the default and fails here.
var full = FullRow("s-type", "Succeeded", DateTime.UtcNow.AddHours(-2),
durationSeconds: 1800, completedAt: DateTime.UtcNow.AddHours(-1.5),
isPreProvisioned: false, resumedAt: null,
enrollmentType, isHybridJoin, isSelfDeployingProfile);
AssertUsageFieldsEqual(Sut.MapToSessionSummary(full), Sut.MapToSessionSummary(Project(full)));
}
}
@@ -20,6 +20,10 @@ public static class MetricsMath
return finished > 0 ? Math.Round((double)failed / finished * 100, 1) : 0;
}
/// <summary>Share of <paramref name="part"/> in <paramref name="total"/> in percent, one decimal, 0 when total is 0.</summary>
public static double SharePct(int part, int total)
=> total > 0 ? Math.Round(part / (double)total * 100, 1) : 0;
/// <summary>
/// True when the row's terminal state says no real install attempt happened —
/// Skipped (not applicable, e.g. WinGet "Update for X" with the target app absent)
@@ -537,7 +537,8 @@ namespace AutopilotMonitor.Functions.Services
/// Columns the usage-metrics compute (UsageMetricsService) actually consumes, plus the
/// structural PartitionKey (TenantId) / RowKey (SessionId). CompletedAt / IsPreProvisioned /
/// ResumedAt must stay in the set for ComputeEffectiveDuration parity (WhiteGlove Part-2
/// branch and the completed-at fallback). Everything else on the wide Sessions row — most
/// branch and the completed-at fallback); EnrollmentType / IsSelfDeployingProfile /
/// IsHybridJoin feed the deployment-type breakdown. Everything else on the wide Sessions row — most
/// notably FailureSnapshotJson — is never read by the compute, so the projected scan skips
/// it. Non-projected columns map to null/default via the Safe*/`?? ` getters in
/// MapToSessionSummary. internal so UsageMetricsProjectionEquivalenceTests derives its
@@ -546,7 +547,8 @@ namespace AutopilotMonitor.Functions.Services
internal static readonly string[] UsageMetricsSessionProjection =
{
"PartitionKey", "RowKey", "StartedAt", "CompletedAt", "Status", "DurationSeconds",
"Manufacturer", "Model", "IsUserDriven", "IsPreProvisioned", "ResumedAt",
"Manufacturer", "Model", "IsPreProvisioned", "ResumedAt",
"EnrollmentType", "IsSelfDeployingProfile", "IsHybridJoin",
"PlatformScriptCount", "RemediationScriptCount"
};
@@ -164,25 +164,9 @@ namespace AutopilotMonitor.Functions.Services
await Task.WhenAll(sessionsTask, userActivityTask, appRefsTask, platformStatsTask);
var allSessions = await sessionsTask;
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
// Session Metrics
var sessionMetrics = new SessionMetrics
{
Total = allSessions.Count,
Today = allSessions.Count(s => s.StartedAt >= today),
Last7Days = allSessions.Count(s => s.StartedAt >= last7Days),
Last30Days = allSessions.Count(s => s.StartedAt >= last30Days),
Succeeded = allSessions.Count(s => s.Status == SessionStatus.Succeeded),
Failed = allSessions.Count(s => s.Status == SessionStatus.Failed),
InProgress = allSessions.Count(s => s.Status == SessionStatus.InProgress),
Incomplete = allSessions.Count(s => s.Status == SessionStatus.Incomplete),
SuccessRate = CalculateSuccessRate(allSessions)
};
// Tenant Metrics
var tenantMetrics = new TenantMetrics
{
Total = allSessions.Select(s => s.TenantId).Distinct().Count(),
@@ -198,109 +182,20 @@ namespace AutopilotMonitor.Functions.Services
.Count()
};
// User Metrics (from UserActivity table)
var userActivity = await userActivityTask;
var userMetrics = new UserMetrics
{
Total = userActivity.TotalUniqueUsers,
DailyLogins = userActivity.DailyLogins,
Active7Days = userActivity.ActiveUsersLast7Days,
Active30Days = userActivity.ActiveUsersLast30Days,
Note = userActivity.TotalUniqueUsers > 0 ? "" : "No user login activity recorded yet"
};
// Performance Metrics
// Non-terminal (InProgress/Stalled/Pending) sessions carry an unclamped wall-clock
// duration (now - StartedAt) that can read weeks and blow out avg/p95/p99. Clamp every
// sample to the same ceiling used for app-install durations at ingest, and surface how
// many were capped so the window stays honest. Terminal sessions are already <= cap.
const int durationCapSeconds = EventTimestampValidator.DefaultMaxDurationSeconds;
var completedSessions = allSessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
var performanceMetrics = new PerformanceMetrics();
if (completedSessions.Any())
{
var durations = completedSessions.Select(s => Math.Min(s.DurationSeconds!.Value, durationCapSeconds) / 60.0).OrderBy(d => d).ToList();
performanceMetrics.AvgDurationMinutes = Math.Round(durations.Average(), 1);
performanceMetrics.MedianDurationMinutes = MetricsMath.Percentile(durations, 50);
performanceMetrics.P95DurationMinutes = MetricsMath.Percentile(durations, 95);
performanceMetrics.P99DurationMinutes = MetricsMath.Percentile(durations, 99);
performanceMetrics.SampleCount = durations.Count;
performanceMetrics.ClampedSessionCount = completedSessions.Count(s => s.DurationSeconds!.Value > durationCapSeconds);
}
// Hardware Metrics
var totalCount = allSessions.Count;
var hardwareMetrics = new HardwareMetrics
{
TopManufacturers = allSessions
.GroupBy(s => s.Manufacturer)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList(),
TopModels = allSessions
.GroupBy(s => s.Model)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList()
};
// Deployment Type Metrics
var userDrivenCount = allSessions.Count(s => s.IsUserDriven);
var whiteGloveCount = allSessions.Count(s => s.IsPreProvisioned);
var deploymentTypeMetrics = new DeploymentTypeMetrics
{
UserDriven = userDrivenCount,
WhiteGlove = whiteGloveCount,
UserDrivenPercentage = totalCount > 0 ? Math.Round((userDrivenCount / (double)totalCount) * 100, 1) : 0,
WhiteGlovePercentage = totalCount > 0 ? Math.Round((whiteGloveCount / (double)totalCount) * 100, 1) : 0
};
// App & Script Metrics
// appRefsTask was bounded by windowStart (NOT a fresh UtcNow) so the AppInstall lower
// bound is identical to the session window's — see the snapshot note above. relevantApps
// is filtered to sessionIdSet anyway, and an app installs during its session
// (StartedAt >= the session's), so bounding only drops rows already outside the window.
var allAppRefs = await appRefsTask;
var sessionIdSet = new HashSet<string>(allSessions.Select(s => s.SessionId));
var relevantApps = allAppRefs.Where(a => sessionIdSet.Contains(a.SessionId)).ToList();
var appsPerSessionList = relevantApps.GroupBy(a => a.SessionId).Select(g => g.Count()).ToList();
var appScriptMetrics = new AppScriptMetrics
{
AvgAppsPerSession = appsPerSessionList.Count > 0 ? Math.Round(appsPerSessionList.Average(), 1) : 0,
TotalUniqueApps = relevantApps.Select(a => a.AppName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
TotalPlatformScripts = allSessions.Sum(s => s.PlatformScriptCount),
TotalRemediationScripts = allSessions.Sum(s => s.RemediationScriptCount),
AvgPlatformScriptsPerSession = allSessions.Count > 0 ? Math.Round(allSessions.Average(s => (double)s.PlatformScriptCount), 1) : 0,
AvgRemediationScriptsPerSession = allSessions.Count > 0 ? Math.Round(allSessions.Average(s => (double)s.RemediationScriptCount), 1) : 0
};
// Platform Stats (cumulative since release)
var platformStats = await platformStatsTask;
// appRefsTask was bounded by windowStart (NOT a fresh UtcNow) so the AppInstall lower
// bound is identical to the session window's — see the snapshot note above.
return new PlatformUsageMetrics
{
Sessions = sessionMetrics,
Sessions = BuildSessionMetrics(allSessions, now),
Tenants = tenantMetrics,
Users = userMetrics,
Performance = performanceMetrics,
Hardware = hardwareMetrics,
DeploymentTypes = deploymentTypeMetrics,
AppScripts = appScriptMetrics,
Users = BuildUserMetrics(await userActivityTask),
Performance = BuildPerformanceMetrics(allSessions),
Hardware = BuildHardwareMetrics(allSessions),
DeploymentTypes = BuildDeploymentTypeMetrics(allSessions),
AppScripts = BuildAppScriptMetrics(allSessions, await appRefsTask),
PlatformStats = platformStats != null ? new PlatformStats
{
TotalEnrollments = platformStats.TotalEnrollments,
@@ -338,23 +233,7 @@ namespace AutopilotMonitor.Functions.Services
await Task.WhenAll(sessionsTask, tenantStatsTask, userActivityTask, appRefsTask);
var tenantSessions = await sessionsTask;
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
// Session Metrics
var sessionMetrics = new SessionMetrics
{
Total = tenantSessions.Count,
Today = tenantSessions.Count(s => s.StartedAt >= today),
Last7Days = tenantSessions.Count(s => s.StartedAt >= last7Days),
Last30Days = tenantSessions.Count(s => s.StartedAt >= last30Days),
Succeeded = tenantSessions.Count(s => s.Status == SessionStatus.Succeeded),
Failed = tenantSessions.Count(s => s.Status == SessionStatus.Failed),
InProgress = tenantSessions.Count(s => s.Status == SessionStatus.InProgress),
Incomplete = tenantSessions.Count(s => s.Status == SessionStatus.Incomplete),
SuccessRate = CalculateSuccessRate(tenantSessions)
};
var sessionMetrics = BuildSessionMetrics(tenantSessions, now);
// Cumulative "since signup" counter — incremented at registration, seeded/self-healed
// by the nightly maintenance recompute. The window count is a floor: before the first
@@ -366,25 +245,59 @@ namespace AutopilotMonitor.Functions.Services
var tenantMetrics = new TenantMetrics
{
Total = 1,
Active7Days = tenantSessions.Any(s => s.StartedAt >= last7Days) ? 1 : 0,
Active30Days = tenantSessions.Any(s => s.StartedAt >= last30Days) ? 1 : 0
Active7Days = tenantSessions.Any(s => s.StartedAt >= now.AddDays(-7)) ? 1 : 0,
Active30Days = tenantSessions.Any(s => s.StartedAt >= now.AddDays(-30)) ? 1 : 0
};
// User Metrics (from UserActivity table)
var userActivity = await userActivityTask;
var userMetrics = new UserMetrics
return new PlatformUsageMetrics
{
Total = userActivity.TotalUniqueUsers,
DailyLogins = userActivity.DailyLogins,
Active7Days = userActivity.ActiveUsersLast7Days,
Active30Days = userActivity.ActiveUsersLast30Days,
Note = userActivity.TotalUniqueUsers > 0 ? "" : "No user login activity recorded yet"
Sessions = sessionMetrics,
Tenants = tenantMetrics,
Users = BuildUserMetrics(await userActivityTask),
Performance = BuildPerformanceMetrics(tenantSessions),
Hardware = BuildHardwareMetrics(tenantSessions),
DeploymentTypes = BuildDeploymentTypeMetrics(tenantSessions),
AppScripts = BuildAppScriptMetrics(tenantSessions, await appRefsTask)
};
}
// Performance Metrics — clamp runaway non-terminal durations to the shared ceiling and
// report how many were capped (see platform path above for the rationale).
private static SessionMetrics BuildSessionMetrics(List<SessionSummary> sessions, DateTime now)
{
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
return new SessionMetrics
{
Total = sessions.Count,
Today = sessions.Count(s => s.StartedAt >= today),
Last7Days = sessions.Count(s => s.StartedAt >= last7Days),
Last30Days = sessions.Count(s => s.StartedAt >= last30Days),
Succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded),
Failed = sessions.Count(s => s.Status == SessionStatus.Failed),
InProgress = sessions.Count(s => s.Status == SessionStatus.InProgress),
Incomplete = sessions.Count(s => s.Status == SessionStatus.Incomplete),
SuccessRate = CalculateSuccessRate(sessions)
};
}
private static UserMetrics BuildUserMetrics(UserActivityMetrics userActivity) => new()
{
Total = userActivity.TotalUniqueUsers,
DailyLogins = userActivity.DailyLogins,
Active7Days = userActivity.ActiveUsersLast7Days,
Active30Days = userActivity.ActiveUsersLast30Days,
Note = userActivity.TotalUniqueUsers > 0 ? "" : "No user login activity recorded yet"
};
private static PerformanceMetrics BuildPerformanceMetrics(List<SessionSummary> sessions)
{
// Non-terminal (InProgress/Stalled/Pending) sessions carry an unclamped wall-clock
// duration (now - StartedAt) that can read weeks and blow out avg/p95/p99. Clamp every
// sample to the same ceiling used for app-install durations at ingest, and surface how
// many were capped so the window stays honest. Terminal sessions are already <= cap.
const int durationCapSeconds = EventTimestampValidator.DefaultMaxDurationSeconds;
var completedSessions = tenantSessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
var completedSessions = sessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList();
var performanceMetrics = new PerformanceMetrics();
if (completedSessions.Any())
@@ -398,85 +311,96 @@ namespace AutopilotMonitor.Functions.Services
performanceMetrics.ClampedSessionCount = completedSessions.Count(s => s.DurationSeconds!.Value > durationCapSeconds);
}
// Hardware Metrics
var totalCount = tenantSessions.Count;
var hardwareMetrics = new HardwareMetrics();
return performanceMetrics;
}
if (totalCount > 0)
private static HardwareMetrics BuildHardwareMetrics(List<SessionSummary> sessions) => new()
{
TopManufacturers = TopHardware(sessions, s => s.Manufacturer),
TopModels = TopHardware(sessions, s => s.Model)
};
private static List<HardwareCount> TopHardware(List<SessionSummary> sessions, Func<SessionSummary, string> key)
=> sessions
.GroupBy(key)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = MetricsMath.SharePct(g.Count(), sessions.Count)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList();
/// <summary>
/// Splits the window by the fleet enrollment class (<see cref="TimeAttributionCalculator.GetEnrollmentClass"/>),
/// so the deployment modes exclude each other and sum to the Autopilot v1 count. The session's
/// IsUserDriven flag is no input: the agent sets it at every registration, self-deploying and
/// device preparation included.
/// </summary>
internal static DeploymentTypeMetrics BuildDeploymentTypeMetrics(IReadOnlyCollection<SessionSummary> sessions)
{
int devicePreparation = 0, selfDeploying = 0, whiteGlove = 0, userDriven = 0, hybridJoin = 0;
foreach (var session in sessions)
{
hardwareMetrics.TopManufacturers = tenantSessions
.GroupBy(s => s.Manufacturer)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList();
hardwareMetrics.TopModels = tenantSessions
.GroupBy(s => s.Model)
.Select(g => new HardwareCount
{
Name = g.Key,
Count = g.Count(),
Percentage = Math.Round((g.Count() / (double)totalCount) * 100, 1)
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToList();
switch (TimeAttributionCalculator.GetEnrollmentClass(session))
{
case "device_preparation": devicePreparation++; break;
case "self_deploying": selfDeploying++; break;
case "whiteglove": whiteGlove++; break;
default: userDriven++; break;
}
if (session.IsHybridJoin) hybridJoin++;
}
// Deployment Type Metrics
var userDrivenCount = tenantSessions.Count(s => s.IsUserDriven);
var whiteGloveCount = tenantSessions.Count(s => s.IsPreProvisioned);
var deploymentTypeMetrics = new DeploymentTypeMetrics
var total = sessions.Count;
var autopilotV1 = total - devicePreparation;
var entraJoin = total - hybridJoin;
return new DeploymentTypeMetrics
{
UserDriven = userDrivenCount,
WhiteGlove = whiteGloveCount,
UserDrivenPercentage = totalCount > 0 ? Math.Round((userDrivenCount / (double)totalCount) * 100, 1) : 0,
WhiteGlovePercentage = totalCount > 0 ? Math.Round((whiteGloveCount / (double)totalCount) * 100, 1) : 0
};
// App & Script Metrics
// appRefsTask reused windowStart (identical to the session window lower bound) — joined
// to tenantSessionIdSet below.
var tenantAppRefs = await appRefsTask;
var tenantSessionIdSet = new HashSet<string>(tenantSessions.Select(s => s.SessionId));
var relevantTenantApps = tenantAppRefs.Where(a => tenantSessionIdSet.Contains(a.SessionId)).ToList();
var tenantAppsPerSession = relevantTenantApps.GroupBy(a => a.SessionId).Select(g => g.Count()).ToList();
var tenantAppScriptMetrics = new AppScriptMetrics
{
AvgAppsPerSession = tenantAppsPerSession.Count > 0 ? Math.Round(tenantAppsPerSession.Average(), 1) : 0,
TotalUniqueApps = relevantTenantApps.Select(a => a.AppName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
TotalPlatformScripts = tenantSessions.Sum(s => s.PlatformScriptCount),
TotalRemediationScripts = tenantSessions.Sum(s => s.RemediationScriptCount),
AvgPlatformScriptsPerSession = tenantSessions.Count > 0 ? Math.Round(tenantSessions.Average(s => (double)s.PlatformScriptCount), 1) : 0,
AvgRemediationScriptsPerSession = tenantSessions.Count > 0 ? Math.Round(tenantSessions.Average(s => (double)s.RemediationScriptCount), 1) : 0
};
return new PlatformUsageMetrics
{
Sessions = sessionMetrics,
Tenants = tenantMetrics,
Users = userMetrics,
Performance = performanceMetrics,
Hardware = hardwareMetrics,
DeploymentTypes = deploymentTypeMetrics,
AppScripts = tenantAppScriptMetrics
AutopilotV1 = autopilotV1,
AutopilotV1Percentage = MetricsMath.SharePct(autopilotV1, total),
DevicePreparation = devicePreparation,
DevicePreparationPercentage = MetricsMath.SharePct(devicePreparation, total),
EntraJoin = entraJoin,
EntraJoinPercentage = MetricsMath.SharePct(entraJoin, total),
HybridJoin = hybridJoin,
HybridJoinPercentage = MetricsMath.SharePct(hybridJoin, total),
UserDriven = userDriven,
UserDrivenPercentage = MetricsMath.SharePct(userDriven, total),
SelfDeploying = selfDeploying,
SelfDeployingPercentage = MetricsMath.SharePct(selfDeploying, total),
WhiteGlove = whiteGlove,
WhiteGlovePercentage = MetricsMath.SharePct(whiteGlove, total)
};
}
private double CalculateSuccessRate(List<SessionSummary> sessions)
/// <summary>
/// App rows are joined to the window's sessions; an app installs during its session
/// (StartedAt >= the session's), so a lower bound equal to the window start drops only rows
/// already outside the window.
/// </summary>
private static AppScriptMetrics BuildAppScriptMetrics(List<SessionSummary> sessions, List<SessionAppRef> appRefs)
{
var completed = sessions.Count(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed);
if (completed == 0) return 0;
var sessionIdSet = new HashSet<string>(sessions.Select(s => s.SessionId));
var relevantApps = appRefs.Where(a => sessionIdSet.Contains(a.SessionId)).ToList();
var appsPerSessionList = relevantApps.GroupBy(a => a.SessionId).Select(g => g.Count()).ToList();
var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded);
return Math.Round((succeeded / (double)completed) * 100, 1);
return new AppScriptMetrics
{
AvgAppsPerSession = appsPerSessionList.Count > 0 ? Math.Round(appsPerSessionList.Average(), 1) : 0,
TotalUniqueApps = relevantApps.Select(a => a.AppName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
TotalPlatformScripts = sessions.Sum(s => s.PlatformScriptCount),
TotalRemediationScripts = sessions.Sum(s => s.RemediationScriptCount),
AvgPlatformScriptsPerSession = sessions.Count > 0 ? Math.Round(sessions.Average(s => (double)s.PlatformScriptCount), 1) : 0,
AvgRemediationScriptsPerSession = sessions.Count > 0 ? Math.Round(sessions.Average(s => (double)s.RemediationScriptCount), 1) : 0
};
}
private static double CalculateSuccessRate(List<SessionSummary> sessions)
=> MetricsMath.SharePct(
sessions.Count(s => s.Status == SessionStatus.Succeeded),
sessions.Count(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed));
}
}
@@ -1453,10 +1453,28 @@ export interface DeliveryLatencyMetrics {
clockSkewPercent: number;
}
/** Sessions in the window in three groups, each summing to Total: enrollment type (AutopilotV1 + DevicePreparation), join type (EntraJoin + HybridJoin) and Autopilot deployment mode (UserDriven + SelfDeploying + WhiteGlove, summing to AutopilotV1). Every percentage is the share of all sessions in the window. */
export interface DeploymentTypeMetrics {
/** Windows Autopilot sessions (classic, enrollment type v1). */
autopilotV1: number;
autopilotV1Percentage: number;
/** Windows Autopilot device preparation sessions (enrollment type v2). */
devicePreparation: number;
devicePreparationPercentage: number;
/** Sessions whose Autopilot profile does not ask for a hybrid join. */
entraJoin: number;
entraJoinPercentage: number;
/** Sessions whose Autopilot profile asks for a hybrid join. */
hybridJoin: number;
hybridJoinPercentage: number;
/** Autopilot v1 sessions that are neither pre-provisioned nor self-deploying. */
userDriven: number;
whiteGlove: number;
userDrivenPercentage: number;
/** Autopilot v1 sessions with a self-deploying profile that are not pre-provisioned. Sessions whose agent predates the profile flag count as user-driven. */
selfDeploying: number;
selfDeployingPercentage: number;
/** Pre-provisioned (White Glove) sessions. */
whiteGlove: number;
whiteGlovePercentage: number;
}
@@ -3463,7 +3481,7 @@ export interface PlatformUsageMetrics {
performance: PerformanceMetrics;
/** Hardware metrics */
hardware: HardwareMetrics;
/** Deployment type metrics (User Driven vs White Glove) */
/** Sessions in the window by enrollment type, join type and Autopilot deployment mode */
deploymentTypes: DeploymentTypeMetrics;
/** App and script count metrics */
appScripts: AppScriptMetrics;
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Shared.Models
public HardwareMetrics Hardware { get; set; } = new();
/// <summary>
/// Deployment type metrics (User Driven vs White Glove)
/// Sessions in the window by enrollment type, join type and Autopilot deployment mode
/// </summary>
public DeploymentTypeMetrics DeploymentTypes { get; set; } = new();
@@ -161,11 +161,44 @@ namespace AutopilotMonitor.Shared.Models
public double Percentage { get; set; }
}
/// <summary>
/// Sessions in the window in three groups, each summing to <see cref="SessionMetrics.Total"/>:
/// enrollment type (<see cref="AutopilotV1"/> + <see cref="DevicePreparation"/>), join type
/// (<see cref="EntraJoin"/> + <see cref="HybridJoin"/>) and Autopilot deployment mode
/// (<see cref="UserDriven"/> + <see cref="SelfDeploying"/> + <see cref="WhiteGlove"/>, summing to
/// <see cref="AutopilotV1"/>). Every percentage is the share of all sessions in the window.
/// </summary>
public class DeploymentTypeMetrics
{
/// <summary>Windows Autopilot sessions (classic, enrollment type v1).</summary>
public int AutopilotV1 { get; set; }
public double AutopilotV1Percentage { get; set; }
/// <summary>Windows Autopilot device preparation sessions (enrollment type v2).</summary>
public int DevicePreparation { get; set; }
public double DevicePreparationPercentage { get; set; }
/// <summary>Sessions whose Autopilot profile does not ask for a hybrid join.</summary>
public int EntraJoin { get; set; }
public double EntraJoinPercentage { get; set; }
/// <summary>Sessions whose Autopilot profile asks for a hybrid join.</summary>
public int HybridJoin { get; set; }
public double HybridJoinPercentage { get; set; }
/// <summary>Autopilot v1 sessions that are neither pre-provisioned nor self-deploying.</summary>
public int UserDriven { get; set; }
public int WhiteGlove { get; set; }
public double UserDrivenPercentage { get; set; }
/// <summary>
/// Autopilot v1 sessions with a self-deploying profile that are not pre-provisioned. Sessions
/// whose agent predates the profile flag count as user-driven.
/// </summary>
public int SelfDeploying { get; set; }
public double SelfDeployingPercentage { get; set; }
/// <summary>Pre-provisioned (White Glove) sessions.</summary>
public int WhiteGlove { get; set; }
public double WhiteGlovePercentage { get; set; }
}
@@ -1,21 +1,42 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuth } from '../../../../contexts/AuthContext';
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { CalculatingCard } from "@/components/CalculatingCard";
import { useWindowDays } from "@/hooks/useWindowDays";
import { windowProgressKey } from "@/lib/timeWindow";
import type { PlatformUsageMetrics } from "@/lib/generated/wire-types.generated";
import {
AppScriptsBlock,
DeploymentTypeBreakdown,
HardwareBlock,
PerformanceBlock,
SessionOutcomeCards,
SessionRecencyCards,
} from "@/components/UsageMetricsBlocks";
// A fresh cross-tenant compute takes tens of seconds server-side; the default 30s fetch timeout
// aborted it client-side (HTTP 499 on the backend) while the server kept computing. Give this one
// call generous headroom — the backend caches the result for 15 minutes afterwards.
const FETCH_TIMEOUT_MS = 180_000;
/** The backend's default window for these metrics; a URL `?days=` replaces it (no selector). */
const USAGE_DEFAULT_WINDOW_DAYS = 90;
export function SectionPlatformUsage() {
// useSearchParams (the ?days= window) requires a Suspense boundary under the static export.
return (
<Suspense fallback={<div className="min-h-screen bg-gray-50" />}>
<PlatformUsageContent />
</Suspense>
);
}
function PlatformUsageContent() {
const router = useRouter();
const { getAccessToken } = useAuth();
@@ -23,12 +44,15 @@ export function SectionPlatformUsage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
// Platform roles may request up to 365 days; the section has no tenant of its own.
const { days, ready: windowReady } = useWindowDays({ defaultDays: USAGE_DEFAULT_WINDOW_DAYS, tenantId: undefined });
// Progress estimate records only fresh computes — a 15-min-cache hit finishing in ~100ms
// would make the next cold load look stuck at the bar's cap.
const progress = useFetchProgress('platformUsage.lastFreshFetchMs');
const progress = useFetchProgress(windowProgressKey('platformUsage.lastFreshFetchMs', days));
const { begin: progressBegin, finish: progressFinish } = progress;
const fetchMetrics = useCallback(async (showRefreshing = false) => {
if (!windowReady) return;
let recordEstimate = false;
try {
if (showRefreshing) {
@@ -40,7 +64,7 @@ export function SectionPlatformUsage() {
progressBegin();
// Platform-wide metrics - cross-tenant (Global Admin only)
const data = await fetchJson<PlatformUsageMetrics>(api.metrics.globalUsage(), getAccessToken, {
const data = await fetchJson<PlatformUsageMetrics>(api.metrics.globalUsage(undefined, days), getAccessToken, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
setMetrics(data);
@@ -57,7 +81,7 @@ export function SectionPlatformUsage() {
setLoading(false);
setRefreshing(false);
}
}, [getAccessToken, progressBegin, progressFinish]);
}, [windowReady, days, getAccessToken, progressBegin, progressFinish]);
useEffect(() => {
// Fetch metrics on mount
@@ -68,15 +92,6 @@ export function SectionPlatformUsage() {
void run();
}, [fetchMetrics]);
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
@@ -171,108 +186,15 @@ export function SectionPlatformUsage() {
<div className="text-sm text-gray-500 mb-1" title={`Sessions started in the last ${metrics.windowDays ?? 90} days. All-time total is under Platform Statistics.`}>Sessions in Window</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.total.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-3xl font-bold text-blue-600">{metrics.sessions.today.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 7 Days</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.sessions.last7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 30 Days</div>
<div className="text-3xl font-bold text-purple-600">{metrics.sessions.last30Days.toLocaleString()}</div>
</div>
<SessionRecencyCards sessions={metrics.sessions} />
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.sessions.succeeded.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.sessions.failed.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1" title="Terminal, non-failure: timed out with no completion or failure signal. Excluded from the success rate.">Incomplete</div>
<div className="text-3xl font-bold text-slate-500">{metrics.sessions.incomplete.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">In Progress</div>
<div className="text-3xl font-bold text-yellow-600">{metrics.sessions.inProgress.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.successRate}%</div>
</div>
</div>
<SessionOutcomeCards sessions={metrics.sessions} />
</div>
{/* Deployment Types */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Deployment Types</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm text-gray-500 mb-1">User Driven</div>
<div className="text-3xl font-bold text-blue-600">{metrics.deploymentTypes.userDriven.toLocaleString()}</div>
</div>
<div className="text-right">
<span className="text-2xl font-semibold text-blue-500">{metrics.deploymentTypes.userDrivenPercentage}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${metrics.deploymentTypes.userDrivenPercentage}%` }}
></div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm text-gray-500 mb-1">Pre-Provisioned</div>
<div className="text-3xl font-bold text-purple-600">{metrics.deploymentTypes.whiteGlove.toLocaleString()}</div>
</div>
<div className="text-right">
<span className="text-2xl font-semibold text-purple-500">{metrics.deploymentTypes.whiteGlovePercentage}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-purple-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${metrics.deploymentTypes.whiteGlovePercentage}%` }}
></div>
</div>
</div>
</div>
</div>
<DeploymentTypeBreakdown deploymentTypes={metrics.deploymentTypes} />
{/* Apps & Scripts */}
{metrics.appScripts && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Apps & Scripts</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Avg Apps / Session</div>
<div className="text-3xl font-bold text-blue-600">{metrics.appScripts.avgAppsPerSession}</div>
<p className="text-xs text-gray-400 mt-1">{metrics.appScripts.totalUniqueApps.toLocaleString()} unique apps total</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Avg Platform Scripts / Session</div>
<div className="text-3xl font-bold text-teal-600">{metrics.appScripts.avgPlatformScriptsPerSession}</div>
<p className="text-xs text-gray-400 mt-1">{metrics.appScripts.totalPlatformScripts.toLocaleString()} executions total</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Avg Remediation Scripts / Session</div>
<div className="text-3xl font-bold text-amber-600">{metrics.appScripts.avgRemediationScriptsPerSession}</div>
<p className="text-xs text-gray-400 mt-1">{metrics.appScripts.totalRemediationScripts.toLocaleString()} executions total</p>
</div>
</div>
</div>
)}
{metrics.appScripts && <AppScriptsBlock appScripts={metrics.appScripts} />}
{/* Tenant & User Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
@@ -351,28 +273,7 @@ export function SectionPlatformUsage() {
</div>
</div>
{/* Performance Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Average Duration</div>
<div className="text-3xl font-bold text-gray-900">{formatDuration(metrics.performance.avgDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Median Duration</div>
<div className="text-3xl font-bold text-blue-600">{formatDuration(metrics.performance.medianDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P95 Duration</div>
<div className="text-3xl font-bold text-orange-600">{formatDuration(metrics.performance.p95DurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P99 Duration</div>
<div className="text-3xl font-bold text-red-600">{formatDuration(metrics.performance.p99DurationMinutes)}</div>
</div>
</div>
</div>
<PerformanceBlock performance={metrics.performance} />
{/* Platform Statistics (Cumulative Since Release) */}
{metrics.platformStats && (
@@ -430,68 +331,7 @@ export function SectionPlatformUsage() {
</div>
)}
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Manufacturers</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topManufacturers.length > 0 ? (
metrics.hardware.topManufacturers.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
{/* Top Models */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Models</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topModels.length > 0 ? (
metrics.hardware.topModels.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
</div>
<HardwareBlock hardware={metrics.hardware} />
</div>
</div>
);
@@ -1,6 +1,8 @@
"use client";
import React from "react";
import { enrollmentClassLabel } from "@/lib/enrollmentClass";
import { TIME_ATTRIBUTION_SEGMENTS } from "@/components/timeAttributionSegments";
/**
* F1 fleet time attribution (insights spec §F1 "Surfaces"): renders the sweep-maintained
@@ -50,24 +52,6 @@ export interface TimeAttributionResponseDto {
/** UI gate for rendering class statistics (spec: ≥20 clean sessions per class). */
const MIN_SESSIONS_FOR_PANEL = 20;
const SEGMENT_META: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
{ key: "unattributed", label: "Unattributed", color: "bg-gray-300" },
];
const CLASS_LABELS: Record<string, string> = {
user_driven: "User-driven",
whiteglove: "Pre-provisioning (WhiteGlove)",
self_deploying: "Self-deploying",
device_preparation: "Device Preparation (v2)",
};
function formatMinutes(totalSeconds: number): string {
const s = Math.max(0, Math.round(totalSeconds));
if (s < 60) return `${s}s`;
@@ -108,7 +92,7 @@ export default function TimeAttributionSection({ data }: { data: TimeAttribution
<div key={cls.enrollmentClass}>
<div className="flex items-baseline justify-between flex-wrap gap-1">
<span className="text-sm font-medium text-gray-700">
{CLASS_LABELS[cls.enrollmentClass] ?? cls.enrollmentClass}
{enrollmentClassLabel(cls.enrollmentClass)}
</span>
<span className="text-xs text-gray-400">
insufficient data (n={cls.cleanSessionCount}, needs {MIN_SESSIONS_FOR_PANEL})
@@ -119,7 +103,7 @@ export default function TimeAttributionSection({ data }: { data: TimeAttribution
);
}
const parts = SEGMENT_META
const parts = TIME_ATTRIBUTION_SEGMENTS
.map(meta => ({
...meta,
stat: cls.segmentStats.find(s => s.segmentKey === meta.key),
@@ -132,7 +116,7 @@ export default function TimeAttributionSection({ data }: { data: TimeAttribution
<div key={cls.enrollmentClass}>
<div className="flex items-baseline justify-between flex-wrap gap-1 mb-1.5">
<span className="text-sm font-medium text-gray-700">
{CLASS_LABELS[cls.enrollmentClass] ?? cls.enrollmentClass}
{enrollmentClassLabel(cls.enrollmentClass)}
<span className="ml-2 text-xs font-normal text-gray-400">
n={cls.cleanSessionCount} · median total ≈ {formatMinutes(stackTotal)}
</span>
@@ -198,7 +182,7 @@ export default function TimeAttributionSection({ data }: { data: TimeAttribution
<td className="py-1.5 pr-4 text-gray-700 max-w-[220px] truncate" title={app.appName || app.appId}>
{app.appName || app.appId}
</td>
<td className="py-1.5 pr-4 text-xs text-gray-400">{CLASS_LABELS[cls] ?? cls}</td>
<td className="py-1.5 pr-4 text-xs text-gray-400">{enrollmentClassLabel(cls)}</td>
<td className="py-1.5 pr-4 text-right text-gray-500">{app.sessionCount}</td>
<td className="py-1.5 pr-4 text-right text-gray-700">{formatMinutes(app.medianSeconds)}</td>
<td className="py-1.5 text-right text-gray-700">
@@ -3,6 +3,7 @@
import React from "react";
import type { SessionTimeBreakdown } from "@/lib/generated/wire-types.generated";
import { osUpdateDetail } from "./timeAttributionLogic";
import { TIME_ATTRIBUTION_SEGMENTS } from "@/components/timeAttributionSegments";
/**
* F1 time attribution (insights spec §F1): renders the PRE-COMPUTED breakdown row of a
@@ -16,17 +17,6 @@ import { osUpdateDetail } from "./timeAttributionLogic";
/** The session time breakdown (wire shape). */
export type SessionTimeBreakdownDto = SessionTimeBreakdown;
const SEGMENT_META: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
{ key: "unattributed", label: "Unattributed", color: "bg-gray-300" },
];
const FLAG_LABELS: Record<string, string> = {
ClockSkewDropped: "Clock-skewed data dropped",
PartialObservation: "Agent started late — early phases underobserved",
@@ -59,7 +49,7 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
// the Windows Update slice.
const updateDetail = osUpdateDetail(breakdown.osUpdates ?? []);
const parts = SEGMENT_META
const parts = TIME_ATTRIBUTION_SEGMENTS
.map(meta => ({
...meta,
seconds: totals.get(meta.key) ?? 0,
@@ -12,6 +12,14 @@ import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
import type { PlatformUsageMetrics } from "@/lib/generated/wire-types.generated";
import {
AppScriptsBlock,
DeploymentTypeBreakdown,
HardwareBlock,
PerformanceBlock,
SessionOutcomeCards,
SessionRecencyCards,
} from "@/components/UsageMetricsBlocks";
import { fetchJson } from "@/lib/apiClient";
import { useWindowDays } from "@/hooks/useWindowDays";
import { notifyApiError } from "@/contexts/NotificationContext";
@@ -96,15 +104,6 @@ function UsageMetricsContent() {
const selectedTenantName = tenants.find(t => t.tenantId === selectedTenantId)?.domainName;
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
@@ -195,108 +194,15 @@ function UsageMetricsContent() {
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.total.toLocaleString()}</div>
<p className="text-xs text-gray-400 mt-1">last {metrics.windowDays ?? 90} days</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-3xl font-bold text-blue-600">{metrics.sessions.today.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 7 Days</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.sessions.last7Days.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Last 30 Days</div>
<div className="text-3xl font-bold text-purple-600">{metrics.sessions.last30Days.toLocaleString()}</div>
</div>
<SessionRecencyCards sessions={metrics.sessions} />
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.sessions.succeeded.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.sessions.failed.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1" title="Terminal, non-failure: timed out with no completion or failure signal. Excluded from the success rate.">Incomplete</div>
<div className="text-3xl font-bold text-slate-500">{metrics.sessions.incomplete.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">In Progress</div>
<div className="text-3xl font-bold text-yellow-600">{metrics.sessions.inProgress.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-3xl font-bold text-gray-900">{metrics.sessions.successRate}%</div>
</div>
</div>
<SessionOutcomeCards sessions={metrics.sessions} />
</div>
{/* Deployment Types */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Deployment Types</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm text-gray-500 mb-1">User Driven</div>
<div className="text-3xl font-bold text-blue-600">{metrics.deploymentTypes.userDriven.toLocaleString()}</div>
</div>
<div className="text-right">
<span className="text-2xl font-semibold text-blue-500">{metrics.deploymentTypes.userDrivenPercentage}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${metrics.deploymentTypes.userDrivenPercentage}%` }}
></div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm text-gray-500 mb-1">Pre-Provisioned</div>
<div className="text-3xl font-bold text-purple-600">{metrics.deploymentTypes.whiteGlove.toLocaleString()}</div>
</div>
<div className="text-right">
<span className="text-2xl font-semibold text-purple-500">{metrics.deploymentTypes.whiteGlovePercentage}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-purple-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${metrics.deploymentTypes.whiteGlovePercentage}%` }}
></div>
</div>
</div>
</div>
</div>
<DeploymentTypeBreakdown deploymentTypes={metrics.deploymentTypes} />
{/* Apps & Scripts */}
{metrics.appScripts && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Apps & Scripts</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Avg Apps / Session</div>
<div className="text-3xl font-bold text-blue-600">{metrics.appScripts.avgAppsPerSession}</div>
<p className="text-xs text-gray-400 mt-1">{metrics.appScripts.totalUniqueApps.toLocaleString()} unique apps total</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Avg Platform Scripts / Session</div>
<div className="text-3xl font-bold text-teal-600">{metrics.appScripts.avgPlatformScriptsPerSession}</div>
<p className="text-xs text-gray-400 mt-1">{metrics.appScripts.totalPlatformScripts.toLocaleString()} executions total</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Avg Remediation Scripts / Session</div>
<div className="text-3xl font-bold text-amber-600">{metrics.appScripts.avgRemediationScriptsPerSession}</div>
<p className="text-xs text-gray-400 mt-1">{metrics.appScripts.totalRemediationScripts.toLocaleString()} executions total</p>
</div>
</div>
</div>
)}
{metrics.appScripts && <AppScriptsBlock appScripts={metrics.appScripts} />}
{/* User Statistics */}
<div className="mb-6">
@@ -355,91 +261,9 @@ function UsageMetricsContent() {
)}
</div>
{/* Performance Statistics */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Average Duration</div>
<div className="text-3xl font-bold text-gray-900">{formatDuration(metrics.performance.avgDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Median Duration</div>
<div className="text-3xl font-bold text-blue-600">{formatDuration(metrics.performance.medianDurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P95 Duration</div>
<div className="text-3xl font-bold text-orange-600">{formatDuration(metrics.performance.p95DurationMinutes)}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">P99 Duration</div>
<div className="text-3xl font-bold text-red-600">{formatDuration(metrics.performance.p99DurationMinutes)}</div>
</div>
</div>
</div>
<PerformanceBlock performance={metrics.performance} />
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Manufacturers</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topManufacturers.length > 0 ? (
metrics.hardware.topManufacturers.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
{/* Top Models */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Models</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{metrics.hardware.topModels.length > 0 ? (
metrics.hardware.topModels.map((item, index) => (
<div key={index} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900">{item.name || 'Unknown'}</span>
<div className="text-right">
<span className="text-sm font-semibold text-gray-900">{item.count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({item.percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
</div>
<HardwareBlock hardware={metrics.hardware} />
</>
)}
</div>
@@ -0,0 +1,231 @@
import type { ReactNode } from "react";
import type {
AppScriptMetrics,
DeploymentTypeMetrics,
HardwareCount,
HardwareMetrics,
PerformanceMetrics,
SessionMetrics,
} from "@/lib/generated/wire-types.generated";
import { ENROLLMENT_CLASS_LABELS } from "@/lib/enrollmentClass";
// The blocks the tenant Usage Metrics page and the platform Usage Metrics section share.
function StatCard({ label, title, valueClass, note, children }: {
label: string;
title?: string;
valueClass: string;
note?: string;
children: ReactNode;
}) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1" title={title}>{label}</div>
<div className={`text-3xl font-bold ${valueClass}`}>{children}</div>
{note != null && <p className="text-xs text-gray-400 mt-1">{note}</p>}
</div>
);
}
/** Today / last 7 / last 30 days: three cards for the page's own session grid. */
export function SessionRecencyCards({ sessions }: { sessions: SessionMetrics }) {
return (
<>
<StatCard label="Today" valueClass="text-blue-600">{sessions.today.toLocaleString()}</StatCard>
<StatCard label="Last 7 Days" valueClass="text-indigo-600">{sessions.last7Days.toLocaleString()}</StatCard>
<StatCard label="Last 30 Days" valueClass="text-purple-600">{sessions.last30Days.toLocaleString()}</StatCard>
</>
);
}
export function SessionOutcomeCards({ sessions }: { sessions: SessionMetrics }) {
return (
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mt-4">
<StatCard label="Succeeded" valueClass="text-green-600">{sessions.succeeded.toLocaleString()}</StatCard>
<StatCard label="Failed" valueClass="text-red-600">{sessions.failed.toLocaleString()}</StatCard>
<StatCard
label="Incomplete"
title="Terminal, non-failure: timed out with no completion or failure signal. Excluded from the success rate."
valueClass="text-slate-500"
>
{sessions.incomplete.toLocaleString()}
</StatCard>
<StatCard label="In Progress" valueClass="text-yellow-600">{sessions.inProgress.toLocaleString()}</StatCard>
<StatCard label="Success Rate" valueClass="text-gray-900">{sessions.successRate}%</StatCard>
</div>
);
}
function ShareRow({ label, count, percentage, barClass }: {
label: string;
count: number;
percentage: number;
barClass: string;
}) {
return (
<div className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between gap-3 mb-2">
<span className="font-medium text-gray-900">{label}</span>
<div className="text-right whitespace-nowrap">
<span className="text-sm font-semibold text-gray-900">{count.toLocaleString()}</span>
<span className="text-xs text-gray-500 ml-2">({percentage}%)</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`${barClass} h-2 rounded-full transition-all duration-300`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
}
interface ShareGroup {
title: string;
hint: string;
barClass: string;
rows: { label: string; count: number; percentage: number }[];
}
function deploymentGroups(t: DeploymentTypeMetrics): ShareGroup[] {
return [
{
title: "Enrollment Type",
hint: "Share of all sessions in the window.",
barClass: "bg-blue-600",
rows: [
{ label: "Autopilot (v1)", count: t.autopilotV1, percentage: t.autopilotV1Percentage },
{ label: ENROLLMENT_CLASS_LABELS.device_preparation, count: t.devicePreparation, percentage: t.devicePreparationPercentage },
],
},
{
title: "Join Type",
hint: "Share of all sessions in the window.",
barClass: "bg-indigo-600",
rows: [
{ label: "Entra joined", count: t.entraJoin, percentage: t.entraJoinPercentage },
{ label: "Hybrid joined", count: t.hybridJoin, percentage: t.hybridJoinPercentage },
],
},
{
title: "Autopilot Deployment Mode",
hint: "Autopilot (v1) sessions by mode, as a share of all sessions in the window.",
barClass: "bg-purple-600",
rows: [
{ label: ENROLLMENT_CLASS_LABELS.user_driven, count: t.userDriven, percentage: t.userDrivenPercentage },
{ label: ENROLLMENT_CLASS_LABELS.self_deploying, count: t.selfDeploying, percentage: t.selfDeployingPercentage },
{ label: ENROLLMENT_CLASS_LABELS.whiteglove, count: t.whiteGlove, percentage: t.whiteGlovePercentage },
],
},
];
}
/** Enrollment type, join type and Autopilot deployment mode of the sessions in the window. */
export function DeploymentTypeBreakdown({ deploymentTypes }: { deploymentTypes: DeploymentTypeMetrics }) {
return (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Deployment Types</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{deploymentGroups(deploymentTypes).map((group) => (
<div key={group.title} className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-4 pt-4 text-sm text-gray-500" title={group.hint}>{group.title}</div>
<div className="divide-y divide-gray-200">
{group.rows.map((row) => (
<ShareRow key={row.label} {...row} barClass={group.barClass} />
))}
</div>
</div>
))}
</div>
</div>
);
}
export function AppScriptsBlock({ appScripts }: { appScripts: AppScriptMetrics }) {
return (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Apps & Scripts</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
label="Avg Apps / Session"
valueClass="text-blue-600"
note={`${appScripts.totalUniqueApps.toLocaleString()} unique apps total`}
>
{appScripts.avgAppsPerSession}
</StatCard>
<StatCard
label="Avg Platform Scripts / Session"
valueClass="text-teal-600"
note={`${appScripts.totalPlatformScripts.toLocaleString()} executions total`}
>
{appScripts.avgPlatformScriptsPerSession}
</StatCard>
<StatCard
label="Avg Remediation Scripts / Session"
valueClass="text-amber-600"
note={`${appScripts.totalRemediationScripts.toLocaleString()} executions total`}
>
{appScripts.avgRemediationScriptsPerSession}
</StatCard>
</div>
</div>
);
}
function formatDuration(minutes: number): string {
if (minutes < 60) {
return `${minutes.toFixed(1)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hours}h ${mins}m`;
}
export function PerformanceBlock({ performance }: { performance: PerformanceMetrics }) {
return (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Performance</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard label="Average Duration" valueClass="text-gray-900">{formatDuration(performance.avgDurationMinutes)}</StatCard>
<StatCard label="Median Duration" valueClass="text-blue-600">{formatDuration(performance.medianDurationMinutes)}</StatCard>
<StatCard label="P95 Duration" valueClass="text-orange-600">{formatDuration(performance.p95DurationMinutes)}</StatCard>
<StatCard label="P99 Duration" valueClass="text-red-600">{formatDuration(performance.p99DurationMinutes)}</StatCard>
</div>
</div>
);
}
function TopHardwareList({ title, items, barClass }: { title: string; items: HardwareCount[]; barClass: string }) {
return (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">{title}</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200">
{items.length > 0 ? (
items.map((item, index) => (
<ShareRow
key={index}
label={item.name || "Unknown"}
count={item.count}
percentage={item.percentage}
barClass={barClass}
/>
))
) : (
<div className="p-8 text-center text-gray-500">No data available</div>
)}
</div>
</div>
</div>
);
}
export function HardwareBlock({ hardware }: { hardware: HardwareMetrics }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<TopHardwareList title="Top Manufacturers" items={hardware.topManufacturers} barClass="bg-blue-600" />
<TopHardwareList title="Top Models" items={hardware.topModels} barClass="bg-indigo-600" />
</div>
);
}
@@ -0,0 +1,11 @@
/** Time-attribution segments in display order, with label and bar colour (session lane and fleet panel). */
export const TIME_ATTRIBUTION_SEGMENTS: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
{ key: "unattributed", label: "Unattributed", color: "bg-gray-300" },
];
@@ -0,0 +1,15 @@
/**
* Display names of the fleet enrollment classes (backend `TimeAttributionCalculator.GetEnrollmentClass`:
* pre-provisioning → device preparation → self-deploying → user-driven).
*/
export const ENROLLMENT_CLASS_LABELS = {
user_driven: "User-driven",
whiteglove: "Pre-provisioning (WhiteGlove)",
self_deploying: "Self-deploying",
device_preparation: "Device Preparation (v2)",
} as const;
/** The display name of an enrollment class key; an unknown key shows as itself. */
export function enrollmentClassLabel(enrollmentClass: string): string {
return (ENROLLMENT_CLASS_LABELS as Record<string, string>)[enrollmentClass] ?? enrollmentClass;
}
@@ -7428,9 +7428,93 @@
},
"DeploymentTypeMetrics": {
"kind": "object",
"doc": "Sessions in the window in three groups, each summing to Total: enrollment type (AutopilotV1 + DevicePreparation), join type (EntraJoin + HybridJoin) and Autopilot deployment mode (UserDriven + SelfDeploying + WhiteGlove, summing to AutopilotV1). Every percentage is the share of all sessions in the window.",
"fields": [
{
"name": "autopilotV1",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Windows Autopilot sessions (classic, enrollment type v1)."
},
{
"name": "autopilotV1Percentage",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "devicePreparation",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Windows Autopilot device preparation sessions (enrollment type v2)."
},
{
"name": "devicePreparationPercentage",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "entraJoin",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Sessions whose Autopilot profile does not ask for a hybrid join."
},
{
"name": "entraJoinPercentage",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "hybridJoin",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Sessions whose Autopilot profile asks for a hybrid join."
},
{
"name": "hybridJoinPercentage",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "userDriven",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Autopilot v1 sessions that are neither pre-provisioned nor self-deploying."
},
{
"name": "userDrivenPercentage",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "selfDeploying",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Autopilot v1 sessions with a self-deploying profile that are not pre-provisioned. Sessions whose agent predates the profile flag count as user-driven."
},
{
"name": "selfDeployingPercentage",
"type": {
"kind": "primitive",
"name": "number"
@@ -7441,14 +7525,8 @@
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "userDrivenPercentage",
"type": {
"kind": "primitive",
"name": "number"
}
},
"doc": "Pre-provisioned (White Glove) sessions."
},
{
"name": "whiteGlovePercentage",
@@ -16319,7 +16397,7 @@
"kind": "ref",
"name": "DeploymentTypeMetrics"
},
"doc": "Deployment type metrics (User Driven vs White Glove)"
"doc": "Sessions in the window by enrollment type, join type and Autopilot deployment mode"
},
{
"name": "appScripts",
@@ -1453,10 +1453,28 @@ export interface DeliveryLatencyMetrics {
clockSkewPercent: number;
}
/** Sessions in the window in three groups, each summing to Total: enrollment type (AutopilotV1 + DevicePreparation), join type (EntraJoin + HybridJoin) and Autopilot deployment mode (UserDriven + SelfDeploying + WhiteGlove, summing to AutopilotV1). Every percentage is the share of all sessions in the window. */
export interface DeploymentTypeMetrics {
/** Windows Autopilot sessions (classic, enrollment type v1). */
autopilotV1: number;
autopilotV1Percentage: number;
/** Windows Autopilot device preparation sessions (enrollment type v2). */
devicePreparation: number;
devicePreparationPercentage: number;
/** Sessions whose Autopilot profile does not ask for a hybrid join. */
entraJoin: number;
entraJoinPercentage: number;
/** Sessions whose Autopilot profile asks for a hybrid join. */
hybridJoin: number;
hybridJoinPercentage: number;
/** Autopilot v1 sessions that are neither pre-provisioned nor self-deploying. */
userDriven: number;
whiteGlove: number;
userDrivenPercentage: number;
/** Autopilot v1 sessions with a self-deploying profile that are not pre-provisioned. Sessions whose agent predates the profile flag count as user-driven. */
selfDeploying: number;
selfDeployingPercentage: number;
/** Pre-provisioned (White Glove) sessions. */
whiteGlove: number;
whiteGlovePercentage: number;
}
@@ -3463,7 +3481,7 @@ export interface PlatformUsageMetrics {
performance: PerformanceMetrics;
/** Hardware metrics */
hardware: HardwareMetrics;
/** Deployment type metrics (User Driven vs White Glove) */
/** Sessions in the window by enrollment type, join type and Autopilot deployment mode */
deploymentTypes: DeploymentTypeMetrics;
/** App and script count metrics */
appScripts: AppScriptMetrics;
-7
View File
@@ -4,7 +4,6 @@
"016e952b3d382f92": 1,
"04f18ccf5471e650": 1,
"09722ff0399d0079": 1,
"0d52e627a698ed73": 1,
"1102405933d6d36d": 1,
"12a9c705cd1be309": 1,
"1321e7dfb58dc00e": 1,
@@ -18,9 +17,7 @@
"1d7bffc15ccf7436": 1,
"29bbb90f9b9e306a": 1,
"2a985532f2e52066": 1,
"2eead0be45077bd5": 1,
"33829a769f2eacb9": 1,
"34398099b2dfe86d": 1,
"376b94382a0f2fc3": 1,
"391740d0bd84d73b": 1,
"3a83e25903a9f78b": 1,
@@ -36,11 +33,9 @@
"629fe688894bc289": 1,
"64c0bc57fd5351c3": 1,
"654a9df3c383502e": 1,
"6792a21ecd44b52c": 1,
"6a3941fb9f2fdf26": 1,
"7247997f500a7474": 1,
"757cad917cd84ca1": 1,
"7eadf5a3615bfd95": 1,
"8084f14fe903eabb": 1,
"853f4f98ba80f168": 1,
"8be66cde251a0e1c": 1,
@@ -51,7 +46,6 @@
"94cba67a903b5d04": 1,
"953067f449c638aa": 1,
"99f2b03f158babad": 1,
"9ac8c860f07eec4e": 1,
"9ef49d93827bfe4b": 1,
"9f468c94ae4a7636": 1,
"a3b1b3898c247667": 1,
@@ -64,7 +58,6 @@
"bf3345bef96687f1": 1,
"c20b0ee6fe8ea9bb": 1,
"c69b00e7d0d6e3dd": 1,
"cb3abe4fe2f6babc": 1,
"cdcc39d2dad88d50": 1,
"d171e886de82fa87": 1,
"d2f7815d2bf8f83d": 1,