diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs new file mode 100644 index 00000000..c8b970e1 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FleetHealthPayloadTests.cs @@ -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; + +/// +/// Covers the server-side Fleet Health aggregation in +/// , 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). +/// +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(), 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 + { + 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 + { + 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(); + // 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 { 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 + { + 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 + { + // 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(); + // 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 + { + 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs new file mode 100644 index 00000000..ee4057ac --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs @@ -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 +{ + /// + /// 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. + /// + public class GetFleetHealthMetricsFunction + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepo; + + public GetFleetHealthMetricsFunction( + ILogger logger, + ISessionRepository sessionRepo) + { + _logger = logger; + _sessionRepo = sessionRepo; + } + + [Function("GetFleetHealthMetrics")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs new file mode 100644 index 00000000..956c806e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs @@ -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 +{ + /// + /// Global-admin Fleet Health aggregation. Optional ?tenantId= scopes to one tenant + /// (cheaper per-tenant index scan); omitted means all tenants. Shares the aggregation with + /// the tenant function — see . + /// + public class GetGlobalFleetHealthMetricsFunction + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepo; + + public GetGlobalFleetHealthMetricsFunction( + ILogger logger, + ISessionRepository sessionRepo) + { + _logger = logger; + _sessionRepo = sessionRepo; + } + + [Function("GetGlobalFleetHealthMetrics")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs index acd43a91..0d69c997 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs @@ -88,6 +88,186 @@ public static class MetricsMath }; } + /// + /// Builds the complete Fleet Health response from the (already time-windowed) session list. + /// Single source of truth for the tenant (metrics/fleet-health) and global + /// (global/metrics/fleet-health) 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). + /// + public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList 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, + }; + } + + /// + /// 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). + /// + private static List BuildFleetDailyData(IReadOnlyList sessions, int days) + { + var buckets = new Dictionary(); + 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(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 BuildFleetFailureReasons(IReadOnlyList sessions) + { + var reasons = new Dictionary(); + 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 BuildFleetModelHealth(IReadOnlyList sessions) + { + var models = new Dictionary(); + 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 BuildFleetSlowestModels(IReadOnlyList sessions) + { + var acc = new Dictionary(); + 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 BuildFleetTopFailingModels(IReadOnlyList sessions) + { + var acc = new Dictionary(); + 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(); + } + + /// "{Manufacturer} {Model}" trimmed, or "Unknown" when both are blank. + private static string FleetModelKey(SessionSummary s) + { + var key = $"{s.Manufacturer} {s.Model}".Trim(); + return string.IsNullOrEmpty(key) ? "Unknown" : key; + } + /// Share of total bytes (0-100, one decimal) not pulled from the CDN. 0 when no bytes. private static double OffloadPercent(long offloaded, long total) => total > 0 ? Math.Round((double)offloaded / total * 100, 1) : 0; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 2a7f75e0..69eb588b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs new file mode 100644 index 00000000..c6f6bfe6 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// 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 MetricsMath.BuildFleetHealthPayload. + /// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the + /// client; this payload carries the data, not the formatting. + /// + public sealed class FleetHealthMetrics + { + public bool Success { get; set; } = true; + public int Days { get; set; } + public FleetHealthStats Stats { get; set; } = new(); + /// One entry per day in the window, oldest-first. Date is UTC "yyyy-MM-dd". + public List DailyData { get; set; } = new(); + /// Top failure reasons by count (descending). + public List FailureReasons { get; set; } = new(); + /// Device models by enrollment volume (descending). + public List ModelHealth { get; set; } = new(); + /// Device models by average successful enrollment duration (descending). + public List SlowestModels { get; set; } = new(); + /// Device models by failure count (descending). + public List 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; } + /// Succeeded / total * 100 (one decimal). Note: over all sessions, not just terminal. + public double SuccessRate { get; set; } + /// Average duration in minutes over non-in-progress sessions that carry a duration. + 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; } + } +}