perf(geo): parallel projected scans for geographic metrics + shared progress UX

Same pattern as the Platform Usage fix (e63a0bc2): the geographic endpoints
ran two sequential full-column window scans per request while the web app's
default 30s fetch timeout could abort the response client-side.

Backend:
- New column-projected queries: GetGeoWindowSessionsAsync ($select Geo*
  grouping fields + effective-duration inputs) and
  GetGeoAppInstallSummariesAsync ($select session join key, window filter,
  throughput inputs and all Delivery Optimization counters; returned
  AppInstallSummary objects carry only those fields by documented contract)
- All four geographic functions (tenant/global map + tenant/global location
  drilldown) run their scans concurrently via Task.WhenAll; the map
  endpoints use both projections, the drilldowns keep full-row sessions
  (LocationSessionRow returns nearly every column) but switch to the lean
  app scan
- GeoMetricsProjectionEquivalenceTests pins both projections, incl. the
  location grouping key and the DoDownloadMode=-1 "no DO telemetry" marker

Web:
- New shared useFetchProgress hook + CalculatingCard component (elapsed
  counter + progress bar sized from the last recorded fetch duration,
  capped at 95%)
- geographic-performance map + sessions drilldown: 180s AbortSignal and
  the calculating card instead of a bare loading line
- SectionPlatformUsage refactored onto the shared hook/component (drops
  its inline copy of the timer/estimate logic)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-11 12:28:17 +02:00
