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 @@ -22,10 +22,12 @@ import { useAppDispatch } from "@/hooks/hooks.ts"
import { Button } from "@/components/ui/button.tsx"
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip.tsx"
import { Typography } from "@/components/ui/typography.tsx"
import { HighlightSearchMatch } from "@/components/ui/highlight-search-match.tsx"

interface ClusterConnectionGroupProps {
clusterId: string
connections: Array<{ connectionId: string; connection: ConnectionState }>
highlight?: string
onEdit?: (connectionId: string) => void
}

Expand All @@ -39,7 +41,7 @@ const getLatestTimestamp = R.pipe(
// storage key for persisting open/closed state of cluster groups
const getStorageKey = (clusterId: string) => `cluster-group-open-${clusterId}`

export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: ClusterConnectionGroupProps) => {
export const ClusterConnectionGroup = ({ clusterId, connections, highlight = "", onEdit }: ClusterConnectionGroupProps) => {
const dispatch = useAppDispatch()
const [isOpen, setIsOpen] = useState(() => {
const stored = localStorage.getItem(getStorageKey(clusterId))
Expand Down Expand Up @@ -147,7 +149,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: Clust
title={firstNodeAlias || clusterId}
to={`/${clusterId}/${firstConnectedConnection.connectionId}/cluster-topology`}
>
{firstNodeAlias || clusterId}
<HighlightSearchMatch query={highlight} text={firstNodeAlias || clusterId} />
</Link>
</Typography>
</div>
Expand All @@ -164,7 +166,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: Clust
title={firstNodeAlias || clusterId}
variant="code"
>
{firstNodeAlias || clusterId}
<HighlightSearchMatch query={highlight} text={firstNodeAlias || clusterId} />
</Typography>
</div>
)}
Expand Down Expand Up @@ -236,6 +238,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, onEdit }: Clust
connection={connection}
connectionId={connectionId}
hideOpenButton={true}
highlight={highlight}
isNested={true}
key={connectionId}
onEdit={onEdit}
Expand Down
121 changes: 85 additions & 36 deletions apps/frontend/src/components/connection/Connection.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import { useState } from "react"
import { useSelector } from "react-redux"
import { HousePlug } from "lucide-react"
import { MAX_CONNECTIONS } from "@common/src/constants.ts"
import ConnectionForm from "../ui/connection-form.tsx"
import EditForm from "../ui/edit-form.tsx"
import RouteContainer from "../ui/route-container.tsx"
import { Button } from "../ui/button.tsx"
import { EmptyState } from "../ui/empty-state.tsx"
import { SearchInput } from "../ui/search-input.tsx"
import { Typography } from "../ui/typography.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,16 +61,37 @@ 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

const totalResults = filteredStandaloneConnections.length +
Object.values(displayClusterGroups).reduce((sum, conns) => sum + conns.length, 0)

const highlight = q && totalResults < MAX_CONNECTIONS ? q : ""

return (
<RouteContainer title="connection">
{/* top header */}
<div className="flex items-center justify-between h-10">
<Typography className="flex items-center gap-2" variant="heading">
<HousePlug size={20}/> Connections
<HousePlug size={20} /> Connections
</Typography>
{hasConnectionsWithHistory && (
<Button
Expand Down Expand Up @@ -91,41 +122,59 @@ export function Connection() {
title="You Have No Connections!"
/>
) : (
<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}
/>
))}
<>
{/* Search */}
<SearchInput
onChange={(e) => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery("")}
placeholder="Search connections by host, port, or alias..."
value={searchQuery}
/>
<div className="flex-1 h-full border border-input rounded-md shadow-xs overflow-y-auto px-4 py-2">
{!hasAnyResults && q ? (
<div className="text-center py-8 text-muted-foreground min-h-40">
No connections match "{searchQuery}"
</div>
</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}
highlight={highlight}
key={clusterId}
onEdit={handleEditConnection}
/>
))}
</div>
</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>
</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}
highlight={highlight}
key={connectionId}
onEdit={handleEditConnection}
/>
))}
</div>
</div>
)}
</>
)}
</div></>
)}
</RouteContainer>
)
Expand Down
7 changes: 5 additions & 2 deletions apps/frontend/src/components/connection/ConnectionEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button.tsx"
import { ConnectionStatusBadge } from "@/components/ui/connection-status-badge"
import { ConnectionActionButtons } from "@/components/ui/connection-action-buttons.tsx"
import { Typography } from "@/components/ui/typography.tsx"
import { HighlightSearchMatch } from "@/components/ui/highlight-search-match"
import { cn } from "@/lib/utils.ts"
import history from "@/history.ts"
import { useAppDispatch } from "@/hooks/hooks.ts"
Expand All @@ -21,6 +22,7 @@ interface ConnectionEntryProps {
clusterId?: string
hideOpenButton?: boolean
isNested?: boolean
highlight?: string
onEdit?: (connectionId: string) => void
}

