Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 32 additions & 17 deletions apps/frontend/src/components/ui/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ type AppHeaderProps = {
function AppHeader({ title, icon, className }: AppHeaderProps) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const badgeRef = useRef<HTMLDivElement>(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!)) ?? {} as Partial<ReturnType<ReturnType<typeof selectConnectionDetails>>>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think, this change is unnecessary, let's keep the original (line 24)

const { host, port, username, alias } = connectionDetails as { host?: string; port?: string; username?: string; alias?: string }
const clusterData = useSelector(selectCluster(clusterId!))
const ToggleIcon = isOpen ? CircleChevronUp : CircleChevronDown

Expand All @@ -34,6 +36,14 @@ 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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's move the nullish coalescing operator higher where (line 35) where allConnections is initialized using useSelector

(conn) => conn.connectionDetails.clusterId === clusterId && conn.status === CONNECTED,
)
: isConnected
const effectiveConnected = isConnected || isClusterConnected

const handleNavigate = (primaryKey: string) => {
navigate(`/${clusterId}/${primaryKey}/dashboard`)
setIsOpen(false)
Expand All @@ -42,7 +52,10 @@ function AppHeader({ title, icon, className }: AppHeaderProps) {
// 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)
}
}
Expand Down Expand Up @@ -75,41 +88,43 @@ function AppHeader({ title, icon, className }: AppHeaderProps) {
</Typography>
<div>
<Badge
className="h-5 w-auto text-nowrap px-2 py-4 flex items-center gap-2 justify-between cursor-pointer"
className={cn(
"h-5 w-auto text-nowrap px-2 py-4 flex items-center gap-2 justify-between",
effectiveConnected ? "cursor-pointer" : "cursor-not-allowed",
)}
onClick={() => effectiveConnected && setIsOpen(!isOpen)}
ref={badgeRef}
variant="default"
>
<div className="flex flex-col gap-1">
<Typography
className="flex items-center"
variant="bodySm"
>
<Dot className={isConnected ? "text-green-500" : "text-gray-400"} size={45} />
<Dot className={effectiveConnected ? "text-green-500" : "text-gray-400"} size={45} />
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Doesn't this change the semantic of the Dot? I.e. it used to mean that "you, user, are connected to this node in the cluster" but now it means "you, user, are connected to SOME node in this cluster" — if so, this is not a desired outcome — the users connecting from Electron app will hate it because they cannot even physically connect to all nodes, they actually need to see what instances (of 12 max) they are connected to. Instead of "connected", we can think of it as "have active glide client + ws connections".

{id}
</Typography>
</div>
<button aria-label="Toggle dropdown" disabled={!isConnected} onClick={() => isConnected && setIsOpen(!isOpen)}>
<ToggleIcon
className={isConnected
? "text-primary cursor-pointer hover:text-primary/80"
: "text-gray-400 cursor-not-allowed"
}
size={18}
/>
</button>
<ToggleIcon
className={effectiveConnected
? "text-primary hover:text-primary/80"
: "text-gray-400"
}
size={18}
/>
</Badge>
{isOpen && (
<div className="p-4 w-auto text-nowrap py-3 border bg-gray-50 dark:bg-gray-800 text-sm dark:border-tw-dark-border
rounded z-100 absolute top-10 right-0" ref={dropdownRef}>
<ul className="space-y-2">
{Object.entries(clusterData.clusterNodes).map(([primaryKey, primary]) => {
const nodeIsConnected = allConnections?.[primaryKey]?.status === CONNECTED
const isCurrentNode = primaryKey === id

return (
<li className="flex flex-col gap-1" key={primaryKey}>
<button className="flex items-center cursor-pointer hover:bg-primary/20"
disabled={!nodeIsConnected}
<button className={`flex items-center cursor-pointer hover:bg-primary/20 ${isCurrentNode ? "bg-primary/10" : ""}`}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's use cn util instead of raw templates

onClick={() => handleNavigate(primaryKey)}>
<Dot className={nodeIsConnected ? "text-green-500" : "text-gray-400"} size={45} />
<Dot className={isCurrentNode ? "text-green-500" : "text-primary"} size={45} />
<Typography variant="bodySm">
{`${primary.host}:${primary.port}`}
</Typography>
Expand Down
15 changes: 12 additions & 3 deletions apps/frontend/src/hooks/useIsConnected.ts
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This hook is a convenience util to get connection status by URL params effectively. This change breaks the abstraction boundary — part of the job is done in the selector, another part — in the hook. What effectively this change is doing — is making clusterId an argument for the selector that has only one argument. So the selector now needs to accept two parameters (this is a minimal change surface) or accept an object { id/nodeId, clusterId } (which is a much bigger surface for the change). But in any case, derived state should be still produced in the selector, not hook.

Another option is (also within the selector) is to flatten.filter connections — this way we don't even need clusterId.

return Object.values(connections).some(
(conn) => conn.connectionDetails.clusterId === clusterId && conn.status === CONNECTED,
)
}

return status === CONNECTED
}

export default useIsConnected
5 changes: 3 additions & 2 deletions apps/server/src/actions/cluster.ts
Original file line number Diff line number Diff line change
@@ -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<Deps, void>(
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
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/actions/command.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { VALKEY } from "valkey-common"
import { sendValkeyRunCommand } from "../send-command"
import { type Deps, withDeps } from "./utils"
import { resolveClient } from "../utils"

type CommandAction = {
command: string
connectionId: string
}

export const sendRequested = withDeps<Deps, void>(
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)
Expand Down
21 changes: 11 additions & 10 deletions apps/server/src/actions/keys.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,8 +10,8 @@ type GetKeysPayload = {
}

export const getKeysRequested = withDeps<Deps, void>(
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)
Expand All @@ -34,11 +35,11 @@ interface KeyPayload {
}

export const getKeyTypeRequested = withDeps<Deps, void>(
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)
Expand All @@ -59,11 +60,11 @@ export const getKeyTypeRequested = withDeps<Deps, void>(
)

export const deleteKeyRequested = withDeps<Deps, void>(
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)
Expand Down Expand Up @@ -97,11 +98,11 @@ interface AddKeyRequestedPayload extends KeyPayload {
}

export const addKeyRequested = withDeps<Deps, void>(
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 {
Expand All @@ -121,11 +122,11 @@ export const addKeyRequested = withDeps<Deps, void>(
)

export const updateKeyRequested = withDeps<Deps, void>(
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 {
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/actions/stats.ts
Original file line number Diff line number Diff line change
@@ -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<Deps, void>(
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} )
},
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { client: GlideClient | GlideClusterClient; clusterId?: string }>,
clusterNodesMap: Map<string, string[]>,
): { 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
}
3 changes: 3 additions & 0 deletions docker/Dockerfile.app
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading