Implement Reconnection Logic in ApiServerContext
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
bcb7460bf2
commit
39908c811a
@ -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'}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user