diff --git a/apps/frontend/src/components/cluster-topology/cluster-node.tsx b/apps/frontend/src/components/cluster-topology/cluster-node.tsx index c7e8a34e..db24429c 100644 --- a/apps/frontend/src/components/cluster-topology/cluster-node.tsx +++ b/apps/frontend/src/components/cluster-topology/cluster-node.tsx @@ -43,8 +43,8 @@ export function ClusterNode({ const connectionDetails: ConnectionDetails = { host: primary.host, port: primary.port.toString(), - ...(primary.username && primary.password && { - username: primary.username, + ...(primary.password && { + username: primary.username ?? "", password: await secureStorage.encrypt(primary.password), }), tls: primary.tls, diff --git a/apps/frontend/src/components/connection/Connection.tsx b/apps/frontend/src/components/connection/Connection.tsx index ad2691c5..53eb555a 100644 --- a/apps/frontend/src/components/connection/Connection.tsx +++ b/apps/frontend/src/components/connection/Connection.tsx @@ -7,15 +7,24 @@ import RouteContainer from "../ui/route-container.tsx" import { Button } from "../ui/button.tsx" import { EmptyState } from "../ui/empty-state.tsx" import { Typography } from "../ui/typography.tsx" +import { SearchInput } from "../ui/search-input.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, connectionId: string, connection: ConnectionState) => { + const { host, port, username, alias } = connection.connectionDetails + return [connectionId, host, port, username, alias] + .filter(Boolean) + .some((v) => v!.toLowerCase().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,10 +60,26 @@ 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(({ connectionId, connection }) => matchesSearch(q, connectionId, connection)) + if (matched.length > 0) filteredClusterGroups[clusterId] = matched + } + } + const filteredStandaloneConnections = q + ? standaloneConnections.filter(({ connectionId, connection }) => matchesSearch(q, connectionId, 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 + return ( {/* top header */} @@ -92,38 +117,56 @@ export function Connection() { /> ) : (
- {/* for clusters */} - {hasClusterGroups && ( -
- Clusters -
- {Object.entries(clusterGroups).map(([clusterId, clusterConnections]) => ( - - ))} -
-
- )} + {/* Search */} +
+ setSearchQuery(e.target.value)} + onClear={() => setSearchQuery("")} + placeholder="Search connections by host, port, or alias..." + value={searchQuery} + /> +
- {/* for standalone instances */} - {hasStandaloneConnections && ( -
- Instances -
- {standaloneConnections.map(({ connectionId, connection }) => ( - - ))} -
+ {!hasAnyResults && q ? ( +
+ No connections match "{searchQuery}"
+ ) : ( + <> + {/* for clusters */} + {hasFilteredClusters && ( +
+ Clusters +
+ {Object.entries(displayClusterGroups).map(([clusterId, clusterConnections]) => ( + + ))} +
+
+ )} + + {/* for standalone instances */} + {hasFilteredStandalone && ( +
+ Instances +
+ {filteredStandaloneConnections.map(({ connectionId, connection }) => ( + + ))} +
+
+ )} + )}
)} diff --git a/apps/frontend/src/components/ui/app-header.tsx b/apps/frontend/src/components/ui/app-header.tsx index 42a0351e..adeb634b 100644 --- a/apps/frontend/src/components/ui/app-header.tsx +++ b/apps/frontend/src/components/ui/app-header.tsx @@ -1,10 +1,11 @@ import { useNavigate, useParams } from "react-router" import { useSelector } from "react-redux" -import { useState, useRef, useEffect, type ReactNode } from "react" +import { useState, useRef, useEffect, useMemo, type ReactNode } from "react" import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight } from "lucide-react" import { CONNECTED } from "@common/src/constants.ts" import { Badge } from "./badge" import { Typography } from "./typography" +import { SearchInput } from "./search-input" import type { RootState } from "@/store.ts" import { selectConnectionDetails } from "@/state/valkey-features/connection/connectionSelectors.ts" import { selectCluster } from "@/state/valkey-features/cluster/clusterSelectors" @@ -18,10 +19,14 @@ type AppHeaderProps = { function AppHeader({ title, icon, className }: AppHeaderProps) { const [isOpen, setIsOpen] = useState(false) + const [nodeSearch, setNodeSearch] = useState("") const dropdownRef = useRef(null) + const badgeRef = useRef(null) + const searchRef = useRef(null) const navigate = useNavigate() const { id, clusterId } = useParams<{ id: string; clusterId: string }>() - const { host, port, username, alias } = useSelector(selectConnectionDetails(id!)) + const connectionDetails = useSelector(selectConnectionDetails(id!)) + const { host, port, username, alias } = connectionDetails ?? {} const clusterData = useSelector(selectCluster(clusterId!)) const ToggleIcon = isOpen ? CircleChevronUp : CircleChevronDown @@ -34,15 +39,51 @@ function AppHeader({ title, icon, className }: AppHeaderProps) { state.valkeyConnection?.connections, ) + // For cluster mode, consider connected if any node in the cluster is connected + const isClusterConnected = clusterId + ? Object.values(allConnections ?? {}).some( + (conn) => conn.connectionDetails.clusterId === clusterId && conn.status === CONNECTED, + ) + : isConnected + const effectiveConnected = isConnected || isClusterConnected + const handleNavigate = (primaryKey: string) => { navigate(`/${clusterId}/${primaryKey}/dashboard`) setIsOpen(false) + setNodeSearch("") + } + + const toggleDropdown = () => { + if (!effectiveConnected) return + const next = !isOpen + setIsOpen(next) + if (!next) setNodeSearch("") } + // Filter cluster nodes by search query + const filteredNodes = useMemo(() => { + if (!clusterData?.clusterNodes) return [] + const entries = Object.entries(clusterData.clusterNodes) + if (!nodeSearch) return entries + const q = nodeSearch.toLowerCase() + return entries.filter(([key, primary]) => + key.includes(q) || + `${primary.host}:${primary.port}`.toLowerCase().includes(q), + ) + }, [clusterData?.clusterNodes, nodeSearch]) + + // Auto-focus search when dropdown opens + useEffect(() => { + if (isOpen) setTimeout(() => searchRef.current?.focus(), 0) + }, [isOpen]) + // for closing the dropdown when we click anywhere in screen useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && + badgeRef.current && !badgeRef.current.contains(event.target as Node) + ) { setIsOpen(false) } } @@ -75,7 +116,12 @@ function AppHeader({ title, icon, className }: AppHeaderProps) {
@@ -83,51 +129,63 @@ function AppHeader({ title, icon, className }: AppHeaderProps) { className="flex items-center" variant="bodySm" > - + {id}
- +
{isOpen && ( -
-
    - {Object.entries(clusterData.clusterNodes).map(([primaryKey, primary]) => { - const nodeIsConnected = allConnections?.[primaryKey]?.status === CONNECTED - - return ( -
  • - - {primary.replicas?.map((replica) => ( -
    - - + {primary.replicas?.map((replica) => ( +
    + + + {replica.host}:{replica.port} - -
    - ))} -
  • - ) - })} +
+ ))} + + ) + }) + )}
)} diff --git a/apps/frontend/src/hooks/useIsConnected.ts b/apps/frontend/src/hooks/useIsConnected.ts index 64307b8a..59aa1472 100644 --- a/apps/frontend/src/hooks/useIsConnected.ts +++ b/apps/frontend/src/hooks/useIsConnected.ts @@ -1,12 +1,21 @@ import { useSelector } from "react-redux" import { useParams } from "react-router" import { CONNECTED } from "@common/src/constants.ts" -import { selectStatus } from "@/state/valkey-features/connection/connectionSelectors.ts" +import { selectStatus, selectConnections } from "@/state/valkey-features/connection/connectionSelectors.ts" const useIsConnected = (): boolean => { - const { id } = useParams<{ id: string }>() + const { id, clusterId } = useParams<{ id: string; clusterId: string }>() const status = useSelector(selectStatus(id!)) - return status === CONNECTED + const connections = useSelector(selectConnections) + + // For cluster routes, consider connected if ANY node in the same cluster is connected + if (clusterId && status !== CONNECTED) { + return Object.values(connections).some( + (conn) => conn.connectionDetails.clusterId === clusterId && conn.status === CONNECTED, + ) + } + + return status === CONNECTED } export default useIsConnected diff --git a/apps/server/src/actions/cluster.ts b/apps/server/src/actions/cluster.ts index ae8e84b5..04df8e55 100644 --- a/apps/server/src/actions/cluster.ts +++ b/apps/server/src/actions/cluster.ts @@ -1,10 +1,11 @@ import { GlideClusterClient } from "@valkey/valkey-glide" import { type Deps, withDeps } from "./utils" import { setClusterDashboardData } from "../set-dashboard-data" +import { resolveClient } from "../utils" export const setClusterData = withDeps( - async ({ ws, clients, connectionId, action }) => { - const connection = clients.get(connectionId) + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection && connection.client instanceof GlideClusterClient) { const { clusterId } = action.payload diff --git a/apps/server/src/actions/command.ts b/apps/server/src/actions/command.ts index f56a949f..9801c326 100644 --- a/apps/server/src/actions/command.ts +++ b/apps/server/src/actions/command.ts @@ -1,6 +1,7 @@ import { VALKEY } from "valkey-common" import { sendValkeyRunCommand } from "../send-command" import { type Deps, withDeps } from "./utils" +import { resolveClient } from "../utils" type CommandAction = { command: string @@ -8,8 +9,8 @@ type CommandAction = { } export const sendRequested = withDeps( - async ({ ws, clients, connectionId, action }) => { - const connection = clients.get(connectionId!) + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection) { await sendValkeyRunCommand(connection.client, ws, action.payload as CommandAction) diff --git a/apps/server/src/actions/keys.ts b/apps/server/src/actions/keys.ts index 759d3216..d14178e4 100644 --- a/apps/server/src/actions/keys.ts +++ b/apps/server/src/actions/keys.ts @@ -1,6 +1,7 @@ import { VALKEY } from "valkey-common" import { addKey, deleteKey, getKeyInfoSingle, getKeys, updateKey } from "../keys-browser" import { type Deps, withDeps } from "./utils" +import { resolveClient } from "../utils" type GetKeysPayload = { connectionId: string; @@ -9,8 +10,8 @@ type GetKeysPayload = { } export const getKeysRequested = withDeps( - async ({ ws, clients, connectionId, action }) => { - const connection = clients.get(connectionId) + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection) { await getKeys(connection.client, ws, action.payload as GetKeysPayload) @@ -34,11 +35,11 @@ interface KeyPayload { } export const getKeyTypeRequested = withDeps( - async ({ ws, clients, connectionId, action }) => { + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { const { key } = action.payload as unknown as KeyPayload console.debug("Handling getKeyTypeRequested for key:", key) - const connection = clients.get(connectionId) + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection) { await getKeyInfoSingle(connection.client, ws, action.payload as unknown as KeyPayload) @@ -59,11 +60,11 @@ export const getKeyTypeRequested = withDeps( ) export const deleteKeyRequested = withDeps( - async ({ ws, clients, connectionId, action }) => { + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { const { key } = action.payload as unknown as KeyPayload console.debug("Handling deleteKeyRequested for key:", key) - const connection = clients.get(connectionId) + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection) { await deleteKey(connection.client, ws, action.payload as unknown as KeyPayload) @@ -97,11 +98,11 @@ interface AddKeyRequestedPayload extends KeyPayload { } export const addKeyRequested = withDeps( - async ({ ws, clients, connectionId, action }) => { + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { const { key } = action.payload as unknown as KeyPayload console.debug("Handling addKeyRequested for key:", key) - const connection = clients.get(connectionId) + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection) { await addKey(connection.client, ws, action.payload as unknown as AddKeyRequestedPayload) } else { @@ -121,11 +122,11 @@ export const addKeyRequested = withDeps( ) export const updateKeyRequested = withDeps( - async ({ ws, clients, connectionId, action }) => { + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { const { key } = action.payload as unknown as KeyPayload console.debug("Handling updateKeyRequested for key:", key) - const connection = clients.get(connectionId) + const connection = resolveClient(connectionId, clients, clusterNodesMap) if (connection) { await updateKey(connection.client, ws, action.payload as unknown as AddKeyRequestedPayload) } else { diff --git a/apps/server/src/actions/stats.ts b/apps/server/src/actions/stats.ts index 54e8d2be..d610b674 100644 --- a/apps/server/src/actions/stats.ts +++ b/apps/server/src/actions/stats.ts @@ -1,10 +1,11 @@ import { GlideClient, GlideClusterClient } from "@valkey/valkey-glide" import { type Deps, withDeps } from "./utils" import { setDashboardData } from "../set-dashboard-data" +import { resolveClient } from "../utils" export const setData = withDeps( - async ({ ws, clients, connectionId, action }) => { - const connection = clients.get(connectionId) + async ({ ws, clients, connectionId, clusterNodesMap, action }) => { + const connection = resolveClient(connectionId, clients, clusterNodesMap) const { address } = action.payload await setDashboardData(connectionId, connection?.client as GlideClient | GlideClusterClient, ws, address as {host: string, port: number} ) }, diff --git a/apps/server/src/utils.ts b/apps/server/src/utils.ts index 183f8251..9c77bceb 100644 --- a/apps/server/src/utils.ts +++ b/apps/server/src/utils.ts @@ -141,3 +141,29 @@ export async function isLastConnectedClusterNode( const currentClusterId = connection?.clusterId return clusterNodesMap.get(currentClusterId!)?.length === 1 } + +/** + * Resolve the Glide client for a connectionId. Falls back to the cluster client + * when the requested node wasn't individually connected (e.g. switching shards + * in the header dropdown). + */ +export function resolveClient( + connectionId: string, + clients: Map, + clusterNodesMap: Map, +): { client: GlideClient | GlideClusterClient; clusterId?: string } | undefined { + const direct = clients.get(connectionId) + if (direct) return direct + + // Find a cluster that contains a connected node sharing the same cluster + for (const [clusterId, nodeIds] of clusterNodesMap.entries()) { + const connectedPeer = nodeIds.find((nid) => clients.has(nid)) + if (connectedPeer) { + const peer = clients.get(connectedPeer)! + if (peer.client instanceof GlideClusterClient) { + return { client: peer.client, clusterId } + } + } + } + return undefined +} diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index a3517a83..ff4cfa06 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -16,6 +16,9 @@ RUN npm run build:all # -------- Production Runtime -------- FROM node:22-bookworm-slim +# Install CA certificates for TLS connections to ElastiCache +RUN apt-get update && apt-get install -y ca-certificates && update-ca-certificates && rm -rf /var/lib/apt/lists/* + WORKDIR /app ENV NODE_ENV=production