From 5b808f08deb4d2ba2268d79e387a05e7ddfeecc8 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 11 Jul 2026 12:43:16 +0200 Subject: [PATCH] perf(apps): projected app-install scans behind one query core + progress UX for the apps surface Third slice of the metrics-performance pass (after e63a0bc2 usage, 86f5b507 geo): the app endpoints ran unprojected full-column AppInstallSummaries window scans behind the web app's default 30s fetch timeout. Backend: - All AppInstallSummaries scans (full, geo, app-metrics, apps-dashboard) now delegate to one private QueryAppInstallSummariesAsync core so filter semantics stay in lockstep - New projections: AppMetricsProjection for metrics/app + global (ranking inputs, failure code and the DO counters) and AppsDashboardProjection for the App Dashboard list/analytics/sessions endpoints (drops the whole DO telemetry block, which those aggregations never read) - AppsAnalyticsHelper.LoadSummariesAsync and both app-metrics functions switched to the projected variants - AppsProjectionEquivalenceTests pins both projections, incl. the TenantId-from-PartitionKey fallback for legacy rows Web: - CalculatingInline: compact in-place variant of the calculating card (shared progress bar + elapsed caption extracted) - Apps installs tab and the app detail page (analytics + sessions fetches) use 180s AbortSignals and show inline progress while aggregating Co-Authored-By: Claude Fable 5 --- .../AppsProjectionEquivalenceTests.cs | 137 ++++++++++++++++++ .../TableStorage/TableMetricsRepository.cs | 6 + .../Functions/Apps/AppsAnalyticsHelper.cs | 8 +- .../Metrics/GetAppMetricsFunction.cs | 7 +- .../Metrics/GetGlobalAppMetricsFunction.cs | 10 +- .../Services/TableStorageService.Metrics.cs | 120 ++++++++------- .../DataAccess/IMetricsRepository.cs | 14 ++ .../app/apps/[appName]/page.tsx | 26 +++- .../app/apps/components/InstallsTab.tsx | 23 ++- .../components/CalculatingCard.tsx | 83 +++++++---- 10 files changed, 336 insertions(+), 98 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AppsProjectionEquivalenceTests.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppsProjectionEquivalenceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppsProjectionEquivalenceTests.cs new file mode 100644 index 00000000..555c25fd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppsProjectionEquivalenceTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Azure.Data.Tables; +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 AppInstallSummaries scans driving the app endpoints produce +/// identical aggregation inputs to the full-row drain — AppMetricsProjection for +/// metrics/app (+global) and AppsDashboardProjection for the App Dashboard +/// (list / analytics / sessions). As in the sibling equivalence suites, the "projected" row is +/// built with the projected keys ONLY, exactly like a live $select response. +/// +public class AppsProjectionEquivalenceTests +{ + private const string TenantId = "00000000-0000-0000-0000-000000000a99"; + + private static readonly TableStorageService Sut = + new(new Mock().Object, NullLogger.Instance); + + /// Full wide AppInstallSummaries row with every column family populated. + private static TableEntity FullRow() + { + return new TableEntity(TenantId, Guid.NewGuid().ToString()) + { + ["SessionId"] = "sess-1", + ["TenantId"] = TenantId, + ["AppName"] = "Contoso App", + ["AppType"] = "Win32", + ["AppVersion"] = "2.1.0", + ["Status"] = "Failed", + ["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-6)), + ["CompletedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-5)), + ["DurationSeconds"] = 340, + ["DownloadBytes"] = 180_000_000L, + ["DownloadDurationSeconds"] = 25, + ["AttemptNumber"] = 2, + ["InstallerPhase"] = "Install", + ["FailureCode"] = "0x80070643", + ["FailureMessage"] = "Fatal error during installation. " + new string('z', 400), + ["ExitCode"] = 1603, + ["DetectionResult"] = "NotDetected", + // DO telemetry block (dashboard-irrelevant, app-metrics-relevant). + ["DoDownloadMode"] = 1, + ["DoFileSize"] = 180_000_000L, + ["DoTotalBytesDownloaded"] = 180_000_000L, + ["DoBytesFromPeers"] = 40_000_000L, + ["DoBytesFromHttp"] = 140_000_000L, + ["DoBytesFromLanPeers"] = 40_000_000L, + ["DoBytesFromGroupPeers"] = 0L, + ["DoBytesFromInternetPeers"] = 0L, + ["DoBytesFromLinkLocalPeers"] = 0L, + ["DoBytesFromCacheServer"] = 2_000_000L, + ["DoPercentPeerCaching"] = 22, + ["DoDownloadDuration"] = "00:00:25", + ["DoCacheHost"] = "mcc.contoso.local", + }; + } + + 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; + } + + [Fact] + public void AppMetrics_projection_preserves_ranking_and_DO_rollup_inputs() + { + var full = FullRow(); + var fromFull = Sut.MapToAppInstallSummary(full); + var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.AppMetricsProjection)); + + Assert.Equal(fromFull.AppName, fromProjected.AppName); + Assert.Equal(fromFull.Status, fromProjected.Status); + Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt); + Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds); + Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes); + Assert.Equal(fromFull.FailureCode, fromProjected.FailureCode); + + 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.BytesFromCacheServer, aggProjected.BytesFromCacheServer); + Assert.Equal(aggFull.PercentPeerCaching, aggProjected.PercentPeerCaching); + } + + [Fact] + public void AppsDashboard_projection_preserves_list_analytics_and_sessions_inputs() + { + var full = FullRow(); + var fromFull = Sut.MapToAppInstallSummary(full); + var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.AppsDashboardProjection)); + + // TenantId doubles as the session-join key in the global aggregated view. + Assert.Equal(fromFull.TenantId, fromProjected.TenantId); + Assert.Equal(fromFull.SessionId, fromProjected.SessionId); + Assert.Equal(fromFull.AppName, fromProjected.AppName); + Assert.Equal(fromFull.AppType, fromProjected.AppType); + Assert.Equal(fromFull.AppVersion, fromProjected.AppVersion); + Assert.Equal(fromFull.Status, fromProjected.Status); + Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt); + Assert.Equal(fromFull.CompletedAt, fromProjected.CompletedAt); + Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds); + Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes); + Assert.Equal(fromFull.AttemptNumber, fromProjected.AttemptNumber); + Assert.Equal(fromFull.InstallerPhase, fromProjected.InstallerPhase); + Assert.Equal(fromFull.FailureCode, fromProjected.FailureCode); + Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage); + Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode); + Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult); + } + + [Fact] + public void AppsDashboard_projection_falls_back_to_PartitionKey_for_TenantId() + { + // Legacy rows without a TenantId column resolve TenantId from the PartitionKey — the + // projection must not break that fallback (PartitionKey is always transferred). + var full = FullRow(); + full.Remove("TenantId"); + var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.AppsDashboardProjection)); + Assert.Equal(TenantId, fromProjected.TenantId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index 6d1c4f90..338b823e 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -47,6 +47,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null) => _storage.GetGeoAppInstallSummariesAsync(sinceUtc, tenantId); + public Task> GetAppMetricsSummariesAsync(DateTime sinceUtc, string? tenantId = null) + => _storage.GetAppMetricsSummariesAsync(sinceUtc, tenantId); + + public Task> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null) + => _storage.GetAppsDashboardSummariesAsync(sinceUtc, tenantId); + public Task GetPlatformStatsAsync() => _storage.GetPlatformStatsAsync(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index e46e8dbe..226d99d6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -79,13 +79,13 @@ namespace AutopilotMonitor.Functions.Functions.Apps /// - tenantId != null → tenant-scoped (per-tenant endpoint or global admin viewing one tenant) /// - tenantId == null → all tenants (global admin aggregated view) /// - public static async Task> LoadSummariesAsync( + public static Task> LoadSummariesAsync( IMetricsRepository repo, string? tenantId, int days) { var sinceUtc = DateTime.UtcNow.AddDays(-days); - return string.IsNullOrEmpty(tenantId) - ? await repo.GetAllAppInstallSummariesAsync(sinceUtc) - : await repo.GetAppInstallSummariesByTenantAsync(tenantId, sinceUtc); + // Column-projected to what the Build* aggregations actually read — the DO telemetry + // block on the wide row is dashboard-irrelevant transfer (see AppsDashboardProjection). + return repo.GetAppsDashboardSummariesAsync(sinceUtc, string.IsNullOrEmpty(tenantId) ? null : tenantId); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs index 2af0e35e..2a3218e5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs @@ -40,9 +40,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); - // 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); + // Window pushed server-side and column-projected to what the payload aggregation + // reads (see AppMetricsProjection). The in-memory Where stays as the exact trim + // (the OData StartedAt filter is second-granular). + var allSummaries = await _metricsRepo.GetAppMetricsSummariesAsync(cutoff, tenantId); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); // Aggregation (slowest/failing ranking + Delivery Optimization rollup) is shared diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs index 815353a5..dbf2d928 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs @@ -41,11 +41,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); - // 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); + // Window pushed server-side and column-projected to what the payload aggregation + // reads (see AppMetricsProjection). The in-memory Where stays as the exact trim + // (the OData StartedAt filter is second-granular). + var allSummaries = await _metricsRepo.GetAppMetricsSummariesAsync( + cutoff, string.IsNullOrWhiteSpace(tenantIdFilter) ? null : tenantIdFilter); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); // Aggregation (slowest/failing ranking + Delivery Optimization rollup) is shared diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index 4d4504b0..218e5053 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -337,17 +337,34 @@ namespace AutopilotMonitor.Functions.Services /// are deserialized and returned over the wire. is a server-derived /// DateTime (never caller-supplied text), so interpolating it into the OData filter is injection-safe. /// - public async Task> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null) + public Task> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + return QueryAppInstallSummariesAsync(tenantId, sinceUtc, select: null); + } + + /// + /// Shared core for every AppInstallSummaries scan: optional tenant partition, optional + /// server-side StartedAt ge window, optional column projection. All public variants + /// (full, geo, app-metrics, apps-dashboard) delegate here so filter semantics stay in + /// lockstep. sinceUtc is server-derived, so interpolating it is injection-safe. + /// + private async Task> QueryAppInstallSummariesAsync(string? tenantId, DateTime? sinceUtc, string[]? select) + { + if (!string.IsNullOrEmpty(tenantId)) + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var filter = $"PartitionKey eq '{tenantId}'"; + var filters = new List(); + if (!string.IsNullOrEmpty(tenantId)) + filters.Add($"PartitionKey eq '{tenantId}'"); if (sinceUtc.HasValue) - filter += $" and StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'"; - var query = tableClient.QueryAsync(filter: filter); + filters.Add($"StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'"); + var filter = filters.Count > 0 ? string.Join(" and ", filters) : null; + + var query = tableClient.QueryAsync(filter: filter, select: select); var summaries = new List(); await foreach (var entity in query) @@ -359,7 +376,7 @@ namespace AutopilotMonitor.Functions.Services } catch (Exception ex) { - _logger.LogError(ex, $"Failed to get app install summaries for tenant {tenantId}"); + _logger.LogError(ex, "Failed to query app install summaries (tenant={TenantId})", tenantId ?? "(all)"); return new List(); } } @@ -370,30 +387,51 @@ namespace AutopilotMonitor.Functions.Services /// (otherwise full-table) scan to the window so only in-window rows are deserialized. /// is server-derived, so interpolating it is injection-safe. /// - public async Task> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null) + public Task> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null) + => QueryAppInstallSummariesAsync(tenantId: null, sinceUtc, select: null); + + /// + /// Columns MetricsMath.BuildAppMetricsPayload consumes (slowest/failing ranking + DO rollup): + /// grouping key, status/duration/bytes, failure code and every counter DoAggregator sums. + /// internal so AppsProjectionEquivalenceTests derives its keep-set from this array. + /// + internal static readonly string[] AppMetricsProjection = { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var filter = sinceUtc.HasValue - ? $"StartedAt ge datetime'{sinceUtc.Value:yyyy-MM-ddTHH:mm:ss}Z'" - : null; - var query = tableClient.QueryAsync(filter: filter); + "PartitionKey", "RowKey", "AppName", "Status", "StartedAt", + "DurationSeconds", "DownloadBytes", "FailureCode", + "DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp", + "DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers", + "DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer" + }; - var summaries = new List(); - await foreach (var entity in query) - { - summaries.Add(MapToAppInstallSummary(entity)); - } + /// + /// Column-projected windowed scan for the app-metrics endpoints (metrics/app + + /// global/metrics/app). Returned objects carry ONLY the + /// fields — everything else is defaults and must not be read. + /// + public Task> GetAppMetricsSummariesAsync(DateTime sinceUtc, string? tenantId = null) + => QueryAppInstallSummariesAsync(tenantId, sinceUtc, AppMetricsProjection); - return summaries; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get all app install summaries"); - return new List(); - } - } + /// + /// Columns the App Dashboard endpoints (AppsAnalyticsHelper: list / analytics / sessions) + /// consume — union of all three Build* methods. Drops the Delivery Optimization telemetry + /// block and DownloadDurationSeconds, which the dashboard never reads. internal so + /// AppsProjectionEquivalenceTests derives its keep-set from this array. + /// + internal static readonly string[] AppsDashboardProjection = + { + "PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "AppType", "AppVersion", + "Status", "StartedAt", "CompletedAt", "DurationSeconds", "DownloadBytes", + "AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult" + }; + + /// + /// Column-projected windowed scan for the App Dashboard endpoints. Returned objects carry + /// ONLY the fields — everything else is defaults and + /// must not be read. + /// + public Task> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null) + => QueryAppInstallSummariesAsync(tenantId, sinceUtc, AppsDashboardProjection); /// /// Columns the (SessionId, AppName) pair projection transfers. The usage-metrics compute @@ -463,34 +501,8 @@ namespace AutopilotMonitor.Functions.Services /// 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(); - } - } + public Task> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null) + => QueryAppInstallSummariesAsync(tenantId, sinceUtc, GeoAppInstallProjection); // internal (not private) so GeoMetricsProjectionEquivalenceTests can pin that a row // carrying only GeoAppInstallProjection maps to the same geo-relevant fields as a full row. diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index f30135de..d616c264 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -41,6 +41,20 @@ namespace AutopilotMonitor.Shared.DataAccess /// cross-tenant scan. /// Task> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null); + /// + /// Column-projected windowed scan for the app-metrics endpoints (slowest/failing ranking + + /// DO rollup): grouping key, status/duration/bytes, failure code and the DO counters. The + /// returned objects carry ONLY those fields — everything else is defaults and must not be + /// read. Omit for the cross-tenant scan. + /// + Task> GetAppMetricsSummariesAsync(DateTime sinceUtc, string? tenantId = null); + /// + /// Column-projected windowed scan for the App Dashboard endpoints (list / analytics / + /// sessions): everything those aggregations read, WITHOUT the Delivery Optimization + /// telemetry block. The returned objects carry ONLY the projected fields — everything else + /// is defaults and must not be read. Omit for the cross-tenant scan. + /// + Task> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null); // --- Platform Stats --- Task GetPlatformStatsAsync(); diff --git a/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx b/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx index ad687dbc..476e673b 100644 --- a/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/[appName]/page.tsx @@ -13,8 +13,14 @@ import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; import { useAggregatedAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; +import { CalculatingInline } from "@/components/CalculatingCard"; +import { useFetchProgress } from "@/hooks/useFetchProgress"; import { chartColors } from "../../../components/charts/chartTheme"; +// A cross-tenant analytics aggregation can take tens of seconds server-side; the default 30s +// fetch timeout would abort it client-side while the server keeps computing. +const APPS_FETCH_TIMEOUT_MS = 180_000; + // Lazy-load recharts on the detail page only — keeps the rest of the app's // initial bundle untouched. const AppLineChart = dynamic(() => import("../../../components/charts/AppLineChart"), { @@ -181,6 +187,8 @@ export default function AppDetailPage() { const [days, setDays] = useState<7 | 30 | 90>(initialDays as 7 | 30 | 90); const [analytics, setAnalytics] = useState(null); const [loading, setLoading] = useState(true); + const progress = useFetchProgress("appAnalytics.lastFetchMs"); + const { begin: progressBegin, finish: progressFinish } = progress; // Sessions panel state const [sessions, setSessions] = useState(null); @@ -275,14 +283,19 @@ export default function AppDetailPage() { const fetchAnalytics = async () => { if (!appName) return; if (!isGlobalAdmin && !tenantId) return; + let succeeded = false; try { setLoading(true); + progressBegin(); const url = useGlobalEndpoint ? api.apps.globalAnalytics(appName, days, selectedTenantId || undefined) : api.apps.analytics(tenantId, appName, days); - const response = await authenticatedFetch(url, getAccessToken); + const response = await authenticatedFetch(url, getAccessToken, { + signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS), + }); if (response.ok) { setAnalytics((await response.json()) as AnalyticsResponse); + succeeded = true; } else { addNotification( "error", @@ -304,6 +317,7 @@ export default function AppDetailPage() { ); } } finally { + progressFinish(succeeded); setLoading(false); } }; @@ -316,7 +330,9 @@ export default function AppDetailPage() { const url = useGlobalEndpoint ? api.apps.globalSessions(appName, days, status, offset, SESSIONS_PAGE_SIZE, selectedTenantId || undefined) : api.apps.sessions(tenantId, appName, days, status, offset, SESSIONS_PAGE_SIZE); - const response = await authenticatedFetch(url, getAccessToken); + const response = await authenticatedFetch(url, getAccessToken, { + signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS), + }); if (response.ok) { setSessions((await response.json()) as SessionsResponse); } @@ -410,7 +426,11 @@ export default function AppDetailPage() {
{loading || !analytics ? ( -
Loading…
+ ) : ( <> {/* Summary cards */} diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index e8454cbe..28b90420 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -9,9 +9,15 @@ import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { formatBytes, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "@/components/DoBreakdownBar"; +import { CalculatingInline } from "@/components/CalculatingCard"; +import { useFetchProgress } from "@/hooks/useFetchProgress"; import type { SoftwareTabScope, TimeRange } from "./types"; import { rangeToDays } from "./types"; +// A cross-tenant apps aggregation can take tens of seconds server-side; the default 30s fetch +// timeout would abort it client-side while the server keeps computing. +const APPS_FETCH_TIMEOUT_MS = 180_000; + interface AppRow { appName: string; appType: string; @@ -81,6 +87,9 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const [sortDir, setSortDir] = useState("desc"); const [page, setPage] = useState(0); + const progress = useFetchProgress("appsInstalls.lastFetchMs"); + const { begin: progressBegin, finish: progressFinish } = progress; + useEffect(() => { if (!scopeInitialized) return; if (!isGlobalAdmin && !tenantId) return; @@ -88,8 +97,10 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const days = rangeToDays(timeRange); const run = async () => { + let succeeded = false; try { setLoading(true); + progressBegin(); const listUrl = routeGlobal ? api.apps.globalList(days, selectedTenantId || undefined) : api.apps.list(tenantId, days); @@ -98,13 +109,14 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { : api.metrics.app(tenantId, days); const [listRes, metricsRes] = await Promise.all([ - authenticatedFetch(listUrl, getAccessToken), - authenticatedFetch(metricsUrl, getAccessToken), + authenticatedFetch(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), + authenticatedFetch(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }), ]); if (cancelled) return; if (listRes.ok) { setData((await listRes.json()) as AppsListResponse); + succeeded = true; } else { addNotification("error", "Backend Error", `Failed to load apps: ${listRes.statusText}`, "apps-list-error"); } @@ -121,6 +133,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { addNotification("error", "Backend Not Reachable", "Unable to load app dashboard data.", "apps-list-error"); } } finally { + progressFinish(succeeded); if (!cancelled) setLoading(false); } }; @@ -241,7 +254,11 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { {/* Table */}
{loading ? ( -
Loading…
+ ) : filteredAndSorted.length === 0 ? (
{search ? "No apps match your search." : "No app install data in this window."} diff --git a/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx b/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx index ec937b48..d3f231a9 100644 --- a/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx +++ b/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx @@ -1,11 +1,63 @@ '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. + * Loading states for long-running server aggregations. Both variants show 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. + * + * CalculatingCard: full-page card (page-level loading states). + * CalculatingInline: compact block for in-place placeholders (tab/table bodies). */ + +function ProgressBarWithCaption({ elapsedMs, estimateMs }: { elapsedMs: number; estimateMs: number }) { + const progressPercent = Math.min(95, (elapsedMs / estimateMs) * 100); + const elapsedSeconds = Math.floor(elapsedMs / 1000); + const overEstimate = elapsedMs > estimateMs; + + return ( + <> +
+
+
+

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

+ + ); +} + +export function CalculatingInline({ + label, + elapsedMs, + estimateMs, +}: { + label: string; + elapsedMs: number; + estimateMs: number; +}) { + return ( +
+
+

{label}

+ +
+
+ ); +} + export function CalculatingCard({ title, subtitle, @@ -17,10 +69,6 @@ export function CalculatingCard({ elapsedMs: number; estimateMs: number; }) { - const progressPercent = Math.min(95, (elapsedMs / estimateMs) * 100); - const elapsedSeconds = Math.floor(elapsedMs / 1000); - const overEstimate = elapsedMs > estimateMs; - return (
@@ -28,24 +76,7 @@ export function CalculatingCard({

{title}

{subtitle}

-
-
-
-

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

+