mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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();
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
|
||||
+11
-5
@@ -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)
|
||||
|
||||
+15
-9
@@ -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;
|
||||
|
||||
+12
-9
@@ -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)
|
||||
|
||||
+14
-10
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user