Skip to content

Conversation

@Jitmisra
Copy link
Contributor

Implemented a modern fast Spotlight Search to improve accessibility and navigation for users.... Includes a context aware results, keyboard shortcuts cmd + k(mac) / Ctrl+K(windows) and correct routing for namespaces, clusters, shards, and nodes....

Monosnap.screencast.2025-11-19.16-36-43.mp4

@PragmaTwice
Copy link
Member

Hmm I just realized that you haven't joined the apache organization on GitHub. Since you are a committer I think you can join by yourself via the process in the documentation: https://infra.apache.org/new-committers-guide.html#config-access

@Jitmisra
Copy link
Contributor Author

Hmm I just realized that you haven't joined the apache organization on GitHub. Since you are a committer I think you can join by yourself via the process in the documentation: https://infra.apache.org/new-committers-guide.html#config-access

sure i will do it

Copy link
Member

@PragmaTwice PragmaTwice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements a modern, context-aware spotlight search feature to improve navigation and accessibility across the web UI. The search supports keyboard shortcuts (Cmd+K on macOS, Ctrl+K on Windows) and provides intelligent filtering based on the current page context (namespace, cluster, shard, or node level).

Key Changes:

  • Added a new SpotlightSearch component with context-aware search and keyboard navigation
  • Integrated search button with keyboard shortcut display in the application banner
  • Added global CSS styling for keyboard shortcut indicators

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.

File Description
webui/src/app/ui/spotlight-search.tsx New spotlight search component with keyboard shortcuts, context-aware filtering, and hierarchical navigation for namespaces, clusters, shards, and nodes
webui/src/app/ui/banner.tsx Added search button to the banner with keyboard shortcut indicator and event dispatcher
webui/src/app/layout.tsx Integrated SpotlightSearch component into the root layout
webui/src/app/globals.css Added styling for kbd elements to display keyboard shortcuts in light and dark modes

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +228 to +234
}, [open, results, selectedIndex, allData.length, loadAllData]);

const handleSelect = (result: SearchResult) => {
router.push(result.path);
setOpen(false);
setQuery("");
};
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleSelect function is missing from the dependency array of this useEffect hook. This can lead to stale closures where the effect uses an outdated version of handleSelect. Add handleSelect to the dependency array or use useCallback to memoize the function.

Suggested change
}, [open, results, selectedIndex, allData.length, loadAllData]);
const handleSelect = (result: SearchResult) => {
router.push(result.path);
setOpen(false);
setQuery("");
};
}, [open, results, selectedIndex, allData.length, loadAllData, handleSelect]);
const handleSelect = useCallback((result: SearchResult) => {
router.push(result.path);
setOpen(false);
setQuery("");
}, [router]);

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +193
const event = new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
ctrlKey: true,
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keyboard event dispatch is incorrect. Setting both metaKey: true and ctrlKey: true doesn't match the actual keyboard shortcut logic in spotlight-search.tsx (line 197), which uses || (either Cmd OR Ctrl, not both). This will trigger the search on any platform. Instead, dispatch the event with only one modifier based on the user's platform:

const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const event = new KeyboardEvent("keydown", {
    key: "k",
    metaKey: isMac,
    ctrlKey: !isMac,
});
Suggested change
const event = new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
ctrlKey: true,
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const event = new KeyboardEvent("keydown", {
key: "k",
metaKey: isMac,
ctrlKey: !isMac,

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +230
⌘K
</Box>
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The search button only displays the macOS keyboard shortcut symbol "⌘K" regardless of the user's platform. Windows/Linux users see "⌘K" but should see "Ctrl+K". Update the button to display the correct shortcut based on the platform:

const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Then in the kbd element:
{isMac ? '⌘K' : 'Ctrl+K'}

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +121
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),
});
}
}
}
}

Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadAllData function makes multiple nested API calls sequentially (for each namespace -> for each cluster -> for each shard -> for each node), which can result in very poor performance and long loading times. Consider parallelizing these API calls using Promise.all() where possible:

const namespaces = await fetchNamespaces();
await Promise.all(namespaces.map(async (ns) => {
    const clusters = await fetchClusters(ns);
    await Promise.all(clusters.map(async (cluster) => {
        // Similar pattern for shards and nodes
    }));
}));

This will significantly reduce the total loading time by making concurrent requests instead of waiting for each to complete sequentially.

