diff --git a/apps/frontend/src/components/Cluster.tsx b/apps/frontend/src/components/Cluster.tsx index e5930253..834bdb0d 100644 --- a/apps/frontend/src/components/Cluster.tsx +++ b/apps/frontend/src/components/Cluster.tsx @@ -1,66 +1,45 @@ import { useSelector } from "react-redux" import { Server } from "lucide-react" import { useParams } from "react-router" -import { Card } from "./ui/card" import { AppHeader } from "./ui/app-header" -import { selectClusterData } from "@/state/valkey-features/cluster/clusterSelectors" +import ClusterNode from "./ui/cluster-node" +import { selectCluster } from "@/state/valkey-features/cluster/clusterSelectors" export function Cluster() { const { clusterId } = useParams() - const clusterData = useSelector(selectClusterData(clusterId!)) + const clusterData = useSelector(selectCluster(clusterId!)) - const formatRole = (role: string | null) => { - if (!role) return "UNKNOWN" - const normalized = role.toLowerCase() - if (normalized === "master") return "PRIMARY" - if (normalized === "slave") return "REPLICA" - return role.toUpperCase() + if (!clusterData.clusterNodes || !clusterData.data) { + return ( +
+ } title="Cluster Topology" /> +
+
+ No cluster data available +
+
+
+ ) } + const clusterEntries = Object.entries(clusterData.clusterNodes) + return (
} title="Cluster Topology" />
- {Object.entries(clusterData).map(([nodeAddress, nodeInfo]) => ( - -
- {formatRole(nodeInfo.role)} -
-
- {nodeInfo.server_name ?? "Unnamed Node"}@ {nodeAddress} -
- -
-
- Uptime:{" "} - {nodeInfo.uptime_in_days ?? "N/A"} days -
-
- Port:{" "} - {nodeInfo.tcp_port ?? "N/A"} -
-
- Memory:{" "} - {nodeInfo.used_memory_human ?? "N/A"} -
-
- CPU (sys):{" "} - {nodeInfo.used_cpu_sys ?? "N/A"} -
-
- Ops/sec:{" "} - {nodeInfo.instantaneous_ops_per_sec ?? "N/A"} -
-
- Commands:{" "} - {nodeInfo.total_commands_processed ?? "N/A"} -
-
-
+ {clusterEntries.map(([primaryKey, primary]) => ( + ))}
) - } diff --git a/apps/frontend/src/components/ui/app-header.tsx b/apps/frontend/src/components/ui/app-header.tsx index 8e344bdc..1aae4a3e 100644 --- a/apps/frontend/src/components/ui/app-header.tsx +++ b/apps/frontend/src/components/ui/app-header.tsx @@ -1,7 +1,9 @@ -import { useParams } from "react-router" +import { useNavigate, useParams } from "react-router" import { useSelector } from "react-redux" -import type { ReactNode } from "react" +import { useState, useRef, useEffect, type ReactNode } from "react" +import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight } from "lucide-react" import { selectConnectionDetails } from "@/state/valkey-features/connection/connectionSelectors.ts" +import { selectCluster } from "@/state/valkey-features/cluster/clusterSelectors" import { cn } from "@/lib/utils.ts" type AppHeaderProps = { @@ -11,21 +13,92 @@ type AppHeaderProps = { }; function AppHeader({ title, icon, className }: AppHeaderProps) { - const { id } = useParams<{ id: string }>() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + const navigate = useNavigate() + const { id, clusterId } = useParams<{ id: string; clusterId: string }>() const { host, port, username } = useSelector(selectConnectionDetails(id!)) + const clusterData = useSelector(selectCluster(clusterId!)) + const ToggleIcon = isOpen ? CircleChevronUp : CircleChevronDown + + const handleNavigate = (port: number) => { + navigate(`/${clusterId}/localhost-${port}/dashboard`) + setIsOpen(false) + } + + // 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)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside) + } + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, [isOpen]) return ( -
-

- {icon} - {title} -

-
- - {username}@{host}:{port} - -
-
+ <> + {id && !clusterId ? ( +
+

+ {icon} + {title} +

+ +
+ + {username}@{host}:{port} + +
+
+ ) : ( +
+

+ {icon} + {title} +

+
+
+
+ {id} +
+ +
+ {isOpen && ( +
+
    + {Object.entries(clusterData.clusterNodes).map(([primaryKey, primary]) => ( +
  • + + {primary.replicas?.map((replica) => ( +
    + + +
    + ))} +
  • + ))} +
+
+ )} +
+
+ )} + + ) } diff --git a/apps/frontend/src/components/ui/cluster-node.tsx b/apps/frontend/src/components/ui/cluster-node.tsx new file mode 100644 index 00000000..b376cc00 --- /dev/null +++ b/apps/frontend/src/components/ui/cluster-node.tsx @@ -0,0 +1,116 @@ +import { Dot, LayoutDashboard, Terminal } from "lucide-react" +import { useNavigate } from "react-router" +import { Card } from "./card" + +interface ReplicaNode { + id: string + host: string + port: number +} + +interface PrimaryNode { + host: string + port: number + replicas: ReplicaNode[] +} + +interface ParsedNodeInfo { + server_name: string | null + uptime_in_days: string | null + tcp_port: string | null + used_memory_human: string | null + used_cpu_sys: string | null + instantaneous_ops_per_sec: string | null + total_commands_processed: string | null + role: string | null + connected_clients: string | null +} + +interface ClusterNodeProps { + primaryKey: string + primary: PrimaryNode + primaryData: ParsedNodeInfo + allNodeData: Record + clusterId: string +} + +export default function ClusterNode({ primaryKey, primary, primaryData, allNodeData, clusterId }: ClusterNodeProps) { + const navigate = useNavigate() + + const formatRole = (role: string | null) => { + if (!role) return "UNKNOWN" + const normalized = role.toLowerCase() + if (normalized === "master") return "PRIMARY" + if (normalized === "slave") return "REPLICA" + return role.toUpperCase() + } + + return ( + + {/* for primary */} +
+ {formatRole(primaryData?.role)} +
+
{primaryData?.server_name}{primaryKey}
+
+
+ Memory: + {primaryData?.used_memory_human ?? "N/A"} +
+
+ CPU (sys): + {primaryData?.used_cpu_sys ?? "N/A"} +
+
+ Commands: + {primaryData?.total_commands_processed ?? "N/A"} +
+
+ Clients: + {primaryData?.connected_clients ?? "N/A"} +
+
+
+ {/* for replicas */} + {primary.replicas.length > 0 && ( +
+ REPLICAS ({primary.replicas.length}) + {primary.replicas.map((replica) => ( +
+
+
+ {`${replica.host}:${replica.port}`} +
+ Mem: {allNodeData[`${replica.host}:${replica.port}`]?.used_memory_human} + Clients: {allNodeData[`${replica.host}:${replica.port}`]?.connected_clients} +
+
+ +
+ {/* replica buttons */} +
+ + +
+
+ ))} +
+ )} + + {/* primary buttons */} +
+ + +
+
+ ) +} diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts index a79dc768..91512145 100644 --- a/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts +++ b/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts @@ -27,7 +27,7 @@ interface ParsedNodeInfo { interface ClusterState { [clusterId: string]: { - nodes: Record; + clusterNodes: Record; data: { [nodeAddress: string]: ParsedNodeInfo; }; @@ -42,19 +42,19 @@ const clusterSlice = createSlice({ }, reducers: { addCluster: (state, action) => { - const { clusterId, nodes } = action.payload + const { clusterId, clusterNodes } = action.payload if (!state.clusters[clusterId]) { state.clusters[clusterId] = { - nodes: {}, + clusterNodes: {}, data: {}, } } - state.clusters[clusterId].nodes = nodes + state.clusters[clusterId].clusterNodes = clusterNodes }, updateClusterInfo: (state, action) => { - const { clusterId, nodes } = action.payload + const { clusterId, clusterNodes } = action.payload if (state.clusters[clusterId]) { - state.clusters[clusterId].nodes = nodes + state.clusters[clusterId].clusterNodes = clusterNodes } }, removeCluster: (state, action) => { diff --git a/apps/server/src/connection.ts b/apps/server/src/connection.ts index 1a507585..b1c0cf8e 100644 --- a/apps/server/src/connection.ts +++ b/apps/server/src/connection.ts @@ -78,16 +78,31 @@ async function discoverCluster(client: GlideClient) { const clusterNodes = response.reduce((acc, slotRange) => { const [, , ...nodes] = slotRange - return nodes.reduce((nodesById, [host, port], idx) => { - const id = `${host}-${port}` - return nodesById[id] - ? nodesById - : { ...nodesById, [id]: { host, port, role: idx === 0 ? "primary" : "replica" } } - }, acc) + // transform CLUSTER from flat response into a nested structure (primaryNode → replicas[]) + const [primaryHost, primaryPort] = nodes[0] + const primaryKey = `${primaryHost}:${primaryPort}` + + if (!acc[primaryKey]) { + acc[primaryKey] = { + host: primaryHost, + port: primaryPort, + replicas: [], + } + } + // add replicas under their primary + nodes.slice(1).forEach(([host, port, id]) => { + const replica = { id, host, port } + // avoid duplicates + if (!acc[primaryKey].replicas.some((r) => r.id === id)) { + acc[primaryKey].replicas.push(replica) + } + }) + + return acc }, {} as Record) return { clusterNodes, clusterId }