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
3 changes: 3 additions & 0 deletions apps/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import { SidebarInset, SidebarProvider } from "./components/ui/sidebar"
import { AppSidebar } from "./components/ui/app-sidebar"
import { Toaster } from "./components/ui/sonner"
import { DarkModeProvider } from "./contexts/DarkModeContext"
import { useWebSocketNavigation } from "./hooks/useWebSocketNavigation"
import { connectPending } from "@/state/wsconnection/wsConnectionSlice"

function App() {
const dispatch = useDispatch()

useWebSocketNavigation()

useEffect(() => {
dispatch(connectPending())
}, [dispatch])
Expand Down
117 changes: 117 additions & 0 deletions apps/frontend/src/components/Reconnect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux"
import { useNavigate } from "react-router"
import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants"
import { Loader2, WifiOff, AlertCircle, ServerOff } from "lucide-react"
import type { RootState } from "@/store"
import { connectPending } from "@/state/wsconnection/wsConnectionSlice"

export function Reconnect() {
const dispatch = useDispatch()
const navigate = useNavigate()
const wsConnection = useSelector((state: RootState) => state.websocket)
const { status, reconnect, errorMessage } = wsConnection

useEffect(() => {
// redirect to previous location on successful connection
if (status === CONNECTED) {
const redirectTo = sessionStorage.getItem("previousLocation") || "/connect"
sessionStorage.removeItem("previousLocation")
navigate(redirectTo, { replace: true })
}
}, [status, navigate])

const handleManualReconnect = () => {
dispatch(connectPending())
}

const getProgressPercentage = () => {
if (reconnect.maxRetries === 0) return 0
return ((reconnect.currentAttempt) / reconnect.maxRetries) * 100
}

const getNextRetrySeconds = () => {
if (!reconnect.nextRetryDelay) return 0
return Math.ceil(reconnect.nextRetryDelay / 1000)
}

const isExhausted = status === ERROR && !reconnect.isRetrying

return (
<div className="flex items-center justify-center min-h-screen dark:from-gray-900 dark:to-gray-800">
<div className="w-full max-w-md p-8 space-y-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<div className="flex justify-center">
{status === CONNECTING && reconnect.isRetrying ? (
<div className="relative">
<Loader2 className="w-16 h-16 text-tw-primary animate-spin" />
</div>
) : isExhausted ? (
<ServerOff className="w-12 h-12 text-red-500" />
) : (
<div className="p-4 bg-gray-100 dark:bg-gray-700 rounded-full">
<WifiOff className="w-12 h-12 text-gray-500" />
</div>
)}
</div>

<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{isExhausted
? "WebSocket Server Disconnected"
: "Reconnecting to Server..."}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
{isExhausted
? "Unable to connect to the WebSocket server"
: "Attempting to restore connection to the WebSocket server"}
</p>
{errorMessage && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700 dark:text-red-400 text-left">
{errorMessage}
</p>
</div>
</div>
)}
</div>

{/* Retry Progress */}
{reconnect.isRetrying && (
<div className="space-y-3">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className="bg-tw-primary h-full transition-all duration-300 ease-out"
style={{ width: `${getProgressPercentage()}%` }}
/>
</div>

{/* Retry Information */}
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Attempt {reconnect.currentAttempt} of {reconnect.maxRetries}
</span>
{reconnect.nextRetryDelay && (
<span className="text-gray-600 dark:text-gray-400">
Next retry in {getNextRetrySeconds()}s
</span>
)}
</div>
</div>
)}

{isExhausted && (
<div className="space-y-2 pt-4">
<button
className="w-full border p-2 rounded bg-tw-primary text-white hover:bg-tw-primary/80 cursor-pointer transition-colors"
onClick={handleManualReconnect}
>
Try Reconnecting
</button>
</div>
)}
</div>
</div>
)
}
4 changes: 2 additions & 2 deletions apps/frontend/src/hooks/useIsConnected.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useSelector } from "react-redux"
import { useParams } from "react-router"
import { CONNECTED, DISCONNECTED } from "@common/src/constants.ts"
import { CONNECTED, CONNECTING, DISCONNECTED } from "@common/src/constants.ts"
import { selectStatus } from "@/state/valkey-features/connection/connectionSelectors.ts"

const useIsConnected = (): boolean => {
const { id } = useParams<{ id: string }>()
const status = useSelector(selectStatus(id!))
// user will stay in the page if connected or disconnected
return status === CONNECTED || status === DISCONNECTED
return status === CONNECTED || status === DISCONNECTED || status === CONNECTING
}

export default useIsConnected
37 changes: 37 additions & 0 deletions apps/frontend/src/hooks/useWebSocketNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react"
import { useSelector } from "react-redux"
import { useLocation, useNavigate } from "react-router"
import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants"
import type { RootState } from "@/store"

