diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GeoMetricsProjectionEquivalenceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GeoMetricsProjectionEquivalenceTests.cs new file mode 100644 index 00000000..a9e9ab9e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GeoMetricsProjectionEquivalenceTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Functions.Metrics; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins that the column-projected scans driving the geographic endpoints produce identical +/// aggregation inputs to the full-row drains — sessions via GeoMetricsSessionProjection +/// (Geo* grouping fields + effective-duration inputs) and app installs via +/// GeoAppInstallProjection (join key, window filter, throughput inputs and every counter +/// sums). As in the sibling equivalence suites, the "projected" row is +/// built with the projected keys ONLY, exactly like a live $select response. +/// +public class GeoMetricsProjectionEquivalenceTests +{ + private const string TenantId = "00000000-0000-0000-0000-000000000fe0"; + + private static readonly TableStorageService Sut = + new(new Mock().Object, NullLogger.Instance); + + private static TableEntity Project(TableEntity full, string[] projection) + { + var keep = new HashSet(projection, StringComparer.Ordinal); + var projected = new TableEntity(full.PartitionKey, full.RowKey); + foreach (var kv in full) + { + if (keep.Contains(kv.Key)) + projected[kv.Key] = kv.Value; + } + return projected; + } + + // ===== Sessions (map aggregation) ===== + + private static TableEntity FullSessionRow(string sessionId, string status, DateTime startedAt, int? durationSeconds) + { + var e = new TableEntity(TenantId, sessionId) + { + ["Status"] = status, + ["StartedAt"] = new DateTimeOffset(startedAt), + ["GeoCountry"] = "DE", + ["GeoRegion"] = "Hessen", + ["GeoCity"] = "Frankfurt", + ["GeoLoc"] = "50.11,8.68", + ["IsPreProvisioned"] = false, + // Noise the geo aggregation never reads. + ["SerialNumber"] = "SN-FULL", + ["Manufacturer"] = "Contoso", + ["OsName"] = "Windows 11", + ["FailureSnapshotJson"] = "{\"big\":\"" + new string('x', 2000) + "\"}", + }; + if (durationSeconds.HasValue) e["DurationSeconds"] = durationSeconds.Value; + return e; + } + + [Fact] + public void Session_projection_preserves_geo_grouping_and_duration_inputs() + { + var full = FullSessionRow("s-geo", "Succeeded", DateTime.UtcNow.AddHours(-4), durationSeconds: 2400); + var fromFull = Sut.MapToSessionSummary(full); + var fromProjected = Sut.MapToSessionSummary(Project(full, TableStorageService.GeoMetricsSessionProjection)); + + Assert.Equal(fromFull.TenantId, fromProjected.TenantId); + Assert.Equal(fromFull.SessionId, fromProjected.SessionId); + Assert.Equal(fromFull.Status, fromProjected.Status); + Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt); + Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds); + Assert.Equal(fromFull.GeoCountry, fromProjected.GeoCountry); + Assert.Equal(fromFull.GeoRegion, fromProjected.GeoRegion); + Assert.Equal(fromFull.GeoCity, fromProjected.GeoCity); + Assert.Equal(fromFull.GeoLoc, fromProjected.GeoLoc); + + // The grouping key must be identical too — it is composed from the Geo* fields. + Assert.Equal( + GetGeographicMetricsFunction.GetLocationKey(fromFull, "city"), + GetGeographicMetricsFunction.GetLocationKey(fromProjected, "city")); + } + + // ===== App installs (throughput + Delivery Optimization) ===== + + private static TableEntity FullAppRow(string sessionId) + { + return new TableEntity(TenantId, Guid.NewGuid().ToString()) + { + ["SessionId"] = sessionId, + ["AppName"] = "Contoso App", + ["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-3)), + ["DownloadBytes"] = 250_000_000L, + ["DownloadDurationSeconds"] = 42, + ["DoDownloadMode"] = 1, + ["DoTotalBytesDownloaded"] = 250_000_000L, + ["DoBytesFromPeers"] = 100_000_000L, + ["DoBytesFromHttp"] = 150_000_000L, + ["DoBytesFromLanPeers"] = 60_000_000L, + ["DoBytesFromGroupPeers"] = 30_000_000L, + ["DoBytesFromInternetPeers"] = 10_000_000L, + ["DoBytesFromLinkLocalPeers"] = 0L, + ["DoBytesFromCacheServer"] = 5_000_000L, + // Noise the geo aggregation never reads. + ["Status"] = "Succeeded", + ["FailureMessage"] = new string('y', 500), + ["DetectionResult"] = "Detected", + }; + } + + [Fact] + public void App_projection_preserves_throughput_inputs_and_DoAggregator_result() + { + var full = FullAppRow("s-geo"); + var fromFull = Sut.MapToAppInstallSummary(full); + var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.GeoAppInstallProjection)); + + Assert.Equal(fromFull.SessionId, fromProjected.SessionId); + Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt); + Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes); + Assert.Equal(fromFull.DownloadDurationSeconds, fromProjected.DownloadDurationSeconds); + + var aggFull = DoAggregator.Compute(new List { fromFull }); + var aggProjected = DoAggregator.Compute(new List { fromProjected }); + Assert.Equal(aggFull.DoAppCount, aggProjected.DoAppCount); + Assert.Equal(aggFull.BytesFromPeers, aggProjected.BytesFromPeers); + Assert.Equal(aggFull.BytesFromHttp, aggProjected.BytesFromHttp); + Assert.Equal(aggFull.TotalBytesDownloaded, aggProjected.TotalBytesDownloaded); + Assert.Equal(aggFull.BytesFromLanPeers, aggProjected.BytesFromLanPeers); + Assert.Equal(aggFull.BytesFromGroupPeers, aggProjected.BytesFromGroupPeers); + Assert.Equal(aggFull.BytesFromInternetPeers, aggProjected.BytesFromInternetPeers); + Assert.Equal(aggFull.BytesFromLinkLocalPeers, aggProjected.BytesFromLinkLocalPeers); + Assert.Equal(aggFull.BytesFromCacheServer, aggProjected.BytesFromCacheServer); + Assert.Equal(aggFull.PercentPeerCaching, aggProjected.PercentPeerCaching); + } + + [Fact] + public void App_projection_preserves_no_DO_telemetry_marker() + { + // DoDownloadMode maps to -1 when the column is absent — a projected row without DO columns + // must stay "no telemetry" (DoAggregator filters on DoDownloadMode >= 0), not become 0/mode. + var full = FullAppRow("s-nodo"); + full.Remove("DoDownloadMode"); + var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.GeoAppInstallProjection)); + Assert.Equal(-1, fromProjected.DoDownloadMode); + Assert.False(DoAggregator.Compute(new List { fromProjected }).HasTelemetry); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs index a8a684bd..905b336c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs @@ -112,6 +112,7 @@ public class OffboardingSessionEnumeratorTests public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate, int maxResults = int.MaxValue, bool excludeInFlightDeletions = false) => throw new NotImplementedException(); public Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException(); public Task> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException(); + public Task> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException(); public Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException(); public Task> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException(); public Task> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours) => throw new NotImplementedException(); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs index 55457b3c..3aad2dd9 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs @@ -60,6 +60,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => _storage.GetUsageWindowSessionsAsync(startDate, endDate, tenantId); + public Task> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) + => _storage.GetGeoWindowSessionsAsync(startDate, endDate, tenantId); + public Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => _storage.GetStalledSessionsAsync(tenantId, cutoffTime); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index 242ce008..6d1c4f90 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -44,6 +44,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetAppInstallRefsAsync(DateTime sinceUtc, string? tenantId = null) => _storage.GetAppInstallRefsAsync(sinceUtc, tenantId); + public Task> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null) + => _storage.GetGeoAppInstallSummariesAsync(sinceUtc, tenantId); + public Task GetPlatformStatsAsync() => _storage.GetPlatformStatsAsync(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs index f66b2fd9..625af269 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs @@ -63,15 +63,21 @@ namespace AutopilotMonitor.Functions.Functions.Metrics _logger.LogInformation("Fetching sessions for location '{LocationKey}' tenant {TenantId} ({Days}d, groupBy={GroupBy}, country={Country}, full={Full})", locationKey ?? "(none)", tenantId, days, groupBy, country ?? "(none)", full); - var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); + // Sessions need the full row (LocationSessionRow returns nearly every column); the + // app scan only feeds the per-session DO aggregate, so it uses the geo projection. + // Both scans are independent — run them concurrently (same cutoff window). + var now = DateTime.UtcNow; + var cutoff = now.AddDays(-days); + var sessionsTask = _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, now.AddDays(1), tenantId); + var appSummariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantId); + await Task.WhenAll(sessionsTask, appSummariesTask); + + var sessions = await sessionsTask; var filtered = !string.IsNullOrEmpty(locationKey) ? FilterSessionsByLocation(sessions, locationKey, groupBy) : FilterSessionsByFields(sessions, country!, query["region"], query["city"]); - // Same window as the sessions above (cutoff); BuildRows joins apps to those sessions. - var appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff); - var rows = BuildRows(filtered, appSummaries); + var rows = BuildRows(filtered, await appSummariesTask); var response = req.CreateResponse(HttpStatusCode.OK); if (full) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs index 5894d35c..085eefda 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs @@ -44,16 +44,22 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var groupBy = query["groupBy"] ?? "city"; - // Check if geo-location is enabled for this tenant - var tenantConfig = await _configService.GetConfigurationAsync(tenantId); - var geoEnabled = tenantConfig?.EnableGeoLocation ?? true; + // The three sources are independent — run them concurrently so wall-clock cost is + // the slowest scan, not the sum. Both scans are column-projected: the aggregation + // reads only Geo*/status/duration from sessions and join-key/throughput/DO counters + // from apps (see GeoMetricsSessionProjection / GeoAppInstallProjection). + var now = DateTime.UtcNow; + var cutoff = now.AddDays(-days); + var configTask = _configService.GetConfigurationAsync(tenantId); + var sessionsTask = _maintenanceRepo.GetGeoWindowSessionsAsync(cutoff, now.AddDays(1), tenantId); + var summariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantId); + await Task.WhenAll(configTask, sessionsTask, summariesTask); - var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); - // Push the window server-side so only in-window rows are deserialized. The in-memory - // Where below stays as the exact trim (the OData StartedAt filter is second-granular). - var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff); - var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); + var geoEnabled = (await configTask)?.EnableGeoLocation ?? true; + var sessions = await sessionsTask; + // The window is pushed server-side; the in-memory Where stays as the exact trim + // (the OData StartedAt filter is second-granular). + var summaries = (await summariesTask).Where(s => s.StartedAt >= cutoff).ToList(); var result = ComputeGeographicMetrics(sessions, summaries, groupBy); result.GeoLocationEnabled = geoEnabled; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs index 5b35e28d..63698b78 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs @@ -59,19 +59,22 @@ namespace AutopilotMonitor.Functions.Functions.Metrics _logger.LogInformation("Fetching global sessions for location '{LocationKey}' ({Days}d, groupBy={GroupBy}, country={Country}, full={Full}, tenantFilter={Tenant}) (User: {UserEmail})", locationKey ?? "(none)", days, groupBy, country ?? "(none)", full, filterTenantId ?? "(none)", userEmail); - var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = string.IsNullOrEmpty(filterTenantId) - ? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)) - : await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), filterTenantId); + // Sessions need the full row (LocationSessionRow returns nearly every column); the + // app scan only feeds the per-session DO aggregate, so it uses the geo projection. + // Both scans are independent — run them concurrently (same cutoff window). + var now = DateTime.UtcNow; + var cutoff = now.AddDays(-days); + var tenantFilter = string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId; + var sessionsTask = _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, now.AddDays(1), tenantFilter); + var appSummariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantFilter); + await Task.WhenAll(sessionsTask, appSummariesTask); + + var sessions = await sessionsTask; var filtered = !string.IsNullOrEmpty(locationKey) ? GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy) : GetGeographicLocationSessionsFunction.FilterSessionsByFields(sessions, country!, query["region"], query["city"]); - // Same window as the sessions above (cutoff); BuildRows joins apps to those sessions. - var appSummaries = string.IsNullOrEmpty(filterTenantId) - ? await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff) - : await _metricsRepo.GetAppInstallSummariesByTenantAsync(filterTenantId, cutoff); - var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, appSummaries); + var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, await appSummariesTask); var response = req.CreateResponse(HttpStatusCode.OK); if (full) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs index 36e8fcca..3b36bd69 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs @@ -43,16 +43,20 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var groupBy = query["groupBy"] ?? "city"; var tenantIdFilter = query["tenantId"]; - var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = !string.IsNullOrWhiteSpace(tenantIdFilter) - ? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantIdFilter) - : await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); - // Push the window server-side so only in-window rows are deserialized. The in-memory - // Where below stays as the exact trim (the OData StartedAt filter is second-granular). - var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter) - ? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter, cutoff) - : await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff); - var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); + // Both scans are independent — run them concurrently, and column-projected: the + // aggregation reads only Geo*/status/duration from sessions and join-key/throughput/ + // DO counters from apps (see GeoMetricsSessionProjection / GeoAppInstallProjection). + var now = DateTime.UtcNow; + var cutoff = now.AddDays(-days); + var tenantFilter = string.IsNullOrWhiteSpace(tenantIdFilter) ? null : tenantIdFilter; + var sessionsTask = _maintenanceRepo.GetGeoWindowSessionsAsync(cutoff, now.AddDays(1), tenantFilter); + var summariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantFilter); + await Task.WhenAll(sessionsTask, summariesTask); + + var sessions = await sessionsTask; + // The window is pushed server-side; the in-memory Where stays as the exact trim + // (the OData StartedAt filter is second-granular). + var summaries = (await summariesTask).Where(s => s.StartedAt >= cutoff).ToList(); var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index 3898db38..a4b75eb5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -543,6 +543,27 @@ namespace AutopilotMonitor.Functions.Services public Task> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => QuerySessionsByDateRangeAsync(startDate, endDate, tenantId, UsageMetricsSessionProjection); + /// + /// Columns the geographic-metrics aggregation (ComputeGeographicMetrics + location grouping) + /// consumes, plus the structural PartitionKey (TenantId) / RowKey (SessionId). CompletedAt / + /// IsPreProvisioned / ResumedAt stay in the set for ComputeEffectiveDuration parity (see + /// ). internal so + /// GeoMetricsProjectionEquivalenceTests derives its keep-set from this exact array. + /// + internal static readonly string[] GeoMetricsSessionProjection = + { + "PartitionKey", "RowKey", "StartedAt", "CompletedAt", "Status", "DurationSeconds", + "IsPreProvisioned", "ResumedAt", "GeoCountry", "GeoRegion", "GeoCity", "GeoLoc" + }; + + /// + /// Column-projected date-range query for the geographic-metrics aggregation (map view). + /// The drilldown endpoints keep the full-row query — their response shape returns nearly + /// every session column. + /// + public Task> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) + => QuerySessionsByDateRangeAsync(startDate, endDate, tenantId, GeoMetricsSessionProjection); + private async Task> QuerySessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId, string[]? select) { if (!string.IsNullOrEmpty(tenantId)) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index d5d4739c..4d4504b0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -441,7 +441,60 @@ namespace AutopilotMonitor.Functions.Services } } - private AppInstallSummary MapToAppInstallSummary(TableEntity entity) + /// + /// Columns the geographic aggregations consume: the session join key, the window filter + /// column, download throughput inputs and every field sums. + /// internal so GeoMetricsProjectionEquivalenceTests derives its keep-set from this array. + /// + internal static readonly string[] GeoAppInstallProjection = + { + "PartitionKey", "RowKey", "SessionId", "StartedAt", + "DownloadBytes", "DownloadDurationSeconds", + "DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp", + "DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers", + "DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer" + }; + + /// + /// Column-projected windowed AppInstallSummaries scan for the geographic endpoints. Returns + /// objects with ONLY the + /// fields populated (everything else is defaults) — callers must not read fields outside the + /// projection. Same filter semantics as the full getters (server-side StartedAt ge + + /// optional tenant partition). is server-derived, so + /// interpolating it is injection-safe. + /// + public async Task> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null) + { + if (!string.IsNullOrEmpty(tenantId)) + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); + var filter = $"StartedAt ge datetime'{sinceUtc:yyyy-MM-ddTHH:mm:ss}Z'"; + if (!string.IsNullOrEmpty(tenantId)) + filter = $"PartitionKey eq '{tenantId}' and " + filter; + + var query = tableClient.QueryAsync(filter: filter, select: GeoAppInstallProjection); + + var summaries = new List(); + await foreach (var entity in query) + { + summaries.Add(MapToAppInstallSummary(entity)); + } + + return summaries; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get geo app install summaries"); + return new List(); + } + } + + // internal (not private) so GeoMetricsProjectionEquivalenceTests can pin that a row + // carrying only GeoAppInstallProjection maps to the same geo-relevant fields as a full row. + internal AppInstallSummary MapToAppInstallSummary(TableEntity entity) { return new AppInstallSummary { diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs index f5ce1f20..0db35a24 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs @@ -74,6 +74,12 @@ namespace AutopilotMonitor.Shared.DataAccess /// the projection come back as defaults — callers must not read them. /// Task> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null); + /// + /// Column-projected variant of for the + /// geographic-metrics aggregation (map view): Geo* fields + status/duration inputs only. + /// Fields outside the projection come back as defaults — callers must not read them. + /// + Task> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null); Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime); Task> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff); Task> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index a985f743..f30135de 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -33,6 +33,14 @@ namespace AutopilotMonitor.Shared.DataAccess /// for the cross-tenant scan. /// Task> GetAppInstallRefsAsync(DateTime sinceUtc, string? tenantId = null); + /// + /// Column-projected windowed scan for the geographic endpoints: session join key, window + /// filter column, download throughput inputs and the Delivery Optimization counters. The + /// returned objects carry ONLY those fields — everything + /// else is defaults and must not be read. Omit for the + /// cross-tenant scan. + /// + Task> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null); // --- Platform Stats --- Task GetPlatformStatsAsync(); diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx index 69372f03..67e3fe89 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx @@ -5,24 +5,13 @@ import { useRouter } from 'next/navigation'; import { api } from '@/lib/api'; import { useAuth } from '../../../../contexts/AuthContext'; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { useFetchProgress } from "@/hooks/useFetchProgress"; +import { CalculatingCard } from "@/components/CalculatingCard"; // A fresh cross-tenant compute takes tens of seconds server-side; the default 30s fetch timeout // aborted it client-side (HTTP 499 on the backend) while the server kept computing. Give this one // call generous headroom — the backend caches the result for 15 minutes afterwards. const FETCH_TIMEOUT_MS = 180_000; -// Last observed fresh-compute fetch duration, used to size the progress bar. Only fresh computes -// are recorded (a cache hit finishing in ~100ms would make the next cold load look stuck at 95%). -const ESTIMATE_STORAGE_KEY = 'platformUsage.lastFreshFetchMs'; -const DEFAULT_ESTIMATE_MS = 45_000; - -function readFetchEstimateMs(): number { - try { - const v = Number(localStorage.getItem(ESTIMATE_STORAGE_KEY)); - return Number.isFinite(v) && v > 1_000 ? v : DEFAULT_ESTIMATE_MS; - } catch { - return DEFAULT_ESTIMATE_MS; - } -} interface SessionMetrics { total: number; @@ -118,21 +107,13 @@ export function SectionPlatformUsage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); - const [loadStartedAt, setLoadStartedAt] = useState(null); - const [elapsedMs, setElapsedMs] = useState(0); - const [estimateMs, setEstimateMs] = useState(DEFAULT_ESTIMATE_MS); - - // Tick the elapsed clock while a request is in flight so the calculating card can show - // progress instead of an opaque spinner. - useEffect(() => { - if (loadStartedAt === null) return; - setElapsedMs(0); - const id = setInterval(() => setElapsedMs(Date.now() - loadStartedAt), 500); - return () => clearInterval(id); - }, [loadStartedAt]); + // Progress estimate records only fresh computes — a 15-min-cache hit finishing in ~100ms + // would make the next cold load look stuck at the bar's cap. + const progress = useFetchProgress('platformUsage.lastFreshFetchMs'); + const { begin: progressBegin, finish: progressFinish } = progress; const fetchMetrics = async (showRefreshing = false) => { - const startedAt = Date.now(); + let recordEstimate = false; try { if (showRefreshing) { setRefreshing(true); @@ -140,8 +121,7 @@ export function SectionPlatformUsage() { setLoading(true); } setError(null); - setEstimateMs(readFetchEstimateMs()); - setLoadStartedAt(startedAt); + progressBegin(); // Platform-wide metrics - cross-tenant (Global Admin only) const response = await authenticatedFetch(api.metrics.globalUsage(), getAccessToken, { @@ -154,9 +134,7 @@ export function SectionPlatformUsage() { const data: PlatformUsageMetrics = await response.json(); setMetrics(data); - if (!data.fromCache) { - try { localStorage.setItem(ESTIMATE_STORAGE_KEY, String(Date.now() - startedAt)); } catch { /* storage unavailable */ } - } + recordEstimate = !data.fromCache; } catch (err) { if (err instanceof TokenExpiredError) { console.error('Session expired:', err.message); @@ -169,9 +147,9 @@ export function SectionPlatformUsage() { setError(err instanceof Error ? err.message : 'Failed to fetch platform usage metrics'); } } finally { + progressFinish(recordEstimate); setLoading(false); setRefreshing(false); - setLoadStartedAt(null); } }; @@ -196,36 +174,13 @@ export function SectionPlatformUsage() { }; if (loading) { - // Progress is an estimate from the last fresh compute — capped at 95% so it never claims - // "done" before the response actually lands. - const progressPercent = Math.min(95, (elapsedMs / estimateMs) * 100); - const elapsedSeconds = Math.floor(elapsedMs / 1000); - const overEstimate = elapsedMs > estimateMs; return ( -
-
-
-
-

