From 5312216f6b934e046775fdd978a35cb14482e86e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 31 Jul 2026 01:21:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20hooks-warnings=20batch=201=20?= =?UTF-8?q?=E2=80=94=20static-components,=20purity,=20immutability=20to=20?= =?UTF-8?q?zero=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First burn-down batch of the React-Compiler hooks warnings (163 -> 141, CI ratchet lowered to match). static-components (14, real remount bugs): SortIcon (geographic- performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/ ResultsDropdown (GlobalSearch) were defined inside render — a new component type every render, so React unmounted and rebuilt their subtrees each time (GlobalSearch tore down its dropdown on every keystroke). All hoisted to module level with explicit props. purity (6): Date.now() during render replaced with mount-time clocks (useState lazy init) for the date-input caps (Maintenance sections) and trial-expiry checks (TenantManagement, SectionTenantConfigReport); FeedbackSection refreshes its clock per fetch so "Xm ago" labels track the data; DownloadProgress falls back to NaN instead of wall-clock for unparseable event timestamps (rate calc already guards via Number.isFinite). immutability (5): fetch functions were declared below the effects that call them — moved above (diagnosis, fleet-health, DistressReports). This unblocked deeper compiler analysis, surfacing 3 latent set-state-in-effect findings in those files (93 -> 96); they belong to the set-state category and stay for its own batch. Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean, vitest 611/611, static export builds. Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- .../components/DistressReportsSection.tsx | 10 +- .../app/admin/components/FeedbackSection.tsx | 7 +- .../admin/components/MaintenanceSection.tsx | 5 +- .../components/MaintenanceTriggerSection.tsx | 5 +- .../components/TenantManagementSection.tsx | 5 +- .../sections/SectionTenantConfigReport.tsx | 5 +- .../app/diagnosis/page.tsx | 164 ++++++++--------- .../app/fleet-health/page.tsx | 46 ++--- .../app/geographic-performance/page.tsx | 25 +-- .../components/VulnerabilityReportSection.tsx | 24 +-- .../components/DownloadProgress.tsx | 6 +- .../components/GlobalSearch.tsx | 167 ++++++++++-------- 13 files changed, 256 insertions(+), 215 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e77495ba..885cc924 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: # in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower # the cap to match; never raise it. - name: Lint - run: npm run lint -- --max-warnings 163 + run: npm run lint -- --max-warnings 141 - name: Tests run: npm test diff --git a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx index 5bcd92c4..5c67ccd2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx @@ -146,11 +146,6 @@ export function DistressReportsSection({ return sortDir === "asc" ? cmp : -cmp; }); - useEffect(() => { - fetchReports(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const fetchReports = async () => { try { setLoading(true); @@ -172,6 +167,11 @@ export function DistressReportsSection({ } }; + useEffect(() => { + fetchReports(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Summary stats const errorTypeCounts = reports.reduce>((acc, r) => { acc[r.errorType] = (acc[r.errorType] ?? 0) + 1; diff --git a/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx index 9aad51c1..6e0e4987 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx @@ -35,6 +35,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro const [activeTab, setActiveTab] = useState("InApp"); const [inAppFilter, setInAppFilter] = useState("all"); const [currentPage, setCurrentPage] = useState(0); + // Reference clock for the "Xm ago" labels — render must stay pure + // (react-hooks/purity), so it's captured at mount and refreshed per fetch, + // keeping the labels in step with the data they describe. + const [nowMs, setNowMs] = useState(() => Date.now()); const entriesPerPage = 5; const fetchFeedback = useCallback(async () => { @@ -56,6 +60,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro return dateB - dateA; }); setEntries(sorted); + setNowMs(Date.now()); } catch (err) { if (err instanceof TokenExpiredError) { console.error("Session expired while fetching feedback"); @@ -114,7 +119,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro const formatTimeAgo = (dateStr: string | null): string => { if (!dateStr) return "unknown"; - const diff = Date.now() - new Date(dateStr).getTime(); + const diff = nowMs - new Date(dateStr).getTime(); const minutes = Math.floor(diff / 60000); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceSection.tsx index 7229ed11..5bb8b9f2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceSection.tsx @@ -17,6 +17,9 @@ export function MaintenanceSection({ }: MaintenanceSectionProps) { const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); const [maintenanceDate, setMaintenanceDate] = useState(""); + // Latest selectable day (yesterday), fixed at mount — render must stay pure + // (react-hooks/purity) and the cap doesn't need a live midnight rollover. + const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]); const [reseedingRules, setReseedingRules] = useState(false); const [reseedingGatherRules, setReseedingGatherRules] = useState(false); const [reseedingImePatterns, setReseedingImePatterns] = useState(false); @@ -244,7 +247,7 @@ export function MaintenanceSection({ type="date" value={maintenanceDate} onChange={(e) => setMaintenanceDate(e.target.value)} - max={new Date(Date.now() - 86400000).toISOString().split('T')[0]} + max={maxMaintenanceDate} className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />

diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx index 72215fe2..8d20f208 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx @@ -17,6 +17,9 @@ export function MaintenanceTriggerSection({ }: MaintenanceTriggerSectionProps) { const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); const [maintenanceDate, setMaintenanceDate] = useState(""); + // Latest selectable day (yesterday), fixed at mount — render must stay pure + // (react-hooks/purity) and the cap doesn't need a live midnight rollover. + const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]); const [refreshingVersions, setRefreshingVersions] = useState(false); const handleRefreshLatestVersions = async () => { @@ -143,7 +146,7 @@ export function MaintenanceTriggerSection({ type="date" value={maintenanceDate} onChange={(e) => setMaintenanceDate(e.target.value)} - max={new Date(Date.now() - 86400000).toISOString().split('T')[0]} + max={maxMaintenanceDate} className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />

diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index fa215dc7..6ef31bca 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -74,6 +74,9 @@ function TenantManagementSectionInner({ // Read-only Global Readers may view tenants (incl. config report) but not edit them. const canMutate = useCanMutatePlatform(); const [searchQuery, setSearchQuery] = useState(""); + // Mount-time clock for trial-expiry checks — render must stay pure + // (react-hooks/purity); day-granularity expiry doesn't need a live clock. + const [nowMs] = useState(() => Date.now()); // Deep link from GA notifications: ?tenantId=… seeds the search box once, so the // list opens filtered to the tenant the notification is about. @@ -597,7 +600,7 @@ function TenantManagementSectionInner({ {(() => { const isEnterpriseTier = editingTenant.planTier === "enterprise"; const trialActive = !!editingTenant.trialExpiresUtc && - new Date(editingTenant.trialExpiresUtc).getTime() > Date.now(); + new Date(editingTenant.trialExpiresUtc).getTime() > nowMs; const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community"; return ( ([]); const [selectedTenantId, setSelectedTenantId] = useState(''); + // Mount-time clock for trial-expiry checks — render must stay pure + // (react-hooks/purity); day-granularity expiry doesn't need a live clock. + const [nowMs] = useState(() => Date.now()); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); const [loadingTenants, setLoadingTenants] = useState(true); @@ -531,7 +534,7 @@ export function SectionTenantConfigReport() { Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`} + value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`} configKey="planTier" defaults={DEFAULTS} /> diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index f9833f29..560fe366 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -64,87 +64,8 @@ function DiagnosisContent() { const { globalAdminMode } = useAdminMode(); - useEffect(() => { - if (!sessionId) return; - if (!globalAdminMode && !tenantId) return; // wait for real tenant ID - sessionIdRef.current = sessionId; - if (lastFetchedSessionId.current !== sessionId) { - hasInitialFetch.current = false; - lastFetchedSessionId.current = sessionId; - sessionFetchDone.current = false; - analysisFetchDone.current = false; - setSessionTenantId(null); - setLoading(true); - } - if (hasInitialFetch.current) return; - hasInitialFetch.current = true; - - // Performance: eager-set sessionTenantId from TenantContext when known, so - // the events/analysis effect fires in parallel with fetchSessionDetails - // instead of waiting for its roundtrip — eliminates the fetch waterfall. - // Global Admins in all-tenant view fall through with null (backend resolves - // the tenant from the session itself). - if (!globalAdminMode && isGuid(tenantId)) { - setSessionTenantId(tenantId); - } - - fetchSessionDetails(); - }, [sessionId, tenantId, globalAdminMode]); - - useEffect(() => { - if (sessionTenantId && sessionId) { - Promise.all([fetchEvents(), fetchAnalysisResults()]); - } - }, [sessionTenantId, sessionId]); - - // SignalR groups - subscribe-then-fetch pattern - useEffect(() => { - const effectiveTenantId = sessionTenantId || tenantId; - if (!sessionId || !isConnected || !effectiveTenantId) return; - if (!hasJoinedGroups.current) { - const joinAndCatchUp = async () => { - await joinGroup(`session-${effectiveTenantId}-${sessionId}`); - hasJoinedGroups.current = true; - // Re-fetch after group join to catch any missed during join - Promise.all([fetchEvents(), fetchAnalysisResults()]); - }; - joinAndCatchUp(); - } - return () => { - if (hasJoinedGroups.current && effectiveTenantId) { - leaveGroup(`session-${effectiveTenantId}-${sessionId}`); - hasJoinedGroups.current = false; - } - }; - }, [sessionId, isConnected, sessionTenantId, tenantId]); - - // Real-time analysis updates - useEffect(() => { - const handleEventStream = (data: { - sessionId: string; - session: Session; - newRuleResults?: RuleResult[]; - }) => { - if (data.sessionId !== sessionIdRef.current) return; - if (data.session) setSession(data.session); - if (data.newRuleResults?.length) { - setAnalysisResults((prev) => { - const existingIds = new Set(prev.map((r) => r.ruleId)); - const newResults = data.newRuleResults!.filter( - (r) => !existingIds.has(r.ruleId) - ); - return [...prev, ...newResults].sort( - (a, b) => b.confidenceScore - a.confidenceScore - ); - }); - } - }; - on("eventStream", handleEventStream); - return () => { - off("eventStream", handleEventStream); - }; - }, [on, off]); - + // Declared ahead of the effects that call them (react-hooks/immutability: + // a later declaration would freeze the effect's view of the binding). const fetchSessionDetails = async () => { try { // Fetch the single session directly instead of pulling the entire session @@ -234,6 +155,87 @@ function DiagnosisContent() { } }; + useEffect(() => { + if (!sessionId) return; + if (!globalAdminMode && !tenantId) return; // wait for real tenant ID + sessionIdRef.current = sessionId; + if (lastFetchedSessionId.current !== sessionId) { + hasInitialFetch.current = false; + lastFetchedSessionId.current = sessionId; + sessionFetchDone.current = false; + analysisFetchDone.current = false; + setSessionTenantId(null); + setLoading(true); + } + if (hasInitialFetch.current) return; + hasInitialFetch.current = true; + + // Performance: eager-set sessionTenantId from TenantContext when known, so + // the events/analysis effect fires in parallel with fetchSessionDetails + // instead of waiting for its roundtrip — eliminates the fetch waterfall. + // Global Admins in all-tenant view fall through with null (backend resolves + // the tenant from the session itself). + if (!globalAdminMode && isGuid(tenantId)) { + setSessionTenantId(tenantId); + } + + fetchSessionDetails(); + }, [sessionId, tenantId, globalAdminMode]); + + useEffect(() => { + if (sessionTenantId && sessionId) { + Promise.all([fetchEvents(), fetchAnalysisResults()]); + } + }, [sessionTenantId, sessionId]); + + // SignalR groups - subscribe-then-fetch pattern + useEffect(() => { + const effectiveTenantId = sessionTenantId || tenantId; + if (!sessionId || !isConnected || !effectiveTenantId) return; + if (!hasJoinedGroups.current) { + const joinAndCatchUp = async () => { + await joinGroup(`session-${effectiveTenantId}-${sessionId}`); + hasJoinedGroups.current = true; + // Re-fetch after group join to catch any missed during join + Promise.all([fetchEvents(), fetchAnalysisResults()]); + }; + joinAndCatchUp(); + } + return () => { + if (hasJoinedGroups.current && effectiveTenantId) { + leaveGroup(`session-${effectiveTenantId}-${sessionId}`); + hasJoinedGroups.current = false; + } + }; + }, [sessionId, isConnected, sessionTenantId, tenantId]); + + // Real-time analysis updates + useEffect(() => { + const handleEventStream = (data: { + sessionId: string; + session: Session; + newRuleResults?: RuleResult[]; + }) => { + if (data.sessionId !== sessionIdRef.current) return; + if (data.session) setSession(data.session); + if (data.newRuleResults?.length) { + setAnalysisResults((prev) => { + const existingIds = new Set(prev.map((r) => r.ruleId)); + const newResults = data.newRuleResults!.filter( + (r) => !existingIds.has(r.ruleId) + ); + return [...prev, ...newResults].sort( + (a, b) => b.confidenceScore - a.confidenceScore + ); + }); + } + }; + on("eventStream", handleEventStream); + return () => { + off("eventStream", handleEventStream); + }; + }, [on, off]); + const copyRemediation = (result: RuleResult) => { const text = result.remediation .map((r) => { diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index e97f1f7f..e8b8a23c 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -77,6 +77,29 @@ export default function FleetHealthPage() { signalR: { on, off, isConnected }, }); + const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => { + try { + const d = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const endpoint = routeGlobal + ? api.metrics.globalApp(d, selectedTenantId || undefined) + : api.metrics.app(tenantId, d); + const response = await authenticatedFetch(endpoint, getAccessToken); + if (response.ok) { + const json = await response.json(); + setAppMetrics(json); + } else { + addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error'); + } + } catch (error) { + if (error instanceof TokenExpiredError) { + addNotification('error', 'Session Expired', error.message, 'session-expired-error'); + } else { + console.error("Failed to fetch app metrics:", error); + addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error'); + } + } + }; + // App install metrics are already backend-aggregated; fetch alongside fleet data. useEffect(() => { if (!scopeInitialized) return; @@ -147,29 +170,6 @@ export default function FleetHealthPage() { }; }, [isConnected, effectiveTenantId]); - const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => { - try { - const d = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const endpoint = routeGlobal - ? api.metrics.globalApp(d, selectedTenantId || undefined) - : api.metrics.app(tenantId, d); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (response.ok) { - const json = await response.json(); - setAppMetrics(json); - } else { - addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error'); - } - } catch (error) { - if (error instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', error.message, 'session-expired-error'); - } else { - console.error("Failed to fetch app metrics:", error); - addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error'); - } - } - }; - // Server payload, with presentation-friendly defaults so the JSX renders // without null guards once loading clears. `avgDuration` keeps the card's // original field name (server sends avgDurationMinutes). diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx index 79d180d5..1a139329 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx @@ -82,8 +82,16 @@ interface GeographicMetricsResponse { type GroupBy = "city" | "region" | "country"; type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgApiLatencyMs" | "avgDoPercentPeerCaching"; + type TimeRange = "7d" | "30d" | "90d"; +// Module-level so React keeps a stable component identity across renders +// (react-hooks/static-components: an inline component type remounts on every render). +function SortIcon({ col, sortBy, sortDesc }: { col: SortBy; sortBy: SortBy; sortDesc: boolean }) { + if (sortBy !== col) return ↕; + return {sortDesc ? "▼" : "▲"}; +} + const durationColor = (value: number, globalAvg: number) => { if (globalAvg <= 0) return "text-gray-700"; const ratio = value / globalAvg; @@ -220,11 +228,6 @@ export default function GeographicPerformancePage() { } }; - const SortIcon = ({ col }: { col: SortBy }) => { - if (sortBy !== col) return ↕; - return {sortDesc ? "▼" : "▲"}; - }; - if (loading) { return ( handleSort("sessionCount")} > - Sessions + Sessions Success @@ -443,7 +446,7 @@ export default function GeographicPerformancePage() { className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700" onClick={() => handleSort("avgDurationMinutes")} > - Avg Duration + Avg Duration P95 @@ -452,26 +455,26 @@ export default function GeographicPerformancePage() { className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700" onClick={() => handleSort("appLoadScore")} > - App-Load-Score + App-Load-Score handleSort("avgThroughputBytesPerSec")} > - Throughput + Throughput handleSort("avgApiLatencyMs")} title="Agent→backend HTTP round-trip measured on the devices — high values indicate network distance to the backend region" > - API Latency + API Latency handleSort("avgDoPercentPeerCaching")} > - P2P % + P2P % vs Global diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx index a5a71f2e..5855e4a3 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/VulnerabilityReportSection.tsx @@ -94,6 +94,15 @@ function confidenceTooltip(confidence?: string): string { return "Unknown identification confidence"; } +type FindingsSortKey = "severity" | "software" | "cves" | "cvss"; + +// Module-level so React keeps a stable component identity across renders +// (react-hooks/static-components: an inline component type remounts on every render). +function SortIndicator({ column, sortKey, sortAsc }: { column: FindingsSortKey; sortKey: FindingsSortKey; sortAsc: boolean }) { + if (sortKey !== column) return ↕; + return {sortAsc ? "▲" : "▼"}; +} + export default function VulnerabilityReportSection({ events, vulnerabilityReport, @@ -104,7 +113,7 @@ export default function VulnerabilityReportSection({ const [activeTab, setActiveTab] = useState("findings"); const [rescanning, setRescanning] = useState(false); const [findingsSearch, setFindingsSearch] = useState(""); - const [findingsSortKey, setFindingsSortKey] = useState<"severity" | "software" | "cves" | "cvss">("severity"); + const [findingsSortKey, setFindingsSortKey] = useState("severity"); const [findingsSortAsc, setFindingsSortAsc] = useState(false); const [expandedRows, setExpandedRows] = useState>(new Set()); const [inventorySearch, setInventorySearch] = useState(""); @@ -397,11 +406,6 @@ export default function VulnerabilityReportSection({ }); }; - const SortIndicator = ({ column }: { column: typeof findingsSortKey }) => { - if (findingsSortKey !== column) return ↕; - return {findingsSortAsc ? "\u25B2" : "\u25BC"}; - }; - // Guard: no relevant data → render nothing (placed after all hooks to satisfy Rules of Hooks) if (vulnReportData.length === 0 && inventoryEvents.length === 0) return null; @@ -573,18 +577,18 @@ export default function VulnerabilityReportSection({ toggleFindingsSort("severity")}> - Risk + Risk toggleFindingsSort("software")}> - Software + Software Version toggleFindingsSort("cves")}> - CVEs + CVEs KEV toggleFindingsSort("cvss")}> - Max CVSS + Max CVSS diff --git a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx index ed71f773..1cb77e64 100644 --- a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx @@ -214,7 +214,11 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr bytesTotal: isNaN(bytesTotal) ? 0 : bytesTotal, downloadRateBps: effectiveRateBps, lastUpdated: evt.timestamp, - lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? Date.now()), + // NaN when no event ever carried a parseable timestamp: the rate + // fallback above guards with Number.isFinite, and wall-clock time at + // memo-recompute would be an arbitrary base for event-time deltas + // anyway (render must also stay pure — react-hooks/purity). + lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? NaN), isComplete, isSkipped: isSkippedEvent || (existing?.isSkipped ?? false), firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++, diff --git a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx index fb22b243..d85711c1 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx @@ -17,6 +17,93 @@ interface QuickSearchResult { matchedField: 'sessionId' | 'serialNumber' | 'deviceName'; } +const fieldLabel = (field: string) => { + switch (field) { + case 'sessionId': return 'Session ID'; + case 'serialNumber': return 'Serial'; + case 'deviceName': return 'Device'; + default: return field; + } +}; + +const statusColor = (status: string) => { + switch (status) { + case 'Succeeded': return 'text-green-600'; + case 'Failed': return 'text-red-600'; + case 'InProgress': return 'text-blue-600'; + case 'Pending': return 'text-amber-600'; + case 'Stalled': return 'text-orange-600'; + default: return 'text-gray-500'; + } +}; + +// Module-level so React keeps stable component identities across renders +// (react-hooks/static-components: an inline component type remounts on every +// keystroke — the dropdown subtree would be torn down and rebuilt mid-typing). +const SearchIcon = () => ( + + + +); + +function ResultsDropdown({ mobile, visible, loading, results, query, selectedIndex, onNavigate }: { + mobile?: boolean; + visible: boolean; + loading: boolean; + results: QuickSearchResult[]; + query: string; + selectedIndex: number; + onNavigate: (result: QuickSearchResult) => void; +}) { + if (!visible) return null; + return ( +

+ {loading ? ( +
+ + + + + Searching... +
+ ) : results.length === 0 ? ( +
+ {query.length >= 2 ? 'No results found' : 'Type at least 2 characters'} +
+ ) : ( + results.map((result, idx) => ( + + )) + )} +
+ ); +} + export default function GlobalSearch() { const [query, setQuery] = useState(''); const [mobileOpen, setMobileOpen] = useState(false); @@ -138,82 +225,6 @@ export default function GlobalSearch() { } }; - const fieldLabel = (field: string) => { - switch (field) { - case 'sessionId': return 'Session ID'; - case 'serialNumber': return 'Serial'; - case 'deviceName': return 'Device'; - default: return field; - } - }; - - const statusColor = (status: string) => { - switch (status) { - case 'Succeeded': return 'text-green-600'; - case 'Failed': return 'text-red-600'; - case 'InProgress': return 'text-blue-600'; - case 'Pending': return 'text-amber-600'; - case 'Stalled': return 'text-orange-600'; - default: return 'text-gray-500'; - } - }; - - const SearchIcon = () => ( - - - - ); - - const ResultsDropdown = ({ mobile }: { mobile?: boolean }) => { - if (!showDropdown) return null; - return ( -
- {loading ? ( -
- - - - - Searching... -
- ) : results.length === 0 ? ( -
- {query.length >= 2 ? 'No results found' : 'Type at least 2 characters'} -
- ) : ( - results.map((result, idx) => ( - - )) - )} -
- ); - }; - return (
{/* Desktop: inline search field centered (visible md+) */} @@ -244,7 +255,7 @@ export default function GlobalSearch() { )}
- + {/* Mobile: magnifying glass button pushed right (visible - + )}