diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index 8d55dbc2..0d411c30 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -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])
diff --git a/apps/frontend/src/components/Reconnect.tsx b/apps/frontend/src/components/Reconnect.tsx
new file mode 100644
index 00000000..04d525ac
--- /dev/null
+++ b/apps/frontend/src/components/Reconnect.tsx
@@ -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 (
+
+
+
+ {status === CONNECTING && reconnect.isRetrying ? (
+
+
+
+ ) : isExhausted ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {isExhausted
+ ? "WebSocket Server Disconnected"
+ : "Reconnecting to Server..."}
+
+
+ {isExhausted
+ ? "Unable to connect to the WebSocket server"
+ : "Attempting to restore connection to the WebSocket server"}
+
+ {errorMessage && (
+
+ )}
+
+
+ {/* Retry Progress */}
+ {reconnect.isRetrying && (
+
+
+
+ {/* Retry Information */}
+
+
+ Attempt {reconnect.currentAttempt} of {reconnect.maxRetries}
+
+ {reconnect.nextRetryDelay && (
+
+ Next retry in {getNextRetrySeconds()}s
+
+ )}
+
+
+ )}
+
+ {isExhausted && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/frontend/src/hooks/useIsConnected.ts b/apps/frontend/src/hooks/useIsConnected.ts
index f96631b9..29a1a56d 100644
--- a/apps/frontend/src/hooks/useIsConnected.ts
+++ b/apps/frontend/src/hooks/useIsConnected.ts
@@ -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
diff --git a/apps/frontend/src/hooks/useWebSocketNavigation.ts b/apps/frontend/src/hooks/useWebSocketNavigation.ts
new file mode 100644
index 00000000..d4126e80
--- /dev/null
+++ b/apps/frontend/src/hooks/useWebSocketNavigation.ts
@@ -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) {
+ // 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])
+}
diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx
index c2f69c9b..05c8b112 100644
--- a/apps/frontend/src/main.tsx
+++ b/apps/frontend/src/main.tsx
@@ -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"
@@ -32,6 +33,7 @@ const AppWithHistory = () => {
}>
} path="/" />
} path="/connect" />
+ } path="/reconnect" />
} path="/settings" />
} path="/learnmore" />
diff --git a/apps/frontend/src/state/epics/rootEpic.ts b/apps/frontend/src/state/epics/rootEpic.ts
index f0528bf2..0f886b8e 100644
--- a/apps/frontend/src/state/epics/rootEpic.ts
+++ b/apps/frontend/src/state/epics/rootEpic.ts
@@ -1,6 +1,6 @@
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"
@@ -8,6 +8,7 @@ export const registerEpics = (store: Store) => {
merge(
wsConnectionEpic(store),
connectionEpic(store),
+ autoReconnectEpic(store),
deleteConnectionEpic(),
sendRequestEpic(),
setDataEpic(),
diff --git a/apps/frontend/src/state/epics/valkeyEpics.ts b/apps/frontend/src/state/epics/valkeyEpics.ts
index 9aa86559..ce44a2e4 100644
--- a/apps/frontend/src/state/epics/valkeyEpics.ts
+++ b/apps/frontend/src/state/epics/valkeyEpics.ts
@@ -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(
@@ -46,6 +48,7 @@ export const connectionEpic = (store: Store) =>
}
}),
),
+
action$.pipe(
select(connectRejected),
tap(({ payload: { err, connectionId } }) => {
@@ -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
@@ -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)
diff --git a/apps/frontend/src/state/epics/wsEpics.ts b/apps/frontend/src/state/epics/wsEpics.ts
index 57bafd8c..0c30b788 100644
--- a/apps/frontend/src/state/epics/wsEpics.ts
+++ b/apps/frontend/src/state/epics/wsEpics.ts
@@ -1,22 +1,24 @@
import { webSocket, WebSocketSubject } from "rxjs/webSocket"
-import { of, EMPTY, merge } from "rxjs"
+import { of, EMPTY, merge, timer } from "rxjs"
import {
catchError,
mergeMap,
tap,
ignoreElements,
filter,
- switchMap
+ switchMap,
+ retry
} from "rxjs/operators"
-import { CONNECTED, VALKEY } from "@common/src/constants.ts"
-import { toast } from "sonner"
+import { CONNECTED, VALKEY, WS_RETRY_CONFIG, retryDelay } from "@common/src/constants.ts"
import { action$ } from "../middleware/rxjsMiddleware/rxjsMiddlware"
import type { PayloadAction, Store } from "@reduxjs/toolkit"
import { connectionBroken } from "@/state/valkey-features/connection/connectionSlice"
import {
connectFulfilled,
connectPending,
- connectRejected
+ connectRejected,
+ reconnectAttempt,
+ reconnectExhausted
} from "@/state/wsconnection/wsConnectionSlice"
let socket$: WebSocketSubject | null = null
@@ -28,40 +30,65 @@ const connect = (store: Store) =>
if (socket$) {
return EMPTY
}
- socket$ = webSocket({
- url: "ws://localhost:8080",
- deserializer: (message) => JSON.parse(message.data),
- serializer: (message) => JSON.stringify(message),
- openObserver: {
- next: () => {
- console.log("Socket Connection opened")
- store.dispatch(connectFulfilled())
+ // Create new WebSocket instance - required for retry logic to work
+ const createSocket = () => {
+ socket$ = webSocket({
+ url: "ws://localhost:8080",
+ deserializer: (message) => JSON.parse(message.data),
+ serializer: (message) => JSON.stringify(message),
+ openObserver: {
+ next: () => {
+ console.log("Socket Connection opened")
+ store.dispatch(connectFulfilled())
+ },
},
- },
- closeObserver: {
- next: () => {
- console.log("Socket Connection closed")
- const state = store.getState()
- const connections = state[VALKEY.CONNECTION.name]?.connections || {}
+ closeObserver: {
+ next: (event) => {
+ console.log("Socket Connection closed", event)
+ const state = store.getState()
+ const connections = state[VALKEY.CONNECTION.name]?.connections || {}
- toast.error("WebSocket connection lost! Try reconnecting.", { duration: 5000 })
+ // Mark all connected Valkey connections as broken
+ Object.keys(connections).forEach((connectionId) => {
+ if (connections[connectionId].status === CONNECTED) {
+ console.log(`Dispatching connectionBroken for ${connectionId}`)
+ store.dispatch(connectionBroken({ connectionId }))
+ }
+ })
+ socket$ = null
+ },
+ },
+ })
+ return socket$
+ }
+
+ return of(null).pipe(
+ mergeMap(() => {
+ const socket = createSocket()
+ return socket.pipe(ignoreElements())
+ }),
+ retry({
+ count: WS_RETRY_CONFIG.MAX_RETRIES,
+ delay: (error, retryCount) => {
+ console.error(`WebSocket error (attempt ${retryCount}):`, error)
+
+ const delay = retryDelay(retryCount - 1)
+
+ store.dispatch(reconnectAttempt({
+ attempt: retryCount,
+ maxRetries: WS_RETRY_CONFIG.MAX_RETRIES,
+ nextRetryDelay: delay,
+ }))
- Object.keys(connections).forEach((connectionId) => {
- console.log(`Checking connection ${connectionId}, status: ${connections[connectionId].status}`)
- if (connections[connectionId].status === CONNECTED) {
- console.log(`Dispatching connectionBroken for ${connectionId}`)
- store.dispatch(connectionBroken({ connectionId }))
- }
- })
- socket$ = null
+ return timer(delay)
},
- },
- })
- return socket$.pipe(
- ignoreElements(),
+ resetOnSuccess: true,
+ }),
catchError((err) => {
- console.error("WebSocket connection error:", err)
- return of(connectRejected(err))
+ console.error("WebSocket connection failed permanently:", err)
+ store.dispatch(reconnectExhausted())
+ store.dispatch(connectRejected(err))
+ return EMPTY
}),
)
}),
@@ -85,13 +112,15 @@ const emitActions = (store: Store) =>
console.error("WebSocket error in message stream:", err)
const state = store.getState()
const connections = state[VALKEY.CONNECTION.name]?.connections || {}
- toast.error("WebSocket connection Lost!", { duration: 5000 })
Object.keys(connections).forEach((connectionId) => {
if (connections[connectionId].status === CONNECTED) {
store.dispatch(connectionBroken({ connectionId }))
}
})
+
+ // trigger reconnection
+ store.dispatch(connectPending())
return EMPTY
}),
ignoreElements(),
diff --git a/apps/frontend/src/state/wsconnection/wsConnectionSlice.ts b/apps/frontend/src/state/wsconnection/wsConnectionSlice.ts
index 182b6ef6..492eccca 100644
--- a/apps/frontend/src/state/wsconnection/wsConnectionSlice.ts
+++ b/apps/frontend/src/state/wsconnection/wsConnectionSlice.ts
@@ -1,12 +1,31 @@
import { CONNECTED, CONNECTING, ERROR, NOT_CONNECTED } from "@common/src/constants"
-import { createSlice } from "@reduxjs/toolkit"
+import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
+
+interface ReconnectState {
+ isRetrying: boolean
+ currentAttempt: number
+ maxRetries: number
+ nextRetryDelay: number | null
+}
+
+interface WsConnectionState {
+ status: typeof NOT_CONNECTED | typeof CONNECTED | typeof CONNECTING | typeof ERROR
+ errorMessage: string | null
+ reconnect: ReconnectState
+}
const wsConnectionSlice = createSlice({
name: "wsconnection",
initialState: {
status: NOT_CONNECTED,
errorMessage: null,
- },
+ reconnect: {
+ isRetrying: false,
+ currentAttempt: 0,
+ maxRetries: 8,
+ nextRetryDelay: null,
+ },
+ } as WsConnectionState,
reducers: {
connectPending: (state) => {
state.status = CONNECTING
@@ -18,14 +37,49 @@ const wsConnectionSlice = createSlice({
},
connectRejected: (state, action) => {
state.status = ERROR
- state.errorMessage = action.payload || "Unknown error"
+ state.errorMessage = action.payload?.message || "Server error"
+ state.reconnect.isRetrying = false
+ },
+ reconnectAttempt: (state, action: PayloadAction<{
+ attempt: number
+ maxRetries: number
+ nextRetryDelay: number
+ }>) => {
+ state.reconnect.isRetrying = true
+ state.reconnect.currentAttempt = action.payload.attempt
+ state.reconnect.maxRetries = action.payload.maxRetries
+ state.reconnect.nextRetryDelay = action.payload.nextRetryDelay
+ state.status = CONNECTING
+ },
+ reconnectFailed: (state, action: PayloadAction<{ error: string }>) => {
+ state.reconnect.isRetrying = false
+ state.errorMessage = action.payload.error
+ },
+ reconnectExhausted: (state) => {
+ state.status = ERROR
+ state.reconnect.isRetrying = false
+ state.errorMessage = "Maximum reconnection attempts reached"
},
resetConnection: (state) => {
state.status = NOT_CONNECTED
state.errorMessage = null
+ state.reconnect = {
+ isRetrying: false,
+ currentAttempt: 0,
+ maxRetries: 8,
+ nextRetryDelay: null,
+ }
},
},
})
export default wsConnectionSlice.reducer
-export const { connectPending, connectFulfilled, connectRejected, resetConnection } = wsConnectionSlice.actions
+export const {
+ connectPending,
+ connectFulfilled,
+ connectRejected,
+ reconnectAttempt,
+ reconnectFailed,
+ reconnectExhausted,
+ resetConnection,
+} = wsConnectionSlice.actions
diff --git a/common/src/constants.ts b/common/src/constants.ts
index bcaa4647..3528a61b 100644
--- a/common/src/constants.ts
+++ b/common/src/constants.ts
@@ -67,7 +67,25 @@ export const CONNECTING = "Connecting"
export const ERROR = "Error"
export const NOT_CONNECTED = "Not Connected"
export const DISCONNECTED = "Disconnected"
+export const RECONNECTING = "Reconnecting"
export const LOCAL_STORAGE = {
VALKEY_CONNECTIONS: "VALKEY_CONNECTIONS",
}
+
+export const WS_RETRY_CONFIG = {
+ MAX_RETRIES: 8,
+ BASE_DELAY: 1000,
+ MAX_DELAY: 30000,
+} as const
+
+// fibonacci backoff
+export const retryDelay = (retryCount: number): number => {
+ let a = 1, b = 1
+ for (let i = 2; i <= retryCount; i++) {
+ [a, b] = [b, a + b]
+ }
+
+ const delay = WS_RETRY_CONFIG.BASE_DELAY * b
+ return Math.min(delay, WS_RETRY_CONFIG.MAX_DELAY)
+}