diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsDeploymentTypeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsDeploymentTypeTests.cs new file mode 100644 index 00000000..44ebaeb7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsDeploymentTypeTests.cs @@ -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; + +/// +/// 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. +/// +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 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()); + + 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(); + maintenanceRepo + .Setup(r => r.GetUsageWindowSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(sessions); + var metricsRepo = new Mock(); + metricsRepo.Setup(r => r.GetTenantStatsAsync(It.IsAny())).ReturnsAsync((TenantStats?)null); + metricsRepo.Setup(r => r.GetUserActivityMetricsAsync(It.IsAny())).ReturnsAsync(new UserActivityMetrics()); + metricsRepo.Setup(r => r.GetAllUserActivityMetricsAsync()).ReturnsAsync(new UserActivityMetrics()); + metricsRepo.Setup(r => r.GetAppInstallRefsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + metricsRepo.Setup(r => r.GetPlatformStatsAsync()).ReturnsAsync((PlatformStats?)null); + var service = new UsageMetricsService(metricsRepo.Object, maintenanceRepo.Object, NullLogger.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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsProjectionEquivalenceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsProjectionEquivalenceTests.cs index 37e167a2..fab4154e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsProjectionEquivalenceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UsageMetricsProjectionEquivalenceTests.cs @@ -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))); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs index 36a4ba03..556adbaf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs @@ -20,6 +20,10 @@ public static class MetricsMath return finished > 0 ? Math.Round((double)failed / finished * 100, 1) : 0; } + /// Share of in in percent, one decimal, 0 when total is 0. + public static double SharePct(int part, int total) + => total > 0 ? Math.Round(part / (double)total * 100, 1) : 0; + /// /// 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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index 8e728f3a..cac04cd7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -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" }; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs index 880ac219..cdf8395a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs @@ -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(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 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 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 sessions) => new() + { + TopManufacturers = TopHardware(sessions, s => s.Manufacturer), + TopModels = TopHardware(sessions, s => s.Model) + }; + + private static List TopHardware(List sessions, Func 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(); + + /// + /// Splits the window by the fleet enrollment class (), + /// 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. + /// + internal static DeploymentTypeMetrics BuildDeploymentTypeMetrics(IReadOnlyCollection 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(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 sessions) + /// + /// 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. + /// + private static AppScriptMetrics BuildAppScriptMetrics(List sessions, List appRefs) { - var completed = sessions.Count(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed); - if (completed == 0) return 0; + var sessionIdSet = new HashSet(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 sessions) + => MetricsMath.SharePct( + sessions.Count(s => s.Status == SessionStatus.Succeeded), + sessions.Count(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed)); } } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 5ffcb750..10e1728b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -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; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs index 81f9486b..914d78bf 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs @@ -35,7 +35,7 @@ namespace AutopilotMonitor.Shared.Models public HardwareMetrics Hardware { get; set; } = new(); /// - /// Deployment type metrics (User Driven vs White Glove) + /// Sessions in the window by enrollment type, join type and Autopilot deployment mode /// public DeploymentTypeMetrics DeploymentTypes { get; set; } = new(); @@ -161,11 +161,44 @@ namespace AutopilotMonitor.Shared.Models public double Percentage { get; set; } } + /// + /// Sessions in the window in three groups, each summing to : + /// enrollment type ( + ), join type + /// ( + ) and Autopilot deployment mode + /// ( + + , summing to + /// ). Every percentage is the share of all sessions in the window. + /// public class DeploymentTypeMetrics { + /// Windows Autopilot sessions (classic, enrollment type v1). + public int AutopilotV1 { get; set; } + public double AutopilotV1Percentage { get; set; } + + /// Windows Autopilot device preparation sessions (enrollment type v2). + public int DevicePreparation { get; set; } + public double DevicePreparationPercentage { get; set; } + + /// Sessions whose Autopilot profile does not ask for a hybrid join. + public int EntraJoin { get; set; } + public double EntraJoinPercentage { get; set; } + + /// Sessions whose Autopilot profile asks for a hybrid join. + public int HybridJoin { get; set; } + public double HybridJoinPercentage { get; set; } + + /// Autopilot v1 sessions that are neither pre-provisioned nor self-deploying. public int UserDriven { get; set; } - public int WhiteGlove { get; set; } public double UserDrivenPercentage { get; set; } + + /// + /// Autopilot v1 sessions with a self-deploying profile that are not pre-provisioned. Sessions + /// whose agent predates the profile flag count as user-driven. + /// + public int SelfDeploying { get; set; } + public double SelfDeployingPercentage { get; set; } + + /// Pre-provisioned (White Glove) sessions. + public int WhiteGlove { get; set; } public double WhiteGlovePercentage { get; set; } } diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx index d795aa7f..3d18cba6 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx @@ -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 ( + }> + + + ); +} + +function PlatformUsageContent() { const router = useRouter(); const { getAccessToken } = useAuth(); @@ -23,12 +44,15 @@ export function SectionPlatformUsage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(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(api.metrics.globalUsage(), getAccessToken, { + const data = await fetchJson(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() {
Sessions in Window
{metrics.sessions.total.toLocaleString()}
-
-
Today
-
{metrics.sessions.today.toLocaleString()}
-
-
-
Last 7 Days
-
{metrics.sessions.last7Days.toLocaleString()}
-
-
-
Last 30 Days
-
{metrics.sessions.last30Days.toLocaleString()}
-
+ -
-
-
Succeeded
-
{metrics.sessions.succeeded.toLocaleString()}
-
-
-
Failed
-
{metrics.sessions.failed.toLocaleString()}
-
-
-
Incomplete
-
{metrics.sessions.incomplete.toLocaleString()}
-
-
-
In Progress
-
{metrics.sessions.inProgress.toLocaleString()}
-
-
-
Success Rate
-
{metrics.sessions.successRate}%
-
-
+ - {/* Deployment Types */} -
-

