Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 { CONNECTED } from "@common/src/constants.ts"
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.

won't this fail the linter because of the imports order? it should be below lucide-react

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This in not effecting the linter, but I still organized it in the new commit.

import { ChevronDown, ChevronRight, Network, CircleChevronRight } from "lucide-react"
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 hasConnectedInstance = connections.some(({ connection }) => connection.status === CONNECTED)
const connectedCount = connections.filter(({ connection }) => connection.status === CONNECTED).length

const firstConnectedConnection = connections.find(({ connection }) => connection.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.

while the impact is very minor, we can still iterate over connections only once

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adressed.


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>
)
}
110 changes: 79 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,39 @@ 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) {
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.

maybe using nullish coalescing assignment operator can improve readbility here

if (clusterId)
  (acc.clusterGroups[clusterId] ??= []).push({ connectionId, connection })
else
  acc.standaloneConnections.push({ connectionId, connection })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adressed

if (!acc.clusterGroups[clusterId]) {
acc.clusterGroups[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 +52,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