mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user