Deployment Types

-
-
-
-
-
User Driven
-
{metrics.deploymentTypes.userDriven.toLocaleString()}
-
-
- {metrics.deploymentTypes.userDrivenPercentage}% -
-
-
-
-
-
-
-
-
-
Pre-Provisioned
-
{metrics.deploymentTypes.whiteGlove.toLocaleString()}
-
-
- {metrics.deploymentTypes.whiteGlovePercentage}% -
-
-
-
-
-
-
-
+ - {/* Apps & Scripts */} - {metrics.appScripts && ( -
-

Apps & Scripts

-
-
-
Avg Apps / Session
-
{metrics.appScripts.avgAppsPerSession}
-

{metrics.appScripts.totalUniqueApps.toLocaleString()} unique apps total

-
-
-
Avg Platform Scripts / Session
-
{metrics.appScripts.avgPlatformScriptsPerSession}
-

{metrics.appScripts.totalPlatformScripts.toLocaleString()} executions total

-
-
-
Avg Remediation Scripts / Session
-
{metrics.appScripts.avgRemediationScriptsPerSession}
-

{metrics.appScripts.totalRemediationScripts.toLocaleString()} executions total

-
-
-
- )} + {metrics.appScripts && } {/* Tenant & User Statistics */}
@@ -351,28 +273,7 @@ export function SectionPlatformUsage() {
- {/* Performance Statistics */} -
-

Performance

-
-
-
Average Duration
-
{formatDuration(metrics.performance.avgDurationMinutes)}
-
-
-
Median Duration
-
{formatDuration(metrics.performance.medianDurationMinutes)}
-
-
-
P95 Duration
-
{formatDuration(metrics.performance.p95DurationMinutes)}
-
-
-
P99 Duration
-
{formatDuration(metrics.performance.p99DurationMinutes)}
-
-
-
+ {/* Platform Statistics (Cumulative Since Release) */} {metrics.platformStats && ( @@ -430,68 +331,7 @@ export function SectionPlatformUsage() { )} - {/* Hardware Statistics */} -
- {/* Top Manufacturers */} -
-

Top Manufacturers

-
-
- {metrics.hardware.topManufacturers.length > 0 ? ( - metrics.hardware.topManufacturers.map((item, index) => ( -
-
- {item.name || 'Unknown'} -
- {item.count.toLocaleString()} - ({item.percentage}%) -
-
-
-
-
-
- )) - ) : ( -
No data available
- )} -
-
-
- - {/* Top Models */} -
-

Top Models

-
-
- {metrics.hardware.topModels.length > 0 ? ( - metrics.hardware.topModels.map((item, index) => ( -
-
- {item.name || 'Unknown'} -
- {item.count.toLocaleString()} - ({item.percentage}%) -
-
-
-
-
-
- )) - ) : ( -
No data available
- )} -
-
-
-
+ ); diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/components/TimeAttributionSection.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/components/TimeAttributionSection.tsx index e45e2a95..0b8a4e23 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/components/TimeAttributionSection.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/components/TimeAttributionSection.tsx @@ -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 = { - 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
- {CLASS_LABELS[cls.enrollmentClass] ?? cls.enrollmentClass} + {enrollmentClassLabel(cls.enrollmentClass)} 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
- {CLASS_LABELS[cls.enrollmentClass] ?? cls.enrollmentClass} + {enrollmentClassLabel(cls.enrollmentClass)} n={cls.cleanSessionCount} · median total ≈ {formatMinutes(stackTotal)} @@ -198,7 +182,7 @@ export default function TimeAttributionSection({ data }: { data: TimeAttribution {app.appName || app.appId} - {CLASS_LABELS[cls] ?? cls} + {enrollmentClassLabel(cls)} {app.sessionCount} {formatMinutes(app.medianSeconds)} diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx index dfb8261a..89e53b31 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx @@ -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 = { 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, diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx index f6b17b15..1d7b9934 100644 --- a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -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() {
{metrics.sessions.total.toLocaleString()}

last {metrics.windowDays ?? 90} days

-
-
Today
-
{metrics.sessions.today.toLocaleString()}
-
-
-
Last 7 Days
-
{metrics.sessions.last7Days.toLocaleString()}
-
-
-
Last 30 Days
-
{metrics.sessions.last30Days.toLocaleString()}
-
+
-
-
-
Succeeded
-
{metrics.sessions.succeeded.toLocaleString()}
-
-
-
Failed
-
{metrics.sessions.failed.toLocaleString()}
-
-
-
Incomplete
-
{metrics.sessions.incomplete.toLocaleString()}
-
-
-
In Progress
-
{metrics.sessions.inProgress.toLocaleString()}
-
-
-
Success Rate
-
{metrics.sessions.successRate}%
-
-
+
- {/* Deployment Types */} -
-

Deployment Types

-
-
-
-
-
User Driven
-
{metrics.deploymentTypes.userDriven.toLocaleString()}
-
-
- {metrics.deploymentTypes.userDrivenPercentage}% -
-
-
-
-
-
-
-
-
-
Pre-Provisioned
-
{metrics.deploymentTypes.whiteGlove.toLocaleString()}
-
-
- {metrics.deploymentTypes.whiteGlovePercentage}% -
-
-
-
-
-
-
-
+ - {/* Apps & Scripts */} - {metrics.appScripts && ( -
-

Apps & Scripts

-
-
-
Avg Apps / Session
-
{metrics.appScripts.avgAppsPerSession}
-

{metrics.appScripts.totalUniqueApps.toLocaleString()} unique apps total

-
-
-
Avg Platform Scripts / Session
-
{metrics.appScripts.avgPlatformScriptsPerSession}
-

{metrics.appScripts.totalPlatformScripts.toLocaleString()} executions total

-
-
-
Avg Remediation Scripts / Session
-
{metrics.appScripts.avgRemediationScriptsPerSession}
-

{metrics.appScripts.totalRemediationScripts.toLocaleString()} executions total

-
-
-
- )} + {metrics.appScripts && } {/* User Statistics */}
@@ -355,91 +261,9 @@ function UsageMetricsContent() { )}
- {/* Performance Statistics */} -
-

Performance

-
-
-
Average Duration
-
{formatDuration(metrics.performance.avgDurationMinutes)}
-
-
-
Median Duration
-
{formatDuration(metrics.performance.medianDurationMinutes)}
-
-
-
P95 Duration
-
{formatDuration(metrics.performance.p95DurationMinutes)}
-
-
-
P99 Duration
-
{formatDuration(metrics.performance.p99DurationMinutes)}
-
-
-
+ - {/* Hardware Statistics */} -
- {/* Top Manufacturers */} -
-

Top Manufacturers

-
-
- {metrics.hardware.topManufacturers.length > 0 ? ( - metrics.hardware.topManufacturers.map((item, index) => ( -
-
- {item.name || 'Unknown'} -
- {item.count.toLocaleString()} - ({item.percentage}%) -
-
-
-
-
-
- )) - ) : ( -
No data available
- )} -
-
-
- - {/* Top Models */} -
-

Top Models

-
-
- {metrics.hardware.topModels.length > 0 ? ( - metrics.hardware.topModels.map((item, index) => ( -
-
- {item.name || 'Unknown'} -
- {item.count.toLocaleString()} - ({item.percentage}%) -
-
-
-
-
-
- )) - ) : ( -
No data available
- )} -
-
-
-
+ )}
diff --git a/src/Web/autopilot-monitor-web/components/UsageMetricsBlocks.tsx b/src/Web/autopilot-monitor-web/components/UsageMetricsBlocks.tsx new file mode 100644 index 00000000..fc2db9a9 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/UsageMetricsBlocks.tsx @@ -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 ( +
+
{label}
+
{children}
+ {note != null &&

{note}

} +
+ ); +} + +/** Today / last 7 / last 30 days: three cards for the page's own session grid. */ +export function SessionRecencyCards({ sessions }: { sessions: SessionMetrics }) { + return ( + <> + {sessions.today.toLocaleString()} + {sessions.last7Days.toLocaleString()} + {sessions.last30Days.toLocaleString()} + + ); +} + +export function SessionOutcomeCards({ sessions }: { sessions: SessionMetrics }) { + return ( +
+ {sessions.succeeded.toLocaleString()} + {sessions.failed.toLocaleString()} + + {sessions.incomplete.toLocaleString()} + + {sessions.inProgress.toLocaleString()} + {sessions.successRate}% +
+ ); +} + +function ShareRow({ label, count, percentage, barClass }: { + label: string; + count: number; + percentage: number; + barClass: string; +}) { + return ( +
+
+ {label} +
+ {count.toLocaleString()} + ({percentage}%) +
+
+
+
+
+
+ ); +} + +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 ( +
+

Deployment Types

+
+ {deploymentGroups(deploymentTypes).map((group) => ( +
+
{group.title}
+
+ {group.rows.map((row) => ( + + ))} +
+
+ ))} +
+
+ ); +} + +export function AppScriptsBlock({ appScripts }: { appScripts: AppScriptMetrics }) { + return ( +
+

Apps & Scripts

+
+ + {appScripts.avgAppsPerSession} + + + {appScripts.avgPlatformScriptsPerSession} + + + {appScripts.avgRemediationScriptsPerSession} + +
+
+ ); +} + +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 ( +
+

Performance

+
+ {formatDuration(performance.avgDurationMinutes)} + {formatDuration(performance.medianDurationMinutes)} + {formatDuration(performance.p95DurationMinutes)} + {formatDuration(performance.p99DurationMinutes)} +
+
+ ); +} + +function TopHardwareList({ title, items, barClass }: { title: string; items: HardwareCount[]; barClass: string }) { + return ( +
+

{title}

+
+
+ {items.length > 0 ? ( + items.map((item, index) => ( + + )) + ) : ( +
No data available
+ )} +
+
+
+ ); +} + +export function HardwareBlock({ hardware }: { hardware: HardwareMetrics }) { + return ( +
+ + +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/timeAttributionSegments.ts b/src/Web/autopilot-monitor-web/components/timeAttributionSegments.ts new file mode 100644 index 00000000..efea64b7 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/timeAttributionSegments.ts @@ -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" }, +]; diff --git a/src/Web/autopilot-monitor-web/lib/enrollmentClass.ts b/src/Web/autopilot-monitor-web/lib/enrollmentClass.ts new file mode 100644 index 00000000..0c0b8015 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/enrollmentClass.ts @@ -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)[enrollmentClass] ?? enrollmentClass; +} diff --git a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json index f0d57999..37a0f370 100644 --- a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json @@ -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", diff --git a/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts b/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts index 9f2f9159..4cec9c75 100644 --- a/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts @@ -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; diff --git a/tools/quality/jscpd-baseline.json b/tools/quality/jscpd-baseline.json index 456c6ae3..80e39232 100644 --- a/tools/quality/jscpd-baseline.json +++ b/tools/quality/jscpd-baseline.json @@ -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,