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 e61e191c..37c12025 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { useState, useEffect, useRef } from "react"; import { Session } from "../types"; import { trackEvent } from "@/lib/appInsights"; +import { fuzzyContains } from "@/lib/fuzzy"; // Column definition for the session table interface ColumnDef { @@ -131,6 +132,77 @@ export function SessionTable({ const tenantDropdownRef = useRef(null); const [showTenantSuggestions, setShowTenantSuggestions] = useState(false); const [tenantSelectedIndex, setTenantSelectedIndex] = useState(-1); + const searchDropdownRef = useRef(null); + const [showSearchSuggestions, setShowSearchSuggestions] = useState(false); + const [searchSelectedIndex, setSearchSelectedIndex] = useState(-1); + + // Fuzzy-match sessions by multiple fields for search dropdown (two-phase: exact then Levenshtein) + interface SearchSuggestion { + session: Session; + matchedField: string; + matchedValue: string; + isExact: boolean; + } + + const SEARCH_FIELDS: { key: keyof Session; label: string }[] = [ + { key: "deviceName", label: "Device" }, + { key: "serialNumber", label: "Serial" }, + { key: "model", label: "Model" }, + { key: "manufacturer", label: "Manufacturer" }, + { key: "sessionId", label: "Session ID" }, + { key: "geoCountry", label: "Country" }, + { key: "geoRegion", label: "Region" }, + { key: "geoCity", label: "City" }, + { key: "agentVersion", label: "Agent Version" }, + { key: "osName", label: "OS Name" }, + { key: "osBuild", label: "OS Build" }, + { key: "osDisplayVersion", label: "OS Version" }, + { key: "osEdition", label: "OS Edition" }, + { key: "status", label: "Status" }, + ]; + + const searchSuggestions: SearchSuggestion[] = (() => { + const q = searchQuery.trim().toLowerCase(); + if (q.length < 2 || sessions.length === 0) return []; + if (/^[><]=?\s*\d+$/.test(q)) return []; + + const exactResults: SearchSuggestion[] = []; + const fuzzyResults: SearchSuggestion[] = []; + const seen = new Set(); + + // Phase 1: exact substring matches (highest priority) + for (const session of sessions) { + if (exactResults.length >= 8) break; + if (seen.has(session.sessionId)) continue; + for (const f of SEARCH_FIELDS) { + const val = session[f.key]; + if (val != null && String(val).toLowerCase().includes(q)) { + seen.add(session.sessionId); + exactResults.push({ session, matchedField: f.label, matchedValue: String(val), isExact: true }); + break; + } + } + } + + // Phase 2: Levenshtein fuzzy matches (fill remaining slots, min 3 chars for fuzzy) + if (exactResults.length < 8 && q.length >= 3) { + const maxDist = q.length <= 4 ? 1 : 2; + for (const session of sessions) { + if (exactResults.length + fuzzyResults.length >= 8) break; + if (seen.has(session.sessionId)) continue; + for (const f of SEARCH_FIELDS) { + const val = session[f.key]; + if (val != null && fuzzyContains(String(val), q, maxDist)) { + seen.add(session.sessionId); + fuzzyResults.push({ session, matchedField: f.label, matchedValue: String(val), isExact: false }); + break; + } + } + } + } + + return [...exactResults, ...fuzzyResults]; + })(); // Fuzzy-match tenants by domain name or tenant ID const tenantSuggestions = (() => { @@ -158,8 +230,11 @@ export function SessionTable({ if (tenantDropdownRef.current && !tenantDropdownRef.current.contains(e.target as Node)) { setShowTenantSuggestions(false); } + if (searchDropdownRef.current && !searchDropdownRef.current.contains(e.target as Node)) { + setShowSearchSuggestions(false); + } } - if (showColumnSelector || openFilterColumn || showTenantSuggestions) { + if (showColumnSelector || openFilterColumn || showTenantSuggestions || showSearchSuggestions) { document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); } @@ -395,18 +470,54 @@ export function SessionTable({ )} - {/* Search Input */} -
+ {/* Search Input with Fuzzy Suggestions */} +
onSearchQueryChange(e.target.value)} + onChange={(e) => { + onSearchQueryChange(e.target.value); + setShowSearchSuggestions(true); + setSearchSelectedIndex(-1); + }} + onFocus={() => { + if (searchSuggestions.length > 0) setShowSearchSuggestions(true); + }} + onKeyDown={(e) => { + if (showSearchSuggestions && searchSuggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSearchSelectedIndex((i) => Math.min(i + 1, searchSuggestions.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSearchSelectedIndex((i) => Math.max(i - 1, -1)); + return; + } + if (e.key === "Enter" && searchSelectedIndex >= 0) { + e.preventDefault(); + const selected = searchSuggestions[searchSelectedIndex]; + setShowSearchSuggestions(false); + setSearchSelectedIndex(-1); + router.push(`/sessions/${selected.session.sessionId}`); + return; + } + if (e.key === "Escape") { + setShowSearchSuggestions(false); + return; + } + } + }} className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" /> {searchQuery && ( )} + {/* Search suggestions dropdown */} + {showSearchSuggestions && searchSuggestions.length > 0 && ( +
+ {searchSuggestions.map((s, idx) => { + const statusColors: Record = { + Succeeded: "text-green-600", + Failed: "text-red-600", + InProgress: "text-blue-600", + Pending: "text-amber-600", + }; + return ( + + ); + })} + {/* Hint when more sessions may exist on the server */} + {hasMore && searchSuggestions.length < 8 && ( +
+ + Searching {sessions.length} loaded sessions + + +
+ )} +
+ )}
{/* Status Filter Badges */} diff --git a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx index 347da1ad..d46c0209 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSearch.tsx @@ -227,8 +227,19 @@ export default function GlobalSearch() { onKeyDown={handleKeyDown} onFocus={() => { if (query.length >= 2) setShowDropdown(true); }} placeholder="Search serial, device, session... (Ctrl+K)" - className="w-full pl-9 pr-3 py-1.5 text-sm bg-gray-100 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent focus:bg-white transition-colors" + className="w-full pl-9 pr-8 py-1.5 text-sm bg-gray-100 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent focus:bg-white transition-colors" /> + {query && ( + + )}
@@ -262,8 +273,19 @@ export default function GlobalSearch() { onChange={(e) => handleInputChange(e.target.value)} onKeyDown={handleKeyDown} placeholder="Search serial, device, session..." - className="w-full pl-9 pr-3 py-1.5 text-sm bg-gray-100 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent focus:bg-white transition-colors" + className="w-full pl-9 pr-8 py-1.5 text-sm bg-gray-100 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent focus:bg-white transition-colors" /> + {query && ( + + )}