mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
perf(apps): projected app-install scans behind one query core + progress UX for the apps surface
Third slice of the metrics-performance pass (aftere63a0bc2usage,86f5b507geo): 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user