Calculating platform metrics…

-

- Aggregating sessions, app installs and user activity across all tenants. - A fresh calculation can take a minute — the result is then cached for 15 minutes. -

-
-
-
-

- {elapsedSeconds}s elapsed - {overEstimate - ? ' — taking a bit longer than usual…' - : ` · usually ~${Math.max(1, Math.round(estimateMs / 1000))}s`} -

-
-
-
+ ); } @@ -289,7 +244,7 @@ export function SectionPlatformUsage() { - {refreshing ? `Refreshing… ${Math.floor(elapsedMs / 1000)}s` : 'Refresh'} + {refreshing ? `Refreshing… ${Math.floor(progress.elapsedMs / 1000)}s` : 'Refresh'} diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx index 152a6fb7..a082ad7b 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx @@ -9,8 +9,14 @@ import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useAggregatedAdminScope } from "@/hooks"; +import { useFetchProgress } from "@/hooks/useFetchProgress"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; +import { CalculatingCard } from "@/components/CalculatingCard"; + +// A cross-tenant geo aggregation can take tens of seconds server-side; the default 30s fetch +// timeout would abort it client-side while the server keeps computing. +const GEO_FETCH_TIMEOUT_MS = 180_000; // Dynamically import the map component (Leaflet requires window/document) const GeoMap = dynamic(() => import("./GeoMap"), { ssr: false }); @@ -124,16 +130,24 @@ export default function GeographicPerformancePage() { const scope = useAggregatedAdminScope(); const { isGlobalAdmin, routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope; + const progress = useFetchProgress("geoPerf.lastFetchMs"); + const { begin: progressBegin, finish: progressFinish } = progress; + const fetchGeoMetrics = useCallback(async (range: TimeRange = timeRange, group: GroupBy = groupBy) => { + let succeeded = false; try { + progressBegin(); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const endpoint = routeGlobal ? api.metrics.globalGeographic(days, group, selectedTenantId || undefined) : api.metrics.geographic(tenantId, days, group); - const response = await authenticatedFetch(endpoint, getAccessToken); + const response = await authenticatedFetch(endpoint, getAccessToken, { + signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS), + }); if (response.ok) { const data = await response.json(); setGeoMetrics(data); + succeeded = true; } } catch (error) { if (error instanceof TokenExpiredError) { @@ -142,9 +156,10 @@ export default function GeographicPerformancePage() { console.error("Failed to fetch geographic metrics:", error); } } finally { + progressFinish(succeeded); setLoading(false); } - }, [routeGlobal, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy]); + }, [routeGlobal, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy, progressBegin, progressFinish]); useEffect(() => { if (!scopeInitialized) return; @@ -196,9 +211,12 @@ export default function GeographicPerformancePage() { if (loading) { return ( -
-
Loading geographic performance data...
-
+ ); } 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 32f453ee..11e54ce4 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 @@ -12,6 +12,12 @@ import { SessionStatusBadge } from "@/components/SessionStatusBadge"; import { GlobalAdminBanner } from "@/components/GlobalAdminBanner"; import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; +import { useFetchProgress } from "@/hooks/useFetchProgress"; +import { CalculatingCard } from "@/components/CalculatingCard"; + +// A cross-tenant drilldown can take tens of seconds server-side; the default 30s fetch +// timeout would abort it client-side while the server keeps computing. +const GEO_FETCH_TIMEOUT_MS = 180_000; interface SessionSummary { sessionId: string; @@ -112,16 +118,24 @@ function LocationSessionsContent() { // raw presence so it never falls back to the caller's own-tenant endpoint. const boundedTenantId = boundTenantToDelegatedScope(urlTenantId || undefined, isDelegatedScope, user?.delegatedTenantIds); + const progress = useFetchProgress("geoSessions.lastFetchMs"); + const { begin: progressBegin, finish: progressFinish } = progress; + const fetchSessions = useCallback(async () => { if (!locationKey) return; + let succeeded = false; try { + progressBegin(); const endpoint = crossTenant ? api.metrics.globalGeographicSessions(Number(days), groupBy, locationKey, boundedTenantId) : api.metrics.geographicSessions(tenantId, Number(days), groupBy, locationKey); - const response = await authenticatedFetch(endpoint, getAccessToken); + const response = await authenticatedFetch(endpoint, getAccessToken, { + signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS), + }); if (response.ok) { const result = await response.json(); setData(result); + succeeded = true; } } catch (error) { if (error instanceof TokenExpiredError) { @@ -130,9 +144,10 @@ function LocationSessionsContent() { console.error("Failed to fetch location sessions:", error); } } finally { + progressFinish(succeeded); setLoading(false); } - }, [crossTenant, boundedTenantId, tenantId, getAccessToken, days, groupBy, locationKey]); + }, [crossTenant, boundedTenantId, tenantId, getAccessToken, days, groupBy, locationKey, progressBegin, progressFinish]); useEffect(() => { if (!crossTenant && !tenantId) return; @@ -168,9 +183,12 @@ function LocationSessionsContent() { if (loading) { return ( -
-
Loading sessions for {locationKey}...
-
+ ); } diff --git a/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx b/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx new file mode 100644 index 00000000..ec937b48 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx @@ -0,0 +1,53 @@ +'use client'; + +/** + * Full-page loading card for long-running server aggregations. Shows a progress bar sized + * from the last observed fetch duration (see useFetchProgress) plus an elapsed counter, so + * the user sees the request is alive instead of staring at a bare spinner. The bar is capped + * at 95% — it never claims "done" before the response actually lands. + */ +export function CalculatingCard({ + title, + subtitle, + elapsedMs, + estimateMs, +}: { + title: string; + subtitle: string; + elapsedMs: number; + estimateMs: number; +}) { + const progressPercent = Math.min(95, (elapsedMs / estimateMs) * 100); + const elapsedSeconds = Math.floor(elapsedMs / 1000); + const overEstimate = elapsedMs > estimateMs; + + return ( +
+
+
+
+

{title}

+

{subtitle}

+
+
+
+

+ {elapsedSeconds}s elapsed + {overEstimate + ? ' — taking a bit longer than usual…' + : ` · usually ~${Math.max(1, Math.round(estimateMs / 1000))}s`} +

+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/hooks/useFetchProgress.ts b/src/Web/autopilot-monitor-web/hooks/useFetchProgress.ts new file mode 100644 index 00000000..3815a989 --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/useFetchProgress.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Elapsed/estimate tracking for long-running fetches so loading states can show honest + * progress instead of an opaque spinner. The estimate is the last recorded fetch duration + * (persisted per storageKey in localStorage); callers should record only "fresh" computes — + * a server cache hit finishing in ~100ms would make the next cold load look stuck at the cap. + * + * Usage: call begin() when the request starts and finish(recordEstimate) in finally. + */ +export const DEFAULT_FETCH_ESTIMATE_MS = 45_000; + +function readEstimateMs(storageKey: string, defaultEstimateMs: number): number { + try { + const v = Number(localStorage.getItem(storageKey)); + return Number.isFinite(v) && v > 1_000 ? v : defaultEstimateMs; + } catch { + return defaultEstimateMs; + } +} + +export function useFetchProgress(storageKey: string, defaultEstimateMs = DEFAULT_FETCH_ESTIMATE_MS) { + const startedAtRef = useRef(null); + const [active, setActive] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); + const [estimateMs, setEstimateMs] = useState(defaultEstimateMs); + + useEffect(() => { + if (!active) return; + const id = setInterval(() => { + if (startedAtRef.current !== null) setElapsedMs(Date.now() - startedAtRef.current); + }, 500); + return () => clearInterval(id); + }, [active]); + + const begin = useCallback(() => { + startedAtRef.current = Date.now(); + setElapsedMs(0); + setEstimateMs(readEstimateMs(storageKey, defaultEstimateMs)); + setActive(true); + }, [storageKey, defaultEstimateMs]); + + const finish = useCallback((recordEstimate = true) => { + const started = startedAtRef.current; + if (recordEstimate && started !== null) { + try { + localStorage.setItem(storageKey, String(Date.now() - started)); + } catch { + // storage unavailable — keep the default estimate + } + } + startedAtRef.current = null; + setActive(false); + }, [storageKey]); + + return { begin, finish, elapsedMs, estimateMs, active }; +}