Expand All @@ -30,6 +32,7 @@ export const ConnectionEntry = ({
clusterId,
hideOpenButton = false,
isNested = false,
highlight = "",
onEdit,
}: ConnectionEntryProps) => {
const dispatch = useAppDispatch()
Expand Down Expand Up @@ -73,7 +76,7 @@ export const ConnectionEntry = ({
variant="link"
>
<Link title={label} to={clusterId ? `/${clusterId}/${connectionId}/dashboard` : `/${connectionId}/dashboard`}>
{label}
<HighlightSearchMatch query={highlight} text={label} />
</Link>
</Button>
</Typography>
Expand Down Expand Up @@ -114,7 +117,7 @@ export const ConnectionEntry = ({
variant="link"
>
<Link title={aliasLabel} to={clusterId ? `/${clusterId}/${connectionId}/dashboard` : `/${connectionId}/dashboard`}>
{aliasLabel}
<HighlightSearchMatch query={highlight} text={aliasLabel}/>
</Link>
</Button>
</Typography>
Expand Down
54 changes: 40 additions & 14 deletions apps/frontend/src/components/ui/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useNavigate, useParams } from "react-router"
import { useSelector } from "react-redux"
import { useState, useRef, useEffect, type ReactNode } from "react"
import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight } from "lucide-react"
import { CircleChevronDown, CircleChevronUp, Dot, CornerDownRight, Search } from "lucide-react"
import { CONNECTED } from "@common/src/constants.ts"
import { Badge } from "./badge"
import { Input } from "./input"
import { Typography } from "./typography"
import type { RootState } from "@/store.ts"
import { selectConnectionDetails } from "@/state/valkey-features/connection/connectionSelectors.ts"
Expand All @@ -18,6 +19,7 @@ type AppHeaderProps = {

function AppHeader({ title, icon, className }: AppHeaderProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState("")
const dropdownRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const { id, clusterId } = useParams<{ id: string; clusterId: string }>()
Expand All @@ -37,8 +39,14 @@ function AppHeader({ title, icon, className }: AppHeaderProps) {
const handleNavigate = (primaryKey: string) => {
navigate(`/${clusterId}/${primaryKey}/dashboard`)
setIsOpen(false)
setSearch("")
}

const filteredNodes = Object.entries(clusterData?.clusterNodes ?? {}).filter(([, primary]) => {
const term = search.toLowerCase()
return `${primary.host}:${primary.port}`.toLowerCase().includes(term)
})

// for closing the dropdown when we click anywhere in screen
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
Expand Down Expand Up @@ -73,9 +81,13 @@ function AppHeader({ title, icon, className }: AppHeaderProps) {
{icon}
{title}
</Typography>
<div>
<div ref={dropdownRef}>
<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",
isConnected ? "cursor-pointer" : "cursor-default",
)}
onClick={() => isConnected && setIsOpen(!isOpen)}
variant="default"
>
<div className="flex flex-col gap-1">
Expand All @@ -87,21 +99,35 @@ function AppHeader({ title, icon, className }: AppHeaderProps) {
{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
aria-label="Toggle dropdown"
className={isConnected
? "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}>
rounded z-100 absolute top-10 right-0">
<div className="relative mb-3">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" size={14} />
<Input
autoFocus
className="pl-7 h-7 text-xs"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by host or port"
value={search}
/>
</div>
<ul className="space-y-2">
{Object.entries(clusterData.clusterNodes).map(([primaryKey, primary]) => {
{filteredNodes.length === 0 && (
<li>
<Typography className="text-muted-foreground" variant="caption">No nodes found</Typography>
</li>
)}
{filteredNodes.map(([primaryKey, primary]) => {
const nodeIsConnected = allConnections?.[primaryKey]?.status === CONNECTED

return (
Expand Down
19 changes: 19 additions & 0 deletions apps/frontend/src/components/ui/highlight-search-match.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
interface HighlightMatchProps {
text: string;
query: string;
}

export function HighlightSearchMatch({ text, query }: HighlightMatchProps) {
if (!query) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return <span>{text}</span>
return (
<span>
{text.slice(0, idx)}
<span className="bg-primary text-white">
{text.slice(idx, idx + query.length)}
</span>
{text.slice(idx + query.length)}
</span>
)
}
Loading