diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs index 54783f45..0821ecad 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs @@ -269,7 +269,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers if (raw != null) { try { intValue = Convert.ToInt32(raw); } - catch { intValue = 0; } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) + { + _logger.Warning($"{Name}: LabConfig '{valueName}' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); + intValue = 0; + } } result.Values[valueName] = intValue; if (intValue == 1) @@ -301,7 +305,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers return new MoSetupCheckResult { KeyExists = true, Value = 0 }; int intValue = 0; - try { intValue = Convert.ToInt32(raw); } catch { intValue = 0; } + try { intValue = Convert.ToInt32(raw); } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) + { + _logger.Warning($"{Name}: MoSetup 'AllowUpgradesWithUnsupportedTPMOrCPU' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); + } return new MoSetupCheckResult { KeyExists = true, Value = intValue }; } } @@ -345,7 +353,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers if (raw == null) continue; int intValue = 0; - try { intValue = Convert.ToInt32(raw); } catch { intValue = 0; } + try { intValue = Convert.ToInt32(raw); } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) + { + _logger.Warning($"{Name}: PCHC UpgradeEligibility for SID {sid} has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); + continue; + } if (intValue == 1) { @@ -415,13 +428,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers using (var searcher = new ManagementObjectSearcher( @"root\CIMV2\Security\MicrosoftTpm", "SELECT IsEnabled_InitialValue, IsActivated_InitialValue FROM Win32_Tpm")) + using (var results = searcher.Get()) { - foreach (ManagementObject tpm in searcher.Get()) + foreach (ManagementObject tpm in results) { - var isEnabled = tpm["IsEnabled_InitialValue"] != null - && Convert.ToBoolean(tpm["IsEnabled_InitialValue"]); - // At least one Tpm instance exists — return its enabled state - return isEnabled; + using (tpm) + { + var isEnabled = tpm["IsEnabled_InitialValue"] != null + && Convert.ToBoolean(tpm["IsEnabled_InitialValue"]); + // At least one Tpm instance exists — return its enabled state + return isEnabled; + } } } // No Win32_Tpm instance returned — no TPM present diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 09de93e8..4ac943d1 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1031 +1032 diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index faa26aea..25263711 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -1,10 +1,11 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react"; import { Session } from "../types"; import { trackEvent } from "@/lib/appInsights"; import { fuzzyContains } from "@/utils/fuzzy"; +import { buildUniqueValuesByField } from "./uniqueValuesByField"; // Column definition for the session table interface ColumnDef { @@ -163,8 +164,12 @@ export function SessionTable({ { key: "status", label: "Status" }, ]; - const searchSuggestions: SearchSuggestion[] = (() => { - const q = searchQuery.trim().toLowerCase(); + // Defer expensive suggestion scans so rapid typing keeps the input responsive. + const deferredSearchQuery = useDeferredValue(searchQuery); + const deferredTenantIdFilter = useDeferredValue(tenantIdFilter); + + const searchSuggestions = useMemo(() => { + const q = deferredSearchQuery.trim().toLowerCase(); if (q.length < 2 || sessions.length === 0) return []; if (/^[><]=?\s*\d+$/.test(q)) return []; @@ -204,16 +209,18 @@ export function SessionTable({ } return [...exactResults, ...fuzzyResults]; - })(); + // SEARCH_FIELDS is a stable literal — intentionally omitted from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deferredSearchQuery, sessions]); // Fuzzy-match tenants by domain name or tenant ID - const tenantSuggestions = (() => { - const q = tenantIdFilter.trim().toLowerCase(); + const tenantSuggestions = useMemo(() => { + const q = deferredTenantIdFilter.trim().toLowerCase(); if (q.length < 2 || tenantList.length === 0) return []; return tenantList .filter((t) => t.domainName.toLowerCase().includes(q) || t.tenantId.toLowerCase().includes(q)) .slice(0, 8); - })(); + }, [deferredTenantIdFilter, tenantList]); // Persist visible columns to localStorage useEffect(() => { @@ -242,15 +249,16 @@ export function SessionTable({ } }, [showColumnSelector, openFilterColumn, showTenantSuggestions, showSearchSuggestions]); - // Compute unique values for a filterable field from all sessions (not filtered) - const getUniqueValues = (field: keyof Session): string[] => { - const values = new Set(); - for (const s of sessions) { - const v = s[field]; - if (v != null && v !== "") values.add(String(v)); - } - return [...values].sort(); - }; + // Compute unique values for every filterable field in a single pass over sessions. + // Memoized so that header re-renders pay only a map lookup, not N × O(sessions). + const uniqueValuesByField = useMemo( + () => + buildUniqueValuesByField( + sessions, + ALL_COLUMNS.map((c) => c.filterKey).filter((k): k is keyof Session => !!k), + ), + [sessions], + ); const activeFilterCount = Object.values(columnFilters).reduce( (sum, s) => sum + (s.size > 0 ? 1 : 0), 0 @@ -681,7 +689,7 @@ export function SessionTable({ onSort={onSort} className={["eventCount", "duration", "started", "country", "agentVersion", "osName", "osBuild", "osDisplayVersion", "osEdition", "osLanguage"].includes(col.key) ? "px-3" : undefined} filterKey={col.filterKey} - filterValues={col.filterKey ? getUniqueValues(col.filterKey) : undefined} + filterValues={col.filterKey ? uniqueValuesByField[col.filterKey] : undefined} activeFilter={col.filterKey ? columnFilters[col.filterKey] : undefined} isFilterOpen={openFilterColumn === col.key} onFilterToggle={() => setOpenFilterColumn(openFilterColumn === col.key ? null : col.key)} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/__tests__/uniqueValuesByField.test.ts b/src/Web/autopilot-monitor-web/app/dashboard/components/__tests__/uniqueValuesByField.test.ts new file mode 100644 index 00000000..71950fca --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/__tests__/uniqueValuesByField.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { buildUniqueValuesByField } from "../uniqueValuesByField"; +import type { Session } from "@/types"; + +function s(partial: Partial): Session { + return partial as unknown as Session; +} + +describe("buildUniqueValuesByField", () => { + it("returns an empty string[] for every requested field when sessions is empty", () => { + const result = buildUniqueValuesByField([], ["status", "osName"]); + expect(result.status).toEqual([]); + expect(result.osName).toEqual([]); + }); + + it("collects unique non-empty values per field", () => { + const sessions = [ + s({ status: "success", osName: "Windows 11" }), + s({ status: "failed", osName: "Windows 11" }), + s({ status: "success", osName: "Windows 10" }), + ]; + const result = buildUniqueValuesByField(sessions, ["status", "osName"]); + expect(result.status).toEqual(["failed", "success"]); + expect(result.osName).toEqual(["Windows 10", "Windows 11"]); + }); + + it("skips null, undefined, and empty-string values", () => { + const sessions = [ + s({ status: "success", osName: null as unknown as string }), + s({ status: undefined as unknown as string, osName: "" }), + s({ status: "", osName: "Windows 11" }), + ]; + const result = buildUniqueValuesByField(sessions, ["status", "osName"]); + expect(result.status).toEqual(["success"]); + expect(result.osName).toEqual(["Windows 11"]); + }); + + it("coerces non-string values to string (e.g. numbers)", () => { + const sessions = [ + s({ eventCount: 10 }), + s({ eventCount: 3 }), + s({ eventCount: 10 }), + ]; + const result = buildUniqueValuesByField(sessions, ["eventCount"]); + expect(result.eventCount).toEqual(["10", "3"]); + }); + + it("returns sorted output", () => { + const sessions = [ + s({ status: "zulu" }), + s({ status: "alpha" }), + s({ status: "mike" }), + ]; + const result = buildUniqueValuesByField(sessions, ["status"]); + expect(result.status).toEqual(["alpha", "mike", "zulu"]); + }); + + it("makes a single pass regardless of field count (behavioral: all fields populated from same session list)", () => { + const sessions = [ + s({ status: "success", osName: "Win11", manufacturer: "Dell" }), + s({ status: "failed", osName: "Win10", manufacturer: "HP" }), + ]; + const result = buildUniqueValuesByField(sessions, ["status", "osName", "manufacturer"]); + expect(result.status).toEqual(["failed", "success"]); + expect(result.osName).toEqual(["Win10", "Win11"]); + expect(result.manufacturer).toEqual(["Dell", "HP"]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/uniqueValuesByField.ts b/src/Web/autopilot-monitor-web/app/dashboard/components/uniqueValuesByField.ts new file mode 100644 index 00000000..8fc03468 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/uniqueValuesByField.ts @@ -0,0 +1,22 @@ +import type { Session } from "../types"; + +export type UniqueValuesByField = Partial>; + +export function buildUniqueValuesByField( + sessions: Session[], + fields: readonly (keyof Session)[], +): UniqueValuesByField { + const sets = new Map>(); + for (const f of fields) sets.set(f, new Set()); + + for (const s of sessions) { + for (const f of fields) { + const v = s[f]; + if (v != null && v !== "") sets.get(f)!.add(String(v)); + } + } + + const result: UniqueValuesByField = {}; + for (const f of fields) result[f] = [...sets.get(f)!].sort(); + return result; +} diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index 35b6d14a..c3424c84 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -100,6 +100,16 @@ export function useDashboardSessions({ const loadingMoreRef = useRef(loadingMore); loadingMoreRef.current = loadingMore; + // Synchronous lock: set true BEFORE the first await so two triggers in the same + // render cycle (pagination effect + debounced search effect in page.tsx) cannot + // both pass the guard. The state-mirrored loadingMoreRef above lags by one tick + // and is insufficient for that race. + const fetchLockRef = useRef(false); + // Cancellation token for the progressive loadAll() loop. Bumped whenever the + // session list is being reset (refetch/refetchWith/unmount) so an in-flight + // loop stops appending to a now-stale list. + const loadAllTokenRef = useRef(0); + const hasInitialFetch = useRef(false); const hasGlobalModeInitialized = useRef(false); const hasJoinedGroup = useRef(false); @@ -223,45 +233,62 @@ export function useDashboardSessions({ }, [fetchSessionsBatch, fetchBlockedDevices]); const refetch = useCallback(() => { + loadAllTokenRef.current++; // cancel any in-flight progressive loader setLoading(true); fetchSessions(); }, [fetchSessions]); const refetchWith = useCallback((tenantIdOverride: string) => { + loadAllTokenRef.current++; // cancel any in-flight progressive loader setLoading(true); fetchSessions(undefined, tenantIdOverride); }, [fetchSessions]); const loadMore = useCallback(() => { - if (!cursorRef.current || loadingMoreRef.current) return; + if (!cursorRef.current || fetchLockRef.current) return; + fetchLockRef.current = true; setLoadingMore(true); - fetchSessions(cursorRef.current); + fetchSessions(cursorRef.current).finally(() => { + fetchLockRef.current = false; + }); }, [fetchSessions]); // Progressive loader — fetches ALL remaining sessions batch by batch. // Used when search is active and local results are insufficient. const loadAll = useCallback(async () => { - if (!cursorRef.current || loadingMoreRef.current) return; + if (!cursorRef.current || fetchLockRef.current) return; + fetchLockRef.current = true; + const myToken = ++loadAllTokenRef.current; setLoadingMore(true); - let currentCursor: string | null = cursorRef.current; + try { + let currentCursor: string | null = cursorRef.current; + while (currentCursor && loadAllTokenRef.current === myToken) { + const result = await fetchSessionsBatch(currentCursor); + if (!result || loadAllTokenRef.current !== myToken) break; - while (currentCursor) { - const result = await fetchSessionsBatch(currentCursor); - if (!result) break; - - setSessions((prev) => [...prev, ...result.sessions]); - setCursor(result.cursor); - setHasMore(result.hasMore); - currentCursor = result.hasMore ? result.cursor : null; + setSessions((prev) => [...prev, ...result.sessions]); + setCursor(result.cursor); + setHasMore(result.hasMore); + currentCursor = result.hasMore ? result.cursor : null; + } + } finally { + fetchLockRef.current = false; + setLoadingMore(false); } - - setLoadingMore(false); }, [fetchSessionsBatch]); const removeSession = useCallback((sessionId: string) => { setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId)); }, []); + // Cancel any in-flight progressive loadAll() loop on unmount so it + // doesn't call setSessions against a torn-down component. + useEffect(() => { + return () => { + loadAllTokenRef.current++; + }; + }, []); + // Initial fetch — gated on user role + tenant readiness useEffect(() => { if (user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator") {