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 && ( +
+
+ +

+ {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) +}