export function useWebSocketNavigation() {
const navigate = useNavigate()
const location = useLocation()
const wsConnection = useSelector((state: RootState) => state.websocket)
const previousStatus = useRef(wsConnection.status)

useEffect(() => {
const currentStatus = wsConnection.status
const wasConnected = previousStatus.current === CONNECTED
const isNowDisconnected = currentStatus === CONNECTING && wsConnection.reconnect.isRetrying
const isReconnecting = location.pathname === "/reconnect"

if (isReconnecting || location.pathname === "/connect") {
previousStatus.current = currentStatus
return
}

if (wasConnected && isNowDisconnected) {

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.

I would implement that in epic with chain/flatmap but since it's a single isolated hook, it's fine too

// store location and navigate to reconnecting
sessionStorage.setItem("previousLocation", location.pathname)
navigate("/reconnect", { replace: true })
}

if (currentStatus === ERROR && !wsConnection.reconnect.isRetrying && !isReconnecting) {
sessionStorage.setItem("previousLocation", location.pathname)
navigate("/reconnect", { replace: true })
}

previousStatus.current = currentStatus
}, [wsConnection.status, wsConnection.reconnect.isRetrying, location.pathname, navigate])
}
2 changes: 2 additions & 0 deletions apps/frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Settings from "./components/Settings.tsx"
import LearnMore from "./components/LearnMore.tsx"
import { KeyBrowser } from "./components/KeyBrowser.tsx"
import { Cluster } from "./components/Cluster.tsx"
import { Reconnect } from "./components/Reconnect.tsx"
import { SendCommand } from "@/components/send-command/SendCommand.tsx"
import { Connection } from "@/components/connection/Connection.tsx"
import "./css/index.css"
Expand All @@ -32,6 +33,7 @@ const AppWithHistory = () => {
<Route element={<App />}>
<Route element={<Navigate replace to="/connect" />} path="/" />
<Route element={<Connection />} path="/connect" />
<Route element={<Reconnect />} path="/reconnect" />
<Route element={<Settings />} path="/settings" />
<Route element={<LearnMore />} path="/learnmore" />

Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/state/epics/rootEpic.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { merge } from "rxjs"
import { wsConnectionEpic } from "./wsEpics"
import { connectionEpic, sendRequestEpic, setDataEpic, deleteConnectionEpic } from "./valkeyEpics"
import { connectionEpic, sendRequestEpic, setDataEpic, deleteConnectionEpic, autoReconnectEpic } from "./valkeyEpics"
import { keyBrowserEpic } from "./keyBrowserEpic"
import type { Store } from "@reduxjs/toolkit"

export const registerEpics = (store: Store) => {
merge(
wsConnectionEpic(store),
connectionEpic(store),
autoReconnectEpic(store),
deleteConnectionEpic(),
sendRequestEpic(),
setDataEpic(),
Expand Down
48 changes: 43 additions & 5 deletions apps/frontend/src/state/epics/valkeyEpics.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { merge } from "rxjs"
import { ignoreElements, tap } from "rxjs/operators"
import { ignoreElements, tap, delay } from "rxjs/operators"
import * as R from "ramda"
import { LOCAL_STORAGE, NOT_CONNECTED } from "@common/src/constants.ts"
import { DISCONNECTED, LOCAL_STORAGE, NOT_CONNECTED } from "@common/src/constants.ts"
import { toast } from "sonner"
import { getSocket } from "./wsEpics"
import { connectFulfilled, connectPending, deleteConnection , connectRejected } from "../valkey-features/connection/connectionSlice"
import { connectFulfilled, connectPending, deleteConnection, connectRejected } from "../valkey-features/connection/connectionSlice"
import { sendRequested } from "../valkey-features/command/commandSlice"
import { setData } from "../valkey-features/info/infoSlice"
import { action$, select } from "../middleware/rxjsMiddleware/rxjsMiddlware"
import { setClusterData } from "../valkey-features/cluster/clusterSlice"
import { connectFulfilled as wsConnectFulfilled } from "../wsconnection/wsConnectionSlice"
import type { Store } from "@reduxjs/toolkit"
import { atId } from "@/state/valkey-features/connection/connectionSelectors.ts"

export const connectionEpic = (store: Store) =>
merge(
action$.pipe(
Expand Down Expand Up @@ -46,6 +48,7 @@ export const connectionEpic = (store: Store) =>
}
}),
),

action$.pipe(
select(connectRejected),
tap(({ payload: { err, connectionId } }) => {
Expand All @@ -55,7 +58,42 @@ export const connectionEpic = (store: Store) =>
),
)

export const deleteConnectionEpic = () =>
// reconnect epic
export const autoReconnectEpic = (store: Store) =>
action$.pipe(
select(wsConnectFulfilled),
delay(500), // Small delay to ensure WebSocket is fully connected
tap(() => {
const state = store.getState()
const connections = state.valkeyConnection?.connections || {}

// disconnected Valkey connections
const disconnectedConnections = Object.entries(connections)
.filter(([, connection]) => connection.status === DISCONNECTED)

if (disconnectedConnections.length > 0) {
console.log(`Auto-reconnecting ${disconnectedConnections.length} connection(s)`)
toast.info(`Reconnecting ${disconnectedConnections.length} connection(s)...`)

// reconnect each disconnected connection
disconnectedConnections.forEach(([connectionId, connection]) => {
const { host, port, username, password } = connection.connectionDetails

console.log(`Attempting to reconnect ${connectionId}`)
store.dispatch(connectPending({
connectionId,
host,
port,
username,
password,
}))
})
}
}),
ignoreElements(),
)

export const deleteConnectionEpic = () =>
action$.pipe(
select(deleteConnection),
// TODO: extract reused logic into separate method
Expand All @@ -66,7 +104,7 @@ export const deleteConnectionEpic = () =>
(s) => (s === null ? {} : JSON.parse(s)),
)(LOCAL_STORAGE.VALKEY_CONNECTIONS)
R.pipe(
R.dissoc(connectionId),
R.dissoc(connectionId),
JSON.stringify,
(updated) => localStorage.setItem(LOCAL_STORAGE.VALKEY_CONNECTIONS, updated),
)(currentConnections)
Expand Down
Loading