Suggested change
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),
});
}
}
}
}
const namespaces = await fetchNamespaces();
// For each namespace, fetch clusters in parallel
const namespaceResults = await Promise.all(
namespaces.map(async (ns) => {
const results: SearchResult[] = [];
// Add namespace
results.push({
type: "namespace",
title: ns,
path: `/namespaces/${ns}`,
namespace: ns,
});
// Fetch clusters for this namespace
const clusters = await fetchClusters(ns);
const clusterResults = await Promise.all(
clusters.map(async (cluster) => {
const clusterData: SearchResult[] = [];
// Add cluster
clusterData.push({
type: "cluster",
title: cluster,
subtitle: `in ${ns}`,
path: `/namespaces/${ns}/clusters/${cluster}`,
namespace: ns,
cluster,
});
// Fetch shards for this cluster
const shards = await listShards(ns, cluster);
const shardResults = await Promise.all(
shards.map(async (_shard, i) => {
const shardData: SearchResult[] = [];
// Add shard
shardData.push({
type: "shard",
title: `Shard ${i}`,
subtitle: `${cluster} / ${ns}`,
path: `/namespaces/${ns}/clusters/${cluster}/shards/${i}`,
namespace: ns,
cluster,
shard: String(i),
});
// Fetch nodes for this shard
const nodes = await listNodes(ns, cluster, String(i));
if (Array.isArray(nodes)) {
nodes.forEach((node: any, nodeIndex: number) => {
shardData.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),
});
});
}
return shardData;
})
);
// Flatten shardResults and add to clusterData
clusterData.push(...shardResults.flat());
return clusterData;
})
);
// Flatten clusterResults and add to results
results.push(...clusterResults.flat());
return results;
})
);
// Flatten all results
const data: SearchResult[] = namespaceResults.flat();

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +124
} catch (error) {
console.error("Failed to load search data:", error);
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error is logged to console but not displayed to the user. Users who trigger the search won't know if the data failed to load—they'll just see the loading indicator disappear without explanation. Consider setting an error state and displaying a user-friendly error message:

const [error, setError] = useState<string | null>(null);
// In catch block:
setError("Failed to load search data. Please try again.");
// Then render the error in the UI

Copilot uses AI. Check for mistakes.
Comment on lines +272 to +530
<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>
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SpotlightSearch component lacks ARIA attributes for accessibility. Screen reader users won't understand the purpose of the dialog or how to navigate the search results. Add appropriate ARIA attributes:

  • Add role="combobox" and aria-expanded to the search input
  • Add role="listbox" to the results list
  • Add role="option" and aria-selected to each result item
  • Add aria-label or aria-labelledby to describe the dialog purpose
  • Consider adding aria-activedescendant to track the selected item

Copilot uses AI. Check for mistakes.
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && results[selectedIndex]) {
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no null/bounds check before accessing results[selectedIndex] in the Enter key handler. If selectedIndex is out of bounds (e.g., due to race conditions when results update), this could cause issues. While there's a check for results[selectedIndex] existence, the arrow key handlers on lines 215 and 218 could set selectedIndex to -1 or beyond array bounds if results.length becomes 0 after being set. Add a more robust check:

else if (e.key === "Enter" && results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length) {
    e.preventDefault();
    handleSelect(results[selectedIndex]);
}
Suggested change
} else if (e.key === "Enter" && results[selectedIndex]) {
} else if (
e.key === "Enter" &&
results.length > 0 &&
selectedIndex >= 0 &&
selectedIndex < results.length
) {

Copilot uses AI. Check for mistakes.
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(true);
if (!allData.length) {
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data loading is triggered every time Cmd+K is pressed if allData is empty, but there's no mechanism to prevent duplicate concurrent API calls if the user presses Cmd+K multiple times quickly while data is still loading. This could result in multiple identical API calls running in parallel. Add a check for the loading state:

if ((e.metaKey || e.ctrlKey) && e.key === "k") {
    e.preventDefault();
    setOpen(true);
    if (!allData.length && !loading) {
        loadAllData();
    }
}
Suggested change
if (!allData.length) {
if (!allData.length && !loading) {

Copilot uses AI. Check for mistakes.
Comment on lines +489 to +506
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);
}
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The kbd styling uses fixed color values that may not provide sufficient contrast in all scenarios. The light mode kbd element uses color: #555 on a background that could vary, potentially violating WCAG contrast requirements. Consider using theme-aware colors or CSS variables that adapt to the theme context to ensure adequate contrast ratios (at least 4.5:1 for text).

Copilot uses AI. Check for mistakes.
@codecov-commenter
Copy link

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 49.06%. Comparing base (6c56470) to head (ad37b48).
⚠️ Report is 100 commits behind head on unstable.

Additional details and impacted files
@@             Coverage Diff              @@
##           unstable     #367      +/-   ##
============================================
+ Coverage     43.38%   49.06%   +5.67%     
============================================
  Files            37       45       +8     
  Lines          2971     3783     +812     
============================================
+ Hits           1289     1856     +567     
- Misses         1544     1719     +175     
- Partials        138      208      +70     
Flag Coverage Δ
unittests 49.06% <ø> (+5.67%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@PragmaTwice PragmaTwice merged commit e0d2933 into apache:unstable Nov 21, 2025
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants