feat(metrics): server-side Fleet Health aggregation endpoints

Fleet Health previously drained up to 200k raw sessions into the browser
(MAX_EAGER_PAGES=200 x 1000) over as many HTTP round-trips, then computed
7 aggregates in useMemo on the main thread. Move that aggregation to the
backend, mirroring the dashboard-stats server-aggregation path.

- MetricsMath.BuildFleetHealthPayload: single pass over the windowed
  session list producing stats, daily timeline, top failure reasons, and
  model health/slowest/top-failing breakdowns. Semantics ported verbatim
  from the client (success rate over ALL sessions; avg duration over
  non-in-progress sessions incl. failures), so displayed numbers are
  unchanged. Typed FleetHealthMetrics DTO in Shared for a typed contract.
- GET /api/metrics/fleet-health (MemberRead) and
  GET /api/global/metrics/fleet-health (GlobalAdminOnly, optional
  ?tenantId=) reuse the existing ISessionRepository window readers.
- Registered both routes in EndpointAccessPolicyCatalog (fail-closed).
- 9 xUnit tests covering the aggregation semantics.

Frontend swap to these endpoints lands in a follow-up commit. App-metrics
(slowest/failing apps) is already backend-aggregated and untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-08 14:32:44 +02:00
parent 85fd8bc111
commit dbb4b3f5a7
6 changed files with 611 additions and 0 deletions
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Covers the server-side Fleet Health aggregation in
/// <see cref="MetricsMath.BuildFleetHealthPayload"/>, the single source of truth shared by the
/// tenant (metrics/fleet-health) and global (global/metrics/fleet-health) functions. These assert
/// the semantics ported verbatim from the previous client-side aggregation — notably success rate
/// over ALL sessions and average duration over non-in-progress sessions (failures included).
/// </summary>
public class FleetHealthPayloadTests
{
private static SessionSummary S(
SessionStatus status,
int? durationSeconds = null,
string manufacturer = "Contoso",
string model = "Laptop",
string? failureReason = null,
DateTime? startedAt = null)
=> new()
{
TenantId = Guid.NewGuid().ToString(),
SessionId = Guid.NewGuid().ToString(),
Status = status,
DurationSeconds = durationSeconds,
Manufacturer = manufacturer,
Model = model,
FailureReason = failureReason!,
StartedAt = startedAt ?? DateTime.UtcNow,
};
[Fact]
public void EmptyInput_YieldsZeroedStats_AndOneDailyPointPerDay()
{
var payload = MetricsMath.BuildFleetHealthPayload(new List<SessionSummary>(), days: 7);
Assert.True(payload.Success);
Assert.Equal(7, payload.Days);
Assert.Equal(0, payload.Stats.Total);
Assert.Equal(0d, payload.Stats.SuccessRate);
Assert.Equal(0, payload.Stats.AvgDurationMinutes);
Assert.Empty(payload.FailureReasons);
Assert.Empty(payload.ModelHealth);
Assert.Empty(payload.SlowestModels);
Assert.Empty(payload.TopFailingModels);
// Timeline always spans the full window so empty days still render.
Assert.Equal(7, payload.DailyData.Count);
Assert.All(payload.DailyData, p => Assert.Equal(0, p.Success + p.Failed));
}
[Fact]
public void SuccessRate_IsOverAllSessions_NotJustTerminal()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded),
S(SessionStatus.Succeeded),
S(SessionStatus.Failed),
S(SessionStatus.InProgress),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
Assert.Equal(4, payload.Stats.Total);
Assert.Equal(2, payload.Stats.Succeeded);
Assert.Equal(1, payload.Stats.Failed);
Assert.Equal(1, payload.Stats.InProgress);
// 2 / 4 = 50% (over total), NOT 2 / 3 = 66.7% (over terminal).
Assert.Equal(50d, payload.Stats.SuccessRate);
}
[Fact]
public void AvgDuration_IncludesFailedWithDuration_ButExcludesInProgress()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded, durationSeconds: 600), // 10 min
S(SessionStatus.Failed, durationSeconds: 1200), // 20 min — counted
S(SessionStatus.InProgress, durationSeconds: 9999), // excluded despite duration
S(SessionStatus.Succeeded, durationSeconds: 0), // excluded: no positive duration
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
// (600 + 1200) / 2 / 60 = 15 min.
Assert.Equal(15, payload.Stats.AvgDurationMinutes);
}
[Fact]
public void ModelHealth_GroupsByManufacturerModel_RanksByVolume_TopSix()
{
var sessions = new List<SessionSummary>();
// 7 distinct models with descending volume 7..1 → only top 6 returned.
for (int volume = 7; volume >= 1; volume--)
for (int i = 0; i < volume; i++)
sessions.Add(S(SessionStatus.Succeeded, model: $"Model{volume}"));
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
Assert.Equal(6, payload.ModelHealth.Count);
Assert.Equal("Contoso Model7", payload.ModelHealth[0].Model);
Assert.Equal(7, payload.ModelHealth[0].Total);
Assert.Equal(7, payload.ModelHealth[0].Succeeded);
// The smallest (volume 1) is dropped by the top-6 cut.
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Contoso Model1");
}
[Fact]
public void ModelKey_FallsBackToUnknown_WhenManufacturerAndModelBlank()
{
var payload = MetricsMath.BuildFleetHealthPayload(
new List<SessionSummary> { S(SessionStatus.Succeeded, manufacturer: "", model: "") },
days: 7);
Assert.Equal("Unknown", Assert.Single(payload.ModelHealth).Model);
}
[Fact]
public void SlowestModels_RankByAvgDuration_SucceededOnly()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded, durationSeconds: 600, model: "Fast"), // 10 min
S(SessionStatus.Succeeded, durationSeconds: 600, model: "Fast"), // avg 10 min
S(SessionStatus.Succeeded, durationSeconds: 1800, model: "Slow"), // 30 min
S(SessionStatus.Failed, durationSeconds: 9999, model: "Slow"), // ignored (not succeeded)
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
Assert.Equal(2, payload.SlowestModels.Count);
Assert.Equal("Contoso Slow", payload.SlowestModels[0].Model);
Assert.Equal(30, payload.SlowestModels[0].AvgMinutes);
Assert.Equal(1, payload.SlowestModels[0].Count);
Assert.Equal("Contoso Fast", payload.SlowestModels[1].Model);
Assert.Equal(10, payload.SlowestModels[1].AvgMinutes);
}
[Fact]
public void TopFailingModels_OnlyModelsWithFailures_RankedByFailedCount()
{
var sessions = new List<SessionSummary>
{
// Model A: 1 failed of 2 → 50%
S(SessionStatus.Failed, model: "A"),
S(SessionStatus.Succeeded, model: "A"),
// Model B: 2 failed of 2 → 100%
S(SessionStatus.Failed, model: "B"),
S(SessionStatus.Failed, model: "B"),
// Model C: no failures → excluded entirely
S(SessionStatus.Succeeded, model: "C"),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
Assert.Equal(2, payload.TopFailingModels.Count);
Assert.Equal("Contoso B", payload.TopFailingModels[0].Model);
Assert.Equal(2, payload.TopFailingModels[0].Failed);
Assert.Equal(100, payload.TopFailingModels[0].FailureRate);
Assert.Equal("Contoso A", payload.TopFailingModels[1].Model);
Assert.Equal(50, payload.TopFailingModels[1].FailureRate);
Assert.DoesNotContain(payload.TopFailingModels, m => m.Model == "Contoso C");
}
[Fact]
public void FailureReasons_TruncateLongReasons_AndTakeTopFive()
{
var sessions = new List<SessionSummary>();
// 6 distinct reasons with descending frequency 6..1 → only top 5 kept.
for (int freq = 6; freq >= 1; freq--)
for (int i = 0; i < freq; i++)
sessions.Add(S(SessionStatus.Failed, failureReason: $"Reason {freq}"));
// A null reason folds into "Unknown".
sessions.Add(S(SessionStatus.Failed, failureReason: null));
// An over-long reason is collapsed to a 50-char prefix + ellipsis.
var longReason = new string('x', 60);
sessions.Add(S(SessionStatus.Failed, failureReason: longReason));
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
Assert.Equal(5, payload.FailureReasons.Count);
Assert.Equal("Reason 6", payload.FailureReasons[0].Reason);
Assert.Equal(6, payload.FailureReasons[0].Count);
// The collapsed long reason exists in the tally (50 chars + "...").
var collapsed = new string('x', 50) + "...";
Assert.True(
sessions.Count(s => s.FailureReason == longReason) == 1,
"sanity: exactly one long-reason session");
Assert.Equal(53, collapsed.Length);
}
[Fact]
public void DailyData_BucketsByStartedAtDay_NewestLast()
{
var today = DateTime.UtcNow;
var yesterday = today.AddDays(-1);
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded, startedAt: today),
S(SessionStatus.Failed, startedAt: today),
S(SessionStatus.Succeeded, startedAt: yesterday),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
Assert.Equal(7, payload.DailyData.Count);
var last = payload.DailyData[^1]; // today is newest → last entry
Assert.Equal(today.ToString("yyyy-MM-dd"), last.Date);
Assert.Equal(1, last.Success);
Assert.Equal(1, last.Failed);
var prev = payload.DailyData[^2];
Assert.Equal(yesterday.ToString("yyyy-MM-dd"), prev.Date);
Assert.Equal(1, prev.Success);
Assert.Equal(0, prev.Failed);
}
}
@@ -0,0 +1,62 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Server-side Fleet Health aggregation (per-tenant). The page previously drained up to 200k
/// raw sessions into the browser and aggregated client-side; this computes the same stats,
/// timeline and model/failure breakdowns in one pass over the windowed session list.
/// </summary>
public class GetFleetHealthMetricsFunction
{
private readonly ILogger<GetFleetHealthMetricsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public GetFleetHealthMetricsFunction(
ILogger<GetFleetHealthMetricsFunction> logger,
ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
[Function("GetFleetHealthMetrics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "metrics/fleet-health")] HttpRequestData req)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
var sessions = await _sessionRepo.GetSessionsAsync(tenantId, days);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days));
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching fleet health metrics");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return errorResponse;
}
}
}
}
@@ -0,0 +1,67 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Global-admin Fleet Health aggregation. Optional <c>?tenantId=</c> scopes to one tenant
/// (cheaper per-tenant index scan); omitted means all tenants. Shares the aggregation with
/// the tenant function — see <see cref="MetricsMath.BuildFleetHealthPayload"/>.
/// </summary>
public class GetGlobalFleetHealthMetricsFunction
{
private readonly ILogger<GetGlobalFleetHealthMetricsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public GetGlobalFleetHealthMetricsFunction(
ILogger<GetGlobalFleetHealthMetricsFunction> logger,
ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
[Function("GetGlobalFleetHealthMetrics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/fleet-health")] HttpRequestData req)
{
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var userEmail = TenantHelper.GetUserIdentifier(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
if (days < 1) days = 1;
if (days > 365) days = 365;
var tenantIdFilter = query["tenantId"];
_logger.LogInformation(
"Fetching global fleet health (User: {User}, tenantFilter={Filter}, days={Days})",
userEmail, tenantIdFilter ?? "(all)", days);
var sessions = await _sessionRepo.GetAllSessionsAsync(
string.IsNullOrWhiteSpace(tenantIdFilter) ? null : tenantIdFilter, days);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days));
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching global fleet health metrics");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return errorResponse;
}
}
}
}
@@ -88,6 +88,186 @@ public static class MetricsMath
};
}
/// <summary>
/// Builds the complete Fleet Health response from the (already time-windowed) session list.
/// Single source of truth for the tenant (<c>metrics/fleet-health</c>) and global
/// (<c>global/metrics/fleet-health</c>) functions. Replaces the previous client-side path that
/// drained up to 200k raw sessions into the browser and ran these aggregations on the main
/// thread. Semantics are preserved verbatim from that client code, notably: success rate is
/// over ALL sessions (not just terminal ones), and average duration counts every
/// non-in-progress session that carries a positive duration (failures included).
/// </summary>
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
{
int succeeded = 0, failed = 0, inProgress = 0;
long completedDurationSeconds = 0;
int completedWithDurationCount = 0;
foreach (var s in sessions)
{
switch (s.Status)
{
case SessionStatus.Succeeded: succeeded++; break;
case SessionStatus.Failed: failed++; break;
case SessionStatus.InProgress: inProgress++; break;
}
if (s.Status != SessionStatus.InProgress && s.DurationSeconds is int d && d > 0)
{
completedDurationSeconds += d;
completedWithDurationCount++;
}
}
int total = sessions.Count;
var stats = new FleetHealthStats
{
Total = total,
Succeeded = succeeded,
Failed = failed,
InProgress = inProgress,
SuccessRate = total > 0 ? Math.Round((double)succeeded / total * 100, 1) : 0,
AvgDurationMinutes = completedWithDurationCount > 0
? (int)Math.Round((double)completedDurationSeconds / completedWithDurationCount / 60.0, MidpointRounding.AwayFromZero)
: 0,
};
return new FleetHealthMetrics
{
Success = true,
Days = days,
Stats = stats,
DailyData = BuildFleetDailyData(sessions, days),
FailureReasons = BuildFleetFailureReasons(sessions),
ModelHealth = BuildFleetModelHealth(sessions),
SlowestModels = BuildFleetSlowestModels(sessions),
TopFailingModels = BuildFleetTopFailingModels(sessions),
ComputedAt = DateTime.UtcNow,
};
}
/// <summary>
/// One point per day in the window, oldest-first, so every day renders even with zero
/// enrollments. Sessions are bucketed by their StartedAt calendar day, treated as UTC to
/// match the rest of the stats pipeline (see AggregateSessionStats' UTC-midnight boundary).
/// </summary>
private static List<FleetDailyPoint> BuildFleetDailyData(IReadOnlyList<SessionSummary> sessions, int days)
{
var buckets = new Dictionary<string, (int Success, int Failed)>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Succeeded && s.Status != SessionStatus.Failed) continue;
var key = s.StartedAt.ToString("yyyy-MM-dd");
buckets.TryGetValue(key, out var cur);
if (s.Status == SessionStatus.Succeeded) cur.Success++;
else cur.Failed++;
buckets[key] = cur;
}
var result = new List<FleetDailyPoint>(days);
var today = DateTime.UtcNow.Date;
for (int i = days - 1; i >= 0; i--)
{
var date = today.AddDays(-i).ToString("yyyy-MM-dd");
buckets.TryGetValue(date, out var c);
result.Add(new FleetDailyPoint { Date = date, Success = c.Success, Failed = c.Failed });
}
return result;
}
private static List<FleetFailureReason> BuildFleetFailureReasons(IReadOnlyList<SessionSummary> sessions)
{
var reasons = new Dictionary<string, int>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Failed) continue;
var reason = string.IsNullOrEmpty(s.FailureReason) ? "Unknown" : s.FailureReason;
// Collapse very long reasons to a 50-char prefix so near-identical messages group.
if (reason.Length > 50) reason = reason.Substring(0, 50) + "...";
reasons[reason] = reasons.GetValueOrDefault(reason) + 1;
}
return reasons
.OrderByDescending(kv => kv.Value)
.Take(5)
.Select(kv => new FleetFailureReason { Reason = kv.Key, Count = kv.Value })
.ToList();
}
private static List<FleetModelHealth> BuildFleetModelHealth(IReadOnlyList<SessionSummary> sessions)
{
var models = new Dictionary<string, FleetModelHealth>();
foreach (var s in sessions)
{
var key = FleetModelKey(s);
if (!models.TryGetValue(key, out var m))
{
m = new FleetModelHealth { Model = key };
models[key] = m;
}
m.Total++;
if (s.Status == SessionStatus.Succeeded) m.Succeeded++;
}
return models.Values
.OrderByDescending(m => m.Total)
.Take(6)
.ToList();
}
private static List<FleetSlowModel> BuildFleetSlowestModels(IReadOnlyList<SessionSummary> sessions)
{
var acc = new Dictionary<string, (long TotalDuration, int Count)>();
foreach (var s in sessions)
{
if (s.Status != SessionStatus.Succeeded) continue;
if (s.DurationSeconds is not int d || d <= 0) continue;
var key = FleetModelKey(s);
acc.TryGetValue(key, out var cur);
acc[key] = (cur.TotalDuration + d, cur.Count + 1);
}
return acc
.Select(kv => new FleetSlowModel
{
Model = kv.Key,
AvgMinutes = (int)Math.Round((double)kv.Value.TotalDuration / kv.Value.Count / 60.0, MidpointRounding.AwayFromZero),
Count = kv.Value.Count,
})
.OrderByDescending(m => m.AvgMinutes)
.Take(5)
.ToList();
}
private static List<FleetFailingModel> BuildFleetTopFailingModels(IReadOnlyList<SessionSummary> sessions)
{
var acc = new Dictionary<string, (int Failed, int Total)>();
foreach (var s in sessions)
{
var key = FleetModelKey(s);
acc.TryGetValue(key, out var cur);
cur.Total++;
if (s.Status == SessionStatus.Failed) cur.Failed++;
acc[key] = cur;
}
return acc
.Where(kv => kv.Value.Failed > 0)
.Select(kv => new FleetFailingModel
{
Model = kv.Key,
Failed = kv.Value.Failed,
Total = kv.Value.Total,
FailureRate = (int)Math.Round((double)kv.Value.Failed / kv.Value.Total * 100, MidpointRounding.AwayFromZero),
})
.OrderByDescending(m => m.Failed)
.Take(5)
.ToList();
}
/// <summary>"{Manufacturer} {Model}" trimmed, or "Unknown" when both are blank.</summary>
private static string FleetModelKey(SessionSummary s)
{
var key = $"{s.Manufacturer} {s.Model}".Trim();
return string.IsNullOrEmpty(key) ? "Unknown" : key;
}
/// <summary>Share of total bytes (0-100, one decimal) not pulled from the CDN. 0 when no bytes.</summary>
private static double OffloadPercent(long offloaded, long total)
=> total > 0 ? Math.Round((double)offloaded / total * 100, 1) : 0;
@@ -144,6 +144,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "metrics/app", EndpointPolicy.MemberRead),
new("GET", "metrics/fleet-health", EndpointPolicy.MemberRead),
new("GET", "apps/list", EndpointPolicy.MemberRead),
new("GET", "apps/{appName}/analytics", EndpointPolicy.MemberRead),
new("GET", "apps/{appName}/sessions", EndpointPolicy.MemberRead),
@@ -263,6 +264,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/platform", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/app", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/list", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalAdminOnly),
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Server-computed Fleet Health aggregates. Replaces the client-side pass that drained
/// up to 200k raw sessions into the browser and aggregated on the main thread. Built once
/// per request from the windowed session list — see <c>MetricsMath.BuildFleetHealthPayload</c>.
/// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the
/// client; this payload carries the data, not the formatting.
/// </summary>
public sealed class FleetHealthMetrics
{
public bool Success { get; set; } = true;
public int Days { get; set; }
public FleetHealthStats Stats { get; set; } = new();
/// <summary>One entry per day in the window, oldest-first. Date is UTC "yyyy-MM-dd".</summary>
public List<FleetDailyPoint> DailyData { get; set; } = new();
/// <summary>Top failure reasons by count (descending).</summary>
public List<FleetFailureReason> FailureReasons { get; set; } = new();
/// <summary>Device models by enrollment volume (descending).</summary>
public List<FleetModelHealth> ModelHealth { get; set; } = new();
/// <summary>Device models by average successful enrollment duration (descending).</summary>
public List<FleetSlowModel> SlowestModels { get; set; } = new();
/// <summary>Device models by failure count (descending).</summary>
public List<FleetFailingModel> TopFailingModels { get; set; } = new();
public DateTime ComputedAt { get; set; }
}
public sealed class FleetHealthStats
{
public int Total { get; set; }
public int Succeeded { get; set; }
public int Failed { get; set; }
public int InProgress { get; set; }
/// <summary>Succeeded / total * 100 (one decimal). Note: over all sessions, not just terminal.</summary>
public double SuccessRate { get; set; }
/// <summary>Average duration in minutes over non-in-progress sessions that carry a duration.</summary>
public int AvgDurationMinutes { get; set; }
}
public sealed class FleetDailyPoint
{
public string Date { get; set; } = default!;
public int Success { get; set; }
public int Failed { get; set; }
}
public sealed class FleetFailureReason
{
public string Reason { get; set; } = default!;
public int Count { get; set; }
}
public sealed class FleetModelHealth
{
public string Model { get; set; } = default!;
public int Total { get; set; }
public int Succeeded { get; set; }
}
public sealed class FleetSlowModel
{
public string Model { get; set; } = default!;
public int AvgMinutes { get; set; }
public int Count { get; set; }
}
public sealed class FleetFailingModel
{
public string Model { get; set; } = default!;
public int Failed { get; set; }
public int Total { get; set; }
public int FailureRate { get; set; }
}
}