parent 556707bcd4
commit 86f5b50778
17 changed files with 473 additions and 106 deletions
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins that the column-projected scans driving the geographic endpoints produce identical
/// aggregation inputs to the full-row drains — sessions via <c>GeoMetricsSessionProjection</c>
/// (Geo* grouping fields + effective-duration inputs) and app installs via
/// <c>GeoAppInstallProjection</c> (join key, window filter, throughput inputs and every counter
/// <see cref="DoAggregator"/> sums). 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 GeoMetricsProjectionEquivalenceTests
{
private const string TenantId = "00000000-0000-0000-0000-000000000fe0";
private static readonly TableStorageService Sut =
new(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
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;
}
// ===== Sessions (map aggregation) =====
private static TableEntity FullSessionRow(string sessionId, string status, DateTime startedAt, int? durationSeconds)
{
var e = new TableEntity(TenantId, sessionId)
{
["Status"] = status,
["StartedAt"] = new DateTimeOffset(startedAt),
["GeoCountry"] = "DE",
["GeoRegion"] = "Hessen",
["GeoCity"] = "Frankfurt",
["GeoLoc"] = "50.11,8.68",
["IsPreProvisioned"] = false,
// Noise the geo aggregation never reads.
["SerialNumber"] = "SN-FULL",
["Manufacturer"] = "Contoso",
["OsName"] = "Windows 11",
["FailureSnapshotJson"] = "{\"big\":\"" + new string('x', 2000) + "\"}",
};
if (durationSeconds.HasValue) e["DurationSeconds"] = durationSeconds.Value;
return e;
}
[Fact]
public void Session_projection_preserves_geo_grouping_and_duration_inputs()
{
var full = FullSessionRow("s-geo", "Succeeded", DateTime.UtcNow.AddHours(-4), durationSeconds: 2400);
var fromFull = Sut.MapToSessionSummary(full);
var fromProjected = Sut.MapToSessionSummary(Project(full, TableStorageService.GeoMetricsSessionProjection));
Assert.Equal(fromFull.TenantId, fromProjected.TenantId);
Assert.Equal(fromFull.SessionId, fromProjected.SessionId);
Assert.Equal(fromFull.Status, fromProjected.Status);
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds);
Assert.Equal(fromFull.GeoCountry, fromProjected.GeoCountry);
Assert.Equal(fromFull.GeoRegion, fromProjected.GeoRegion);
Assert.Equal(fromFull.GeoCity, fromProjected.GeoCity);
Assert.Equal(fromFull.GeoLoc, fromProjected.GeoLoc);
// The grouping key must be identical too — it is composed from the Geo* fields.
Assert.Equal(
GetGeographicMetricsFunction.GetLocationKey(fromFull, "city"),
GetGeographicMetricsFunction.GetLocationKey(fromProjected, "city"));
}
// ===== App installs (throughput + Delivery Optimization) =====
private static TableEntity FullAppRow(string sessionId)
{
return new TableEntity(TenantId, Guid.NewGuid().ToString())
{
["SessionId"] = sessionId,
["AppName"] = "Contoso App",
["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-3)),
["DownloadBytes"] = 250_000_000L,
["DownloadDurationSeconds"] = 42,
["DoDownloadMode"] = 1,
["DoTotalBytesDownloaded"] = 250_000_000L,
["DoBytesFromPeers"] = 100_000_000L,
["DoBytesFromHttp"] = 150_000_000L,
["DoBytesFromLanPeers"] = 60_000_000L,
["DoBytesFromGroupPeers"] = 30_000_000L,
["DoBytesFromInternetPeers"] = 10_000_000L,
["DoBytesFromLinkLocalPeers"] = 0L,
["DoBytesFromCacheServer"] = 5_000_000L,
// Noise the geo aggregation never reads.
["Status"] = "Succeeded",
["FailureMessage"] = new string('y', 500),
["DetectionResult"] = "Detected",
};
}
[Fact]
public void App_projection_preserves_throughput_inputs_and_DoAggregator_result()
{
var full = FullAppRow("s-geo");
var fromFull = Sut.MapToAppInstallSummary(full);
var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.GeoAppInstallProjection));
Assert.Equal(fromFull.SessionId, fromProjected.SessionId);
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes);
Assert.Equal(fromFull.DownloadDurationSeconds, fromProjected.DownloadDurationSeconds);
var aggFull = DoAggregator.Compute(new List<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.BytesFromLanPeers, aggProjected.BytesFromLanPeers);
Assert.Equal(aggFull.BytesFromGroupPeers, aggProjected.BytesFromGroupPeers);
Assert.Equal(aggFull.BytesFromInternetPeers, aggProjected.BytesFromInternetPeers);
Assert.Equal(aggFull.BytesFromLinkLocalPeers, aggProjected.BytesFromLinkLocalPeers);
Assert.Equal(aggFull.BytesFromCacheServer, aggProjected.BytesFromCacheServer);
Assert.Equal(aggFull.PercentPeerCaching, aggProjected.PercentPeerCaching);
}
[Fact]
public void App_projection_preserves_no_DO_telemetry_marker()
{
// DoDownloadMode maps to -1 when the column is absent — a projected row without DO columns
// must stay "no telemetry" (DoAggregator filters on DoDownloadMode >= 0), not become 0/mode.
var full = FullAppRow("s-nodo");
full.Remove("DoDownloadMode");
var fromProjected = Sut.MapToAppInstallSummary(Project(full, TableStorageService.GeoAppInstallProjection));
Assert.Equal(-1, fromProjected.DoDownloadMode);
Assert.False(DoAggregator.Compute(new List<AppInstallSummary> { fromProjected }).HasTelemetry);
}
}
@@ -112,6 +112,7 @@ public class OffboardingSessionEnumeratorTests
public Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate, int maxResults = int.MaxValue, bool excludeInFlightDeletions = false) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
public Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours) => throw new NotImplementedException();
@@ -60,6 +60,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<SessionSummary>> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null)
=> _storage.GetUsageWindowSessionsAsync(startDate, endDate, tenantId);
public Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null)
=> _storage.GetGeoWindowSessionsAsync(startDate, endDate, tenantId);
public Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime)
=> _storage.GetStalledSessionsAsync(tenantId, cutoffTime);
@@ -44,6 +44,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<SessionAppRef>> GetAppInstallRefsAsync(DateTime sinceUtc, string? tenantId = null)
=> _storage.GetAppInstallRefsAsync(sinceUtc, tenantId);
public Task<List<AppInstallSummary>> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null)
=> _storage.GetGeoAppInstallSummariesAsync(sinceUtc, tenantId);
public Task<PlatformStats?> GetPlatformStatsAsync()
=> _storage.GetPlatformStatsAsync();
@@ -63,15 +63,21 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation("Fetching sessions for location '{LocationKey}' tenant {TenantId} ({Days}d, groupBy={GroupBy}, country={Country}, full={Full})",
locationKey ?? "(none)", tenantId, days, groupBy, country ?? "(none)", full);
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId);
// Sessions need the full row (LocationSessionRow returns nearly every column); the
// app scan only feeds the per-session DO aggregate, so it uses the geo projection.
// Both scans are independent — run them concurrently (same cutoff window).
var now = DateTime.UtcNow;
var cutoff = now.AddDays(-days);
var sessionsTask = _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, now.AddDays(1), tenantId);
var appSummariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantId);
await Task.WhenAll(sessionsTask, appSummariesTask);
var sessions = await sessionsTask;
var filtered = !string.IsNullOrEmpty(locationKey)
? FilterSessionsByLocation(sessions, locationKey, groupBy)
: FilterSessionsByFields(sessions, country!, query["region"], query["city"]);
// Same window as the sessions above (cutoff); BuildRows joins apps to those sessions.
var appSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff);
var rows = BuildRows(filtered, appSummaries);
var rows = BuildRows(filtered, await appSummariesTask);
var response = req.CreateResponse(HttpStatusCode.OK);
if (full)
@@ -44,16 +44,22 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var groupBy = query["groupBy"] ?? "city";
// Check if geo-location is enabled for this tenant
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
var geoEnabled = tenantConfig?.EnableGeoLocation ?? true;
// The three sources are independent — run them concurrently so wall-clock cost is
// the slowest scan, not the sum. Both scans are column-projected: the aggregation
// reads only Geo*/status/duration from sessions and join-key/throughput/DO counters
// from apps (see GeoMetricsSessionProjection / GeoAppInstallProjection).
var now = DateTime.UtcNow;
var cutoff = now.AddDays(-days);
var configTask = _configService.GetConfigurationAsync(tenantId);
var sessionsTask = _maintenanceRepo.GetGeoWindowSessionsAsync(cutoff, now.AddDays(1), tenantId);
var summariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantId);
await Task.WhenAll(configTask, sessionsTask, summariesTask);
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId);
// Push the window server-side so only in-window rows are deserialized. The in-memory
// Where below stays as the exact trim (the OData StartedAt filter is second-granular).
var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId, cutoff);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
var geoEnabled = (await configTask)?.EnableGeoLocation ?? true;
var sessions = await sessionsTask;
// The window is pushed server-side; the in-memory Where stays as the exact trim
// (the OData StartedAt filter is second-granular).
var summaries = (await summariesTask).Where(s => s.StartedAt >= cutoff).ToList();
var result = ComputeGeographicMetrics(sessions, summaries, groupBy);
result.GeoLocationEnabled = geoEnabled;
@@ -59,19 +59,22 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
_logger.LogInformation("Fetching global sessions for location '{LocationKey}' ({Days}d, groupBy={GroupBy}, country={Country}, full={Full}, tenantFilter={Tenant}) (User: {UserEmail})",
locationKey ?? "(none)", days, groupBy, country ?? "(none)", full, filterTenantId ?? "(none)", userEmail);
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = string.IsNullOrEmpty(filterTenantId)
? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1))
: await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), filterTenantId);
// Sessions need the full row (LocationSessionRow returns nearly every column); the
// app scan only feeds the per-session DO aggregate, so it uses the geo projection.
// Both scans are independent — run them concurrently (same cutoff window).
var now = DateTime.UtcNow;
var cutoff = now.AddDays(-days);
var tenantFilter = string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId;
var sessionsTask = _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, now.AddDays(1), tenantFilter);
var appSummariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantFilter);
await Task.WhenAll(sessionsTask, appSummariesTask);
var sessions = await sessionsTask;
var filtered = !string.IsNullOrEmpty(locationKey)
? GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy)
: GetGeographicLocationSessionsFunction.FilterSessionsByFields(sessions, country!, query["region"], query["city"]);
// Same window as the sessions above (cutoff); BuildRows joins apps to those sessions.
var appSummaries = string.IsNullOrEmpty(filterTenantId)
? await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff)
: await _metricsRepo.GetAppInstallSummariesByTenantAsync(filterTenantId, cutoff);
var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, appSummaries);
var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, await appSummariesTask);
var response = req.CreateResponse(HttpStatusCode.OK);
if (full)
@@ -43,16 +43,20 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var groupBy = query["groupBy"] ?? "city";
var tenantIdFilter = query["tenantId"];
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantIdFilter)
: await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1));
// Push the window server-side so only in-window rows are deserialized. The in-memory
// Where below stays as the exact trim (the OData StartedAt filter is second-granular).
var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter, cutoff)
: await _metricsRepo.GetAllAppInstallSummariesAsync(cutoff);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
// Both scans are independent — run them concurrently, and column-projected: the
// aggregation reads only Geo*/status/duration from sessions and join-key/throughput/
// DO counters from apps (see GeoMetricsSessionProjection / GeoAppInstallProjection).
var now = DateTime.UtcNow;
var cutoff = now.AddDays(-days);
var tenantFilter = string.IsNullOrWhiteSpace(tenantIdFilter) ? null : tenantIdFilter;
var sessionsTask = _maintenanceRepo.GetGeoWindowSessionsAsync(cutoff, now.AddDays(1), tenantFilter);
var summariesTask = _metricsRepo.GetGeoAppInstallSummariesAsync(cutoff, tenantFilter);
await Task.WhenAll(sessionsTask, summariesTask);
var sessions = await sessionsTask;
// The window is pushed server-side; the in-memory Where stays as the exact trim
// (the OData StartedAt filter is second-granular).
var summaries = (await summariesTask).Where(s => s.StartedAt >= cutoff).ToList();
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy);
@@ -543,6 +543,27 @@ namespace AutopilotMonitor.Functions.Services
public Task<List<SessionSummary>> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null)
=> QuerySessionsByDateRangeAsync(startDate, endDate, tenantId, UsageMetricsSessionProjection);
/// <summary>
/// Columns the geographic-metrics aggregation (ComputeGeographicMetrics + location grouping)
/// consumes, plus the structural PartitionKey (TenantId) / RowKey (SessionId). CompletedAt /
/// IsPreProvisioned / ResumedAt stay in the set for ComputeEffectiveDuration parity (see
/// <see cref="UsageMetricsSessionProjection"/>). internal so
/// GeoMetricsProjectionEquivalenceTests derives its keep-set from this exact array.
/// </summary>
internal static readonly string[] GeoMetricsSessionProjection =
{
"PartitionKey", "RowKey", "StartedAt", "CompletedAt", "Status", "DurationSeconds",
"IsPreProvisioned", "ResumedAt", "GeoCountry", "GeoRegion", "GeoCity", "GeoLoc"
};
/// <summary>
/// Column-projected date-range query for the geographic-metrics aggregation (map view).
/// The drilldown endpoints keep the full-row query — their response shape returns nearly
/// every session column.
/// </summary>
public Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null)
=> QuerySessionsByDateRangeAsync(startDate, endDate, tenantId, GeoMetricsSessionProjection);
private async Task<List<SessionSummary>> QuerySessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId, string[]? select)
{
if (!string.IsNullOrEmpty(tenantId))
@@ -441,7 +441,60 @@ namespace AutopilotMonitor.Functions.Services
}
}
private AppInstallSummary MapToAppInstallSummary(TableEntity entity)
/// <summary>
/// Columns the geographic aggregations consume: the session join key, the window filter
/// column, download throughput inputs and every field <see cref="DoAggregator"/> sums.
/// internal so GeoMetricsProjectionEquivalenceTests derives its keep-set from this array.
/// </summary>
internal static readonly string[] GeoAppInstallProjection =
{
"PartitionKey", "RowKey", "SessionId", "StartedAt",
"DownloadBytes", "DownloadDurationSeconds",
"DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp",
"DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers",
"DoBytesFromLinkLocalPeers", "DoBytesFromCacheServer"
};
/// <summary>
/// Column-projected windowed AppInstallSummaries scan for the geographic endpoints. Returns
/// <see cref="AppInstallSummary"/> objects with ONLY the <see cref="GeoAppInstallProjection"/>
/// fields populated (everything else is defaults) — callers must not read fields outside the
/// projection. Same filter semantics as the full getters (server-side <c>StartedAt ge</c> +
/// 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>();
}
}
// internal (not private) so GeoMetricsProjectionEquivalenceTests can pin that a row
// carrying only GeoAppInstallProjection maps to the same geo-relevant fields as a full row.
internal AppInstallSummary MapToAppInstallSummary(TableEntity entity)
{
return new AppInstallSummary
{
@@ -74,6 +74,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// the projection come back as defaults — callers must not read them.
/// </summary>
Task<List<SessionSummary>> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null);
/// <summary>
/// Column-projected variant of <see cref="GetSessionsByDateRangeAsync"/> for the
/// geographic-metrics aggregation (map view): Geo* fields + status/duration inputs only.
/// Fields outside the projection come back as defaults — callers must not read them.
/// </summary>
Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null);
Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime);
Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff);
Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours);
@@ -33,6 +33,14 @@ namespace AutopilotMonitor.Shared.DataAccess
/// for the cross-tenant scan.
/// </summary>
Task<List<SessionAppRef>> GetAppInstallRefsAsync(DateTime sinceUtc, string? tenantId = null);
/// <summary>
/// Column-projected windowed scan for the geographic endpoints: session join key, window
/// filter column, download throughput inputs and the Delivery Optimization counters. The
/// returned <see cref="AppInstallSummary"/> 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>> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null);
// --- Platform Stats ---
Task<PlatformStats?> GetPlatformStatsAsync();
@@ -5,24 +5,13 @@ import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuth } from '../../../../contexts/AuthContext';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { CalculatingCard } from "@/components/CalculatingCard";
// A fresh cross-tenant compute takes tens of seconds server-side; the default 30s fetch timeout
// aborted it client-side (HTTP 499 on the backend) while the server kept computing. Give this one
// call generous headroom — the backend caches the result for 15 minutes afterwards.
const FETCH_TIMEOUT_MS = 180_000;
// Last observed fresh-compute fetch duration, used to size the progress bar. Only fresh computes
// are recorded (a cache hit finishing in ~100ms would make the next cold load look stuck at 95%).
const ESTIMATE_STORAGE_KEY = 'platformUsage.lastFreshFetchMs';
const DEFAULT_ESTIMATE_MS = 45_000;
function readFetchEstimateMs(): number {
try {
const v = Number(localStorage.getItem(ESTIMATE_STORAGE_KEY));
return Number.isFinite(v) && v > 1_000 ? v : DEFAULT_ESTIMATE_MS;
} catch {
return DEFAULT_ESTIMATE_MS;
}
}
interface SessionMetrics {
total: number;
@@ -118,21 +107,13 @@ export function SectionPlatformUsage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [loadStartedAt, setLoadStartedAt] = useState<number | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const [estimateMs, setEstimateMs] = useState(DEFAULT_ESTIMATE_MS);
// Tick the elapsed clock while a request is in flight so the calculating card can show
// progress instead of an opaque spinner.
useEffect(() => {
if (loadStartedAt === null) return;
setElapsedMs(0);
const id = setInterval(() => setElapsedMs(Date.now() - loadStartedAt), 500);
return () => clearInterval(id);
}, [loadStartedAt]);
// Progress estimate records only fresh computes — a 15-min-cache hit finishing in ~100ms
// would make the next cold load look stuck at the bar's cap.
const progress = useFetchProgress('platformUsage.lastFreshFetchMs');
const { begin: progressBegin, finish: progressFinish } = progress;
const fetchMetrics = async (showRefreshing = false) => {
const startedAt = Date.now();
let recordEstimate = false;
try {
if (showRefreshing) {
setRefreshing(true);
@@ -140,8 +121,7 @@ export function SectionPlatformUsage() {
setLoading(true);
}
setError(null);
setEstimateMs(readFetchEstimateMs());
setLoadStartedAt(startedAt);
progressBegin();
// Platform-wide metrics - cross-tenant (Global Admin only)
const response = await authenticatedFetch(api.metrics.globalUsage(), getAccessToken, {
@@ -154,9 +134,7 @@ export function SectionPlatformUsage() {
const data: PlatformUsageMetrics = await response.json();
setMetrics(data);
if (!data.fromCache) {
try { localStorage.setItem(ESTIMATE_STORAGE_KEY, String(Date.now() - startedAt)); } catch { /* storage unavailable */ }
}
recordEstimate = !data.fromCache;
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error('Session expired:', err.message);
@@ -169,9 +147,9 @@ export function SectionPlatformUsage() {
setError(err instanceof Error ? err.message : 'Failed to fetch platform usage metrics');
}
} finally {
progressFinish(recordEstimate);
setLoading(false);
setRefreshing(false);
setLoadStartedAt(null);
}
};
@@ -196,36 +174,13 @@ export function SectionPlatformUsage() {
};
if (loading) {
// Progress is an estimate from the last fresh compute — capped at 95% so it never claims
// "done" before the response actually lands.
const progressPercent = Math.min(95, (elapsedMs / estimateMs) * 100);
const elapsedSeconds = Math.floor(elapsedMs / 1000);
const overEstimate = elapsedMs > estimateMs;
return (
<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">
<div className="text-center">
<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">Calculating platform metrics…</h2>
<p className="mt-2 text-sm text-gray-600">
Aggregating sessions, app installs and user activity across all tenants.
A fresh calculation can take a minute — the result is then cached for 15 minutes.
</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>
</div>
</div>
</div>
<CalculatingCard
title="Calculating platform metrics…"
subtitle="Aggregating sessions, app installs and user activity across all tenants. A fresh calculation can take a minute — the result is then cached for 15 minutes."
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
);
}
@@ -289,7 +244,7 @@ export function SectionPlatformUsage() {
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{refreshing ? `Refreshing… ${Math.floor(elapsedMs / 1000)}s` : 'Refresh'}</span>
<span>{refreshing ? `Refreshing… ${Math.floor(progress.elapsedMs / 1000)}s` : 'Refresh'}</span>
</button>
</div>
</div>
@@ -9,8 +9,14 @@ import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAggregatedAdminScope } from "@/hooks";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { CalculatingCard } from "@/components/CalculatingCard";
// A cross-tenant geo aggregation can take tens of seconds server-side; the default 30s fetch
// timeout would abort it client-side while the server keeps computing.
const GEO_FETCH_TIMEOUT_MS = 180_000;
// Dynamically import the map component (Leaflet requires window/document)
const GeoMap = dynamic(() => import("./GeoMap"), { ssr: false });
@@ -124,16 +130,24 @@ export default function GeographicPerformancePage() {
const scope = useAggregatedAdminScope();
const { isGlobalAdmin, routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
const progress = useFetchProgress("geoPerf.lastFetchMs");
const { begin: progressBegin, finish: progressFinish } = progress;
const fetchGeoMetrics = useCallback(async (range: TimeRange = timeRange, group: GroupBy = groupBy) => {
let succeeded = false;
try {
progressBegin();
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = routeGlobal
? api.metrics.globalGeographic(days, group, selectedTenantId || undefined)
: api.metrics.geographic(tenantId, days, group);
const response = await authenticatedFetch(endpoint, getAccessToken);
const response = await authenticatedFetch(endpoint, getAccessToken, {
signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS),
});
if (response.ok) {
const data = await response.json();
setGeoMetrics(data);
succeeded = true;
}
} catch (error) {
if (error instanceof TokenExpiredError) {
@@ -142,9 +156,10 @@ export default function GeographicPerformancePage() {
console.error("Failed to fetch geographic metrics:", error);
}
} finally {
progressFinish(succeeded);
setLoading(false);
}
}, [routeGlobal, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy]);
}, [routeGlobal, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy, progressBegin, progressFinish]);
useEffect(() => {
if (!scopeInitialized) return;
@@ -196,9 +211,12 @@ export default function GeographicPerformancePage() {
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-gray-600">Loading geographic performance data...</div>
</div>
<CalculatingCard
title="Calculating geographic metrics…"
subtitle="Aggregating sessions, download throughput and Delivery Optimization data by location."
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
);
}
@@ -12,6 +12,12 @@ import { SessionStatusBadge } from "@/components/SessionStatusBadge";
import { GlobalAdminBanner } from "@/components/GlobalAdminBanner";
import { boundTenantToDelegatedScope } from "@/utils/delegatedScope";
import { isHomeTenantTarget } from "@/utils/homeTenantScope";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { CalculatingCard } from "@/components/CalculatingCard";
// A cross-tenant drilldown can take tens of seconds server-side; the default 30s fetch
// timeout would abort it client-side while the server keeps computing.
const GEO_FETCH_TIMEOUT_MS = 180_000;
interface SessionSummary {
sessionId: string;
@@ -112,16 +118,24 @@ function LocationSessionsContent() {
// raw presence so it never falls back to the caller's own-tenant endpoint.
const boundedTenantId = boundTenantToDelegatedScope(urlTenantId || undefined, isDelegatedScope, user?.delegatedTenantIds);
const progress = useFetchProgress("geoSessions.lastFetchMs");
const { begin: progressBegin, finish: progressFinish } = progress;
const fetchSessions = useCallback(async () => {
if (!locationKey) return;
let succeeded = false;
try {
progressBegin();
const endpoint = crossTenant
? api.metrics.globalGeographicSessions(Number(days), groupBy, locationKey, boundedTenantId)
: api.metrics.geographicSessions(tenantId, Number(days), groupBy, locationKey);
const response = await authenticatedFetch(endpoint, getAccessToken);
const response = await authenticatedFetch(endpoint, getAccessToken, {
signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS),
});
if (response.ok) {
const result = await response.json();
setData(result);
succeeded = true;
}
} catch (error) {
if (error instanceof TokenExpiredError) {
@@ -130,9 +144,10 @@ function LocationSessionsContent() {
console.error("Failed to fetch location sessions:", error);
}
} finally {
progressFinish(succeeded);
setLoading(false);
}
}, [crossTenant, boundedTenantId, tenantId, getAccessToken, days, groupBy, locationKey]);
}, [crossTenant, boundedTenantId, tenantId, getAccessToken, days, groupBy, locationKey, progressBegin, progressFinish]);
useEffect(() => {
if (!crossTenant && !tenantId) return;
@@ -168,9 +183,12 @@ function LocationSessionsContent() {
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-gray-600">Loading sessions for {locationKey}...</div>
</div>
<CalculatingCard
title={`Loading sessions for ${locationKey}…`}
subtitle="Collecting sessions and per-session Delivery Optimization data for this location."
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
);
}
@@ -0,0 +1,53 @@
'use client';
/**
* Full-page loading card for long-running server aggregations. Shows a progress bar sized
* from the last observed fetch duration (see useFetchProgress) plus an elapsed counter, so
* the user sees the request is alive instead of staring at a bare spinner. The bar is capped
* at 95% — it never claims "done" before the response actually lands.
*/
export function CalculatingCard({
title,
subtitle,
elapsedMs,
estimateMs,
}: {
title: string;
subtitle: string;
elapsedMs: number;
estimateMs: number;
}) {
const progressPercent = Math.min(95, (elapsedMs / estimateMs) * 100);
const elapsedSeconds = Math.floor(elapsedMs / 1000);
const overEstimate = elapsedMs > estimateMs;
return (
<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">
<div className="text-center">
<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>
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Elapsed/estimate tracking for long-running fetches so loading states can show honest
* progress instead of an opaque spinner. The estimate is the last recorded fetch duration
* (persisted per storageKey in localStorage); callers should record only "fresh" computes —
* a server cache hit finishing in ~100ms would make the next cold load look stuck at the cap.
*
* Usage: call begin() when the request starts and finish(recordEstimate) in finally.
*/
export const DEFAULT_FETCH_ESTIMATE_MS = 45_000;
function readEstimateMs(storageKey: string, defaultEstimateMs: number): number {
try {
const v = Number(localStorage.getItem(storageKey));
return Number.isFinite(v) && v > 1_000 ? v : defaultEstimateMs;
} catch {
return defaultEstimateMs;
}
}
export function useFetchProgress(storageKey: string, defaultEstimateMs = DEFAULT_FETCH_ESTIMATE_MS) {
const startedAtRef = useRef<number | null>(null);
const [active, setActive] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const [estimateMs, setEstimateMs] = useState(defaultEstimateMs);
useEffect(() => {
if (!active) return;
const id = setInterval(() => {
if (startedAtRef.current !== null) setElapsedMs(Date.now() - startedAtRef.current);
}, 500);
return () => clearInterval(id);
}, [active]);
const begin = useCallback(() => {
startedAtRef.current = Date.now();
setElapsedMs(0);
setEstimateMs(readEstimateMs(storageKey, defaultEstimateMs));
setActive(true);
}, [storageKey, defaultEstimateMs]);
const finish = useCallback((recordEstimate = true) => {
const started = startedAtRef.current;
if (recordEstimate && started !== null) {
try {
localStorage.setItem(storageKey, String(Date.now() - started));
} catch {
// storage unavailable — keep the default estimate
}
}
startedAtRef.current = null;
setActive(false);
}, [storageKey]);
return { begin, finish, elapsedMs, estimateMs, active };
}