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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-11 12:43:16 +02:00
parent 86f5b50778
commit 5b808f08de
10 changed files with 336 additions and 98 deletions
@@ -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;
/// <summary>
/// Pins that the column-projected AppInstallSummaries scans driving the app endpoints produce
/// identical aggregation inputs to the full-row drain — <c>AppMetricsProjection</c> for
/// metrics/app (+global) and <c>AppsDashboardProjection</c> 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 <c>$select</c> response.
/// </summary>
public class AppsProjectionEquivalenceTests
{
private const string TenantId = "00000000-0000-0000-0000-000000000a99";
private static readonly TableStorageService Sut =
new(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
/// <summary>Full wide AppInstallSummaries row with every column family populated.</summary>
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<string>(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<AppInstallSummary> { fromFull });
var aggProjected = DoAggregator.Compute(new List<AppInstallSummary> { 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);
}
}
@@ -47,6 +47,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<AppInstallSummary>> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null)
=> _storage.GetGeoAppInstallSummariesAsync(sinceUtc, tenantId);
public Task<List<AppInstallSummary>> GetAppMetricsSummariesAsync(DateTime sinceUtc, string? tenantId = null)
=> _storage.GetAppMetricsSummariesAsync(sinceUtc, tenantId);
public Task<List<AppInstallSummary>> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null)
=> _storage.GetAppsDashboardSummariesAsync(sinceUtc, tenantId);
public Task<PlatformStats?> GetPlatformStatsAsync()
=> _storage.GetPlatformStatsAsync();
@@ -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)
/// </summary>
public static async Task<List<AppInstallSummary>> LoadSummariesAsync(
public static Task<List<AppInstallSummary>> 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);
}
/// <summary>
@@ -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
@@ -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
@@ -337,17 +337,34 @@ namespace AutopilotMonitor.Functions.Services
/// are deserialized and returned over the wire. <paramref name="sinceUtc"/> is a server-derived
/// DateTime (never caller-supplied text), so interpolating it into the OData filter is injection-safe.
/// </summary>
public async Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null)
public Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
return QueryAppInstallSummariesAsync(tenantId, sinceUtc, select: null);
}
/// <summary>
/// Shared core for every AppInstallSummaries scan: optional tenant partition, optional
/// server-side <c>StartedAt ge</c> 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.
/// </summary>
private async Task<List<AppInstallSummary>> 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<string>();
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<TableEntity>(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<TableEntity>(filter: filter, select: select);
var summaries = new List<AppInstallSummary>();
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<AppInstallSummary>();
}
}
@@ -370,30 +387,51 @@ namespace AutopilotMonitor.Functions.Services
/// (otherwise full-table) scan to the window so only in-window rows are deserialized.
/// <paramref name="sinceUtc"/> is server-derived, so interpolating it is injection-safe.
/// </summary>
public async Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null)
public Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null)
=> QueryAppInstallSummariesAsync(tenantId: null, sinceUtc, select: null);
/// <summary>
/// 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.
/// </summary>
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<TableEntity>(filter: filter);
"PartitionKey", "RowKey", "AppName", "Status", "StartedAt",
"DurationSeconds", "DownloadBytes", "FailureCode",
"DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp",
"DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers",
"DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer"
};
var summaries = new List<AppInstallSummary>();
await foreach (var entity in query)
{
summaries.Add(MapToAppInstallSummary(entity));
}
/// <summary>
/// Column-projected windowed scan for the app-metrics endpoints (metrics/app +
/// global/metrics/app). Returned objects carry ONLY the <see cref="AppMetricsProjection"/>
/// fields — everything else is defaults and must not be read.
/// </summary>
public Task<List<AppInstallSummary>> 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<AppInstallSummary>();
}
}
/// <summary>
/// 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.
/// </summary>
internal static readonly string[] AppsDashboardProjection =
{
"PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "AppType", "AppVersion",
"Status", "StartedAt", "CompletedAt", "DurationSeconds", "DownloadBytes",
"AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult"
};
/// <summary>
/// Column-projected windowed scan for the App Dashboard endpoints. Returned objects carry
/// ONLY the <see cref="AppsDashboardProjection"/> fields — everything else is defaults and
/// must not be read.
/// </summary>
public Task<List<AppInstallSummary>> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null)
=> QueryAppInstallSummariesAsync(tenantId, sinceUtc, AppsDashboardProjection);
/// <summary>
/// Columns the (SessionId, AppName) pair projection transfers. The usage-metrics compute
@@ -463,34 +501,8 @@ namespace AutopilotMonitor.Functions.Services
/// optional tenant partition). <paramref name="sinceUtc"/> is server-derived, so
/// interpolating it is injection-safe.
/// </summary>
public async Task<List<AppInstallSummary>> 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<TableEntity>(filter: filter, select: GeoAppInstallProjection);
var summaries = new List<AppInstallSummary>();
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<AppInstallSummary>();
}
}
public Task<List<AppInstallSummary>> 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.
@@ -41,6 +41,20 @@ namespace AutopilotMonitor.Shared.DataAccess
/// cross-tenant scan.
/// </summary>
Task<List<AppInstallSummary>> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null);
/// <summary>
/// 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 <paramref name="tenantId"/> for the cross-tenant scan.
/// </summary>
Task<List<AppInstallSummary>> GetAppMetricsSummariesAsync(DateTime sinceUtc, string? tenantId = null);
/// <summary>
/// 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 <paramref name="tenantId"/> for the cross-tenant scan.
/// </summary>
Task<List<AppInstallSummary>> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null);
// --- Platform Stats ---
Task<PlatformStats?> GetPlatformStatsAsync();
@@ -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<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const progress = useFetchProgress("appAnalytics.lastFetchMs");
const { begin: progressBegin, finish: progressFinish } = progress;
// Sessions panel state
const [sessions, setSessions] = useState<SessionsResponse | null>(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() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 space-y-6">
{loading || !analytics ? (
<div className="text-center text-gray-500 p-8">Loading…</div>
<CalculatingInline
label="Aggregating app analytics…"
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
) : (
<>
{/* Summary cards */}
@@ -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<SortDir>("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 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading…</div>
<CalculatingInline
label="Aggregating app installs…"
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
) : filteredAndSorted.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{search ? "No apps match your search." : "No app install data in this window."}
@@ -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 (
<>
<div
className="mt-6 w-full bg-gray-200 rounded-full h-2.5"
role="progressbar"
aria-valuenow={Math.round(progressPercent)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
></div>
</div>
<p className="mt-2 text-xs text-gray-500">
{elapsedSeconds}s elapsed
{overEstimate
? ' — taking a bit longer than usual…'
: ` · usually ~${Math.max(1, Math.round(estimateMs / 1000))}s`}
</p>
</>
);
}
export function CalculatingInline({
label,
elapsedMs,
estimateMs,
}: {
label: string;
elapsedMs: number;
estimateMs: number;
}) {
return (
<div className="p-8 text-center">
<div className="max-w-xs mx-auto">
<p className="text-gray-500">{label}</p>
<ProgressBarWithCaption elapsedMs={elapsedMs} estimateMs={estimateMs} />
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
@@ -28,24 +76,7 @@ export function CalculatingCard({
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">{title}</h2>
<p className="mt-2 text-sm text-gray-600">{subtitle}</p>
<div
className="mt-6 w-full bg-gray-200 rounded-full h-2.5"
role="progressbar"
aria-valuenow={Math.round(progressPercent)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
></div>
</div>
<p className="mt-2 text-xs text-gray-500">
{elapsedSeconds}s elapsed
{overEstimate
? ' — taking a bit longer than usual…'
: ` · usually ~${Math.max(1, Math.round(estimateMs / 1000))}s`}
</p>
<ProgressBarWithCaption elapsedMs={elapsedMs} estimateMs={estimateMs} />
</div>
</div>
</div>