Merge branch 'dev'
This commit is contained in:
@@ -5,6 +5,7 @@ import AccountSettings from '@/views/Settings/AccountSettings'
|
||||
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
|
||||
import ChildUserSettings from '@/views/Settings/ChildUserSettings'
|
||||
import CircleSettings from '@/views/Settings/CircleSettings'
|
||||
import DeveloperSettings from '@/views/Settings/DeveloperSettings'
|
||||
import Settings from '@/views/Settings/Settings'
|
||||
import SettingsOverview from '@/views/Settings/SettingsOverview'
|
||||
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
|
||||
@@ -117,6 +118,10 @@ const Router = createBrowserRouter([
|
||||
path: 'advanced',
|
||||
element: <AdvancedSettings />,
|
||||
},
|
||||
{
|
||||
path: 'developer',
|
||||
element: <DeveloperSettings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../Config'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { saveTokens, clearAllTokens } from '../utils/TokenStorage'
|
||||
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
|
||||
import { apiClient } from '../utils/apiClient'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
@@ -19,8 +18,7 @@ export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const baseURL = `${API_URL}/api/v1`
|
||||
const baseURL = apiClient.getApiURL()
|
||||
const isAuthenticated = !!token
|
||||
|
||||
const isTokenExpired = () => {
|
||||
@@ -73,22 +71,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await fetch(`${baseURL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Logout API call failed:', error)
|
||||
} finally {
|
||||
await clearAuth()
|
||||
setIsLoading(false)
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
if (!token) return null
|
||||
|
||||
@@ -132,7 +114,6 @@ export const AuthProvider = ({ children }) => {
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
fetchUser,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useAlerts } from '../service/AlertsProvider'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/apiClient.js'
|
||||
import { useAuth } from './useAuth.jsx'
|
||||
|
||||
const SSE_STATES = {
|
||||
CONNECTING: 0,
|
||||
OPEN: 1,
|
||||
CLOSED: 2,
|
||||
}
|
||||
|
||||
const RECONNECT_INTERVALS = [2000, 5000, 10000, 30000, 360000, 600000, 900000] // 2s, 5s, 10s, 30s, 6m, 10m, 15m
|
||||
const RECONNECT_INTERVALS = [10000, 30000, 360000, 600000, 900000, 6000000] // 10s, 30s, 6m, 10m, 15m , 1h
|
||||
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
|
||||
|
||||
@@ -31,6 +32,9 @@ export const useSSE = () => {
|
||||
const isManuallyClosedRef = useRef(false)
|
||||
const lastHeartbeatRef = useRef(Date.now())
|
||||
const heartbeatMonitorRef = useRef(null)
|
||||
const nextReconnectTimeRef = useRef(null)
|
||||
// Track if reconnect is already scheduled to prevent duplicates
|
||||
const isReconnectScheduledRef = useRef(false)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { showError, showNotification } = useNotification()
|
||||
@@ -48,13 +52,13 @@ export const useSSE = () => {
|
||||
}
|
||||
|
||||
// Get the API URL from apiManager
|
||||
const apiUrl = apiClient.baseURL // e.g., "http://localhost:8080/api/v1"
|
||||
const apiUrl = apiClient.getApiURL() // e.g., "http://localhost:8080/api/v1"
|
||||
|
||||
// Build SSE URL - let backend determine circle from authenticated user
|
||||
const sseUrl = `${apiUrl}/realtime/sse`
|
||||
|
||||
return { url: sseUrl, token }
|
||||
}, [])
|
||||
}, [token, isAuthenticated]) // Fixed: Added missing dependencies
|
||||
|
||||
const handleSSEMessage = useCallback(
|
||||
event => {
|
||||
@@ -205,17 +209,6 @@ export const useSSE = () => {
|
||||
return { res: newChoreData }
|
||||
},
|
||||
)
|
||||
|
||||
// Invalidate the specific chore that contains this subtask
|
||||
// if (eventData.data.choreId) {
|
||||
// queryClient.invalidateQueries(['chore', eventData.data.choreId])
|
||||
// queryClient.invalidateQueries([
|
||||
// 'choreDetails',
|
||||
// eventData.data.choreId,
|
||||
// ])
|
||||
// }
|
||||
// Also invalidate general chores list
|
||||
// queryClient.invalidateQueries(['chores'])
|
||||
break
|
||||
|
||||
case 'heartbeat':
|
||||
@@ -256,7 +249,7 @@ export const useSSE = () => {
|
||||
return // Stop processing if JSON parsing fails
|
||||
}
|
||||
},
|
||||
[queryClient, showNotification, showError, userProfile],
|
||||
[queryClient, showNotification, showError, userProfile, showAlert],
|
||||
)
|
||||
|
||||
const stopHeartbeatMonitor = useCallback(() => {
|
||||
@@ -266,8 +259,40 @@ export const useSSE = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Centralized reconnect scheduling function to prevent duplicate scheduling
|
||||
const scheduleReconnect = useCallback((delay, reason) => {
|
||||
// Prevent duplicate scheduling
|
||||
if (isReconnectScheduledRef.current) {
|
||||
console.log('SSE: Reconnect already scheduled, skipping duplicate')
|
||||
return
|
||||
}
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`SSE: Scheduling reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}, reason: ${reason})`,
|
||||
)
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + delay
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
reconnectAttemptsRef.current++
|
||||
nextReconnectTimeRef.current = null
|
||||
// Note: connect will be called by the caller after this returns
|
||||
// We need to trigger it here
|
||||
window.dispatchEvent(new CustomEvent('sse-reconnect'))
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Create connect function that can be called from anywhere
|
||||
const connect = useCallback(() => {
|
||||
// Clear the scheduled flag when actually connecting
|
||||
isReconnectScheduledRef.current = false
|
||||
|
||||
if (isCircuitBreakerOpen) {
|
||||
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||
showError({
|
||||
@@ -323,9 +348,6 @@ export const useSSE = () => {
|
||||
setConnectionState(SSE_STATES.CONNECTING)
|
||||
isManuallyClosedRef.current = false
|
||||
|
||||
// here use EventSource polyfill with Authorization header as the native EventSource does not support headers
|
||||
// the other option was to pass via query param which is less secure and also there.
|
||||
// TODO: use cookie-based once/if at all i move from local storage to httpOnly cookies.
|
||||
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
@@ -333,11 +355,7 @@ export const useSSE = () => {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
// Increase timeout to prevent premature disconnections
|
||||
// Default is 45000ms (45s), increasing to 2 minutes
|
||||
// TODO: send this in the resource object so it can be configured per instance
|
||||
heartbeatTimeout: 120000,
|
||||
// Enable silentTimeoutRetry to handle temporary network issues
|
||||
silentTimeoutRetry: true,
|
||||
})
|
||||
|
||||
@@ -346,15 +364,19 @@ export const useSSE = () => {
|
||||
setConnectionState(SSE_STATES.OPEN)
|
||||
setError(null)
|
||||
reconnectAttemptsRef.current = 0
|
||||
nextReconnectTimeRef.current = null
|
||||
isReconnectScheduledRef.current = false
|
||||
lastHeartbeatRef.current = Date.now()
|
||||
|
||||
// Start heartbeat monitor
|
||||
if (heartbeatMonitorRef.current) {
|
||||
clearInterval(heartbeatMonitorRef.current)
|
||||
}
|
||||
stopHeartbeatMonitor()
|
||||
heartbeatMonitorRef.current = setInterval(() => {
|
||||
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
|
||||
const heartbeatTimeout = 150000 // 2.5 minutes - should be longer than server heartbeat interval
|
||||
const heartbeatTimeout = 150000 // 2.5 minutes
|
||||
|
||||
console.debug(
|
||||
`SSE: Heartbeat check - ${Math.round(timeSinceLastHeartbeat / 1000)}s since last heartbeat`,
|
||||
)
|
||||
|
||||
if (timeSinceLastHeartbeat > heartbeatTimeout) {
|
||||
console.warn(
|
||||
@@ -371,25 +393,28 @@ export const useSSE = () => {
|
||||
}
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
|
||||
// Schedule reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Calculate delay based on current attempt
|
||||
const attemptIndex = Math.min(
|
||||
reconnectAttemptsRef.current,
|
||||
RECONNECT_INTERVALS.length - 1,
|
||||
)
|
||||
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||
|
||||
// Schedule reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Scheduling SSE reconnect in ${delay}ms (attempt ${
|
||||
reconnectAttemptsRef.current + 1
|
||||
})`,
|
||||
`SSE: Scheduling heartbeat-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||
)
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
reconnectAttemptsRef.current++
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
@@ -404,91 +429,139 @@ export const useSSE = () => {
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
stopHeartbeatMonitor()
|
||||
|
||||
if (!isManuallyClosedRef.current) {
|
||||
// Check if this is a 401 unauthorized error
|
||||
const is401Error =
|
||||
error.status === 401 ||
|
||||
error.error?.message?.includes('401') ||
|
||||
error.error?.message?.includes('Unauthorized')
|
||||
// Close the EventSource to prevent it from retrying on its own
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
// Check if this is a timeout error specifically
|
||||
const isTimeoutError =
|
||||
error.error?.message?.includes('No activity within') ||
|
||||
error.error?.message?.includes('timeout')
|
||||
if (isManuallyClosedRef.current) {
|
||||
console.log('SSE: Manually closed, not reconnecting')
|
||||
return
|
||||
}
|
||||
|
||||
if (is401Error) {
|
||||
console.log('SSE 401 error detected, attempting token refresh...')
|
||||
setError('Authentication expired - refreshing token...')
|
||||
// Check if reconnect is already scheduled
|
||||
if (isReconnectScheduledRef.current) {
|
||||
console.log('SSE: Reconnect already scheduled, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshResult = await apiClient.refreshToken()
|
||||
// Check if this is a 401 unauthorized error
|
||||
const is401Error =
|
||||
error.status === 401 ||
|
||||
error.error?.message?.includes('401') ||
|
||||
error.error?.message?.includes('Unauthorized')
|
||||
|
||||
if (refreshResult.success) {
|
||||
// Check if this is a timeout error specifically
|
||||
const isTimeoutError =
|
||||
error.error?.message?.includes('No activity within') ||
|
||||
error.error?.message?.includes('timeout')
|
||||
|
||||
if (is401Error) {
|
||||
console.log('SSE 401 error detected, attempting token refresh...')
|
||||
setError('Authentication expired - refreshing token...')
|
||||
|
||||
try {
|
||||
const refreshResult = await apiClient.refreshToken()
|
||||
|
||||
if (refreshResult.success) {
|
||||
console.log(
|
||||
'Token refreshed successfully, retrying SSE connection...',
|
||||
)
|
||||
setError('Token refreshed - reconnecting...')
|
||||
|
||||
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
|
||||
console.log(
|
||||
'Token refreshed successfully, retrying SSE connection...',
|
||||
`Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`,
|
||||
)
|
||||
setError('Token refreshed - reconnecting...')
|
||||
|
||||
// Reset reconnect attempts since we have a fresh token
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
// Schedule immediate reconnect with fresh token
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect()
|
||||
}, 1000) // Short delay to avoid rapid reconnection
|
||||
|
||||
return // Exit early, don't use exponential backoff for 401 errors
|
||||
} else {
|
||||
// Check if refresh token expired
|
||||
if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
return // Don't attempt reconnection
|
||||
}
|
||||
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
// Don't attempt reconnection if token refresh failed
|
||||
return
|
||||
apiClient.processQueue(null, refreshResult.token)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh error:', refreshError)
|
||||
setError('Authentication error - please log in again')
|
||||
|
||||
// Reset reconnect attempts since we have a fresh token
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
// Schedule immediate reconnect with fresh token
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + 1000
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, 1000)
|
||||
|
||||
return
|
||||
} else if (
|
||||
refreshResult.error === 'Already refreshing' ||
|
||||
refreshResult.error === 'Refresh cooldown active'
|
||||
) {
|
||||
console.log(
|
||||
'SSE: Token refresh in progress by another request, waiting...',
|
||||
)
|
||||
setError('Token refresh in progress - reconnecting soon...')
|
||||
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + 1500
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, 1500)
|
||||
|
||||
return
|
||||
} else if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
return
|
||||
} else {
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
return
|
||||
}
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh error:', refreshError)
|
||||
setError('Authentication error - please log in again')
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule reconnect for non-401 errors
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
const attemptIndex = Math.min(
|
||||
reconnectAttemptsRef.current,
|
||||
RECONNECT_INTERVALS.length - 1,
|
||||
)
|
||||
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||
|
||||
console.log(
|
||||
`Scheduling SSE reconnect in ${delay}ms (attempt ${
|
||||
reconnectAttemptsRef.current + 1
|
||||
})`,
|
||||
)
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++
|
||||
connect()
|
||||
}, delay)
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
}
|
||||
|
||||
// Schedule reconnect for non-401 errors
|
||||
const attemptIndex = Math.min(
|
||||
reconnectAttemptsRef.current,
|
||||
RECONNECT_INTERVALS.length - 1,
|
||||
)
|
||||
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||
|
||||
console.log(
|
||||
`SSE: Scheduling error-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||
)
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
reconnectAttemptsRef.current++
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create SSE connection:', err)
|
||||
@@ -508,12 +581,14 @@ export const useSSE = () => {
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
isManuallyClosedRef.current = true
|
||||
isReconnectScheduledRef.current = false
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
|
||||
nextReconnectTimeRef.current = null
|
||||
stopHeartbeatMonitor()
|
||||
|
||||
if (eventSourceRef.current) {
|
||||
@@ -539,7 +614,7 @@ export const useSSE = () => {
|
||||
disconnect()
|
||||
}
|
||||
},
|
||||
[connect, disconnect],
|
||||
[connect, disconnect, isAuthenticated],
|
||||
)
|
||||
|
||||
const isSSEEnabled = useCallback(() => {
|
||||
@@ -551,7 +626,6 @@ export const useSSE = () => {
|
||||
console.log('SSE auto-connect effect triggered')
|
||||
console.log('Token valid:', isAuthenticated)
|
||||
|
||||
// Check if SSE is enabled in settings
|
||||
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
|
||||
console.log('SSE enabled in settings:', isSSEEnabledSetting)
|
||||
|
||||
@@ -563,12 +637,10 @@ export const useSSE = () => {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // Only run once on mount
|
||||
}, [isAuthenticated]) // Fixed: Added isAuthenticated dependency
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
@@ -580,9 +652,12 @@ export const useSSE = () => {
|
||||
}
|
||||
}, [stopHeartbeatMonitor])
|
||||
|
||||
// Update EventSource message handler when handleSSEMessage changes (e.g., when userProfile loads)
|
||||
// Update EventSource message handler when handleSSEMessage changes
|
||||
useEffect(() => {
|
||||
if (eventSourceRef.current && eventSourceRef.current.readyState === SSE_STATES.OPEN) {
|
||||
if (
|
||||
eventSourceRef.current &&
|
||||
eventSourceRef.current.readyState === SSE_STATES.OPEN
|
||||
) {
|
||||
console.log('SSE: Updating message handler with latest userProfile')
|
||||
eventSourceRef.current.onmessage = handleSSEMessage
|
||||
}
|
||||
@@ -592,21 +667,29 @@ export const useSSE = () => {
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
// App went to background, maintain connection but log the state
|
||||
console.log(
|
||||
'SSE: App backgrounded, maintaining connection but reducing activity',
|
||||
)
|
||||
} else {
|
||||
// App came to foreground, ensure connection is active
|
||||
console.log('SSE: App foregrounded, ensuring connection is active')
|
||||
|
||||
const isSSEEnabledSetting =
|
||||
localStorage.getItem('sse_enabled') === 'true'
|
||||
|
||||
// Check actual EventSource state, not React state
|
||||
const isCurrentlyConnected =
|
||||
eventSourceRef.current?.readyState === SSE_STATES.OPEN
|
||||
const isCurrentlyConnecting =
|
||||
eventSourceRef.current?.readyState === SSE_STATES.CONNECTING
|
||||
|
||||
if (
|
||||
isAuthenticated &&
|
||||
isSSEEnabledSetting &&
|
||||
connectionState !== SSE_STATES.OPEN
|
||||
!isCurrentlyConnected &&
|
||||
!isCurrentlyConnecting &&
|
||||
!isReconnectScheduledRef.current
|
||||
) {
|
||||
console.log('SSE: Reconnecting after visibility change')
|
||||
connect()
|
||||
}
|
||||
}
|
||||
@@ -617,7 +700,7 @@ export const useSSE = () => {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [connectionState, connect])
|
||||
}, [connect, isAuthenticated])
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
@@ -629,7 +712,6 @@ export const useSSE = () => {
|
||||
disconnect,
|
||||
toggleSSEEnabled,
|
||||
isSSEEnabled,
|
||||
// Helper function to check connection status
|
||||
getConnectionStatus: () => {
|
||||
switch (connectionState) {
|
||||
case SSE_STATES.CONNECTING:
|
||||
@@ -641,14 +723,28 @@ export const useSSE = () => {
|
||||
return 'disconnected'
|
||||
}
|
||||
},
|
||||
// Additional debugging information
|
||||
getDebugInfo: () => ({
|
||||
connectionState,
|
||||
reconnectAttempts: reconnectAttemptsRef.current,
|
||||
isCircuitBreakerOpen,
|
||||
isReconnectScheduled: isReconnectScheduledRef.current,
|
||||
lastHeartbeat: lastHeartbeatRef.current,
|
||||
timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current,
|
||||
isManuallyCloseRef: isManuallyClosedRef.current,
|
||||
nextReconnectTime: nextReconnectTimeRef.current,
|
||||
timeUntilReconnect: nextReconnectTimeRef.current
|
||||
? nextReconnectTimeRef.current - Date.now()
|
||||
: null,
|
||||
reconnectIntervals: RECONNECT_INTERVALS,
|
||||
currentReconnectDelay:
|
||||
reconnectAttemptsRef.current < RECONNECT_INTERVALS.length
|
||||
? RECONNECT_INTERVALS[reconnectAttemptsRef.current]
|
||||
: RECONNECT_INTERVALS[RECONNECT_INTERVALS.length - 1],
|
||||
maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||
circuitBreakerResetTime: CIRCUIT_BREAKER_RESET_TIME,
|
||||
heartbeatTimeout: 120000,
|
||||
heartbeatMonitorInterval: 60000,
|
||||
heartbeatMonitorTimeout: 150000,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { API_URL } from '../Config'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import {
|
||||
@@ -8,13 +9,38 @@ import {
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseURL = `${API_URL}/api/v1`
|
||||
this.customServerURL = `${API_URL}/api/v1`
|
||||
this.isRefreshing = false
|
||||
this.failedQueue = []
|
||||
this.lastRefreshTime = 0
|
||||
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initPromise) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
if (this.initialized) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.initPromise = this._doInit()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
async _doInit() {
|
||||
const { value: serverURL } = await Preferences.get({
|
||||
key: 'customServerUrl',
|
||||
})
|
||||
|
||||
this.customServerURL = `${serverURL || API_URL}/api/v1`
|
||||
this.initialized = true
|
||||
}
|
||||
getApiURL() {
|
||||
return this.customServerURL
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
// Check if refresh token is expired BEFORE attempting refresh
|
||||
const refreshExpired = await isRefreshTokenExpired()
|
||||
@@ -106,13 +132,19 @@ class ApiClient {
|
||||
|
||||
// Helper to avoid repeating cleanup code
|
||||
async handleLogout() {
|
||||
logout().then(async () => {
|
||||
await clearAllTokens()
|
||||
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||
}) // fire and forget
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await logout()
|
||||
} catch (e) {
|
||||
console.error('Error during logout', e)
|
||||
}
|
||||
|
||||
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||
// fire and forget
|
||||
}
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
await this.init()
|
||||
const url = `${this.customServerURL}${endpoint}`
|
||||
const config = {
|
||||
// credentials: 'include',
|
||||
...options,
|
||||
@@ -151,25 +183,33 @@ class ApiClient {
|
||||
|
||||
// If already refreshing, just return the queued promise
|
||||
if (this.isRefreshing) {
|
||||
console.log('Token refresh already in progress, queueing request')
|
||||
return queuedPromise
|
||||
}
|
||||
|
||||
// Check if we're within the refresh cooldown period
|
||||
const now = Date.now()
|
||||
if (now - this.lastRefreshTime < this.refreshCooldown) {
|
||||
console.warn('Token refresh attempted too soon, forcing logout')
|
||||
this.processQueue(new Error('Refresh cooldown active'), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
|
||||
// Attempt to refresh the token
|
||||
const refreshResult = await this.refreshToken()
|
||||
|
||||
if (refreshResult.success) {
|
||||
// Process queue with success - this will retry all queued requests
|
||||
this.processQueue(null, refreshResult.token)
|
||||
} else if (refreshResult.error === 'Refresh cooldown active') {
|
||||
// We're in cooldown - token was just refreshed, retry with current token
|
||||
console.log('Refresh cooldown - retrying with current token')
|
||||
const currentToken = this.getToken()
|
||||
if (currentToken) {
|
||||
this.processQueue(null, currentToken)
|
||||
} else {
|
||||
this.processQueue(new Error('No token available'), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
} else if (refreshResult.error === 'Already refreshing') {
|
||||
// This shouldn't happen since we check isRefreshing above, but handle it anyway
|
||||
console.log('Already refreshing - waiting for refresh to complete')
|
||||
return queuedPromise
|
||||
} else {
|
||||
// Refresh failed
|
||||
// Actual refresh failure - logout
|
||||
this.processQueue(new Error(refreshResult.error), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
@@ -223,7 +263,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
getAssetURL(path) {
|
||||
return `${this.baseURL}/assets/${path}`
|
||||
return `${this.customServerURL}/assets/${path}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const HEADERS = () => {
|
||||
}
|
||||
|
||||
const apiManager = {
|
||||
getApiURL: () => apiClient.baseURL,
|
||||
getApiURL: () => apiClient.getApiURL(),
|
||||
}
|
||||
|
||||
const createChore = userID => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
// Token storage keys
|
||||
@@ -16,8 +17,7 @@ const isNativePlatform = () => {
|
||||
if (_isNativePlatform === null) {
|
||||
try {
|
||||
_isNativePlatform =
|
||||
typeof window !== 'undefined' &&
|
||||
window.Capacitor?.isNativePlatform?.()
|
||||
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
|
||||
} catch (error) {
|
||||
console.warn('Platform detection failed, defaulting to web:', error)
|
||||
_isNativePlatform = false
|
||||
@@ -48,9 +48,11 @@ export const saveTokens = async ({
|
||||
if (accessTokenExpiry) {
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry)
|
||||
}
|
||||
|
||||
// On native platforms, also save refresh tokens to Capacitor Preferences
|
||||
if (isNativePlatform()) {
|
||||
if (refreshTokenExpiry) {
|
||||
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry)
|
||||
}
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native platforms, also save refresh tokens to Capacitor Preferences
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await Preferences.set({
|
||||
|
||||
@@ -115,7 +115,7 @@ const LoginSettings = () => {
|
||||
key: 'customServerUrl',
|
||||
value: serverURL,
|
||||
}).then(() => {
|
||||
apiClient.baseURL = serverURL + '/api/v1'
|
||||
apiClient.customServerURL = serverURL + '/api/v1'
|
||||
Navigate('/login')
|
||||
})
|
||||
}}
|
||||
|
||||
537
src/views/Settings/DeveloperSettings.jsx
Normal file
537
src/views/Settings/DeveloperSettings.jsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import { Refresh, Token } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { RefreshToken } from '../../utils/Fetcher'
|
||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
lastEvent,
|
||||
error: sseError,
|
||||
getConnectionStatus,
|
||||
getDebugInfo,
|
||||
} = useSSEContext()
|
||||
|
||||
const [accessTokenExpiry, setAccessTokenExpiry] = useState(null)
|
||||
const [refreshTokenExpiry, setRefreshTokenExpiry] = useState(null)
|
||||
const [timeLeft, setTimeLeft] = useState({
|
||||
access: null,
|
||||
refresh: null,
|
||||
})
|
||||
const [isNativePlatform, setIsNativePlatform] = useState(false)
|
||||
const [sseDebugInfo, setSSEDebugInfo] = useState(null)
|
||||
const [timeSinceLastHeartbeat, setTimeSinceLastHeartbeat] = useState(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
||||
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativePlatform(isNative())
|
||||
|
||||
const loadTokenData = async () => {
|
||||
const accessExpiry = localStorage.getItem('token_expiry')
|
||||
setAccessTokenExpiry(accessExpiry)
|
||||
|
||||
if (isNative()) {
|
||||
const refreshExpiry = await getRefreshTokenExpiry()
|
||||
setRefreshTokenExpiry(refreshExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
loadTokenData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date()
|
||||
|
||||
let accessTime = null
|
||||
if (accessTokenExpiry) {
|
||||
const accessExpiryDate = new Date(accessTokenExpiry)
|
||||
const diff = accessExpiryDate - now
|
||||
accessTime = diff > 0 ? diff : 0
|
||||
}
|
||||
|
||||
let refreshTime = null
|
||||
if (refreshTokenExpiry) {
|
||||
const refreshExpiryDate = new Date(refreshTokenExpiry)
|
||||
const diff = refreshExpiryDate - now
|
||||
refreshTime = diff > 0 ? diff : 0
|
||||
}
|
||||
|
||||
setTimeLeft({
|
||||
access: accessTime,
|
||||
refresh: refreshTime,
|
||||
})
|
||||
|
||||
if (getDebugInfo) {
|
||||
const debugInfo = getDebugInfo()
|
||||
setSSEDebugInfo(debugInfo)
|
||||
setTimeSinceLastHeartbeat(debugInfo.timeSinceLastHeartbeat)
|
||||
}
|
||||
}
|
||||
|
||||
calculateTimeLeft()
|
||||
const interval = setInterval(calculateTimeLeft, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [accessTokenExpiry, refreshTokenExpiry, getDebugInfo])
|
||||
|
||||
const formatTimeLeft = milliseconds => {
|
||||
if (milliseconds === null) return 'N/A'
|
||||
if (milliseconds === 0) return 'Expired'
|
||||
|
||||
const totalSeconds = Math.floor(milliseconds / 1000)
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
const parts = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`)
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const getExpiryStatus = milliseconds => {
|
||||
if (milliseconds === null) return 'neutral'
|
||||
if (milliseconds === 0) return 'danger'
|
||||
if (milliseconds < 5 * 60 * 1000) return 'warning' // Less than 5 minutes
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const handleRefreshToken = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
const result = await apiClient.refreshToken()
|
||||
|
||||
if (result.success) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Token refreshed successfully',
|
||||
})
|
||||
|
||||
// Reload token expiry data
|
||||
const accessExpiry = localStorage.getItem('token_expiry')
|
||||
setAccessTokenExpiry(accessExpiry)
|
||||
|
||||
if (isNativePlatform) {
|
||||
const refreshExpiry = await getRefreshTokenExpiry()
|
||||
setRefreshTokenExpiry(refreshExpiry)
|
||||
}
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Token refresh failed: ${result.error}`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Token refresh error: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDirectRefreshToken = async () => {
|
||||
setIsRefreshingDirect(true)
|
||||
try {
|
||||
const response = await RefreshToken()
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Refresh token endpoint called successfully',
|
||||
})
|
||||
|
||||
// Reload token expiry data
|
||||
const accessExpiry = localStorage.getItem('token_expiry')
|
||||
setAccessTokenExpiry(accessExpiry)
|
||||
|
||||
if (isNativePlatform) {
|
||||
const refreshExpiry = await getRefreshTokenExpiry()
|
||||
setRefreshTokenExpiry(refreshExpiry)
|
||||
}
|
||||
|
||||
console.log('Refresh token response:', data)
|
||||
} else {
|
||||
const error = await response.text()
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Refresh token endpoint failed: ${response.status} ${error}`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Refresh token endpoint error: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsRefreshingDirect(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='developer'>
|
||||
<Typography level='h3'>Developer Settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
View technical information about your authentication tokens and session
|
||||
state. This information is useful for debugging and development
|
||||
purposes.
|
||||
</Typography>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-lg'>Authentication Tokens</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Refresh />}
|
||||
onClick={handleRefreshToken}
|
||||
loading={isRefreshing}
|
||||
disabled={isRefreshing || isRefreshingDirect}
|
||||
>
|
||||
Refresh Token
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Token />}
|
||||
onClick={handleDirectRefreshToken}
|
||||
loading={isRefreshingDirect}
|
||||
disabled={isRefreshing || isRefreshingDirect}
|
||||
>
|
||||
Call Refresh Endpoint
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Access Token
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Time Left:</Typography>
|
||||
<Chip color={getExpiryStatus(timeLeft.access)} variant='soft'>
|
||||
{formatTimeLeft(timeLeft.access)}
|
||||
</Chip>
|
||||
</Box>
|
||||
{accessTokenExpiry && (
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }} color='neutral'>
|
||||
Expires: {new Date(accessTokenExpiry).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Refresh Token
|
||||
</Typography>
|
||||
{isNativePlatform ? (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Time Left:</Typography>
|
||||
<Chip
|
||||
color={getExpiryStatus(timeLeft.refresh)}
|
||||
variant='soft'
|
||||
>
|
||||
{formatTimeLeft(timeLeft.refresh)}
|
||||
</Chip>
|
||||
</Box>
|
||||
{refreshTokenExpiry && (
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }} color='neutral'>
|
||||
Expires: {new Date(refreshTokenExpiry).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Refresh tokens are managed via HTTP-only cookies on web platform
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography level='title-lg'>Platform Information</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography level='body-sm'>
|
||||
Platform:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{isNativePlatform ? 'Native' : 'Web'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography level='title-lg'>Server-Sent Events (SSE)</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Connection Status
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={
|
||||
isConnected ? 'success' : isConnecting ? 'warning' : 'neutral'
|
||||
}
|
||||
variant='soft'
|
||||
>
|
||||
{getConnectionStatus
|
||||
? getConnectionStatus().toUpperCase()
|
||||
: 'Unknown'}
|
||||
</Chip>
|
||||
</Box>
|
||||
{sseError && (
|
||||
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
|
||||
Error: {sseError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Last Event Received
|
||||
</Typography>
|
||||
{lastEvent ? (
|
||||
<>
|
||||
<Typography level='body-sm'>
|
||||
Type:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{lastEvent.type}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral' sx={{ mt: 0.5 }}>
|
||||
Received:{' '}
|
||||
{lastEvent.timestamp
|
||||
? new Date(lastEvent.timestamp).toLocaleString()
|
||||
: 'N/A'}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No events received yet
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Heartbeat Status
|
||||
</Typography>
|
||||
{sseDebugInfo?.lastHeartbeat ? (
|
||||
<>
|
||||
<Typography level='body-sm'>
|
||||
Last Heartbeat:{' '}
|
||||
{new Date(sseDebugInfo.lastHeartbeat).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mt: 0.5 }}>
|
||||
Time Since Last Heartbeat:{' '}
|
||||
<Chip
|
||||
variant='soft'
|
||||
size='sm'
|
||||
color={
|
||||
timeSinceLastHeartbeat > 120000 ? 'warning' : 'success'
|
||||
}
|
||||
>
|
||||
{formatTimeLeft(timeSinceLastHeartbeat)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No heartbeat received yet
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Reconnection Schedule
|
||||
</Typography>
|
||||
{sseDebugInfo ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{sseDebugInfo.nextReconnectTime ? (
|
||||
<>
|
||||
<Typography level='body-sm'>
|
||||
Next Reconnect:{' '}
|
||||
{new Date(
|
||||
sseDebugInfo.nextReconnectTime,
|
||||
).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Time Until Reconnect:{' '}
|
||||
<Chip variant='soft' size='sm' color='warning'>
|
||||
{formatTimeLeft(sseDebugInfo.timeUntilReconnect)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Current Delay:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{formatTimeLeft(sseDebugInfo.currentReconnectDelay)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No reconnection scheduled
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No reconnection information available
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Timeout Configuration
|
||||
</Typography>
|
||||
{sseDebugInfo ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Typography level='body-sm'>
|
||||
Heartbeat Timeout:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{formatTimeLeft(sseDebugInfo.heartbeatTimeout)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Monitor Interval:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{formatTimeLeft(sseDebugInfo.heartbeatMonitorInterval)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Monitor Timeout:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{formatTimeLeft(sseDebugInfo.heartbeatMonitorTimeout)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Circuit Breaker Reset:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{formatTimeLeft(sseDebugInfo.circuitBreakerResetTime)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No timeout information available
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' mb={1}>
|
||||
Debug Information
|
||||
</Typography>
|
||||
{sseDebugInfo ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Typography level='body-sm'>
|
||||
Reconnect Attempts:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{sseDebugInfo.reconnectAttempts} /{' '}
|
||||
{sseDebugInfo.maxReconnectAttempts}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Circuit Breaker:{' '}
|
||||
<Chip
|
||||
variant='soft'
|
||||
size='sm'
|
||||
color={
|
||||
sseDebugInfo.isCircuitBreakerOpen ? 'danger' : 'success'
|
||||
}
|
||||
>
|
||||
{sseDebugInfo.isCircuitBreakerOpen ? 'OPEN' : 'CLOSED'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Connection State:{' '}
|
||||
<Chip variant='soft' size='sm'>
|
||||
{sseDebugInfo.connectionState === 0
|
||||
? 'CONNECTING'
|
||||
: sseDebugInfo.connectionState === 1
|
||||
? 'OPEN'
|
||||
: 'CLOSED'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No debug information available
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeveloperSettings
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Api,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Code,
|
||||
FamilyRestroom,
|
||||
Notifications,
|
||||
Palette,
|
||||
@@ -115,6 +116,13 @@ const SettingsOverview = () => {
|
||||
'Configure webhooks, real-time updates, and other advanced features for enhanced productivity.',
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
title: 'Developer Settings',
|
||||
description:
|
||||
'View technical information about authentication tokens, SSE connections, and debug data.',
|
||||
icon: <Code />,
|
||||
},
|
||||
]
|
||||
|
||||
const handleCardClick = settingId => {
|
||||
|
||||
Reference in New Issue
Block a user