From ad37b48a5cf842588992c501d5a8ad74a93b314b Mon Sep 17 00:00:00 2001 From: agnik Date: Wed, 19 Nov 2025 16:48:16 +0530 Subject: [PATCH] feat: added global modern Spotlight Search --- webui/src/app/globals.css | 31 ++ webui/src/app/layout.tsx | 2 + webui/src/app/ui/banner.tsx | 59 ++- webui/src/app/ui/spotlight-search.tsx | 532 ++++++++++++++++++++++++++ 4 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 webui/src/app/ui/spotlight-search.tsx diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css index 43e5dd4b..6f4d1ab4 100644 --- a/webui/src/app/globals.css +++ b/webui/src/app/globals.css @@ -484,3 +484,34 @@ img[data-loaded="false"] { box-shadow: none; } } + +/* Spotlight Search Keyboard Shortcuts */ +kbd { + display: inline-block; + padding: 3px 8px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.7rem; + line-height: 1.2; + font-weight: 600; + color: #555; + background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%); + border: 1px solid #d0d0d0; + border-radius: 6px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(255, 255, 255, 0.8) inset, + 0 -1px 0 rgba(0, 0, 0, 0.05) inset; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.dark kbd { + color: #d0d0d0; + background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); + border: 1px solid #4a4a4a; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.05) inset, + 0 -1px 0 rgba(0, 0, 0, 0.2) inset; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); +} diff --git a/webui/src/app/layout.tsx b/webui/src/app/layout.tsx index f235d881..c0a5c874 100644 --- a/webui/src/app/layout.tsx +++ b/webui/src/app/layout.tsx @@ -25,6 +25,7 @@ import { Container } from "@mui/material"; import { ThemeProvider } from "./theme-provider"; import Footer from "./ui/footer"; import Breadcrumb from "./ui/breadcrumb"; +import SpotlightSearch from "./ui/spotlight-search"; const inter = Inter({ subsets: ["latin"] }); @@ -45,6 +46,7 @@ export default function RootLayout({ suppressHydrationWarning > + + + + + ([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [allData, setAllData] = useState([]); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + + // Load all data when dialog opens + const loadAllData = useCallback(async () => { + setLoading(true); + try { + const data: SearchResult[] = []; + const namespaces = await fetchNamespaces(); + + for (const ns of namespaces) { + // Add namespace + data.push({ + type: "namespace", + title: ns, + path: `/namespaces/${ns}`, + namespace: ns, + }); + + // Add clusters + const clusters = await fetchClusters(ns); + for (const cluster of clusters) { + data.push({ + type: "cluster", + title: cluster, + subtitle: `in ${ns}`, + path: `/namespaces/${ns}/clusters/${cluster}`, + namespace: ns, + cluster, + }); + + // Add shards + const shards = await listShards(ns, cluster); + for (let i = 0; i < shards.length; i++) { + data.push({ + type: "shard", + title: `Shard ${i}`, + subtitle: `${cluster} / ${ns}`, + path: `/namespaces/${ns}/clusters/${cluster}/shards/${i}`, + namespace: ns, + cluster, + shard: String(i), + }); + + // Add nodes + const nodes = await listNodes(ns, cluster, String(i)); + for (let nodeIndex = 0; nodeIndex < (nodes as any[]).length; nodeIndex++) { + const node = (nodes as any[])[nodeIndex]; + data.push({ + type: "node", + title: node.addr || node.id, + subtitle: `${node.role} in Shard ${i} / ${cluster}`, + path: `/namespaces/${ns}/clusters/${cluster}/shards/${i}/nodes/${nodeIndex}`, + namespace: ns, + cluster, + shard: String(i), + }); + } + } + } + } + + setAllData(data); + } catch (error) { + console.error("Failed to load search data:", error); + } finally { + setLoading(false); + } + }, []); + + // Context-aware search function + const contextAwareSearch = useCallback( + (searchQuery: string) => { + // Parse current context from pathname + const pathParts = pathname.split("/").filter(Boolean); + const currentNamespace = pathParts[1]; + const currentCluster = pathParts[3]; + const currentShard = pathParts[5]; + + // If no query, show context-relevant items only + if (!searchQuery.trim()) { + let contextData: SearchResult[] = []; + + if (currentShard) { + // In shard page - show only nodes in this shard + contextData = allData.filter( + (item) => + item.type === "node" && + item.namespace === currentNamespace && + item.cluster === currentCluster && + item.shard === currentShard + ); + } else if (currentCluster) { + // In cluster page - show only shards in this cluster + contextData = allData.filter( + (item) => + item.type === "shard" && + item.namespace === currentNamespace && + item.cluster === currentCluster + ); + } else if (currentNamespace) { + // In namespace page - show only clusters in this namespace + contextData = allData.filter( + (item) => item.type === "cluster" && item.namespace === currentNamespace + ); + } else { + // On home/namespaces page - show only namespaces + contextData = allData.filter((item) => item.type === "namespace"); + } + + return contextData.slice(0, 10); + } + + // With query, search everything + const lowerQuery = searchQuery.toLowerCase(); + const filtered = allData.filter((item) => { + const searchText = + `${item.title} ${item.subtitle || ""} ${item.type}`.toLowerCase(); + return searchText.includes(lowerQuery); + }); + + return filtered.slice(0, 10); + }, + [allData, pathname] + ); + + // Update results when query or pathname changes + useEffect(() => { + const filtered = contextAwareSearch(query); + setResults(filtered); + setSelectedIndex(0); + }, [query, contextAwareSearch]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+K or Ctrl+K to open + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen(true); + if (!allData.length) { + loadAllData(); + } + } + + // Escape to close + if (e.key === "Escape") { + setOpen(false); + setQuery(""); + } + + // Arrow navigation when open + if (open) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && results[selectedIndex]) { + e.preventDefault(); + handleSelect(results[selectedIndex]); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [open, results, selectedIndex, allData.length, loadAllData]); + + const handleSelect = (result: SearchResult) => { + router.push(result.path); + setOpen(false); + setQuery(""); + }; + + const handleClose = () => { + setOpen(false); + setQuery(""); + }; + + const getTypeIcon = (type: string) => { + switch (type) { + case "namespace": + return ; + case "cluster": + return ; + case "shard": + return ; + case "node": + return ; + default: + return null; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case "namespace": + return "#3b82f6"; // blue + case "cluster": + return "#8b5cf6"; // purple + case "shard": + return "#10b981"; // green + case "node": + return "#f59e0b"; // orange + default: + return "#6b7280"; + } + }; + + return ( + + theme.palette.mode === "dark" + ? "rgba(30, 30, 30, 0.95)" + : "rgba(255, 255, 255, 0.95)", + }, + }} + slotProps={{ + backdrop: { + sx: { + backdropFilter: "blur(4px)", + backgroundColor: "rgba(0, 0, 0, 0.3)", + }, + }, + }} + > + + + theme.palette.mode === "dark" + ? "rgba(255, 255, 255, 0.08)" + : "rgba(0, 0, 0, 0.08)", + }} + > + + + setQuery(e.target.value)} + variant="standard" + InputProps={{ + disableUnderline: true, + sx: { + fontSize: "1.125rem", + fontWeight: 400, + "& input::placeholder": { + opacity: 0.6, + }, + }, + }} + /> + + + + {loading ? ( + + + Loading resources... + + + ) : results.length > 0 ? ( + + {results.map((result, index) => ( + + handleSelect(result)} + sx={{ + borderRadius: 3, + px: 2.5, + py: 1.5, + transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + transform: "translateX(4px)", + bgcolor: (theme) => + theme.palette.mode === "dark" + ? alpha(getTypeColor(result.type), 0.15) + : alpha(getTypeColor(result.type), 0.08), + }, + "&.Mui-selected": { + bgcolor: (theme) => + theme.palette.mode === "dark" + ? alpha(getTypeColor(result.type), 0.2) + : alpha(getTypeColor(result.type), 0.12), + "&:hover": { + bgcolor: (theme) => + theme.palette.mode === "dark" + ? alpha(getTypeColor(result.type), 0.25) + : alpha(getTypeColor(result.type), 0.15), + }, + }, + }} + > + + + theme.palette.mode === "dark" + ? alpha(getTypeColor(result.type), 0.2) + : alpha(getTypeColor(result.type), 0.12), + color: getTypeColor(result.type), + flexShrink: 0, + }} + > + {getTypeIcon(result.type)} + + + + {result.title} + + {result.subtitle && ( + + {result.subtitle} + + )} + + + theme.palette.mode === "dark" + ? alpha(getTypeColor(result.type), 0.25) + : alpha(getTypeColor(result.type), 0.15), + color: getTypeColor(result.type), + border: "none", + textTransform: "capitalize", + }} + /> + + + + ))} + + ) : ( + + + + {query ? "No results found" : "Start typing to search all resources..."} + + + )} + + + theme.palette.mode === "dark" + ? "rgba(255, 255, 255, 0.08)" + : "rgba(0, 0, 0, 0.08)", + display: "flex", + gap: 3, + justifyContent: "flex-end", + bgcolor: (theme) => + theme.palette.mode === "dark" + ? "rgba(0, 0, 0, 0.2)" + : "rgba(0, 0, 0, 0.02)", + }} + > + + + ↑↓ Navigate + + + + + Select + + + + + Esc Close + + + + + + ); +}