Skip to content
Merged
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
71 changes: 25 additions & 46 deletions apps/frontend/src/components/Cluster.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-4 h-full flex flex-col">
<AppHeader icon={<Server size={20} />} title="Cluster Topology" />
<div className="flex-1 flex items-center justify-center">
<div className="text-tw-dark-border text-center">
No cluster data available
</div>
</div>
</div>
)
}

const clusterEntries = Object.entries(clusterData.clusterNodes)

return (
<div className="p-4">
<AppHeader icon={<Server size={20} />} title="Cluster Topology" />

<div className="flex flex-wrap gap-4 mt-4">
{Object.entries(clusterData).map(([nodeAddress, nodeInfo]) => (
<Card className="flex flex-col p-4 w-[280px]" key={nodeAddress}>
<div className="text-xl font-semibold mb-1 truncate">
{formatRole(nodeInfo.role)}
</div>
<div className="text-sm text-muted-foreground mb-2">
{nodeInfo.server_name ?? "Unnamed Node"}@ {nodeAddress}
</div>

<div className="text-sm space-y-1">
<div>
<span className="font-medium">Uptime:</span>{" "}
{nodeInfo.uptime_in_days ?? "N/A"} days
</div>
<div>
<span className="font-medium">Port:</span>{" "}
{nodeInfo.tcp_port ?? "N/A"}
</div>
<div>
<span className="font-medium">Memory:</span>{" "}
{nodeInfo.used_memory_human ?? "N/A"}
</div>
<div>
<span className="font-medium">CPU (sys):</span>{" "}
{nodeInfo.used_cpu_sys ?? "N/A"}
</div>
<div>
<span className="font-medium">Ops/sec:</span>{" "}
{nodeInfo.instantaneous_ops_per_sec ?? "N/A"}
</div>
<div>
<span className="font-medium">Commands:</span>{" "}
{nodeInfo.total_commands_processed ?? "N/A"}
</div>
</div>
</Card>
{clusterEntries.map(([primaryKey, primary]) => (
<ClusterNode
allNodeData={clusterData.data}
clusterId={clusterId!}
key={primaryKey}
primary={primary}
primaryData={clusterData.data[primaryKey]}
primaryKey={primaryKey}
/>
))}
</div>
</div>
)

}
101 changes: 87 additions & 14 deletions apps/frontend/src/components/ui/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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<HTMLDivElement>(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 (
<div className={cn("flex h-10 mb-4 gap-2 items-center justify-between", className)}>
<h1 className="font-bold text-xl flex items-center gap-2">
{icon}
{title}
</h1>
<div className="">
<span className="text-sm font-light border-2 border-tw-primary text-tw-primary px-3 py-1 rounded">
{username}@{host}:{port}
</span>
</div>
</div>
<>
{id && !clusterId ? (
<div className={cn("flex h-10 mb-4 gap-2 items-center justify-between", className)}>
<h1 className="font-bold text-xl flex items-center gap-2">
{icon}
{title}
</h1>

<div className="">
<span className="text-sm font-light border border-tw-primary text-tw-primary px-3 py-1 rounded">
{username}@{host}:{port}
</span>
</div>
</div>
) : (
<div className={cn("flex h-10 mb-4 gap-2 items-center justify-between relative", className)}>
<h1 className="font-bold text-xl flex items-center gap-2">
{icon}
{title}
</h1>
<div>
<div className="h-5 w-50 px-2 py-4 border-tw-primary border rounded flex items-center gap-2 justify-between">
<div className="flex flex-col gap-1">
<span className="font-light text-sm text-tw-primary flex items-center"><Dot className="text-green-500" size={45} />{id}</span>
</div>
<button onClick={() => setIsOpen(!isOpen)}>
<ToggleIcon className="text-tw-primary cursor-pointer hover:text-tw-primary/80" size={18} />
</button>
</div>
{isOpen && (
<div className="p-4 w-50 py-3 border bg-gray-50 dark:bg-gray-800 text-sm dark:border-tw-dark-border
rounded z-10 absolute top-10 right-0" ref={dropdownRef}>
<ul className="space-y-2">
{Object.entries(clusterData.clusterNodes).map(([primaryKey, primary]) => (
<li className="flex flex-col gap-1" key={primaryKey}>
<button className="font-normal flex items-center cursor-pointer hover:bg-tw-primary/20"
onClick={() => handleNavigate(primary.port)}>
<Dot className="text-green-500" size={45} />
{primaryKey}</button>
{primary.replicas?.map((replica) => (
<div className="flex items-center ml-4">
<CornerDownRight className="text-tw-dark-border" size={20} />
<button className="font-normal flex items-center text-xs">
<Dot className="text-tw-primary" size={24} />{replica.host}:{replica.port}
</button>
</div>
))}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}

</>
)
}

Expand Down
116 changes: 116 additions & 0 deletions apps/frontend/src/components/ui/cluster-node.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ParsedNodeInfo>
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 (
<Card className="dark:bg-gray-800">
{/* for primary */}
<div className="flex items-center">
<span className="font-bold">{formatRole(primaryData?.role)}</span> <Dot className="text-green-500" size={34} />
</div>
<div className="flex flex-col text-xs text-tw-dark-border"><span>{primaryData?.server_name}</span><span>{primaryKey}</span></div>
<div className="text-xs space-y-1.5">
<div className="flex justify-between">
<span className="text-tw-dark-border">Memory:</span>
<span>{primaryData?.used_memory_human ?? "N/A"}</span>
</div>
<div className="flex justify-between">
<span className="text-tw-dark-border">CPU (sys):</span>
<span>{primaryData?.used_cpu_sys ?? "N/A"}</span>
</div>
<div className="flex justify-between">
<span className="text-tw-dark-border">Commands:</span>
<span>{primaryData?.total_commands_processed ?? "N/A"}</span>
</div>
<div className="flex justify-between">
<span className="text-tw-dark-border">Clients:</span>
<span>{primaryData?.connected_clients ?? "N/A"}</span>
</div>
</div>
<div className="border-b mt-0"></div>
{/* for replicas */}
{primary.replicas.length > 0 && (
<div className="mt-0">
<span className="text-xs text-tw-dark-border">REPLICAS ({primary.replicas.length})</span>
{primary.replicas.map((replica) => (
<div className="bg-tw-primary/10 rounded-sm p-2 text-xs text-tw-dark-border" key={replica.id}>
<div className="flex items-center justify-between space-y-1">
<div className="">
<span>{`${replica.host}:${replica.port}`}</span>
<div className="flex gap-4">
<span>Mem: {allNodeData[`${replica.host}:${replica.port}`]?.used_memory_human}</span>
<span>Clients: {allNodeData[`${replica.host}:${replica.port}`]?.connected_clients}</span>
</div>
</div>
<Dot className="text-tw-primary" size={30} />
</div>
{/* replica buttons */}
<div className="mt-2 flex gap-2">
<button className="flex items-center gap-1.5 border dark:border-tw-dark-border px-2 py-0.5
rounded cursor-pointer hover:bg-tw-primary hover:text-white"
onClick={() => { navigate(`/${clusterId}/localhost-${replica.port}/dashboard`) }}><LayoutDashboard size={12} />
Dashboard</button>
<button className="flex items-center gap-1.5 border dark:border-tw-dark-border px-2 py-0.5
rounded cursor-pointer hover:bg-tw-primary hover:text-white"
onClick={() => { navigate(`/${clusterId}/localhost-${replica.port}/sendcommand`) }}><Terminal size={12} /> Command</button>
</div>
</div>
))}
</div>
)}

{/* primary buttons */}
<div className="mt-2 flex gap-2 text-xs items-center justify-center">
<button className="w-1/2 flex items-center justify-center gap-1.5 border px-2 py-1 rounded cursor-pointer
hover:bg-tw-primary hover:text-white"
onClick={() => { navigate(`/${clusterId}/localhost-${primary.port}/dashboard`) }}><LayoutDashboard size={12} /> Dashboard</button>
<button className="w-1/2 flex items-center justify-center gap-1.5 border px-2 py-1 rounded cursor-pointer
hover:bg-tw-primary hover:text-white"
onClick={() => { navigate(`/${clusterId}/localhost-${primary.port}/sendcommand`) }}><Terminal size={12} /> Command</button>
</div>
</Card>
)
}
12 changes: 6 additions & 6 deletions apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface ParsedNodeInfo {

interface ClusterState {
[clusterId: string]: {
nodes: Record<string, MasterNode>;
clusterNodes: Record<string, MasterNode>;
data: {
[nodeAddress: string]: ParsedNodeInfo;
};
Expand All @@ -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) => {
Expand Down
29 changes: 22 additions & 7 deletions apps/server/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
host: string;
port: number;
role: "primary" | "replica";
replicas: { id: string; host: string; port: number }[];
}>)

return { clusterNodes, clusterId }
Expand Down
Loading