mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
+34
-194
@@ -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>
|
||||
);
|
||||
|
||||
+6
-22
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user