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; }
+ }
+}