Implement Reconnection Logic in ApiServerContext
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added state management for connection issues, including reconnect progress and attempts, to enhance the robustness of the API server connection.
- Introduced functions to handle reconnect scheduling, countdowns, and teardown of socket connections, improving error handling during disconnections.
- Updated the connectToServer function to manage socket connections more effectively, ensuring a smoother user experience during connectivity issues.
- Enhanced the disconnect event handling to provide better feedback and state management when the API server is disconnected.
This commit is contained in:
Tom Butcher 2026-08-23 18:27:05 +01:00
parent bcb7460bf2
commit 39908c811a

View File

@ -8,7 +8,7 @@ import {
useCallback useCallback
} from 'react' } from 'react'
import io from 'socket.io-client' import io from 'socket.io-client'
import { message, Modal, Space, Button, Typography } from 'antd' import { message, Modal, Space, Button, Typography, Flex } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@ -20,12 +20,14 @@ import LockIcon from '../../Icons/LockIcon'
import config from '../../../config' import config from '../../../config'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import ProgressDisplay from '../common/ProgressDisplay'
const logger = loglevel.getLogger('ApiServerContext') const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
const { Text } = Typography const { Text } = Typography
const SPOTLIGHT_CACHE_TTL_MS = 10_000 const SPOTLIGHT_CACHE_TTL_MS = 10_000
const RECONNECT_DELAYS_MS = [3000, 5000, 10000, 15000]
const spotlightCache = new Map() const spotlightCache = new Map()
const runningSpotlightFetches = new Map() const runningSpotlightFetches = new Map()
@ -134,6 +136,22 @@ const ApiServerProvider = ({ children }) => {
const subscribedActivityServerSubscriptionsRef = useRef(new Set()) const subscribedActivityServerSubscriptionsRef = useRef(new Set())
const notificationListenersRef = useRef(new Set()) const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set()) const completedLaunchSessionsRef = useRef(new Set())
const [connectionIssue, setConnectionIssue] = useState(false)
const [reconnectProgress, setReconnectProgress] = useState(0)
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(
RECONNECT_DELAYS_MS[0]
)
const [isReconnecting, setIsReconnecting] = useState(false)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef(null)
const reconnectProgressTimerRef = useRef(null)
const reconnectScheduledRef = useRef(false)
const isReconnectingRef = useRef(false)
const hasConnectedOnceRef = useRef(false)
const connectToServerRef = useRef(null)
const scheduleReconnectRef = useRef(null)
const attemptReconnectRef = useRef(null)
const teardownSocketRef = useRef(null)
const handleActivityUpdate = useCallback(async (data) => { const handleActivityUpdate = useCallback(async (data) => {
logger.debug('Notifying activity update:', data) logger.debug('Notifying activity update:', data)
@ -299,15 +317,138 @@ const ApiServerProvider = ({ children }) => {
[getUserSettings, messageApi] [getUserSettings, messageApi]
) )
const clearReconnectTimers = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (reconnectProgressTimerRef.current) {
clearInterval(reconnectProgressTimerRef.current)
reconnectProgressTimerRef.current = null
}
}, [])
const resetReconnectState = useCallback(() => {
clearReconnectTimers()
reconnectAttemptRef.current = 0
reconnectScheduledRef.current = false
isReconnectingRef.current = false
setIsReconnecting(false)
setReconnectProgress(0)
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
}, [clearReconnectTimers])
const startReconnectCountdown = useCallback(
(delayMs) => {
clearReconnectTimers()
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
setIsReconnecting(false)
reconnectProgressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
reconnectTimerRef.current = setTimeout(() => {
attemptReconnectRef.current?.()
}, delayMs)
},
[clearReconnectTimers]
)
const markConnectionLost = useCallback(() => {
isReconnectingRef.current = false
setIsReconnecting(false)
setConnecting(false)
setConnected(false)
setConnectionIssue(true)
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
clearSubscriptions()
scheduleReconnectRef.current?.()
}, [clearSubscriptions])
const scheduleReconnect = useCallback(() => {
if (!token || authenticated != true) {
return
}
if (isReconnectingRef.current || reconnectScheduledRef.current) {
return
}
reconnectScheduledRef.current = true
setConnectionIssue(true)
const delayIndex = Math.min(
reconnectAttemptRef.current,
RECONNECT_DELAYS_MS.length - 1
)
startReconnectCountdown(RECONNECT_DELAYS_MS[delayIndex])
}, [token, authenticated, startReconnectCountdown])
const attemptReconnect = useCallback(() => {
if (!token || authenticated != true) {
return
}
if (isReconnectingRef.current) {
return
}
clearReconnectTimers()
reconnectScheduledRef.current = false
isReconnectingRef.current = true
reconnectAttemptRef.current += 1
setIsReconnecting(true)
setReconnectProgress(100)
setConnecting(true)
setConnectionIssue(true)
if (socketRef.current) {
if (!socketRef.current.connected) {
socketRef.current.connect()
}
} else {
connectToServerRef.current?.()
}
}, [token, authenticated, clearReconnectTimers])
const teardownSocket = useCallback(() => {
resetReconnectState()
setConnectionIssue(false)
setConnecting(false)
const socket = socketRef.current
socketRef.current = null
socket?.disconnect()
}, [resetReconnectState])
scheduleReconnectRef.current = scheduleReconnect
attemptReconnectRef.current = attemptReconnect
teardownSocketRef.current = teardownSocket
const connectToServer = useCallback(() => { const connectToServer = useCallback(() => {
if (token && authenticated == true) { if (token && authenticated == true) {
if (socketRef.current) {
if (!socketRef.current.connected) {
socketRef.current.connect()
}
return
}
logger.debug('Token is available, connecting to api server...') logger.debug('Token is available, connecting to api server...')
const newSocket = io(config.apiServerUrl, { const newSocket = io(config.apiServerUrl, {
reconnectionAttempts: 3, reconnection: false,
reconnectionAttempts: 0,
forceNew: true,
timeout: 3000, timeout: 3000,
auth: { type: 'user' } auth: { type: 'user' }
}) })
if (typeof newSocket.io?.reconnection === 'function') {
newSocket.io.reconnection(false)
}
setConnecting(true) setConnecting(true)
@ -317,6 +458,13 @@ const ApiServerProvider = ({ children }) => {
if (result?.valid !== true) { if (result?.valid !== true) {
setConnecting(false) setConnecting(false)
setError('Api Server authentication failed') setError('Api Server authentication failed')
if (hasConnectedOnceRef.current) {
markConnectionLost()
newSocket.disconnect()
return
}
resetReconnectState()
setConnectionIssue(false)
newSocket.disconnect() newSocket.disconnect()
return return
} }
@ -327,9 +475,12 @@ const ApiServerProvider = ({ children }) => {
if (!newSocket.connected) { if (!newSocket.connected) {
return return
} }
hasConnectedOnceRef.current = true
setConnecting(false) setConnecting(false)
setConnected(true) setConnected(true)
setError(null) setError(null)
setConnectionIssue(false)
resetReconnectState()
resubscribeActivityListeners(newSocket) resubscribeActivityListeners(newSocket)
}) })
}) })
@ -359,23 +510,29 @@ const ApiServerProvider = ({ children }) => {
}) })
}) })
newSocket.on('disconnect', () => { newSocket.on('disconnect', (reason) => {
logger.debug('Api Server disconnected') if (socketRef.current !== newSocket) {
return
}
logger.debug('Api Server disconnected', reason)
setError('Api Server disconnected') setError('Api Server disconnected')
clearSubscriptions()
setConnected(false) if (reason === 'io client disconnect') {
setUserSettings(createEmptyUserSettings()) return
setUserSettingsLoaded(false) }
markConnectionLost()
}) })
newSocket.on('connect_error', (err) => { newSocket.on('connect_error', (err) => {
if (socketRef.current !== newSocket) {
return
}
logger.error('Api Server connection error:', err) logger.error('Api Server connection error:', err)
messageApi.error('Api Server connection error: ' + err.message)
setError('Api Server connection error') setError('Api Server connection error')
clearSubscriptions() markConnectionLost()
setConnected(false)
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
}) })
newSocket.on('error', (err) => { newSocket.on('error', (err) => {
@ -388,33 +545,42 @@ const ApiServerProvider = ({ children }) => {
}, [ }, [
token, token,
authenticated, authenticated,
messageApi,
handleActivityUpdate, handleActivityUpdate,
clearSubscriptions, clearSubscriptions,
getUserSettings, getUserSettings,
resubscribeActivityListeners resubscribeActivityListeners,
resetReconnectState,
markConnectionLost
]) ])
connectToServerRef.current = connectToServer
useEffect(() => { useEffect(() => {
if (token && authenticated == true) { if (token && authenticated == true) {
connectToServer() connectToServerRef.current?.()
} else if (!token && socketRef.current) { return
logger.debug('Token not available, disconnecting api server...')
socketRef.current.disconnect()
socketRef.current = null
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
} }
// Clean up function logger.debug('Token not available, disconnecting api server...')
teardownSocketRef.current?.()
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
}, [token, authenticated])
useEffect(() => {
return () => { return () => {
if (socketRef.current) { teardownSocketRef.current?.()
logger.debug('Cleaning up api server connection...')
socketRef.current.disconnect()
socketRef.current = null
}
} }
}, [token, authenticated, connectToServer]) }, [])
useEffect(() => {
if (!token || authenticated != true || !connectionIssue) {
return
}
if (!reconnectScheduledRef.current && !isReconnectingRef.current) {
scheduleReconnect()
}
}, [token, authenticated, connectionIssue, scheduleReconnect])
useEffect(() => { useEffect(() => {
const handlePageVisible = () => { const handlePageVisible = () => {
@ -429,7 +595,7 @@ const ApiServerProvider = ({ children }) => {
if (!socket.connected) { if (!socket.connected) {
logger.debug('Page visible with disconnected socket, reconnecting...') logger.debug('Page visible with disconnected socket, reconnecting...')
socket.connect() attemptReconnect()
return return
} }
@ -448,10 +614,7 @@ const ApiServerProvider = ({ children }) => {
logger.debug('Page restored from bfcache, reconnecting socket...') logger.debug('Page restored from bfcache, reconnecting socket...')
subscribedActivityServerSubscriptionsRef.current.clear() subscribedActivityServerSubscriptionsRef.current.clear()
setConnected(false) setConnected(false)
if (socketRef.current) { teardownSocket()
socketRef.current.disconnect()
socketRef.current = null
}
connectToServer() connectToServer()
} }
@ -462,7 +625,14 @@ const ApiServerProvider = ({ children }) => {
document.removeEventListener('visibilitychange', handlePageVisible) document.removeEventListener('visibilitychange', handlePageVisible)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
} }
}, [token, authenticated, connectToServer, resubscribeActivityListeners]) }, [
token,
authenticated,
connectToServer,
resubscribeActivityListeners,
attemptReconnect,
teardownSocket
])
const setObjectActivity = (id, type, mode) => { const setObjectActivity = (id, type, mode) => {
logger.debug('Setting activity for', id, mode) logger.debug('Setting activity for', id, mode)
@ -1951,9 +2121,15 @@ const ApiServerProvider = ({ children }) => {
const tryFetchRenderedFile = async () => { const tryFetchRenderedFile = async () => {
try { try {
const result = await fetchTemplateDownload(id, content, object, type, { const result = await fetchTemplateDownload(
renderRequestId id,
}) content,
object,
type,
{
renderRequestId
}
)
finish(result) finish(result)
return true return true
} catch (err) { } catch (err) {
@ -2460,6 +2636,11 @@ const ApiServerProvider = ({ children }) => {
} }
} }
const reconnectSecondsRemaining = Math.max(
1,
Math.ceil(reconnectRemainingMs / 1000)
)
// Sanitize a string so it is safe to use as a filename on most file systems // Sanitize a string so it is safe to use as a filename on most file systems
const formatFileName = (name) => { const formatFileName = (name) => {
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
@ -2553,6 +2734,45 @@ const ApiServerProvider = ({ children }) => {
> >
{contextHolder} {contextHolder}
{children} {children}
<Modal
title={
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
}
open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: 480 }}
zIndex={3000}
closable={false}
centered
maskClosable={false}
getContainer={() => document.body}
footer={[
<Button
key='reconnect'
loading={isReconnecting}
onClick={() => attemptReconnect()}
>
Reconnect
</Button>
]}
>
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
</Modal>
<Modal <Modal
title={ title={
<Space size={'middle'}> <Space size={'middle'}>