mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
dashboard performance and more coding safety checks in integrity analyser
This commit is contained in:
+25
-8
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
1031
|
||||
1032
|
||||
|
||||
@@ -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<SearchSuggestion[]>(() => {
|
||||
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<string>();
|
||||
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)}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildUniqueValuesByField } from "../uniqueValuesByField";
|
||||
import type { Session } from "@/types";
|
||||
|
||||
function s(partial: Partial<Session>): 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Session } from "../types";
|
||||
|
||||
export type UniqueValuesByField = Partial<Record<keyof Session, string[]>>;
|
||||
|
||||
export function buildUniqueValuesByField(
|
||||
sessions: Session[],
|
||||
fields: readonly (keyof Session)[],
|
||||
): UniqueValuesByField {
|
||||
const sets = new Map<keyof Session, Set<string>>();
|
||||
for (const f of fields) sets.set(f, new Set<string>());
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user