From b76a6ccaf038f3b30b78cf84c7076d6aa8895e92 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 11 May 2026 13:40:40 +0200 Subject: [PATCH] fix(dashboard/stats): server-aggregate cards so they stop drifting with pagination The five dashboard cards (Active Sessions, Success Rate, Avg. Duration, Total Today, Failed Today) were derived from whatever sessions the client had paginated into memory. With default pageSize=10 and SignalR-driven appends, "Total Today" would jump 40 -> 22 -> 20 within a single visit as the list grew/shrank. "Last 7 days" was a printed label only - the client compute had no 7-day filter at all. Now: - GET /api/sessions/stats?days=7 (MemberRead, JWT tenant) - GET /api/global/sessions/stats?days=7&tenantId= (GlobalAdminOnly, optional tenant filter; absent = cross-tenant sum) - TableStorageService.AggregateSessionStats drains SessionsIndex once over the window: UTC-midnight boundary for today, success rate over terminal sessions only (InProgress no longer drags it down), avg duration over Succeeded with DurationSeconds > 0 only. - useDashboardStats hook owns the cards: initial fetch + debounced refetch on SignalR newSession/newevents in the active scope + forced refetch on reconnect to recover messages missed during an outage. - Client compute removed from useDashboardFilters. New submittedTenantIdFilter state isolates typing-in-filter from submitted-filter so keystrokes never trigger backend round-trips. Tests: 17 xUnit (aggregation invariants + days parsing) + 7 vitest (URL builder). Suites: backend 1177/1178 (unrelated RawTablePaginationTests flake), web 266/266. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SessionStatsAggregationTests.cs | 196 ++++++++++++++ .../TableStorage/TableSessionRepository.cs | 6 + .../Sessions/GetAllSessionStatsFunction.cs | 72 +++++ .../Sessions/GetSessionStatsFunction.cs | 81 ++++++ .../Security/EndpointAccessPolicyCatalog.cs | 2 + .../Services/TableStorageService.Sessions.cs | 95 +++++++ .../DataAccess/ISessionRepository.cs | 14 + .../Models/SessionApiModels.cs | 37 +++ .../dashboard/hooks/useDashboardFilters.ts | 36 +-- .../app/dashboard/hooks/useDashboardStats.ts | 245 ++++++++++++++++++ .../app/dashboard/page.tsx | 39 ++- .../lib/__tests__/sessionStatsApi.test.ts | 74 ++++++ src/Web/autopilot-monitor-web/lib/api.ts | 7 + 13 files changed, 864 insertions(+), 40 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/SessionStatsAggregationTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/sessionStatsApi.test.ts diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatsAggregationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatsAggregationTests.cs new file mode 100644 index 00000000..8245a756 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionStatsAggregationTests.cs @@ -0,0 +1,196 @@ +using AutopilotMonitor.Functions.Functions.Sessions; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests the pure aggregation pass that powers the dashboard stats cards. +/// HTTP-trigger end (auth, repo wiring) is covered by the live runtime; here +/// we only assert the deterministic counting + today/window-boundary logic. +/// +public class SessionStatsAggregationTests +{ + private static SessionSummary MakeSession( + SessionStatus status, + DateTime startedAt, + int? durationSeconds = null) + => new() + { + SessionId = Guid.NewGuid().ToString(), + TenantId = "00000000-0000-0000-0000-000000000001", + Status = status, + StartedAt = startedAt, + DurationSeconds = durationSeconds, + }; + + [Fact] + public void Empty_session_list_yields_zero_stats() + { + var stats = TableStorageService.AggregateSessionStats(Array.Empty(), days: 7); + + Assert.Equal(7, stats.Days); + Assert.Equal(0, stats.ActiveCount); + Assert.Equal(0, stats.TotalLastNDays); + Assert.Equal(0, stats.SucceededLastNDays); + Assert.Equal(0, stats.FailedLastNDays); + Assert.Equal(0, stats.SuccessRatePct); + Assert.Equal(0, stats.AvgDurationMinutes); + Assert.Equal(0, stats.TotalToday); + Assert.Equal(0, stats.FailedToday); + } + + [Fact] + public void Active_count_includes_InProgress_Pending_Stalled_but_not_terminal() + { + var now = DateTime.UtcNow; + var sessions = new[] + { + MakeSession(SessionStatus.InProgress, now.AddHours(-1)), + MakeSession(SessionStatus.Pending, now.AddHours(-2)), + MakeSession(SessionStatus.Stalled, now.AddHours(-3)), + MakeSession(SessionStatus.Succeeded, now.AddHours(-4), 600), + MakeSession(SessionStatus.Failed, now.AddHours(-5)), + MakeSession(SessionStatus.Unknown, now.AddHours(-6)), + }; + + var stats = TableStorageService.AggregateSessionStats(sessions, days: 7); + + Assert.Equal(3, stats.ActiveCount); + } + + [Fact] + public void Success_rate_is_succeeded_over_terminal_only() + { + var now = DateTime.UtcNow; + // 3 succeeded + 1 failed → 75% (NOT 60% — InProgress must not pull rate down) + var sessions = new[] + { + MakeSession(SessionStatus.Succeeded, now.AddHours(-1), 600), + MakeSession(SessionStatus.Succeeded, now.AddHours(-2), 700), + MakeSession(SessionStatus.Succeeded, now.AddHours(-3), 800), + MakeSession(SessionStatus.Failed, now.AddHours(-4)), + MakeSession(SessionStatus.InProgress, now.AddHours(-5)), + }; + + var stats = TableStorageService.AggregateSessionStats(sessions, days: 7); + + Assert.Equal(75, stats.SuccessRatePct); + } + + [Fact] + public void Success_rate_zero_when_no_terminal_sessions_in_window() + { + var now = DateTime.UtcNow; + var sessions = new[] + { + MakeSession(SessionStatus.InProgress, now.AddHours(-1)), + MakeSession(SessionStatus.Stalled, now.AddHours(-2)), + }; + + var stats = TableStorageService.AggregateSessionStats(sessions, days: 7); + + Assert.Equal(0, stats.SuccessRatePct); + } + + [Fact] + public void Avg_duration_uses_succeeded_only_with_positive_duration() + { + var now = DateTime.UtcNow; + var sessions = new[] + { + MakeSession(SessionStatus.Succeeded, now.AddHours(-1), 1800), // 30 min + MakeSession(SessionStatus.Succeeded, now.AddHours(-2), 600), // 10 min + MakeSession(SessionStatus.Succeeded, now.AddHours(-3), null), // missing duration — skipped + MakeSession(SessionStatus.Failed, now.AddHours(-4), 7200), // failed — skipped (often runaway durations skew the avg) + MakeSession(SessionStatus.InProgress, now.AddHours(-5), 60), // active — skipped + }; + + var stats = TableStorageService.AggregateSessionStats(sessions, days: 7); + + // (30 + 10) / 2 = 20 minutes + Assert.Equal(20, stats.AvgDurationMinutes); + } + + [Fact] + public void Today_counters_use_UTC_midnight_boundary() + { + var utcMidnight = DateTime.UtcNow.Date; + var sessions = new[] + { + MakeSession(SessionStatus.Succeeded, utcMidnight.AddHours(2), 600), // today + MakeSession(SessionStatus.Failed, utcMidnight.AddHours(5)), // today + MakeSession(SessionStatus.Succeeded, utcMidnight.AddSeconds(-1)), // yesterday (one tick before UTC midnight) + MakeSession(SessionStatus.Failed, utcMidnight.AddDays(-1).AddHours(10)), // yesterday + }; + + var stats = TableStorageService.AggregateSessionStats(sessions, days: 7); + + Assert.Equal(2, stats.TotalToday); + Assert.Equal(1, stats.FailedToday); + } + + [Fact] + public void Total_last_n_days_equals_input_list_count() + { + // The drain caller is responsible for the days-window scope — + // aggregation just trusts whatever it gets and reports the total it saw. + var now = DateTime.UtcNow; + var sessions = new[] + { + MakeSession(SessionStatus.Succeeded, now.AddDays(-1), 600), + MakeSession(SessionStatus.Failed, now.AddDays(-2)), + MakeSession(SessionStatus.InProgress, now.AddDays(-3)), + }; + + var stats = TableStorageService.AggregateSessionStats(sessions, days: 7); + + Assert.Equal(3, stats.TotalLastNDays); + } + + [Fact] + public void ComputedAt_is_set_to_utc_now_within_tolerance() + { + var before = DateTime.UtcNow; + var stats = TableStorageService.AggregateSessionStats(Array.Empty(), days: 7); + var after = DateTime.UtcNow; + + Assert.InRange(stats.ComputedAt, before.AddSeconds(-1), after.AddSeconds(1)); + } + + // ── Days-query parsing on the HTTP layer ──────────────────────────────── + + [Fact] + public void TryParseDays_defaults_to_seven_when_missing() + { + var ok = GetSessionStatsFunction.TryParseDays(null, out var days, out var error); + Assert.True(ok); + Assert.Equal(7, days); + Assert.Null(error); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("366")] + [InlineData("banana")] + public void TryParseDays_rejects_invalid_input(string raw) + { + var ok = GetSessionStatsFunction.TryParseDays(raw, out _, out var error); + Assert.False(ok); + Assert.NotNull(error); + } + + [Theory] + [InlineData("1", 1)] + [InlineData("7", 7)] + [InlineData("30", 30)] + [InlineData("365", 365)] + public void TryParseDays_accepts_valid_input(string raw, int expected) + { + var ok = GetSessionStatsFunction.TryParseDays(raw, out var days, out var error); + Assert.True(ok); + Assert.Equal(expected, days); + Assert.Null(error); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 8ff3a94d..4fdd1ae9 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -46,6 +46,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation) => _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation); + public Task GetSessionStatsAsync(string tenantId, int days) + => _storage.GetSessionStatsAsync(tenantId, days); + + public Task GetAllSessionStatsAsync(string? tenantIdFilter, int days) + => _storage.GetAllSessionStatsAsync(tenantIdFilter, days); + public Task DeleteSessionAsync(string tenantId, string sessionId) => _storage.DeleteSessionAsync(tenantId, sessionId); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs new file mode 100644 index 00000000..540a8638 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Web; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// Cross-tenant dashboard stats (Global Admin). Optional ?tenantId= + /// restricts the window to a single tenant; absent means platform-wide. + /// + public class GetAllSessionStatsFunction + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepo; + + public GetAllSessionStatsFunction(ILogger logger, ISessionRepository sessionRepo) + { + _logger = logger; + _sessionRepo = sessionRepo; + } + + [Function("GetAllSessionStats")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/sessions/stats")] HttpRequestData req) + { + try + { + // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware. + var callerTenantId = TenantHelper.GetTenantId(req); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + + if (!GetSessionStatsFunction.TryParseDays(query["days"], out var days, out var error)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = error }); + return bad; + } + + var tenantIdFilterRaw = query["tenantId"]; + string? tenantIdFilter = null; + if (!string.IsNullOrEmpty(tenantIdFilterRaw)) + { + if (!Guid.TryParse(tenantIdFilterRaw, out _)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId format" }); + return bad; + } + tenantIdFilter = tenantIdFilterRaw; + } + + _logger.LogInformation( + "Computing cross-tenant session stats (caller={Caller}, filter={Filter}, days={Days})", + callerTenantId, tenantIdFilter ?? "none", days); + + var stats = await _sessionRepo.GetAllSessionStatsAsync(tenantIdFilter, days); + return await req.OkAsync(new { success = true, stats }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error computing cross-tenant session stats"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs new file mode 100644 index 00000000..f56b8192 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Net; +using System.Web; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// Per-tenant dashboard stats. Returns the same numbers regardless of how the + /// client has paginated the session list — the aggregation runs server-side + /// over a SessionsIndex window. + /// + public class GetSessionStatsFunction + { + private const int DefaultDays = 7; + private const int MaxDays = 365; + + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepo; + + public GetSessionStatsFunction(ILogger logger, ISessionRepository sessionRepo) + { + _logger = logger; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionStats")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/stats")] HttpRequestData req) + { + try + { + // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware. + var tenantId = TenantHelper.GetTenantId(req); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + + if (!TryParseDays(query["days"], out var days, out var error)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = error }); + return bad; + } + + _logger.LogInformation( + "Computing session stats (tenant={TenantId}, days={Days})", + tenantId, days); + + var stats = await _sessionRepo.GetSessionStatsAsync(tenantId, days); + return await req.OkAsync(new { success = true, stats }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error computing session stats"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + + internal static bool TryParseDays(string? raw, out int days, out string? error) + { + days = DefaultDays; + error = null; + if (string.IsNullOrEmpty(raw)) return true; + + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + || parsed < 1 || parsed > MaxDays) + { + error = $"days must be a positive integer between 1 and {MaxDays}"; + return false; + } + + days = parsed; + return true; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 19e3c2cf..6797ce16 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -137,6 +137,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "search/sessions-by-cve", EndpointPolicy.MemberRead), new("GET", "metrics/summary", EndpointPolicy.MemberRead), new("GET", "sessions", EndpointPolicy.MemberRead), + new("GET", "sessions/stats", EndpointPolicy.MemberRead), new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead, TenantScoping.QueryParam), // sessions/{sessionId}/signals + /decision-graph live in the GlobalAdminOnly block below @@ -235,6 +236,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), new("POST", "preview/send-welcome-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), new("GET", "global/sessions", EndpointPolicy.GlobalAdminOnly), + new("GET", "global/sessions/stats", EndpointPolicy.GlobalAdminOnly), new("GET", "global/audit/logs", EndpointPolicy.GlobalAdminOnly), new("GET", "global/metrics/platform", EndpointPolicy.GlobalAdminOnly), new("GET", "global/metrics/app", EndpointPolicy.GlobalAdminOnly), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 0c34f128..9332555e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -793,6 +793,101 @@ namespace AutopilotMonitor.Functions.Services return all; } + /// + /// Aggregates the per-tenant dashboard stats in a single SessionsIndex drain. + /// Computed server-side so the cards don't depend on whatever the client has + /// paginated into memory. + /// + public async Task GetSessionStatsAsync(string tenantId, int days) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + if (days < 1) throw new ArgumentOutOfRangeException(nameof(days)); + + var sessions = await GetSessionsAsync(tenantId, days); + return AggregateSessionStats(sessions, days); + } + + /// + /// Cross-tenant variant. Routes through the per-tenant index when + /// is set (cheaper scan). + /// + public async Task GetAllSessionStatsAsync(string? tenantIdFilter, int days) + { + if (days < 1) throw new ArgumentOutOfRangeException(nameof(days)); + + var sessions = await GetAllSessionsAsync(tenantIdFilter, days); + return AggregateSessionStats(sessions, days); + } + + /// + /// Pure aggregation pass over the windowed session list. Today-counters use + /// UTC midnight as the boundary so every caller sees the same number regardless + /// of browser timezone. + /// + internal static SessionStats AggregateSessionStats(IReadOnlyList sessions, int days) + { + var utcMidnight = DateTime.UtcNow.Date; + + int active = 0; + int succeeded = 0; + int failed = 0; + long succeededDurationSeconds = 0; + int succeededWithDurationCount = 0; + int totalToday = 0; + int failedToday = 0; + + foreach (var s in sessions) + { + switch (s.Status) + { + case SessionStatus.InProgress: + case SessionStatus.Pending: + case SessionStatus.Stalled: + active++; + break; + case SessionStatus.Succeeded: + succeeded++; + if (s.DurationSeconds is int d && d > 0) + { + succeededDurationSeconds += d; + succeededWithDurationCount++; + } + break; + case SessionStatus.Failed: + failed++; + break; + } + + if (s.StartedAt >= utcMidnight) + { + totalToday++; + if (s.Status == SessionStatus.Failed) failedToday++; + } + } + + int terminal = succeeded + failed; + int successRatePct = terminal > 0 + ? (int)Math.Round((double)succeeded / terminal * 100.0) + : 0; + int avgDurationMinutes = succeededWithDurationCount > 0 + ? (int)Math.Round((double)succeededDurationSeconds / succeededWithDurationCount / 60.0) + : 0; + + return new SessionStats + { + Days = days, + ActiveCount = active, + TotalLastNDays = sessions.Count, + SucceededLastNDays = succeeded, + FailedLastNDays = failed, + SuccessRatePct = successRatePct, + AvgDurationMinutes = avgDurationMinutes, + TotalToday = totalToday, + FailedToday = failedToday, + ComputedAt = DateTime.UtcNow, + }; + } + /// /// Fallback: queries the Sessions table directly (pre-migration, before SessionsIndex is populated). /// diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 3f035fb9..3ee2e64d 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -47,6 +47,20 @@ namespace AutopilotMonitor.Shared.DataAccess Task> GetAllSessionsPageAsync( string? tenantIdFilter, int? days, int pageSize, string? continuation); + /// + /// Server-side aggregation for the dashboard stats cards (per-tenant scope). + /// Drains the SessionsIndex for the last days and computes + /// counts in a single pass — the client never derives these from a paginated list. + /// + Task GetSessionStatsAsync(string tenantId, int days); + + /// + /// Cross-tenant variant of (Global Admin). + /// optionally restricts to a single tenant + /// (routes through the per-tenant index for cheaper scan). + /// + Task GetAllSessionStatsAsync(string? tenantIdFilter, int days); + Task DeleteSessionAsync(string tenantId, string sessionId); // --- Session Updates --- diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 93685f4c..ab338241 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -339,6 +339,43 @@ namespace AutopilotMonitor.Shared.Models Unknown } + /// + /// Aggregated session counters for the dashboard stats cards. + /// Computed server-side over a windowed scan of the SessionsIndex so the + /// numbers don't drift with whatever the client happens to have paginated + /// into memory. + /// + public class SessionStats + { + /// Window the windowed counters were computed over (matches request). + public int Days { get; set; } + + /// InProgress sessions inside the window. Used for the "Active Sessions" card. + public int ActiveCount { get; set; } + + /// Total sessions started inside the window. + public int TotalLastNDays { get; set; } + + public int SucceededLastNDays { get; set; } + public int FailedLastNDays { get; set; } + + /// + /// Succeeded / (Succeeded + Failed) * 100, rounded. Zero when no terminal + /// sessions are in the window (the card renders "0%" rather than NaN). + /// + public int SuccessRatePct { get; set; } + + /// Average duration of Succeeded sessions in the window, in minutes (rounded). + public int AvgDurationMinutes { get; set; } + + /// Sessions whose StartedAt is on or after UTC midnight of the current day. + public int TotalToday { get; set; } + public int FailedToday { get; set; } + + /// UTC timestamp of when the snapshot was produced (server clock). + public DateTime ComputedAt { get; set; } + } + /// /// A tracked IME version sighting. Permanent archive that survives data retention. /// diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardFilters.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardFilters.ts index 6675c84e..aaa34661 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardFilters.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardFilters.ts @@ -18,14 +18,6 @@ interface UseDashboardFiltersParams { loadMore: () => void; } -interface DashboardStats { - activeSessionsCount: number; - successRate: number; - avgDuration: number; - totalToday: number; - failedToday: number; -} - export interface UseDashboardFiltersReturn { searchQuery: string; setSearchQuery: (value: string) => void; @@ -46,13 +38,13 @@ export interface UseDashboardFiltersReturn { sortedSessions: Session[]; paginatedSessions: Session[]; totalPages: number; - stats: DashboardStats; } /** - * Owns the dashboard's filtering, sorting, pagination, and derived stats. - * Pure derivation — no fetches, no side effects beyond localStorage (page size) - * and an Application Insights event for search usage. + * Owns the dashboard's filtering, sorting, and pagination. Pure derivation — + * no fetches, no side effects beyond localStorage (page size) and an + * Application Insights event for search usage. Stats cards moved off this + * hook to useDashboardStats (server-aggregated). */ export function useDashboardFilters({ sessions, @@ -120,25 +112,6 @@ export function useDashboardFilters({ return tenantId ? sessions.filter((s) => s.tenantId === tenantId) : sessions; }, [sessions, globalAdminMode, tenantIdFilter, tenantId]); - const stats = useMemo(() => { - const activeSessions = effectiveSessions.filter((s) => s.status === "InProgress"); - const successRate = effectiveSessions.length > 0 - ? Math.round((effectiveSessions.filter((s) => s.status === "Succeeded").length / effectiveSessions.length) * 100) - : 0; - const avgDuration = effectiveSessions.length > 0 - ? Math.round(effectiveSessions.reduce((sum, s) => sum + s.durationSeconds, 0) / effectiveSessions.length / 60) - : 0; - const today = new Date().toDateString(); - const sessionsToday = effectiveSessions.filter((s) => new Date(s.startedAt).toDateString() === today); - return { - activeSessionsCount: activeSessions.length, - successRate, - avgDuration, - totalToday: sessionsToday.length, - failedToday: sessionsToday.filter((s) => s.status === "Failed").length, - }; - }, [effectiveSessions]); - const filteredSessions = useMemo(() => { return effectiveSessions.filter((session) => { if (statusFilter && session.status !== statusFilter) return false; @@ -257,6 +230,5 @@ export function useDashboardFilters({ handlePreviousPage, handleNextPage, effectiveSessions, filteredSessions, sortedSessions, paginatedSessions, totalPages, - stats, }; } diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts new file mode 100644 index 00000000..4dedcf4d --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts @@ -0,0 +1,245 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { asGuidOrUndefined } from "@/utils/inputValidation"; +import type { NotificationType } from "@/contexts/NotificationContext"; + +export interface DashboardStats { + days: number; + activeCount: number; + totalLastNDays: number; + succeededLastNDays: number; + failedLastNDays: number; + successRatePct: number; + avgDurationMinutes: number; + totalToday: number; + failedToday: number; + computedAt: string; +} + +type AddNotification = ( + type: NotificationType, + title: string, + message: string, + key?: string, + href?: string, +) => void; + +interface SignalRApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: (event: string, handler: (...args: any[]) => void) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + off: (event: string, handler: (...args: any[]) => void) => void; + isConnected: boolean; +} + +interface UseDashboardStatsParams { + tenantId: string | null | undefined; + globalAdminMode: boolean; + submittedTenantIdFilter: string; + days?: number; + getAccessToken: (forceRefresh?: boolean) => Promise; + addNotification: AddNotification; + signalR: SignalRApi; + /** + * When true (e.g. user role not yet known or regular non-admin user), + * the hook stays idle — no fetches, no SignalR subscriptions. + */ + disabled?: boolean; +} + +export interface UseDashboardStatsReturn { + stats: DashboardStats | null; + loading: boolean; + error: string | null; + refresh: () => void; +} + +const DEFAULT_DAYS = 7; +const SIGNALR_DEBOUNCE_MS = 3000; + +/** + * Server-side stats for the dashboard cards. Replaces the old "compute from + * whatever happens to be paginated client-side" path that drifted with the + * load-more cursor and printed a fictional "Last 7 days" label. + * + * Refresh model: initial fetch on scope change + debounced refetch on every + * SignalR newSession/newevents in the active scope. On SignalR reconnect we + * also force a refetch so any messages missed during the outage are reflected. + */ +export function useDashboardStats({ + tenantId, + globalAdminMode, + submittedTenantIdFilter, + days = DEFAULT_DAYS, + getAccessToken, + addNotification, + signalR, + disabled = false, +}: UseDashboardStatsParams): UseDashboardStatsReturn { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Scope refs for SignalR handlers (avoid restarting subscriptions on every keystroke). + const tenantIdRef = useRef(tenantId); + tenantIdRef.current = tenantId; + const globalAdminModeRef = useRef(globalAdminMode); + globalAdminModeRef.current = globalAdminMode; + const submittedFilterRef = useRef(submittedTenantIdFilter); + submittedFilterRef.current = submittedTenantIdFilter; + const daysRef = useRef(days); + daysRef.current = days; + const disabledRef = useRef(disabled); + disabledRef.current = disabled; + + // Generation counter — invalidates an in-flight fetch when the scope shifts + // mid-request (tenant switch, GA toggle, filter Submit). Without this, a slow + // request from the previous scope could land its result over a newer one. + const fetchGenRef = useRef(0); + // Debounce timer for SignalR-triggered refetches. Bursts of events (e.g. 50 + // newevents in one second during a session storm) collapse into a single + // backend call ~SIGNALR_DEBOUNCE_MS after the last event. + const debounceTimerRef = useRef | null>(null); + // Tracks whether SignalR was connected on the previous render so we can + // distinguish "first connection" from "reconnect after outage" — the latter + // needs an explicit refetch to recover from missed messages. + const wasConnectedRef = useRef(false); + + const fetchStats = useCallback(async (): Promise => { + if (disabledRef.current) return; + if (!globalAdminModeRef.current && !tenantIdRef.current) return; + + const myGen = ++fetchGenRef.current; + setLoading(true); + setError(null); + + try { + const url = globalAdminModeRef.current + ? api.globalSessions.stats({ + tenantId: asGuidOrUndefined(submittedFilterRef.current.trim()), + days: daysRef.current, + }) + : api.sessions.stats({ days: daysRef.current }); + + const response = await authenticatedFetch(url, getAccessToken); + + // Stale fetch — a newer scope change won the race. Drop silently. + if (myGen !== fetchGenRef.current) return; + + if (!response.ok) { + let detail = response.statusText; + try { + const body = await response.json(); + if (body?.message) detail = body.message; + } catch { /* not JSON */ } + setError(detail); + return; + } + + const body = await response.json(); + if (body?.success && body?.stats) { + setStats(body.stats as DashboardStats); + } else { + setError("Malformed stats response"); + } + } catch (e) { + if (myGen !== fetchGenRef.current) return; + if (e instanceof TokenExpiredError) { + addNotification("error", "Session Expired", e.message, "session-expired-error"); + } else { + console.error("Failed to fetch dashboard stats:", e); + setError("Unable to load stats"); + } + } finally { + if (myGen === fetchGenRef.current) setLoading(false); + } + }, [getAccessToken, addNotification]); + + const refresh = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + fetchStats(); + }, [fetchStats]); + + const scheduleDebouncedRefresh = useCallback(() => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + fetchStats(); + }, SIGNALR_DEBOUNCE_MS); + }, [fetchStats]); + + // Refetch on scope change (tenant switch, GA toggle, filter Submit, days change). + useEffect(() => { + if (disabled) return; + if (!globalAdminMode && !tenantId) return; + fetchStats(); + // fetchStats is stable; intentionally not in deps to avoid re-running on identity flips. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantId, globalAdminMode, submittedTenantIdFilter, days, disabled]); + + // SignalR-triggered debounced refetch + reconnect-recovery. + useEffect(() => { + if (disabled) return; + + const handleNewSession = (...args: unknown[]) => { + const data = args[0] as { tenantId?: string } | undefined; + if (!isInScope(data?.tenantId)) return; + scheduleDebouncedRefresh(); + }; + + const handleNewEvents = (...args: unknown[]) => { + const data = args[0] as { tenantId?: string } | undefined; + if (!isInScope(data?.tenantId)) return; + scheduleDebouncedRefresh(); + }; + + function isInScope(eventTenantId: string | undefined): boolean { + if (!eventTenantId) return false; + if (globalAdminModeRef.current) { + const filter = submittedFilterRef.current.trim(); + return !filter || eventTenantId === filter; + } + return !!tenantIdRef.current && eventTenantId === tenantIdRef.current; + } + + signalR.on("newSession", handleNewSession); + signalR.on("newevents", handleNewEvents); + + return () => { + signalR.off("newSession", handleNewSession); + signalR.off("newevents", handleNewEvents); + }; + // Re-bind only if connection identity changes — handlers read live refs + // so scope changes don't need to tear down the subscription. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signalR.isConnected, disabled]); + + // Reconnect-recovery: when SignalR transitions disconnected → connected and + // we already had an initial fetch, force a refetch to pick up any sessions + // we missed during the outage. + useEffect(() => { + if (disabled) return; + if (signalR.isConnected) { + const isReconnect = wasConnectedRef.current; + wasConnectedRef.current = true; + if (isReconnect) refresh(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signalR.isConnected, disabled]); + + // Cleanup on unmount. + useEffect(() => { + return () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + fetchGenRef.current++; + }; + }, []); + + return { stats, loading, error, refresh }; +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index a76c204e..96c2dab1 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -20,6 +20,7 @@ import { useTenantSecurityConfig } from "./hooks/useTenantSecurityConfig"; import { useTenantList } from "./hooks/useTenantList"; import { useDashboardFilters } from "./hooks/useDashboardFilters"; import { useDashboardSessions } from "./hooks/useDashboardSessions"; +import { useDashboardStats } from "./hooks/useDashboardStats"; export default function Home() { // useSearchParams() in HomeContent requires a Suspense boundary for static prerender. @@ -82,6 +83,10 @@ function HomeContent() { const { addNotification } = useNotifications(); const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked"); const [tenantIdFilter, setTenantIdFilter] = useState(""); + // Mirrors the last filter value the user actually submitted (Submit / Clear). + // Drives the stats refetch — server-side stats follow the submitted scope so + // typing into the filter input doesn't trigger a backend round-trip per keystroke. + const [submittedTenantIdFilter, setSubmittedTenantIdFilter] = useState(""); const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode(); const signalR = useSignalR(); @@ -114,7 +119,6 @@ function HomeContent() { handlePreviousPage, handleNextPage, effectiveSessions, filteredSessions, sortedSessions, paginatedSessions, totalPages, - stats, } = useDashboardFilters({ sessions, blockedDevicesSet, @@ -126,6 +130,20 @@ function HomeContent() { loadMore, }); + // Stats cards: server-side aggregation so the numbers don't drift with whatever + // the client has paginated into view. Refreshes on SignalR newSession/newevents + // (debounced) and on SignalR reconnect to recover from any missed messages. + const isRegularUser = !!user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator"; + const { stats: dashboardStats, loading: statsLoading } = useDashboardStats({ + tenantId, + globalAdminMode, + submittedTenantIdFilter, + getAccessToken, + addNotification, + signalR, + disabled: isRegularUser, + }); + // Redirect regular users (non-admin, non-operator) to progress portal – they must never see the session list useEffect(() => { if (user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== 'Operator') { @@ -146,7 +164,10 @@ function HomeContent() { // Clear tenant filter when leaving Global Admin mode (refetch is owned by useDashboardSessions) useEffect(() => { - if (!globalAdminMode) setTenantIdFilter(""); + if (!globalAdminMode) { + setTenantIdFilter(""); + setSubmittedTenantIdFilter(""); + } }, [globalAdminMode]); // Auto-load more when the user needs more sessions than currently loaded @@ -185,11 +206,13 @@ function HomeContent() { }; const submitTenantIdFilter = () => { + setSubmittedTenantIdFilter(tenantIdFilter); refetch(); }; const clearTenantIdFilter = () => { setTenantIdFilter(""); + setSubmittedTenantIdFilter(""); refetchWith(""); }; @@ -325,35 +348,35 @@ function HomeContent() { )} - {/* Stats cards */} + {/* Stats cards — server-aggregated (see useDashboardStats) */}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/sessionStatsApi.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/sessionStatsApi.test.ts new file mode 100644 index 00000000..a44c9f5f --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/sessionStatsApi.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; + +// API_BASE_URL is read at import time of lib/api — stub it before the import. +vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const apiPromise = import("../api") as Promise<{ api: any }>; + +/** + * Stats endpoint contract. The dashboard cards used to derive their numbers + * from whatever happened to be paginated client-side, which produced the + * "40 → 22 → 20" drift the user reported. Server-side stats are now driven + * by these two endpoints; here we lock down the URL surface so a refactor + * of api.ts doesn't silently break the wire shape. + */ +describe("api.sessions.stats — per-tenant", () => { + it("uses /api/sessions/stats with no params by default", async () => { + const { api } = await apiPromise; + expect(api.sessions.stats()).toBe("https://test.example/api/sessions/stats"); + }); + + it("honours an explicit days override", async () => { + const { api } = await apiPromise; + expect(api.sessions.stats({ days: 30 })).toBe( + "https://test.example/api/sessions/stats?days=30", + ); + }); + + it("omits days when undefined", async () => { + const { api } = await apiPromise; + expect(api.sessions.stats({ days: undefined })).toBe( + "https://test.example/api/sessions/stats", + ); + }); +}); + +describe("api.globalSessions.stats — GA cross-tenant", () => { + it("uses /api/global/sessions/stats with no params by default", async () => { + const { api } = await apiPromise; + expect(api.globalSessions.stats()).toBe( + "https://test.example/api/global/sessions/stats", + ); + }); + + it("forwards an optional tenantId filter", async () => { + const { api } = await apiPromise; + expect( + api.globalSessions.stats({ tenantId: "11111111-1111-1111-1111-111111111111" }), + ).toBe( + "https://test.example/api/global/sessions/stats?tenantId=11111111-1111-1111-1111-111111111111", + ); + }); + + it("forwards both tenantId and days together", async () => { + const { api } = await apiPromise; + expect( + api.globalSessions.stats({ + tenantId: "22222222-2222-2222-2222-222222222222", + days: 14, + }), + ).toBe( + "https://test.example/api/global/sessions/stats?tenantId=22222222-2222-2222-2222-222222222222&days=14", + ); + }); + + it("omits an empty-string tenantId so the backend treats it as platform-wide", async () => { + // Important: an empty filter means "all tenants" in the GA endpoint. + // The api builder must drop the param entirely rather than send tenantId=. + const { api } = await apiPromise; + expect(api.globalSessions.stats({ tenantId: "", days: 7 })).toBe( + "https://test.example/api/global/sessions/stats?days=7", + ); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 96f555d2..8e7919fb 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -58,6 +58,8 @@ export const api = { `${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`, quickSearch: (q: string) => `${API_BASE_URL}/api/search/quick${qs({ q })}`, + stats: (opts?: { days?: number }) => + `${API_BASE_URL}/api/sessions/stats${qs({ days: opts?.days?.toString() })}`, }, // ── Inspector v1 (global admin only — Plan §M6) ─────────────────────────── @@ -83,6 +85,11 @@ export const api = { pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, })}`, + stats: (opts?: { tenantId?: string; days?: number }) => + `${API_BASE_URL}/api/global/sessions/stats${qs({ + tenantId: opts?.tenantId, + days: opts?.days?.toString(), + })}`, }, // ── Config ────────────────────────────────────────────────────────────────