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
111 changes: 111 additions & 0 deletions apps/frontend/src/components/connection/ClusterConnectionGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useState, useRef, useEffect } from "react"
import { ChevronDown, ChevronRight, Network, CircleChevronRight } from "lucide-react"
import { CONNECTED } from "@common/src/constants.ts"
import { ConnectionEntry } from "./ConnectionEntry.tsx"
import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice"
import history from "@/history.ts"

interface ClusterConnectionGroupProps {
clusterId: string
connections: Array<{ connectionId: string; connection: ConnectionState }>
}

export const ClusterConnectionGroup = ({ clusterId, connections }: ClusterConnectionGroupProps) => {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)

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])

const connected = connections.filter(({ connection }) => connection.status === CONNECTED)
const connectedCount = connected.length
const hasConnectedInstance = connectedCount > 0
const firstConnectedConnection = connected[0]

const handleOpenCluster = () => {
if (firstConnectedConnection) {
history.navigate(`/${clusterId}/${firstConnectedConnection.connectionId}/cluster-topology`)
}
}

return (
<div
className="mb-3 border dark:border-tw-dark-border rounded bg-white dark:bg-tw-dark-primary"
ref={dropdownRef}
>
{/* cluster head */}
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<button
className="p-1 rounded hover:bg-tw-primary/20"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
</button>

<div className="flex items-center gap-2">
<div className="p-2 bg-tw-primary/10 dark:bg-tw-primary/20 rounded">
<Network className="text-tw-primary" size={18} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm text-gray-900 dark:text-white">{clusterId}</h3>
{hasConnectedInstance && (
<span className="px-2 py-0.5 text-xs font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700
dark:text-teal-400 rounded-full">
{connectedCount} connected
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{connections.length} instance{connections.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>

<div className="flex items-center text-sm gap-2">
{hasConnectedInstance && (
<button
className="flex items-center gap-1 p-2 rounded-md text-tw-primary border border-tw-primary/70
hover:bg-tw-primary hover:text-white"
onClick={handleOpenCluster}
>
<CircleChevronRight size={16} />
Open Topology
</button>
)}
</div>
</div>
</div>

{isOpen && (
<div className="border-t dark:border-tw-dark-border">
<div className="p-2 space-y-1">
{connections.map(({ connectionId, connection }) => (
<ConnectionEntry
clusterId={clusterId}
connection={connection}
connectionId={connectionId}
hideOpenButton={true}
isNested={true}
key={connectionId}
/>
))}
</div>
</div>
)}
</div>
)
}
106 changes: 75 additions & 31 deletions apps/frontend/src/components/connection/Connection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,35 @@ import { HousePlug } from "lucide-react"
import * as R from "ramda"
import ConnectionForm from "../ui/connection-form.tsx"
import EditForm from "../ui/edit-form.tsx"
import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice.ts"
import { selectConnections } from "@/state/valkey-features/connection/connectionSelectors.ts"
import { ConnectionEntry, ConnectionEntryHeader } from "@/components/connection/ConnectionEntry.tsx"
import { ConnectionEntry } from "@/components/connection/ConnectionEntry.tsx"
import { ClusterConnectionGroup } from "@/components/connection/ClusterConnectionGroup.tsx"

export function Connection() {
const [showConnectionForm, setShowConnectionForm] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const connections = useSelector(selectConnections)

// grouping connections
const { clusterGroups, standaloneConnections } = Object.entries(connections).reduce<{
clusterGroups: Record<string, Array<{ connectionId: string; connection: ConnectionState }>>
standaloneConnections: Array<{ connectionId: string; connection: ConnectionState }>
}>(
(acc, [connectionId, connection]) => {
const clusterId = connection.connectionDetails.clusterId
if (clusterId)
(acc.clusterGroups[clusterId] ??= []).push({ connectionId, connection })
else
acc.standaloneConnections.push({ connectionId, connection })
return acc
},
{ clusterGroups: {}, standaloneConnections: [] },
)

const hasClusterGroups = Object.keys(clusterGroups).length > 0
const hasStandaloneConnections = standaloneConnections.length > 0

const connectButton = () =>
<button
className="bg-tw-primary text-white px-2 rounded text-sm font-light py-1 cursor-pointer"
Expand All @@ -27,40 +48,63 @@ export function Connection() {
<h1 className="text-xl font-bold flex items-center gap-2 text-gray-700 dark:text-white">
<HousePlug /> Connections
</h1>
{ R.isNotEmpty(connections) && connectButton() }
{R.isNotEmpty(connections) && connectButton()}
</div>
{
showConnectionForm &&
<ConnectionForm onClose={() => setShowConnectionForm(false)} />
}
{
showEditForm && // todo make this a separate URL /:id/edit; it's also unclear what edit means beside saving it in localStorage
<EditForm onClose={() => setShowEditForm(false)} />
}
{
R.isEmpty(connections) ?
<div className=" bg-white dark:bg-tw-dark-primary dark:border-tw-dark-border flex-1 flex items-center justify-center flex-col gap-2">
<span className="text-sm font-light text-gray-500 dark:text-white">

{showConnectionForm && <ConnectionForm onClose={() => setShowConnectionForm(false)} />}
{showEditForm && <EditForm onClose={() => setShowEditForm(false)} />}

{R.isEmpty(connections) ? (
<div className="flex-1 flex items-center justify-center flex-col gap-4">
<div className="text-center">
<h2 className="text-lg font-semibold text-gray-700 dark:text-white mb-2">
You Have No Connections!
</span>
<p className="text-sm font-light text-gray-500 dark:text-white">
Click "+ Add Connection" button to connect to a Valkey instance.
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Click "+ Add Connection" button to connect to a Valkey instance or cluster.
</p>
{ connectButton() }
</div> :
<div className="border-t-1 mt-8 flex flex-col flex-1">
<ConnectionEntryHeader />
{
Object.entries(connections).map(([connectionId, connection]) =>
<ConnectionEntry
clusterId={connection.connectionDetails.clusterId}
connection={connection}
connectionId={connectionId}
key={connectionId}
/>)
}
{connectButton()}
</div>
}
</div>
) : (
<div className="flex-1 mt-8">
{/* for clusters */}
{hasClusterGroups && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 px-1">
Clusters
</h2>
<div>
{Object.entries(clusterGroups).map(([clusterId, clusterConnections]) => (
<ClusterConnectionGroup
clusterId={clusterId}
connections={clusterConnections}
key={clusterId}
/>
))}
</div>
</div>
)}

{/* for standalone instances */}
{hasStandaloneConnections && (
<div>
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 px-1">
Instances
</h2>
<div>
{standaloneConnections.map(({ connectionId, connection }) => (
<ConnectionEntry
connection={connection}
connectionId={connectionId}
key={connectionId}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
Loading
Loading