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 - + )}