=> {
+ 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 ────────────────────────────────────────────────────────────────