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
107 changes: 75 additions & 32 deletions apps/frontend/src/components/connection/Connection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,24 @@ import RouteContainer from "../ui/route-container.tsx"
import { Button } from "../ui/button.tsx"
import { EmptyState } from "../ui/empty-state.tsx"
import { Typography } from "../ui/typography.tsx"
import { SearchInput } from "../ui/search-input.tsx"
import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice.ts"
import { selectConnections } from "@/state/valkey-features/connection/connectionSelectors.ts"
import { ConnectionEntry } from "@/components/connection/ConnectionEntry.tsx"
import { ClusterConnectionGroup } from "@/components/connection/ClusterConnectionGroup.tsx"

const matchesSearch = (q: string, connectionId: string, connection: ConnectionState) => {
const { host, port, username, alias } = connection.connectionDetails
return [connectionId, host, port, username, alias]
.filter(Boolean)
.some((v) => v!.toLowerCase().includes(q))
}

export function Connection() {
const [showConnectionForm, setShowConnectionForm] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const [editingConnectionId, setEditingConnectionId] = useState<string | undefined>(undefined)
const [searchQuery, setSearchQuery] = useState("")
const connections = useSelector(selectConnections)

const handleEditConnection = (connectionId: string) => {
Expand Down Expand Up @@ -51,10 +60,26 @@ export function Connection() {
{ clusterGroups: {}, standaloneConnections: [] },
)

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

// Filter by search query
const q = searchQuery.toLowerCase()
const filteredClusterGroups: typeof clusterGroups = {}
if (q) {
for (const [clusterId, conns] of Object.entries(clusterGroups)) {
const matched = conns.filter(({ connectionId, connection }) => matchesSearch(q, connectionId, connection))
if (matched.length > 0) filteredClusterGroups[clusterId] = matched
}
}
const filteredStandaloneConnections = q
? standaloneConnections.filter(({ connectionId, connection }) => matchesSearch(q, connectionId, connection))
: standaloneConnections

const hasFilteredClusters = q ? Object.keys(filteredClusterGroups).length > 0 : Object.keys(clusterGroups).length > 0
const hasFilteredStandalone = q ? filteredStandaloneConnections.length > 0 : standaloneConnections.length > 0
const hasAnyResults = hasFilteredClusters || hasFilteredStandalone
const displayClusterGroups = q ? filteredClusterGroups : clusterGroups

return (
<RouteContainer title="connection">
{/* top header */}
Expand Down Expand Up @@ -92,38 +117,56 @@ export function Connection() {
/>
) : (
<div className="flex-1">
{/* for clusters */}
{hasClusterGroups && (
<div className="mb-8">
<Typography className="mb-2" variant="bodyLg">Clusters</Typography>
<div>
{Object.entries(clusterGroups).map(([clusterId, clusterConnections]) => (
<ClusterConnectionGroup
clusterId={clusterId}
connections={clusterConnections}
key={clusterId}
onEdit={handleEditConnection}
/>
))}
</div>
</div>
)}
{/* Search */}
<div className="mb-4">
<SearchInput
onChange={(e) => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery("")}
placeholder="Search connections by host, port, or alias..."
value={searchQuery}
/>
</div>

{/* for standalone instances */}
{hasStandaloneConnections && (
<div>
<Typography className="mb-2" variant="bodyLg">Instances</Typography>
<div>
{standaloneConnections.map(({ connectionId, connection }) => (
<ConnectionEntry
connection={connection}
connectionId={connectionId}
key={connectionId}
onEdit={handleEditConnection}
/>
))}
</div>
{!hasAnyResults && q ? (
<div className="text-center py-8 text-muted-foreground min-h-40">
No connections match "{searchQuery}"
</div>
) : (
<>
{/* for clusters */}
{hasFilteredClusters && (
<div className="mb-8">
<Typography className="mb-2" variant="bodyLg">Clusters</Typography>
<div>
{Object.entries(displayClusterGroups).map(([clusterId, clusterConnections]) => (
<ClusterConnectionGroup
clusterId={clusterId}
connections={clusterConnections}
key={clusterId}
onEdit={handleEditConnection}
/>
))}
</div>
</div>
)}

{/* for standalone instances */}
{hasFilteredStandalone && (
<div>
<Typography className="mb-2" variant="bodyLg">Instances</Typography>
<div>
{filteredStandaloneConnections.map(({ connectionId, connection }) => (
<ConnectionEntry
connection={connection}
connectionId={connectionId}
key={connectionId}
onEdit={handleEditConnection}
/>
))}
</div>
</div>
)}
</>
)}
</div>
)}
Expand Down
142 changes: 100 additions & 42 deletions apps/frontend/src/components/ui/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useNavigate, useParams } from "react-router"
import { useSelector } from "react-redux"
import { useState, useRef, useEffect, type ReactNode } from "react"
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react"
import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight } from "lucide-react"
import { CONNECTED } from "@common/src/constants.ts"
import { Badge } from "./badge"
import { Typography } from "./typography"
import { SearchInput } from "./search-input"
import type { RootState } from "@/store.ts"
import { selectConnectionDetails } from "@/state/valkey-features/connection/connectionSelectors.ts"
import { selectCluster } from "@/state/valkey-features/cluster/clusterSelectors"
Expand All @@ -18,10 +19,14 @@ type AppHeaderProps = {

function AppHeader({ title, icon, className }: AppHeaderProps) {
const [isOpen, setIsOpen] = useState(false)
const [nodeSearch, setNodeSearch] = useState("")
const dropdownRef = useRef<HTMLDivElement>(null)
const badgeRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(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!))
const { host, port, username, alias } = connectionDetails ?? {}
const clusterData = useSelector(selectCluster(clusterId!))
const ToggleIcon = isOpen ? CircleChevronUp : CircleChevronDown

Expand All @@ -34,15 +39,51 @@ 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(
(conn) => conn.connectionDetails.clusterId === clusterId && conn.status === CONNECTED,
)
: isConnected
const effectiveConnected = isConnected || isClusterConnected

const handleNavigate = (primaryKey: string) => {
navigate(`/${clusterId}/${primaryKey}/dashboard`)
setIsOpen(false)
setNodeSearch("")
}

const toggleDropdown = () => {
if (!effectiveConnected) return
const next = !isOpen
setIsOpen(next)
if (!next) setNodeSearch("")
}

// Filter cluster nodes by search query
const filteredNodes = useMemo(() => {
if (!clusterData?.clusterNodes) return []
const entries = Object.entries(clusterData.clusterNodes)
if (!nodeSearch) return entries
const q = nodeSearch.toLowerCase()
return entries.filter(([key, primary]) =>
key.includes(q) ||
`${primary.host}:${primary.port}`.toLowerCase().includes(q),
)
}, [clusterData?.clusterNodes, nodeSearch])

// Auto-focus search when dropdown opens
useEffect(() => {
if (isOpen) setTimeout(() => searchRef.current?.focus(), 0)
}, [isOpen])

// 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,59 +116,76 @@ 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={toggleDropdown}
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} />
{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

return (
<li className="flex flex-col gap-1" key={primaryKey}>
<button className="flex items-center cursor-pointer hover:bg-primary/20"
disabled={!nodeIsConnected}
onClick={() => handleNavigate(primaryKey)}>
<Dot className={nodeIsConnected ? "text-green-500" : "text-gray-400"} size={45} />
<Typography variant="bodySm">
{`${primary.host}:${primary.port}`}
</Typography>
</button>
{primary.replicas?.map((replica) => (
<div className="flex items-center ml-4" key={replica.id}>
<CornerDownRight className="text-tw-dark-border" size={20} />
<button className="flex items-center">
<Dot className="text-primary" size={24} />
<Typography variant="caption">
<div className="w-80 border bg-gray-50 dark:bg-gray-800 text-sm dark:border-tw-dark-border
rounded z-100 absolute top-10 right-0 flex flex-col" ref={dropdownRef}>
<div className="p-2 border-b dark:border-tw-dark-border">
<SearchInput
onChange={(e) => setNodeSearch(e.target.value)}
onClear={() => setNodeSearch("")}
placeholder="Filter nodes..."
ref={searchRef}
value={nodeSearch}
/>
</div>
<ul className="overflow-y-auto h-72 p-2 space-y-1">
{filteredNodes.length === 0 ? (
<li className="text-center py-4 text-muted-foreground">No nodes match "{nodeSearch}"</li>
) : (
filteredNodes.map(([primaryKey, primary]) => {
const isCurrentNode = primaryKey === id
return (
<li className="flex flex-col gap-0.5" key={primaryKey}>
<button
className={cn(
"flex items-center w-full rounded px-1 cursor-pointer hover:bg-primary/20",
isCurrentNode && "bg-primary/10",
)}
onClick={() => handleNavigate(primaryKey)}
>
<Dot className={isCurrentNode ? "text-green-500" : "text-primary"} size={32} />
<Typography className="truncate" variant="bodySm">
{`${primary.host}:${primary.port}`}
</Typography>
</button>
{primary.replicas?.map((replica) => (
<div className="flex items-center ml-6" key={replica.id}>
<CornerDownRight className="text-tw-dark-border shrink-0" size={14} />
<Dot className="text-primary shrink-0" size={20} />
<Typography className="truncate" variant="caption">
{replica.host}:{replica.port}
</Typography>
</button>
</div>
))}
</li>
)
})}
</div>
))}
</li>
)
})
)}
</ul>
</div>
)}
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) {
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
Loading
Loading