This is an automated email from the ASF dual-hosted git repository.

twice pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks-controller.git


The following commit(s) were added to refs/heads/unstable by this push:
     new e0d2933  feat(webui): add global modern Spotlight Search (#367)
e0d2933 is described below

commit e0d29334db6c16f8e3d762e33dd86438073b63c7
Author: Agnik Misra <[email protected]>
AuthorDate: Fri Nov 21 13:23:48 2025 +0530

    feat(webui): add global modern Spotlight Search (#367)
    
    Co-authored-by: agnik <[email protected]>
---
 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(-)

diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css
index 43e5dd4..6f4d1ab 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 f235d88..c0a5c87 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
             >
                 <ThemeProvider>
+                    <SpotlightSearch />
                     <Banner />
                     <Container
                         sx={{ marginTop: "64px", height: "calc(100vh - 64px)" 
}}
diff --git a/webui/src/app/ui/banner.tsx b/webui/src/app/ui/banner.tsx
index 9badd30..b9d6b23 100644
--- a/webui/src/app/ui/banner.tsx
+++ b/webui/src/app/ui/banner.tsx
@@ -19,7 +19,16 @@
 
 "use client";
 
-import { AppBar, Container, Toolbar, IconButton, Box, Tooltip, Typography } 
from "@mui/material";
+import {
+    AppBar,
+    Container,
+    Toolbar,
+    IconButton,
+    Box,
+    Tooltip,
+    Typography,
+    Button,
+} from "@mui/material";
 import Image from "next/image";
 import NavLinks from "./nav-links";
 import { useTheme } from "../theme-provider";
@@ -29,6 +38,7 @@ import GitHubIcon from "@mui/icons-material/GitHub";
 import HomeIcon from "@mui/icons-material/Home";
 import FolderIcon from "@mui/icons-material/Folder";
 import MenuBookIcon from "@mui/icons-material/MenuBook";
+import SearchIcon from "@mui/icons-material/Search";
 import { usePathname } from "next/navigation";
 import { useEffect, useState } from "react";
 import Link from "next/link";
@@ -174,6 +184,53 @@ export default function Banner() {
                         <NavLinks links={links} scrolled={scrolled} />
                     </Box>
 
+                    <Box className="flex items-center">
+                        <Button
+                            onClick={() => {
+                                const event = new KeyboardEvent("keydown", {
+                                    key: "k",
+                                    metaKey: true,
+                                    ctrlKey: true,
+                                });
+                                window.dispatchEvent(event);
+                            }}
+                            startIcon={<SearchIcon fontSize="small" />}
+                            sx={{
+                                mr: 1,
+                                px: 1.5,
+                                py: 0.5,
+                                fontSize: "0.875rem",
+                                textTransform: "none",
+                                backgroundColor: isDarkMode
+                                    ? "rgba(255,255,255,0.1)"
+                                    : "rgba(0,0,0,0.05)",
+                                color: isDarkMode ? "rgba(255,255,255,0.9)" : 
"rgba(0,0,0,0.7)",
+                                borderRadius: 2,
+                                transition: "all 0.3s ease",
+                                "&:hover": {
+                                    backgroundColor: isDarkMode
+                                        ? "rgba(255,255,255,0.2)"
+                                        : "rgba(0,0,0,0.08)",
+                                },
+                            }}
+                        >
+                            <Box component="span" sx={{ mr: 0.5 }}>
+                                Search
+                            </Box>
+                            <Box
+                                component="kbd"
+                                sx={{
+                                    fontSize: "0.7rem",
+                                    px: 0.5,
+                                    py: 0.25,
+                                    ml: 0.5,
+                                }}
+                            >
+                                ⌘K
+                            </Box>
+                        </Button>
+                    </Box>
+
                     <Box className="flex items-center">
                         <Tooltip title="Toggle dark mode">
                             <IconButton
diff --git a/webui/src/app/ui/spotlight-search.tsx 
b/webui/src/app/ui/spotlight-search.tsx
new file mode 100644
index 0000000..dd189f9
--- /dev/null
+++ b/webui/src/app/ui/spotlight-search.tsx
@@ -0,0 +1,532 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import {
+    Dialog,
+    DialogContent,
+    TextField,
+    List,
+    ListItem,
+    ListItemButton,
+    Box,
+    Typography,
+    Chip,
+    alpha,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import FolderIcon from "@mui/icons-material/Folder";
+import StorageIcon from "@mui/icons-material/Storage";
+import DnsIcon from "@mui/icons-material/Dns";
+import DeviceHubIcon from "@mui/icons-material/DeviceHub";
+import { fetchNamespaces, fetchClusters, listShards, listNodes } from 
"../lib/api";
+
+interface SearchResult {
+    type: "namespace" | "cluster" | "shard" | "node";
+    title: string;
+    subtitle?: string;
+    path: string;
+    namespace?: string;
+    cluster?: string;
+    shard?: string;
+}
+
+export default function SpotlightSearch() {
+    const [open, setOpen] = useState(false);
+    const [query, setQuery] = useState("");
+    const [results, setResults] = useState<SearchResult[]>([]);
+    const [selectedIndex, setSelectedIndex] = useState(0);
+    const [allData, setAllData] = useState<SearchResult[]>([]);
+    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 <FolderIcon sx={{ fontSize: 18 }} />;
+            case "cluster":
+                return <StorageIcon sx={{ fontSize: 18 }} />;
+            case "shard":
+                return <DnsIcon sx={{ fontSize: 18 }} />;
+            case "node":
+                return <DeviceHubIcon sx={{ fontSize: 18 }} />;
+            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 (
+        <Dialog
+            open={open}
+            onClose={handleClose}
+            maxWidth="md"
+            fullWidth
+            PaperProps={{
+                sx: {
+                    position: "fixed",
+                    top: "15%",
+                    m: 0,
+                    borderRadius: 4,
+                    boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
+                    overflow: "hidden",
+                    backdropFilter: "blur(20px)",
+                    background: (theme) =>
+                        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)",
+                    },
+                },
+            }}
+        >
+            <DialogContent sx={{ p: 0 }}>
+                <Box
+                    sx={{
+                        p: 3,
+                        borderBottom: 1,
+                        borderColor: (theme) =>
+                            theme.palette.mode === "dark"
+                                ? "rgba(255, 255, 255, 0.08)"
+                                : "rgba(0, 0, 0, 0.08)",
+                    }}
+                >
+                    <Box sx={{ display: "flex", alignItems: "center", gap: 2 
}}>
+                        <SearchIcon
+                            sx={{
+                                fontSize: 24,
+                                color: "text.secondary",
+                                opacity: 0.6,
+                            }}
+                        />
+                        <TextField
+                            fullWidth
+                            autoFocus
+                            placeholder={
+                                query
+                                    ? "Search everything..."
+                                    : "Search in current context (or type to 
search all)..."
+                            }
+                            value={query}
+                            onChange={(e) => setQuery(e.target.value)}
+                            variant="standard"
+                            InputProps={{
+                                disableUnderline: true,
+                                sx: {
+                                    fontSize: "1.125rem",
+                                    fontWeight: 400,
+                                    "& input::placeholder": {
+                                        opacity: 0.6,
+                                    },
+                                },
+                            }}
+                        />
+                    </Box>
+                </Box>
+
+                {loading ? (
+                    <Box sx={{ p: 8, textAlign: "center" }}>
+                        <Typography color="text.secondary" sx={{ opacity: 0.7 
}}>
+                            Loading resources...
+                        </Typography>
+                    </Box>
+                ) : results.length > 0 ? (
+                    <List sx={{ maxHeight: 420, overflow: "auto", p: 2 }}>
+                        {results.map((result, index) => (
+                            <ListItem
+                                key={`${result.type}-${result.path}`}
+                                disablePadding
+                                sx={{ mb: 1 }}
+                            >
+                                <ListItemButton
+                                    selected={index === selectedIndex}
+                                    onClick={() => 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),
+                                            },
+                                        },
+                                    }}
+                                >
+                                    <Box
+                                        sx={{
+                                            display: "flex",
+                                            alignItems: "center",
+                                            gap: 2,
+                                            width: "100%",
+                                        }}
+                                    >
+                                        <Box
+                                            sx={{
+                                                display: "flex",
+                                                alignItems: "center",
+                                                justifyContent: "center",
+                                                width: 40,
+                                                height: 40,
+                                                borderRadius: 2.5,
+                                                bgcolor: (theme) =>
+                                                    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)}
+                                        </Box>
+                                        <Box sx={{ flex: 1, minWidth: 0 }}>
+                                            <Typography
+                                                variant="body1"
+                                                sx={{
+                                                    fontWeight: 500,
+                                                    fontSize: "0.95rem",
+                                                    mb: result.subtitle ? 0.25 
: 0,
+                                                    overflow: "hidden",
+                                                    textOverflow: "ellipsis",
+                                                    whiteSpace: "nowrap",
+                                                }}
+                                            >
+                                                {result.title}
+                                            </Typography>
+                                            {result.subtitle && (
+                                                <Typography
+                                                    variant="caption"
+                                                    sx={{
+                                                        color: 
"text.secondary",
+                                                        fontSize: "0.8rem",
+                                                        opacity: 0.7,
+                                                        overflow: "hidden",
+                                                        textOverflow: 
"ellipsis",
+                                                        whiteSpace: "nowrap",
+                                                        display: "block",
+                                                    }}
+                                                >
+                                                    {result.subtitle}
+                                                </Typography>
+                                            )}
+                                        </Box>
+                                        <Chip
+                                            label={result.type}
+                                            size="small"
+                                            sx={{
+                                                height: 24,
+                                                fontSize: "0.7rem",
+                                                fontWeight: 600,
+                                                borderRadius: 2,
+                                                bgcolor: (theme) =>
+                                                    theme.palette.mode === 
"dark"
+                                                        ? 
alpha(getTypeColor(result.type), 0.25)
+                                                        : 
alpha(getTypeColor(result.type), 0.15),
+                                                color: 
getTypeColor(result.type),
+                                                border: "none",
+                                                textTransform: "capitalize",
+                                            }}
+                                        />
+                                    </Box>
+                                </ListItemButton>
+                            </ListItem>
+                        ))}
+                    </List>
+                ) : (
+                    <Box sx={{ p: 8, textAlign: "center" }}>
+                        <SearchIcon
+                            sx={{
+                                fontSize: 48,
+                                color: "text.secondary",
+                                opacity: 0.3,
+                                mb: 2,
+                            }}
+                        />
+                        <Typography
+                            color="text.secondary"
+                            sx={{ opacity: 0.7, fontSize: "0.95rem" }}
+                        >
+                            {query ? "No results found" : "Start typing to 
search all resources..."}
+                        </Typography>
+                    </Box>
+                )}
+
+                <Box
+                    sx={{
+                        px: 3,
+                        py: 2,
+                        borderTop: 1,
+                        borderColor: (theme) =>
+                            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)",
+                    }}
+                >
+                    <Box sx={{ display: "flex", alignItems: "center", gap: 
0.75 }}>
+                        <Typography
+                            variant="caption"
+                            sx={{ color: "text.secondary", fontSize: 
"0.75rem", opacity: 0.7 }}
+                        >
+                            <kbd>↑↓</kbd> Navigate
+                        </Typography>
+                    </Box>
+                    <Box sx={{ display: "flex", alignItems: "center", gap: 
0.75 }}>
+                        <Typography
+                            variant="caption"
+                            sx={{ color: "text.secondary", fontSize: 
"0.75rem", opacity: 0.7 }}
+                        >
+                            <kbd>↵</kbd> Select
+                        </Typography>
+                    </Box>
+                    <Box sx={{ display: "flex", alignItems: "center", gap: 
0.75 }}>
+                        <Typography
+                            variant="caption"
+                            sx={{ color: "text.secondary", fontSize: 
"0.75rem", opacity: 0.7 }}
+                        >
+                            <kbd>Esc</kbd> Close
+                        </Typography>
+                    </Box>
+                </Box>
+            </DialogContent>
+        </Dialog>
+    );
+}

Reply via email to