diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index da8e2a42..acbef433 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -980 +982 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GeographicLocationSessionsDoTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GeographicLocationSessionsDoTests.cs new file mode 100644 index 00000000..e405d297 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GeographicLocationSessionsDoTests.cs @@ -0,0 +1,111 @@ +using AutopilotMonitor.Functions.Functions.Metrics; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Regression guard: the geographic drilldown must surface per-session DO telemetry +/// so operators can troubleshoot which devices contributed to a region's DO % badge. +/// Formula must stay consistent with GetGeographicMetricsFunction (peers / total * 100, +/// preferring DoTotalBytesDownloaded as denominator, falling back to peers+http). +/// +public class GeographicLocationSessionsDoTests +{ + private static AppInstallSummary App(string sessionId, int downloadMode, long peers, long http, long total, + long lan = 0, long group = 0, long internetPeers = 0) + { + return new AppInstallSummary + { + SessionId = sessionId, + DoDownloadMode = downloadMode, + DoBytesFromPeers = peers, + DoBytesFromHttp = http, + DoTotalBytesDownloaded = total, + DoBytesFromLanPeers = lan, + DoBytesFromGroupPeers = group, + DoBytesFromInternetPeers = internetPeers, + }; + } + + private static SessionSummary Session(string id) => new SessionSummary { SessionId = id }; + + [Fact] + public void BuildRows_SessionWithDoAndNonDoApps_ComputesWeightedPercent() + { + var sessions = new List { Session("S1") }; + var apps = new List + { + App("S1", downloadMode: 0, peers: 50_000_000, http: 50_000_000, total: 100_000_000), + App("S1", downloadMode: 1, peers: 0, http: 100_000_000, total: 100_000_000), + App("S1", downloadMode: -1, peers: 0, http: 0, total: 0), // no DO telemetry + }; + + var rows = GetGeographicLocationSessionsFunction.BuildRows(sessions, apps); + + Assert.Single(rows); + var row = rows[0]; + Assert.True(row.HasDoTelemetry); + Assert.Equal(2, row.DoAppCount); + Assert.Equal(3, row.TotalAppCount); + // 50MB peers / 200MB total = 25% + Assert.Equal(25.0, row.DoPercentPeerCaching); + Assert.Equal(50_000_000, row.DoBytesFromPeers); + Assert.Equal(150_000_000, row.DoBytesFromHttp); + Assert.Equal(200_000_000, row.DoTotalBytesDownloaded); + } + + [Fact] + public void BuildRows_SessionWithoutAppData_MarkedNoTelemetry() + { + var rows = GetGeographicLocationSessionsFunction.BuildRows( + new List { Session("S2") }, + new List()); + + Assert.Single(rows); + Assert.False(rows[0].HasDoTelemetry); + Assert.Equal(0, rows[0].DoAppCount); + Assert.Equal(0, rows[0].TotalAppCount); + Assert.Equal(0, rows[0].DoPercentPeerCaching); + } + + [Fact] + public void BuildRows_FallsBackToPeersPlusHttp_WhenTotalBytesMissing() + { + // Legacy records may not populate DoTotalBytesDownloaded — must fall back + // to peers + http so the denominator isn't zero and the % is still meaningful. + var apps = new List + { + App("S3", downloadMode: 0, peers: 40_000_000, http: 60_000_000, total: 0), + }; + var rows = GetGeographicLocationSessionsFunction.BuildRows( + new List { Session("S3") }, apps); + + Assert.True(rows[0].HasDoTelemetry); + Assert.Equal(40.0, rows[0].DoPercentPeerCaching); + Assert.Equal(100_000_000, rows[0].DoTotalBytesDownloaded); + } + + [Fact] + public void BuildRows_MultipleSessions_AggregatesMatchRegionFormula() + { + // Invariant: sum(session peers) / sum(session total bytes) across the drilldown + // must equal the region-level AvgDoPercentPeerCaching computed by + // GetGeographicMetricsFunction from the same app summaries. + var sessions = new List { Session("A"), Session("B"), Session("C") }; + var apps = new List + { + App("A", 0, peers: 30_000_000, http: 70_000_000, total: 100_000_000), + App("B", 0, peers: 80_000_000, http: 20_000_000, total: 100_000_000), + App("C", -1, 0, 0, 0), + }; + + var rows = GetGeographicLocationSessionsFunction.BuildRows(sessions, apps); + var withDo = rows.Where(r => r.HasDoTelemetry).ToList(); + var totalPeers = withDo.Sum(r => r.DoBytesFromPeers); + var totalBytes = withDo.Sum(r => r.DoTotalBytesDownloaded); + var weighted = totalBytes > 0 ? (double)totalPeers / totalBytes * 100 : 0; + + Assert.Equal(2, withDo.Count); + Assert.Equal(55.0, weighted, precision: 6); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs index f1593274..51054d2f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs @@ -12,11 +12,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics { private readonly ILogger _logger; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly IMetricsRepository _metricsRepo; - public GetGeographicLocationSessionsFunction(ILogger logger, IMaintenanceRepository maintenanceRepo) + public GetGeographicLocationSessionsFunction( + ILogger logger, + IMaintenanceRepository maintenanceRepo, + IMetricsRepository metricsRepo) { _logger = logger; _maintenanceRepo = maintenanceRepo; + _metricsRepo = metricsRepo; } [Function("GetGeographicLocationSessions")] @@ -49,11 +54,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); - var filtered = FilterSessionsByLocation(sessions, locationKey, groupBy); + var appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId); + var rows = BuildRows(filtered, appSummaries); + var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true, sessions = filtered, totalCount = filtered.Count }); + await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count }); return response; } catch (Exception ex) @@ -73,5 +80,22 @@ namespace AutopilotMonitor.Functions.Functions.Metrics .OrderByDescending(s => s.StartedAt) .ToList(); } + + internal static List BuildRows( + List sessions, List appSummaries) + { + var appsBySession = appSummaries + .GroupBy(a => a.SessionId) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + var rows = new List(sessions.Count); + foreach (var s in sessions) + { + var apps = appsBySession.TryGetValue(s.SessionId, out var list) ? list : new List(); + var agg = DoAggregator.Compute(apps); + rows.Add(LocationSessionRow.From(s, agg, apps.Count)); + } + return rows; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs index 46d1d461..432c80c8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs @@ -123,17 +123,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics .Where(s => appsBySession.ContainsKey(s.SessionId)) .SelectMany(s => appsBySession[s.SessionId]) .ToList(); - var doApps = allLocationApps.Where(a => a.DoDownloadMode >= 0).ToList(); + var doAgg = DoAggregator.Compute(allLocationApps); var doSessionCount = group .Where(s => appsBySession.ContainsKey(s.SessionId)) .Count(s => appsBySession[s.SessionId].Any(a => a.DoDownloadMode >= 0)); - var totalDoPeers = doApps.Sum(a => a.DoBytesFromPeers); - var totalDoHttp = doApps.Sum(a => a.DoBytesFromHttp); - // Use DoTotalBytesDownloaded as ground truth denominator (accounts for all sources); - // fall back to peers+http sum for old records without the field - var totalDoDownloaded = doApps.Sum(a => a.DoTotalBytesDownloaded); - var totalDoBytes = totalDoDownloaded > 0 ? totalDoDownloaded : (totalDoPeers + totalDoHttp); - var avgDoPct = totalDoBytes > 0 ? (double)totalDoPeers / totalDoBytes * 100 : 0; locations.Add(new LocationMetrics { @@ -155,12 +148,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics TotalDownloadBytes = totalBytes, // Delivery Optimization DoSessionCount = doSessionCount, - AvgDoPercentPeerCaching = Math.Round(avgDoPct, 1), - TotalDoBytesFromPeers = totalDoPeers, - TotalDoBytesFromHttp = totalDoHttp, - TotalDoBytesFromLanPeers = doApps.Sum(a => a.DoBytesFromLanPeers), - TotalDoBytesFromGroupPeers = doApps.Sum(a => a.DoBytesFromGroupPeers), - TotalDoBytesFromInternetPeers = doApps.Sum(a => a.DoBytesFromInternetPeers) + AvgDoPercentPeerCaching = Math.Round(doAgg.PercentPeerCaching, 1), + TotalDoBytesFromPeers = doAgg.BytesFromPeers, + TotalDoBytesFromHttp = doAgg.BytesFromHttp, + TotalDoBytesFromLanPeers = doAgg.BytesFromLanPeers, + TotalDoBytesFromGroupPeers = doAgg.BytesFromGroupPeers, + TotalDoBytesFromInternetPeers = doAgg.BytesFromInternetPeers }); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs index dc164cd5..74c1cbcb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs @@ -11,13 +11,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics { private readonly ILogger _logger; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly IMetricsRepository _metricsRepo; public GetGlobalGeographicLocationSessionsFunction( ILogger logger, - IMaintenanceRepository maintenanceRepo) + IMaintenanceRepository maintenanceRepo, + IMetricsRepository metricsRepo) { _logger = logger; _maintenanceRepo = maintenanceRepo; + _metricsRepo = metricsRepo; } [Function("GetGlobalGeographicLocationSessions")] @@ -50,11 +53,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); - var filtered = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy); + var appSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(); + var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, appSummaries); + var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true, sessions = filtered, totalCount = filtered.Count }); + await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count }); return response; } catch (Exception ex) diff --git a/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs index 706a4361..9e0f6444 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs @@ -75,6 +75,86 @@ namespace AutopilotMonitor.Shared.Models public long TotalDoBytesFromInternetPeers { get; set; } } + /// + /// Session row returned by the geographic drilldown endpoint. Extends + /// with per-session Delivery Optimization aggregates so the user can troubleshoot DO usage + /// without leaving the drilldown view. + /// + public class LocationSessionRow : SessionSummary + { + /// True if any app in this session has DO telemetry (DoDownloadMode >= 0). + public bool HasDoTelemetry { get; set; } + /// Number of apps in this session that have DO telemetry. + public int DoAppCount { get; set; } + /// Total number of app install summaries recorded for this session. + public int TotalAppCount { get; set; } + /// Weighted peer-caching percentage for this session (0-100). + public double DoPercentPeerCaching { get; set; } + public long DoBytesFromPeers { get; set; } + public long DoBytesFromHttp { get; set; } + public long DoTotalBytesDownloaded { get; set; } + public long DoBytesFromLanPeers { get; set; } + public long DoBytesFromGroupPeers { get; set; } + public long DoBytesFromInternetPeers { get; set; } + + public static LocationSessionRow From(SessionSummary s, DoAggregator.DoAggregate a, int totalAppCount) + { + return new LocationSessionRow + { + // SessionSummary fields + SessionId = s.SessionId, + TenantId = s.TenantId, + SerialNumber = s.SerialNumber, + DeviceName = s.DeviceName, + Manufacturer = s.Manufacturer, + Model = s.Model, + StartedAt = s.StartedAt, + CompletedAt = s.CompletedAt, + CurrentPhase = s.CurrentPhase, + CurrentPhaseDetail = s.CurrentPhaseDetail, + Status = s.Status, + FailureReason = s.FailureReason, + FailureSource = s.FailureSource, + EventCount = s.EventCount, + DurationSeconds = s.DurationSeconds, + EnrollmentType = s.EnrollmentType, + DiagnosticsBlobName = s.DiagnosticsBlobName, + LastEventAt = s.LastEventAt, + IsPreProvisioned = s.IsPreProvisioned, + ResumedAt = s.ResumedAt, + StalledAt = s.StalledAt, + IsHybridJoin = s.IsHybridJoin, + OsName = s.OsName, + OsBuild = s.OsBuild, + OsDisplayVersion = s.OsDisplayVersion, + OsEdition = s.OsEdition, + OsLanguage = s.OsLanguage, + IsUserDriven = s.IsUserDriven, + AgentVersion = s.AgentVersion, + ImeAgentVersion = s.ImeAgentVersion, + GeoCountry = s.GeoCountry, + GeoRegion = s.GeoRegion, + GeoCity = s.GeoCity, + GeoLoc = s.GeoLoc, + PlatformScriptCount = s.PlatformScriptCount, + RemediationScriptCount = s.RemediationScriptCount, + PendingActionsJson = s.PendingActionsJson, + PendingActionsQueuedAt = s.PendingActionsQueuedAt, + // DO aggregate + HasDoTelemetry = a.HasTelemetry, + DoAppCount = a.DoAppCount, + TotalAppCount = totalAppCount, + DoPercentPeerCaching = System.Math.Round(a.PercentPeerCaching, 1), + DoBytesFromPeers = a.BytesFromPeers, + DoBytesFromHttp = a.BytesFromHttp, + DoTotalBytesDownloaded = a.TotalBytesDownloaded, + DoBytesFromLanPeers = a.BytesFromLanPeers, + DoBytesFromGroupPeers = a.BytesFromGroupPeers, + DoBytesFromInternetPeers = a.BytesFromInternetPeers, + }; + } + } + /// /// Global average benchmarks for geographic comparison. /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/DoAggregator.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/DoAggregator.cs new file mode 100644 index 00000000..f9c5bcab --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/DoAggregator.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Computes Delivery Optimization peer-caching aggregates from a collection of + /// records. Single source of truth shared between + /// the region-level aggregate (GetGeographicMetricsFunction) and the per-session + /// aggregate surfaced by the drilldown endpoint. + /// + public static class DoAggregator + { + public class DoAggregate + { + public int DoAppCount { get; set; } + public long BytesFromPeers { get; set; } + public long BytesFromHttp { get; set; } + public long TotalBytesDownloaded { get; set; } + public long BytesFromLanPeers { get; set; } + public long BytesFromGroupPeers { get; set; } + public long BytesFromInternetPeers { get; set; } + + public bool HasTelemetry => DoAppCount > 0; + public double PercentPeerCaching => TotalBytesDownloaded > 0 + ? (double)BytesFromPeers / TotalBytesDownloaded * 100 + : 0; + } + + public static DoAggregate Compute(IEnumerable apps) + { + var doApps = apps.Where(a => a.DoDownloadMode >= 0).ToList(); + var peers = doApps.Sum(a => a.DoBytesFromPeers); + var http = doApps.Sum(a => a.DoBytesFromHttp); + var downloaded = doApps.Sum(a => a.DoTotalBytesDownloaded); + return new DoAggregate + { + DoAppCount = doApps.Count, + BytesFromPeers = peers, + BytesFromHttp = http, + TotalBytesDownloaded = downloaded > 0 ? downloaded : (peers + http), + BytesFromLanPeers = doApps.Sum(a => a.DoBytesFromLanPeers), + BytesFromGroupPeers = doApps.Sum(a => a.DoBytesFromGroupPeers), + BytesFromInternetPeers = doApps.Sum(a => a.DoBytesFromInternetPeers), + }; + } + } +} diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx index e2b477d9..b6a72907 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/sessions/page.tsx @@ -22,8 +22,26 @@ interface SessionSummary { failureReason: string | null; durationSeconds: number | null; enrollmentType: string; + // Per-session Delivery Optimization aggregate (added by geographic drilldown endpoint) + hasDoTelemetry?: boolean; + doAppCount?: number; + totalAppCount?: number; + doPercentPeerCaching?: number; + doBytesFromPeers?: number; + doBytesFromHttp?: number; + doTotalBytesDownloaded?: number; + doBytesFromLanPeers?: number; + doBytesFromGroupPeers?: number; + doBytesFromInternetPeers?: number; } +const formatBytes = (bytes: number) => { + if (!bytes || bytes <= 0) return "—"; + if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`; + return `${(bytes / 1024).toFixed(0)} KB`; +}; + interface LocationSessionsResponse { success: boolean; sessions: SessionSummary[]; @@ -139,6 +157,14 @@ function LocationSessionsContent() { .map((s) => s.durationSeconds!); return durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length / 60) : 0; })(), + // Weighted peer-caching % across sessions with DO telemetry (matches region aggregate formula) + doSessions: data.sessions.filter((s) => s.hasDoTelemetry).length, + avgDoPercent: (() => { + const doSessions = data.sessions.filter((s) => s.hasDoTelemetry); + const totalPeers = doSessions.reduce((a, s) => a + (s.doBytesFromPeers ?? 0), 0); + const totalBytes = doSessions.reduce((a, s) => a + (s.doTotalBytesDownloaded ?? 0), 0); + return totalBytes > 0 ? Math.round((totalPeers / totalBytes) * 1000) / 10 : 0; + })(), } : null; @@ -191,7 +217,7 @@ function LocationSessionsContent() {
{/* Summary Stats */} {stats && ( -
+
Sessions
{stats.total}
@@ -219,6 +245,20 @@ function LocationSessionsContent() {
succeeded sessions
+
+
DO Peers
+
= 50 ? "text-green-600" : + stats.avgDoPercent >= 10 ? "text-yellow-600" : + "text-gray-600" + }`}> + {stats.doSessions > 0 ? `${stats.avgDoPercent}%` : "—"} +
+
+ {stats.doSessions > 0 ? `${stats.doSessions} of ${stats.total} sessions` : "no DO telemetry"} +
+
)} @@ -253,6 +293,9 @@ function LocationSessionsContent() { Duration + + DO Peers + Started At @@ -280,6 +323,42 @@ function LocationSessionsContent() { {formatDuration(session.durationSeconds)} + + {session.hasDoTelemetry ? ( +
+ = 50 + ? "bg-green-100 text-green-800" + : (session.doPercentPeerCaching ?? 0) >= 10 + ? "bg-yellow-100 text-yellow-800" + : "bg-gray-100 text-gray-600" + }`} + > + {(session.doPercentPeerCaching ?? 0).toFixed(1)}% + + + ({session.doAppCount}/{session.totalAppCount}) + +
+ ) : ( + 0 + ? `${session.totalAppCount} app(s) installed, no DO telemetry` + : "no app install data" + } + > + — + + )} + {formatDate(session.startedAt)}