-
+const FILTER_THRESHOLD = 5;
+const SKELETON_WIDTHS = ["68%", "52%", "78%", "58%", "44%"];
+
+interface SidebarShellProps {
+ label: string;
+ total: number;
+ filteredCount: number;
+ error: string | null;
+ loading: boolean;
+ action: ReactNode;
+ filterValue: string;
+ onFilterChange: (v: string) => void;
+ filterPlaceholder: string;
+ emptyIcon: ReactNode;
+ emptyTitle: string;
+ emptyHint: string;
+ children: ReactNode;
+}
+
+function SidebarShell({
+ label,
+ total,
+ filteredCount,
+ error,
+ loading,
+ action,
+ filterValue,
+ onFilterChange,
+ filterPlaceholder,
+ emptyIcon,
+ emptyTitle,
+ emptyHint,
+ children,
+}: SidebarShellProps) {
+ const isEmpty = !loading && total === 0;
+ const isFilteredEmpty = !loading && total > 0 && filteredCount === 0;
+ const showFilter = !loading && total > FILTER_THRESHOLD;
+ const isFiltering = filterValue.trim().length > 0;
+
+ return (
+
+ );
+}
+
+function SidebarSkeleton() {
+ return (
+
+ {SKELETON_WIDTHS.map((w, i) => (
+ -
+
+
+
+ ))}
+
+ );
+}
+
+function SidebarEmpty({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) {
+ return (
+
+
{icon}
-
+
{title}
-
- {count > 0 && (
-
- {count}
-
- )}
+
+
+ {hint}
+
-
- {isOpen ? (
-
- ) : (
-
- )}
+ );
+}
+
+function SidebarNoMatches({ query, onClear }: { query: string; onClear: () => void }) {
+ return (
+
+
+ No matches for{" "}
+
+ “{query}”
+
+
+
-
-);
+ );
+}
+
+const matchesQuery = (haystack: string, query: string) => {
+ if (!query) return true;
+ return haystack.replace(/\t/g, " ").toLowerCase().includes(query.toLowerCase());
+};
+
+// Scrolls the currently-active list item into view when the sidebar mounts or
+// the pathname changes. Uses layout effect so users deep-linking to a node
+// don't see a jump after paint.
+function useScrollActiveIntoView(deps: unknown[]) {
+ const ref = useRef
(null);
+ useLayoutEffect(() => {
+ if (!ref.current) return;
+ const active = ref.current.querySelector('[data-active="true"]');
+ if (active) active.scrollIntoView({ block: "nearest" });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+ return ref;
+}
export function NamespaceSidebar() {
const [namespaces, setNamespaces] = useState([]);
const [error, setError] = useState(null);
- const [isOpen, setIsOpen] = useState(true);
- const [sidebarWidth, setSidebarWidth] = useState(260);
- const [isMobile, setIsMobile] = useState(false);
-
- useEffect(() => {
- const checkMobile = () => {
- setIsMobile(window.innerWidth < 768);
- };
-
- checkMobile();
- window.addEventListener("resize", checkMobile);
- return () => window.removeEventListener("resize", checkMobile);
- }, []);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("");
+ const pathname = usePathname();
+ const activeSlug = pathname.startsWith("/namespaces/") ? pathname.split("/")[2] : null;
useEffect(() => {
- const fetchData = async () => {
+ (async () => {
try {
- const fetchedNamespaces = await fetchNamespaces();
- setNamespaces(fetchedNamespaces);
- } catch (err) {
+ setNamespaces(await fetchNamespaces());
+ } catch {
setError("Failed to fetch namespaces");
+ } finally {
+ setLoading(false);
}
- };
- fetchData();
+ })();
}, []);
- const toggleSidebar = () => {
- if (isMobile) {
- setSidebarWidth(isOpen ? 0 : 260);
- }
- setIsOpen(!isOpen);
- };
+ const filtered = useMemo(
+ () => namespaces.filter((n) => matchesQuery(n, filter)),
+ [namespaces, filter]
+ );
+ const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]);
return (
-
+ }
+ filterValue={filter}
+ onFilterChange={setFilter}
+ filterPlaceholder="Filter namespaces…"
+ emptyIcon={}
+ emptyTitle="No namespaces"
+ emptyHint="Create a namespace to organize your Kvrocks clusters."
>
- {isMobile && (
-
- )}
-
-
-
-
-
-
-
- }
- />
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {namespaces.map((namespace) => (
-
-
-
- ))}
-
-
-
-
-
-
+
+ {filtered.map((namespace) => (
+ -
+
+
+
+
+ ))}
+
+
);
}
export function ClusterSidebar({ namespace }: { namespace: string }) {
const [clusters, setClusters] = useState([]);
const [error, setError] = useState(null);
- const [isOpen, setIsOpen] = useState(true);
- const [sidebarWidth, setSidebarWidth] = useState(260);
- const [isMobile, setIsMobile] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("");
+ const pathname = usePathname();
+ const parts = pathname.split("/");
+ const activeSlug = parts[3] === "clusters" ? (parts[4] ?? null) : null;
useEffect(() => {
- const checkMobile = () => {
- setIsMobile(window.innerWidth < 768);
- };
-
- checkMobile();
- window.addEventListener("resize", checkMobile);
- return () => window.removeEventListener("resize", checkMobile);
- }, []);
-
- useEffect(() => {
- const fetchData = async () => {
+ setLoading(true);
+ setFilter("");
+ (async () => {
try {
- const fetchedClusters = await fetchClusters(namespace);
- setClusters(fetchedClusters);
- } catch (err) {
+ setClusters(await fetchClusters(namespace));
+ } catch {
setError("Failed to fetch clusters");
+ } finally {
+ setLoading(false);
}
- };
- fetchData();
+ })();
}, [namespace]);
- const toggleSidebar = () => {
- if (isMobile) {
- setSidebarWidth(isOpen ? 0 : 260);
- }
- setIsOpen(!isOpen);
- };
+ const filtered = useMemo(
+ () => clusters.filter((c) => matchesQuery(c, filter)),
+ [clusters, filter]
+ );
+ const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]);
return (
- }
+ filterValue={filter}
+ onFilterChange={setFilter}
+ filterPlaceholder="Filter clusters…"
+ emptyIcon={}
+ emptyTitle="No clusters"
+ emptyHint={`Add a cluster to ${namespace} to start managing shards and nodes.`}
>
- {isMobile && (
-
- )}
-
-
-
-
-
-
-
- }
- />
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {clusters.map((cluster) => (
-
-
-
- ))}
-
-
-
-
-
-
+
+ {filtered.map((cluster) => (
+ -
+
+
+
+
+ ))}
+
+
);
}
export function ShardSidebar({ namespace, cluster }: { namespace: string; cluster: string }) {
- const [shards, setShards] = useState([]);
+ const [shardCount, setShardCount] = useState(0);
const [error, setError] = useState(null);
- const [isOpen, setIsOpen] = useState(true);
- const [sidebarWidth, setSidebarWidth] = useState(260);
- const [isMobile, setIsMobile] = useState(false);
-
- useEffect(() => {
- const checkMobile = () => {
- setIsMobile(window.innerWidth < 768);
- };
- checkMobile();
- window.addEventListener("resize", checkMobile);
- return () => window.removeEventListener("resize", checkMobile);
- }, []);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("");
+ const pathname = usePathname();
+ const parts = pathname.split("/");
+ const activeSlug = parts[5] === "shards" ? (parts[6] ?? null) : null;
useEffect(() => {
- const fetchData = async () => {
+ setLoading(true);
+ setFilter("");
+ (async () => {
try {
- const fetchedShards = await listShards(namespace, cluster);
- const shardsIndex = fetchedShards.map(
- (shard, index) => "Shard\t" + (index + 1).toString()
- );
- setShards(shardsIndex);
- } catch (err) {
+ const list = await listShards(namespace, cluster);
+ setShardCount(Array.isArray(list) ? list.length : 0);
+ } catch {
setError("Failed to fetch shards");
+ } finally {
+ setLoading(false);
}
- };
- fetchData();
+ })();
}, [namespace, cluster]);
- const toggleSidebar = () => {
- if (isMobile) {
- setSidebarWidth(isOpen ? 0 : 260);
- }
- setIsOpen(!isOpen);
- };
+ const shards = useMemo(
+ () => Array.from({ length: shardCount }, (_, i) => `Shard\t${i + 1}`),
+ [shardCount]
+ );
+ const filtered = useMemo(() => shards.filter((s) => matchesQuery(s, filter)), [shards, filter]);
+ const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]);
return (
- }
+ filterValue={filter}
+ onFilterChange={setFilter}
+ filterPlaceholder="Filter shards…"
+ emptyIcon={}
+ emptyTitle="No shards"
+ emptyHint={`Create a shard in ${cluster} to distribute your data.`}
>
- {isMobile && (
-
- )}
-
-
-
-
-
-
-
- }
- />
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {shards.map((shard, index) => (
-
-
-
- ))}
-
-
-
-
-
-
+
+ {filtered.map((shard) => {
+ const index = parseInt(shard.split("\t")[1]) - 1;
+ return (
+ -
+
+
+
+
+ );
+ })}
+
+
);
}
@@ -417,116 +413,82 @@ export function NodeSidebar({
}) {
const [nodes, setNodes] = useState([]);
const [error, setError] = useState(null);
- const [isOpen, setIsOpen] = useState(true);
- const [sidebarWidth, setSidebarWidth] = useState(260);
- const [isMobile, setIsMobile] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("");
+ const pathname = usePathname();
+ const parts = pathname.split("/");
+ const activeSlug = parts[7] === "nodes" ? (parts[8] ?? null) : null;
useEffect(() => {
- const checkMobile = () => {
- setIsMobile(window.innerWidth < 768);
- };
-
- checkMobile();
- window.addEventListener("resize", checkMobile);
- return () => window.removeEventListener("resize", checkMobile);
- }, []);
-
- useEffect(() => {
- const fetchData = async () => {
+ setLoading(true);
+ setFilter("");
+ (async () => {
try {
- const fetchedNodes = (await listNodes(namespace, cluster, shard)) as NodeItem[];
- setNodes(fetchedNodes);
- } catch (err) {
+ const fetched = (await listNodes(namespace, cluster, shard)) as NodeItem[];
+ setNodes(fetched);
+ } catch {
setError("Failed to fetch nodes");
+ } finally {
+ setLoading(false);
}
- };
- fetchData();
+ })();
}, [namespace, cluster, shard]);
- const toggleSidebar = () => {
- if (isMobile) {
- setSidebarWidth(isOpen ? 0 : 260);
- }
- setIsOpen(!isOpen);
- };
+ const filtered = useMemo(() => {
+ return nodes
+ .map((node, index) => ({ node, index }))
+ .filter(({ node, index }) => {
+ const label = `Node ${index + 1}`;
+ return (
+ matchesQuery(label, filter) ||
+ matchesQuery(node.addr ?? "", filter) ||
+ matchesQuery(node.id ?? "", filter)
+ );
+ });
+ }, [nodes, filter]);
+ const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]);
return (
-
+ }
+ filterValue={filter}
+ onFilterChange={setFilter}
+ filterPlaceholder="Filter nodes, addr, or id…"
+ emptyIcon={}
+ emptyTitle="No nodes"
+ emptyHint="Add a node to bring this shard online."
>
- {isMobile && (
-
- )}
-
-
-
-
-
-
-
- }
- />
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {nodes.map((node, index) => (
-
-
-
- ))}
-
-
-
-
-
-
+
+ {filtered.map(({ node, index }) => (
+ -
+
+
+
+
+ ))}
+
+
);
}
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
index de46a5f..1340cc8 100644
--- a/webui/src/app/ui/sidebarItem.tsx
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -18,6 +18,7 @@
*/
"use client";
+
import {
Alert,
Button,
@@ -27,27 +28,19 @@ import {
DialogContentText,
DialogTitle,
IconButton,
- ListItem,
- ListItemButton,
- ListItemIcon,
- ListItemText,
Menu,
MenuItem,
Snackbar,
- Tooltip,
- Badge,
} from "@mui/material";
-import MoreVertIcon from "@mui/icons-material/MoreVert";
-import { useCallback, useRef, useState } from "react";
-import { usePathname } from "next/navigation";
-import { useRouter } from "next/navigation";
-import { deleteCluster, deleteNamespace, deleteNode, deleteShard } from "../lib/api";
-import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
+import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
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 { useCallback, useRef, useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { deleteCluster, deleteNamespace, deleteNode, deleteShard } from "../lib/api";
interface NamespaceItemProps {
item: string;
@@ -78,70 +71,52 @@ interface NodeItemProps {
type ItemProps = NamespaceItemProps | ClusterItemProps | ShardItemProps | NodeItemProps;
+const iconFor = (type: ItemProps["type"]) => {
+ const cls = "shrink-0 opacity-70";
+ switch (type) {
+ case "namespace":
+ return ;
+ case "cluster":
+ return ;
+ case "shard":
+ return ;
+ case "node":
+ return ;
+ }
+};
+
export default function Item(props: ItemProps) {
const { item, type } = props;
- const [hover, setHover] = useState(false);
- const [showMenu, setShowMenu] = useState(false);
- const listItemRef = useRef(null);
- const openMenu = useCallback(() => setShowMenu(true), []);
- const closeMenu = useCallback(() => (setShowMenu(false), setHover(false)), []);
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const openDeleteConfirmDialog = useCallback(
- () => (setShowDeleteConfirm(true), closeMenu()),
- [closeMenu]
- );
- const closeDeleteConfirmDialog = useCallback(() => setShowDeleteConfirm(false), []);
- const [errorMessage, setErrorMessage] = useState("");
+ const [showMenu, setShowMenu] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+ const anchorRef = useRef(null);
const router = useRouter();
- let activeItem = usePathname().split("/").pop() || "";
+ let activeSegment = usePathname().split("/").pop() || "";
- const getItemIcon = () => {
- switch (type) {
- case "namespace":
- return (
-
- );
- case "cluster":
- return (
-
- );
- case "shard":
- return (
-
- );
- case "node":
- return (
-
- );
- default:
- return null;
- }
- };
+ const openMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowMenu(true);
+ }, []);
+ const closeMenu = useCallback(() => setShowMenu(false), []);
+
+ const openConfirm = useCallback(() => {
+ setShowConfirm(true);
+ closeMenu();
+ }, [closeMenu]);
+ const closeConfirm = useCallback(() => setShowConfirm(false), []);
const confirmDelete = useCallback(async () => {
let response = "";
if (type === "namespace") {
response = await deleteNamespace(item);
- if (response === "") {
- router.push("/namespaces");
- }
- setErrorMessage(response);
- router.refresh();
+ if (response === "") router.push("/namespaces");
} else if (type === "cluster") {
const { namespace } = props as ClusterItemProps;
response = await deleteCluster(namespace, item);
- if (response === "") {
- router.push(`/namespaces/${namespace}`);
- }
- setErrorMessage(response);
- router.refresh();
+ if (response === "") router.push(`/namespaces/${namespace}`);
} else if (type === "shard") {
const { namespace, cluster } = props as ShardItemProps;
response = await deleteShard(
@@ -149,160 +124,87 @@ export default function Item(props: ItemProps) {
cluster,
(parseInt(item.split("\t")[1]) - 1).toString()
);
- if (response === "") {
- router.push(`/namespaces/${namespace}/clusters/${cluster}`);
- }
- setErrorMessage(response);
- router.refresh();
+ if (response === "") router.push(`/namespaces/${namespace}/clusters/${cluster}`);
} else if (type === "node") {
const { namespace, cluster, shard, id } = props as NodeItemProps;
response = await deleteNode(namespace, cluster, shard, id);
- if (response === "") {
+ if (response === "")
router.push(`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}`);
- }
- setErrorMessage(response);
- router.refresh();
}
- closeMenu();
- }, [item, type, props, closeMenu, router]);
+ if (response) setErrorMessage(response);
+ router.refresh();
+ closeConfirm();
+ }, [item, type, props, router, closeConfirm]);
if (type === "shard") {
- activeItem = "Shard\t" + (parseInt(activeItem) + 1);
+ activeSegment = "Shard\t" + (parseInt(activeSegment) + 1);
} else if (type === "node") {
- activeItem = "Node\t" + (parseInt(activeItem) + 1);
+ activeSegment = "Node\t" + (parseInt(activeSegment) + 1);
}
- const isActive = item === activeItem;
-
+ const isActive = item === activeSegment;
const displayName = item.includes("\t")
? item.split("\t")[0] + " " + item.split("\t")[1]
: item;
return (
- setHover(true)}
- onMouseLeave={() => !showMenu && setHover(false)}
- >
-
+
-
-
- {getItemIcon()}
-
-
-
{displayName}
+
- {hover && (
-
-
-
- )}
-
+ aria-label="Item actions"
+ >
+
+
+
-
+ >
);
}
diff --git a/webui/src/app/ui/spotlight-search.tsx b/webui/src/app/ui/spotlight-search.tsx
index dd189f9..b2effab 100644
--- a/webui/src/app/ui/spotlight-search.tsx
+++ b/webui/src/app/ui/spotlight-search.tsx
@@ -19,20 +19,9 @@
"use client";
-import { useEffect, useState, useCallback } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
-import {
- Dialog,
- DialogContent,
- TextField,
- List,
- ListItem,
- ListItemButton,
- Box,
- Typography,
- Chip,
- alpha,
-} from "@mui/material";
+import { Dialog, DialogContent } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import FolderIcon from "@mui/icons-material/Folder";
import StorageIcon from "@mui/icons-material/Storage";
@@ -40,8 +29,10 @@ import DnsIcon from "@mui/icons-material/Dns";
import DeviceHubIcon from "@mui/icons-material/DeviceHub";
import { fetchNamespaces, fetchClusters, listShards, listNodes } from "../lib/api";
+type SearchType = "namespace" | "cluster" | "shard" | "node";
+
interface SearchResult {
- type: "namespace" | "cluster" | "shard" | "node";
+ type: SearchType;
title: string;
subtitle?: string;
path: string;
@@ -50,75 +41,77 @@ interface SearchResult {
shard?: string;
}
+const iconFor = (type: SearchType) => {
+ switch (type) {
+ case "namespace":
+ return ;
+ case "cluster":
+ return ;
+ case "shard":
+ return ;
+ case "node":
+ return ;
+ }
+};
+
export default function SpotlightSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
- const [selectedIndex, setSelectedIndex] = useState(0);
+ const [selected, setSelected] = 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 () => {
+ const loadAll = 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}`,
+ subtitle: 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}`,
+ title: `Shard ${i + 1}`,
+ 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];
+ (nodes as any[]).forEach((node, nodeIndex) => {
data.push({
type: "node",
title: node.addr || node.id,
- subtitle: `${node.role} in Shard ${i} / ${cluster}`,
+ subtitle: `${node.role} · Shard ${i + 1} · ${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);
@@ -127,405 +120,185 @@ export default function SpotlightSearch() {
}
}, []);
- // 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];
+ const contextSearch = useCallback(
+ (q: string) => {
+ const parts = pathname.split("/").filter(Boolean);
+ const [, ns, , cluster, , shard] = parts;
- // 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");
+ if (!q.trim()) {
+ if (shard) {
+ return allData
+ .filter(
+ (i) =>
+ i.type === "node" &&
+ i.namespace === ns &&
+ i.cluster === cluster &&
+ i.shard === shard
+ )
+ .slice(0, 10);
}
-
- return contextData.slice(0, 10);
+ if (cluster) {
+ return allData
+ .filter(
+ (i) => i.type === "shard" && i.namespace === ns && i.cluster === cluster
+ )
+ .slice(0, 10);
+ }
+ if (ns) {
+ return allData
+ .filter((i) => i.type === "cluster" && i.namespace === ns)
+ .slice(0, 10);
+ }
+ return allData.filter((i) => i.type === "namespace").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);
+ const lower = q.toLowerCase();
+ return allData
+ .filter((i) =>
+ `${i.title} ${i.subtitle ?? ""} ${i.type}`.toLowerCase().includes(lower)
+ )
+ .slice(0, 10);
},
[allData, pathname]
);
- // Update results when query or pathname changes
useEffect(() => {
- const filtered = contextAwareSearch(query);
- setResults(filtered);
- setSelectedIndex(0);
- }, [query, contextAwareSearch]);
+ setResults(contextSearch(query));
+ setSelected(0);
+ }, [query, contextSearch]);
+
+ const handleSelect = useCallback(
+ (result: SearchResult) => {
+ router.push(result.path);
+ setOpen(false);
+ setQuery("");
+ },
+ [router]
+ );
- // Handle keyboard shortcuts
useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- // Cmd+K or Ctrl+K to open
+ const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(true);
- if (!allData.length) {
- loadAllData();
- }
+ if (!allData.length) loadAll();
}
-
- // 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));
+ setSelected((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]) {
+ setSelected((prev) => Math.max(prev - 1, 0));
+ } else if (e.key === "Enter" && results[selected]) {
e.preventDefault();
- handleSelect(results[selectedIndex]);
+ handleSelect(results[selected]);
}
}
};
-
- 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";
- }
- };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, results, selected, allData.length, loadAll, handleSelect]);
return (
{
+ setOpen(false);
+ setQuery("");
+ }}
+ maxWidth="sm"
fullWidth
PaperProps={{
sx: {
position: "fixed",
- top: "15%",
+ top: "18vh",
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)",
+ borderRadius: "10px",
+ width: "560px",
+ maxWidth: "calc(100vw - 32px)",
},
}}
slotProps={{
- backdrop: {
- sx: {
- backdropFilter: "blur(4px)",
- backgroundColor: "rgba(0, 0, 0, 0.3)",
- },
- },
+ backdrop: { sx: { backgroundColor: "rgba(15,17,22,0.4)" } },
}}
>
-
- 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,
- },
- },
- }}
- />
-
-
+
+
+ setQuery(e.target.value)}
+ placeholder="Search namespaces, clusters, shards, nodes…"
+ className="w-full border-0 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted dark:text-text-dark-primary dark:placeholder:text-text-dark-muted"
+ />
+
- {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),
- },
- },
- }}
- >
-
+ {loading ? (
+
+ Loading…
+
+ ) : results.length > 0 ? (
+
+ {results.map((result, index) => (
+ -
+ handleSelect(result)}
+ onMouseEnter={() => setSelected(index)}
+ className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors ${
+ index === selected
+ ? "bg-surface-hover dark:bg-surface-dark-hover"
+ : "hover:bg-surface-hover dark:hover:bg-surface-dark-hover"
+ }`}
>
-
- 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)}
-
-
-
+
+ {iconFor(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..."}
-
-
- )}
+
+
+ {result.type}
+
+
+
+ ))}
+
+ ) : (
+
+ {query ? "No results" : "Start typing to search"}
+
+ )}
+
-
- 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
-
-
-
+
+
+ ↑
+ ↓
+ Navigate
+
+
+ ↵
+ Open
+
+
+ Esc
+ Close
+
+
);
diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts
index 387c2dd..9737fdf 100644
--- a/webui/tailwind.config.ts
+++ b/webui/tailwind.config.ts
@@ -28,86 +28,148 @@ const config: Config = {
darkMode: "class",
theme: {
extend: {
+ fontFamily: {
+ sans: [
+ "InterVariable",
+ "Inter",
+ "-apple-system",
+ "BlinkMacSystemFont",
+ "Segoe UI",
+ "Helvetica",
+ "Arial",
+ "sans-serif",
+ ],
+ mono: [
+ "ui-monospace",
+ "SFMono-Regular",
+ "SF Mono",
+ "Menlo",
+ "Consolas",
+ "Liberation Mono",
+ "monospace",
+ ],
+ },
+ fontSize: {
+ "2xs": ["0.6875rem", { lineHeight: "1rem" }],
+ xs: ["0.8125rem", { lineHeight: "1.125rem" }],
+ sm: ["0.875rem", { lineHeight: "1.25rem" }],
+ base: ["0.9375rem", { lineHeight: "1.4rem" }],
+ lg: ["1.0625rem", { lineHeight: "1.55rem" }],
+ xl: ["1.25rem", { lineHeight: "1.65rem" }],
+ "2xl": ["1.5rem", { lineHeight: "1.9rem" }],
+ "3xl": ["1.875rem", { lineHeight: "2.25rem" }],
+ "4xl": ["2.25rem", { lineHeight: "2.625rem" }],
+ "5xl": ["3rem", { lineHeight: "3.25rem" }],
+ },
colors: {
+ // Linear brand accent (indigo/violet)
primary: {
- DEFAULT: "#1976d2",
- light: "#42a5f5",
- dark: "#1565c0",
- contrastText: "#fff",
+ DEFAULT: "#5e6ad2",
+ light: "#8d95f2",
+ dark: "#4a54b8",
+ contrastText: "#ffffff",
},
secondary: {
- DEFAULT: "#9c27b0",
- light: "#ba68c8",
- dark: "#7b1fa2",
- contrastText: "#fff",
+ DEFAULT: "#7170ff",
+ light: "#a5a4ff",
+ dark: "#5352d9",
+ contrastText: "#ffffff",
},
success: {
- DEFAULT: "#2e7d32",
- light: "#4caf50",
- dark: "#1b5e20",
+ DEFAULT: "#4cb782",
+ light: "#68d9a0",
+ dark: "#2f8f5f",
},
error: {
- DEFAULT: "#d32f2f",
- light: "#ef5350",
- dark: "#c62828",
+ DEFAULT: "#eb5757",
+ light: "#ff7a7a",
+ dark: "#c94040",
},
warning: {
- DEFAULT: "#ed6c02",
- light: "#ff9800",
- dark: "#e65100",
+ DEFAULT: "#f2c94c",
+ light: "#f5da7a",
+ dark: "#c9a428",
},
info: {
- DEFAULT: "#0288d1",
- light: "#03a9f4",
- dark: "#01579b",
+ DEFAULT: "#26b5ce",
+ light: "#5cd0e3",
+ dark: "#1a8a9c",
+ },
+ // Linear-style neutral surfaces
+ surface: {
+ // light mode
+ base: "#ffffff",
+ subtle: "#fbfbfc",
+ muted: "#f4f5f8",
+ hover: "#eeeff2",
+ active: "#e6e7eb",
+ // dark mode
+ "dark-base": "#08090a",
+ "dark-subtle": "#101113",
+ "dark-muted": "#181a1f",
+ "dark-hover": "#22262f",
+ "dark-active": "#2a2e37",
},
+ border: {
+ subtle: "#e6e7eb",
+ strong: "#d0d3d9",
+ "dark-subtle": "#23252a",
+ "dark-strong": "#33363d",
+ },
+ text: {
+ primary: "#0d0e10",
+ secondary: "#4b4f57",
+ muted: "#6b7280",
+ "dark-primary": "#f7f8f8",
+ "dark-secondary": "#b4b8c0",
+ "dark-muted": "#8a8f98",
+ },
+ // Legacy aliases kept so any lingering `bg-dark`/`bg-light` styles still resolve
dark: {
- DEFAULT: "#121212",
- paper: "#1e1e1e",
- border: "#333333",
+ DEFAULT: "#08090a",
+ paper: "#101113",
+ border: "#23252a",
},
light: {
- DEFAULT: "#fafafa",
+ DEFAULT: "#fbfbfc",
paper: "#ffffff",
- border: "#e0e0e0",
+ border: "#e6e7eb",
},
},
- backgroundImage: {
- "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
- "gradient-conic":
- "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
- },
boxShadow: {
- card: "0 2px 8px rgba(0, 0, 0, 0.06)",
- "card-hover": "0 4px 12px rgba(0, 0, 0, 0.1)",
- sidebar: "2px 0 5px rgba(0, 0, 0, 0.05)",
- "footer-glow": "0 8px 25px rgba(0, 0, 0, 0.15)",
- subtle: "0 2px 5px rgba(0, 0, 0, 0.05)",
- elevated: "0 10px 30px rgba(0, 0, 0, 0.08)",
+ // Linear-style — extremely subtle, layered, never heavy
+ subtle: "0 1px 0 0 rgba(15, 17, 22, 0.04)",
+ card: "0 1px 2px 0 rgba(15, 17, 22, 0.04)",
+ "card-hover": "0 2px 4px 0 rgba(15, 17, 22, 0.06)",
+ popover: "0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06)",
+ overlay: "0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08)",
+ focus: "0 0 0 2px rgba(94, 106, 210, 0.35)",
},
- transitionProperty: {
- height: "height",
- spacing: "margin, padding",
- footer: "transform, opacity, box-shadow, border-color",
+ borderRadius: {
+ none: "0",
+ xs: "3px",
+ sm: "4px",
+ DEFAULT: "6px",
+ md: "6px",
+ lg: "8px",
+ xl: "10px",
+ "2xl": "12px",
+ "3xl": "16px",
+ full: "9999px",
},
animation: {
- "fade-in-up": "fade-in-up 0.6s ease-out forwards",
- "scale-in": "scale-in 0.4s ease-out forwards",
+ "fade-in": "fade-in 160ms ease-out forwards",
+ "fade-in-up": "fade-in-up 220ms cubic-bezier(0.16, 1, 0.3, 1) forwards",
},
keyframes: {
+ "fade-in": {
+ "0%": { opacity: "0" },
+ "100%": { opacity: "1" },
+ },
"fade-in-up": {
- "0%": { opacity: "0", transform: "translateY(20px)" },
+ "0%": { opacity: "0", transform: "translateY(4px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
- "scale-in": {
- "0%": { opacity: "0", transform: "scale(0.95)" },
- "100%": { opacity: "1", transform: "scale(1)" },
- },
- },
- borderRadius: {
- "2xl": "16px",
- "3xl": "24px",
- "4xl": "32px",
},
},
},
diff --git a/webui/tsconfig.json b/webui/tsconfig.json
index 143f9d7..16f0ff7 100644
--- a/webui/tsconfig.json
+++ b/webui/tsconfig.json
@@ -27,7 +27,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{