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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-11 13:40:40 +02:00
parent 5e2cdf0aad
commit b76a6ccaf0
13 changed files with 864 additions and 40 deletions
@@ -0,0 +1,196 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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<SessionSummary>(), 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<SessionSummary>(), 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);
}
}
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation)
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation);
public Task<SessionStats> GetSessionStatsAsync(string tenantId, int days)
=> _storage.GetSessionStatsAsync(tenantId, days);
public Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days)
=> _storage.GetAllSessionStatsAsync(tenantIdFilter, days);
public Task<bool> DeleteSessionAsync(string tenantId, string sessionId)
=> _storage.DeleteSessionAsync(tenantId, sessionId);
@@ -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
{
/// <summary>
/// Cross-tenant dashboard stats (Global Admin). Optional <c>?tenantId=</c>
/// restricts the window to a single tenant; absent means platform-wide.
/// </summary>
public class GetAllSessionStatsFunction
{
private readonly ILogger<GetAllSessionStatsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public GetAllSessionStatsFunction(ILogger<GetAllSessionStatsFunction> logger, ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
[Function("GetAllSessionStats")]
public async Task<HttpResponseData> 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;
}
}
}
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class GetSessionStatsFunction
{
private const int DefaultDays = 7;
private const int MaxDays = 365;
private readonly ILogger<GetSessionStatsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public GetSessionStatsFunction(ILogger<GetSessionStatsFunction> logger, ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
[Function("GetSessionStats")]
public async Task<HttpResponseData> 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;
}
}
}
@@ -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),
@@ -793,6 +793,101 @@ namespace AutopilotMonitor.Functions.Services
return all;
}
/// <summary>
/// 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.
/// </summary>
public async Task<SessionStats> 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);
}
/// <summary>
/// Cross-tenant variant. Routes through the per-tenant index when
/// <paramref name="tenantIdFilter"/> is set (cheaper scan).
/// </summary>
public async Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days)
{
if (days < 1) throw new ArgumentOutOfRangeException(nameof(days));
var sessions = await GetAllSessionsAsync(tenantIdFilter, days);
return AggregateSessionStats(sessions, days);
}
/// <summary>
/// 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.
/// </summary>
internal static SessionStats AggregateSessionStats(IReadOnlyList<SessionSummary> 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,
};
}
/// <summary>
/// Fallback: queries the Sessions table directly (pre-migration, before SessionsIndex is populated).
/// </summary>
@@ -47,6 +47,20 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(
string? tenantIdFilter, int? days, int pageSize, string? continuation);
/// <summary>
/// Server-side aggregation for the dashboard stats cards (per-tenant scope).
/// Drains the SessionsIndex for the last <paramref name="days"/> days and computes
/// counts in a single pass — the client never derives these from a paginated list.
/// </summary>
Task<SessionStats> GetSessionStatsAsync(string tenantId, int days);
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionStatsAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant
/// (routes through the per-tenant index for cheaper scan).
/// </summary>
Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days);
Task<bool> DeleteSessionAsync(string tenantId, string sessionId);
// --- Session Updates ---
@@ -339,6 +339,43 @@ namespace AutopilotMonitor.Shared.Models
Unknown
}
/// <summary>
/// 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.
/// </summary>
public class SessionStats
{
/// <summary>Window the windowed counters were computed over (matches request).</summary>
public int Days { get; set; }
/// <summary>InProgress sessions inside the window. Used for the "Active Sessions" card.</summary>
public int ActiveCount { get; set; }
/// <summary>Total sessions started inside the window.</summary>
public int TotalLastNDays { get; set; }
public int SucceededLastNDays { get; set; }
public int FailedLastNDays { get; set; }
/// <summary>
/// Succeeded / (Succeeded + Failed) * 100, rounded. Zero when no terminal
/// sessions are in the window (the card renders "0%" rather than NaN).
/// </summary>
public int SuccessRatePct { get; set; }
/// <summary>Average duration of Succeeded sessions in the window, in minutes (rounded).</summary>
public int AvgDurationMinutes { get; set; }
/// <summary>Sessions whose StartedAt is on or after UTC midnight of the current day.</summary>
public int TotalToday { get; set; }
public int FailedToday { get; set; }
/// <summary>UTC timestamp of when the snapshot was produced (server clock).</summary>
public DateTime ComputedAt { get; set; }
}
/// <summary>
/// A tracked IME version sighting. Permanent archive that survives data retention.
/// </summary>
@@ -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 <c>useDashboardStats</c> (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<DashboardStats>(() => {
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,
};
}
@@ -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<string | null>;
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<DashboardStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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<void> => {
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 };
}
@@ -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() {
</div>
)}
{/* Stats cards */}
{/* Stats cards — server-aggregated (see useDashboardStats) */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5 mb-2">
<StatsCard
title="Active Sessions"
value={loading ? "..." : stats.activeSessionsCount.toString()}
value={statsLoading && !dashboardStats ? "..." : (dashboardStats?.activeCount ?? 0).toString()}
description="Currently enrolling"
color="blue"
/>
<StatsCard
title="Success Rate"
value={loading ? "..." : `${stats.successRate}%`}
value={statsLoading && !dashboardStats ? "..." : `${dashboardStats?.successRatePct ?? 0}%`}
description="Last 7 days"
color="green"
/>
<StatsCard
title="Avg. Duration"
value={loading ? "..." : `${stats.avgDuration} min`}
value={statsLoading && !dashboardStats ? "..." : `${dashboardStats?.avgDurationMinutes ?? 0} min`}
description="Last 7 days"
color="purple"
/>
<StatsCard
title="Total Today"
value={loading ? "..." : stats.totalToday.toString()}
value={statsLoading && !dashboardStats ? "..." : (dashboardStats?.totalToday ?? 0).toString()}
description="Started today"
color="indigo"
/>
<StatsCard
title="Failed Today"
value={loading ? "..." : stats.failedToday.toString()}
value={statsLoading && !dashboardStats ? "..." : (dashboardStats?.failedToday ?? 0).toString()}
description="Needs attention"
color="red"
/>
@@ -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",
);
});
});
+7
View File
@@ -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 ────────────────────────────────────────────────────────────────