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 ────────────────────────────────────────────────────────────────