diff --git a/apps/frontend/src/components/cluster-topology/Cluster.tsx b/apps/frontend/src/components/cluster-topology/Cluster.tsx index e044cead..df3823b0 100644 --- a/apps/frontend/src/components/cluster-topology/Cluster.tsx +++ b/apps/frontend/src/components/cluster-topology/Cluster.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useSelector } from "react-redux" import { Server, CheckCircle2 } from "lucide-react" import { useParams } from "react-router" -import { CONNECTED } from "@common/src/constants.ts" +import { CONNECTED, MAX_CONNECTIONS } from "@common/src/constants.ts" import { AppHeader } from "../ui/app-header" import RouteContainer from "../ui/route-container" import { StatCard } from "../ui/stat-card" @@ -62,6 +62,8 @@ export function Cluster() { }) }) + const highlight = searchQuery && filteredEntries.length < MAX_CONNECTIONS ? searchQuery : "" + return ( } title="Cluster Topology" /> @@ -110,6 +112,7 @@ export function Cluster() { return (
- {primaryData?.server_name || primaryKey} + + + PRIMARY
- {`${primary.host}:${primary.port}`} +
@@ -109,7 +114,9 @@ export function ClusterNode({ return (
- {replicaKey} + + +
) })} diff --git a/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx b/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx index 80d32ef2..249b340e 100644 --- a/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx +++ b/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx @@ -22,10 +22,12 @@ import { useAppDispatch } from "@/hooks/hooks.ts" import { Button } from "@/components/ui/button.tsx" import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip.tsx" import { Typography } from "@/components/ui/typography.tsx" +import { HighlightSearchMatch } from "@/components/ui/highlight-search-match.tsx" interface ClusterConnectionGroupProps { clusterId: string connections: Array<{ connectionId: string; connection: ConnectionState }> + highlight?: string onEdit?: (connectionId: string) => void } @@ -39,7 +41,7 @@ const getLatestTimestamp = R.pipe( // storage key for persisting open/closed state of cluster groups const getStorageKey = (clusterId: string) => `cluster-group-open-${clusterId}` -export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: ClusterConnectionGroupProps) => { +export const ClusterConnectionGroup = ({ clusterId, connections, highlight = "", onEdit }: ClusterConnectionGroupProps) => { const dispatch = useAppDispatch() const [isOpen, setIsOpen] = useState(() => { const stored = localStorage.getItem(getStorageKey(clusterId)) @@ -147,7 +149,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: Clust title={firstNodeAlias || clusterId} to={`/${clusterId}/${firstConnectedConnection.connectionId}/cluster-topology`} > - {firstNodeAlias || clusterId} + @@ -164,7 +166,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: Clust title={firstNodeAlias || clusterId} variant="code" > - {firstNodeAlias || clusterId} + )} @@ -236,6 +238,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: Clust connection={connection} connectionId={connectionId} hideOpenButton={true} + highlight={highlight} isNested={true} key={connectionId} onEdit={onEdit} diff --git a/apps/frontend/src/components/connection/Connection.tsx b/apps/frontend/src/components/connection/Connection.tsx index ad2691c5..46beabab 100644 --- a/apps/frontend/src/components/connection/Connection.tsx +++ b/apps/frontend/src/components/connection/Connection.tsx @@ -1,21 +1,27 @@ import { useState } from "react" import { useSelector } from "react-redux" import { HousePlug } from "lucide-react" +import { MAX_CONNECTIONS } from "@common/src/constants.ts" import ConnectionForm from "../ui/connection-form.tsx" import EditForm from "../ui/edit-form.tsx" import RouteContainer from "../ui/route-container.tsx" import { Button } from "../ui/button.tsx" import { EmptyState } from "../ui/empty-state.tsx" +import { SearchInput } from "../ui/search-input.tsx" import { Typography } from "../ui/typography.tsx" import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice.ts" import { selectConnections } from "@/state/valkey-features/connection/connectionSelectors.ts" import { ConnectionEntry } from "@/components/connection/ConnectionEntry.tsx" import { ClusterConnectionGroup } from "@/components/connection/ClusterConnectionGroup.tsx" +const matchesSearch = (q: string, connection: ConnectionState) => + connection.searchableText.includes(q) + export function Connection() { const [showConnectionForm, setShowConnectionForm] = useState(false) const [showEditForm, setShowEditForm] = useState(false) const [editingConnectionId, setEditingConnectionId] = useState(undefined) + const [searchQuery, setSearchQuery] = useState("") const connections = useSelector(selectConnections) const handleEditConnection = (connectionId: string) => { @@ -51,16 +57,37 @@ export function Connection() { { clusterGroups: {}, standaloneConnections: [] }, ) - const hasClusterGroups = Object.keys(clusterGroups).length > 0 - const hasStandaloneConnections = standaloneConnections.length > 0 const hasConnectionsWithHistory = connectionsWithHistory.length > 0 + // Filter by search query + const q = searchQuery.toLowerCase() + const filteredClusterGroups: typeof clusterGroups = {} + if (q) { + for (const [clusterId, conns] of Object.entries(clusterGroups)) { + const matched = conns.filter(({ connection }) => matchesSearch(q, connection)) + if (matched.length > 0) filteredClusterGroups[clusterId] = matched + } + } + const filteredStandaloneConnections = q + ? standaloneConnections.filter(({ connection }) => matchesSearch(q, connection)) + : standaloneConnections + + const hasFilteredClusters = q ? Object.keys(filteredClusterGroups).length > 0 : Object.keys(clusterGroups).length > 0 + const hasFilteredStandalone = q ? filteredStandaloneConnections.length > 0 : standaloneConnections.length > 0 + const hasAnyResults = hasFilteredClusters || hasFilteredStandalone + const displayClusterGroups = q ? filteredClusterGroups : clusterGroups + + const totalResults = filteredStandaloneConnections.length + + Object.values(displayClusterGroups).reduce((sum, conns) => sum + conns.length, 0) + + const highlight = q && totalResults < MAX_CONNECTIONS ? q : "" + return ( {/* top header */}
- Connections + Connections {hasConnectionsWithHistory && ( @@ -114,7 +117,7 @@ export const ConnectionEntry = ({ variant="link" > - {aliasLabel} + diff --git a/apps/frontend/src/components/ui/accordion.tsx b/apps/frontend/src/components/ui/accordion.tsx index 89f3b594..5dc30cc1 100644 --- a/apps/frontend/src/components/ui/accordion.tsx +++ b/apps/frontend/src/components/ui/accordion.tsx @@ -96,7 +96,7 @@ export default function Accordion({ accordionName, accordionItems, valueType = " {formatKey(key)} {singleMetricDescriptions[key] && ( - + )}
{formatMetricValue(key, value, valueType)} diff --git a/apps/frontend/src/components/ui/app-header.tsx b/apps/frontend/src/components/ui/app-header.tsx index 42a0351e..6a0ad4e4 100644 --- a/apps/frontend/src/components/ui/app-header.tsx +++ b/apps/frontend/src/components/ui/app-header.tsx @@ -1,9 +1,10 @@ import { useNavigate, useParams } from "react-router" import { useSelector } from "react-redux" import { useState, useRef, useEffect, type ReactNode } from "react" -import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight } from "lucide-react" +import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight, Search } from "lucide-react" import { CONNECTED } from "@common/src/constants.ts" import { Badge } from "./badge" +import { Input } from "./input" import { Typography } from "./typography" import type { RootState } from "@/store.ts" import { selectConnectionDetails } from "@/state/valkey-features/connection/connectionSelectors.ts" @@ -18,6 +19,7 @@ type AppHeaderProps = { function AppHeader({ title, icon, className }: AppHeaderProps) { const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") const dropdownRef = useRef(null) const navigate = useNavigate() const { id, clusterId } = useParams<{ id: string; clusterId: string }>() @@ -37,8 +39,14 @@ function AppHeader({ title, icon, className }: AppHeaderProps) { const handleNavigate = (primaryKey: string) => { navigate(`/${clusterId}/${primaryKey}/dashboard`) setIsOpen(false) + setSearch("") } + const filteredNodes = Object.entries(clusterData?.clusterNodes ?? {}).filter(([, primary]) => { + const term = search.toLowerCase() + return `${primary.host}:${primary.port}`.toLowerCase().includes(term) + }) + // for closing the dropdown when we click anywhere in screen useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -73,9 +81,13 @@ function AppHeader({ title, icon, className }: AppHeaderProps) { {icon} {title} -
+
isConnected && setIsOpen(!isOpen)} variant="default" >
@@ -87,21 +99,35 @@ function AppHeader({ title, icon, className }: AppHeaderProps) { {id}
- +
{isOpen && (
+ rounded z-100 absolute top-10 right-0"> +
+ + setSearch(e.target.value)} + placeholder="Search by host or port" + value={search} + /> +
    - {Object.entries(clusterData.clusterNodes).map(([primaryKey, primary]) => { + {filteredNodes.length === 0 && ( +
  • + No nodes found +
  • + )} + {filteredNodes.map(([primaryKey, primary]) => { const nodeIsConnected = allConnections?.[primaryKey]?.status === CONNECTED return ( diff --git a/apps/frontend/src/components/ui/highlight-search-match.tsx b/apps/frontend/src/components/ui/highlight-search-match.tsx new file mode 100644 index 00000000..592e7b7c --- /dev/null +++ b/apps/frontend/src/components/ui/highlight-search-match.tsx @@ -0,0 +1,19 @@ +interface HighlightMatchProps { + text: string; + query: string; +} + +export function HighlightSearchMatch({ text, query }: HighlightMatchProps) { + if (!query) return {text} + const idx = text.toLowerCase().indexOf(query.toLowerCase()) + if (idx === -1) return {text} + return ( + + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ) +} diff --git a/apps/frontend/src/state/epics/valkeyEpics.ts b/apps/frontend/src/state/epics/valkeyEpics.ts index 71dafce8..2a81a615 100644 --- a/apps/frontend/src/state/epics/valkeyEpics.ts +++ b/apps/frontend/src/state/epics/valkeyEpics.ts @@ -83,6 +83,7 @@ export const connectionEpic = (store: Store) => connectionDetails: baseConnectionDetails, status: NOT_CONNECTED, connectionHistory: connection?.connectionHistory ?? [], + searchableText: connection?.searchableText ?? "", } currentConnections[payload.connectionId] = connectionToSave @@ -291,6 +292,7 @@ export const updateConnectionDetailsEpic = (store: Store) => if (connection && currentConnections[connectionId]) { currentConnections[connectionId].connectionDetails = connection.connectionDetails currentConnections[connectionId].connectionHistory = connection.connectionHistory || [] + currentConnections[connectionId].searchableText = connection.searchableText ?? "" localStorage.setItem(LOCAL_STORAGE.VALKEY_CONNECTIONS, JSON.stringify(currentConnections)) } } catch (e) { diff --git a/apps/frontend/src/state/valkey-features/connection/connectionSlice.test.ts b/apps/frontend/src/state/valkey-features/connection/connectionSlice.test.ts index 9e83ca9a..5dccba3c 100644 --- a/apps/frontend/src/state/valkey-features/connection/connectionSlice.test.ts +++ b/apps/frontend/src/state/valkey-features/connection/connectionSlice.test.ts @@ -48,13 +48,15 @@ describe("connectionSlice", () => { port: "6379", username: "admin", password: "secret", - tls: false, + tls: false, verifyTlsCertificate: false, alias: "Test", clusterSlotStatsEnabled: false, jsonModuleAvailable: false, }, + searchableText: "conn-1 localhost 6379 admin test", wasEdit: false, + connectionHistory: undefined, }) }) diff --git a/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts b/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts index 973c0a90..6e0eda13 100644 --- a/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts +++ b/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts @@ -50,6 +50,7 @@ export interface ConnectionState { status: ConnectionStatus; errorMessage: string | null; connectionDetails: ConnectionDetails; + searchableText: string; reconnect?: ReconnectState; connectionHistory?: ConnectionHistoryEntry[]; wasEdit?: boolean; @@ -59,9 +60,15 @@ export interface ValkeyConnectionsState { [connectionId: string]: ConnectionState } +const buildSearchableText = (connectionId: string, details: ConnectionDetails) => + [connectionId, details.host, details.port, details.username, details.alias] + .filter(Boolean) + .join(" ") + .toLowerCase() + const currentConnections = R.pipe( (v: string) => localStorage.getItem(v), - (s) => (s === null ? {} : JSON.parse(s)), + (s) => (s === null ? {} : JSON.parse(s) as ValkeyConnectionsState), )(LOCAL_STORAGE.VALKEY_CONNECTIONS) const connectionSlice = createSlice({ @@ -97,7 +104,7 @@ const connectionSlice = createSlice({ clusterSlotStatsEnabled: false, jsonModuleAvailable: false, }, - + searchableText: buildSearchableText(connectionId, connectionDetails), wasEdit: isEdit, ...(isRetry && existingConnection?.reconnect && { reconnect: existingConnection.reconnect, @@ -225,10 +232,12 @@ const connectionSlice = createSlice({ }, updateConnectionDetails: (state, action) => { const { connectionId, ...details } = action.payload - state.connections[connectionId].connectionDetails = { + const merged = { ...state.connections[connectionId].connectionDetails, ...details, } + state.connections[connectionId].connectionDetails = merged + state.connections[connectionId].searchableText = buildSearchableText(connectionId, merged) }, deleteConnection: (state, { payload: { connectionId } }) => { return R.dissocPath(["connections", connectionId], state)