diff --git a/app/src/UI/Header/Header.tsx b/app/src/UI/Header/Header.tsx index db5f7a9f4..096616fbe 100644 --- a/app/src/UI/Header/Header.tsx +++ b/app/src/UI/Header/Header.tsx @@ -377,9 +377,9 @@ const LoginOrOutMemo = React.memo(() => { const NetworkStateControl: React.FC = () => { const handleNetworkStateChange = () => { - dispatch(connected ? NetworkActions.offline() : NetworkActions.online()); + dispatch(connected ? NetworkActions.setAdministrativeStatus(false) : NetworkActions.manualReconnect()); }; - const { connected } = useSelector((state) => state.Network); + const connected = useSelector((state) => state.Network.connected); const dispatch = useDispatch(); return (
diff --git a/app/src/constants/alerts/networkAlerts.ts b/app/src/constants/alerts/networkAlerts.ts index 33d96089d..cea80956a 100644 --- a/app/src/constants/alerts/networkAlerts.ts +++ b/app/src/constants/alerts/networkAlerts.ts @@ -30,6 +30,12 @@ const networkAlertMessages: Record = { severity: AlertSeverity.Error, subject: AlertSubjects.Network, autoClose: 10 + }, + automaticReconnectFailed: { + content: 'We were unable to bring you back online. Please try again later.', + severity: AlertSeverity.Error, + subject: AlertSubjects.Network, + autoClose: 10 } }; diff --git a/app/src/constants/misc.ts b/app/src/constants/misc.ts index bd8e38583..0bd5efb6a 100644 --- a/app/src/constants/misc.ts +++ b/app/src/constants/misc.ts @@ -11,3 +11,5 @@ export const MediumDateTimeFormat = 'MMMM D, YYYY, H:mm a'; //January 5, 2020, 3 export const LongDateFormat = 'dddd, MMMM D, YYYY, H:mm a'; //Monday, January 5, 2020, 3:30 pm export const LongDateTimeFormat = 'dddd, MMMM D, YYYY, H:mm a'; //Monday, January 5, 2020, 3:30 pm + +export const HEALTH_ENDPOINT = '/api/misc/version'; diff --git a/app/src/state/actions/network/NetworkActions.ts b/app/src/state/actions/network/NetworkActions.ts index 582d59fe1..3ee59c133 100644 --- a/app/src/state/actions/network/NetworkActions.ts +++ b/app/src/state/actions/network/NetworkActions.ts @@ -2,8 +2,17 @@ import { createAction } from '@reduxjs/toolkit'; class NetworkActions { private static readonly PREFIX = 'NetworkActions'; - static readonly online = createAction(`${this.PREFIX}/online`); static readonly offline = createAction(`${this.PREFIX}/offline`); + static readonly monitorHeartbeat = createAction(`${this.PREFIX}/monitorHeartbeat`, (cancel: boolean = false) => ({ + payload: cancel + })); + static readonly userLostConnection = createAction(`${this.PREFIX}/userLostConnection`); + static readonly manualReconnect = createAction(`${this.PREFIX}/manualReconnect`); + static readonly automaticReconnectFailed = createAction(`${this.PREFIX}/automaticReconnectFailed`); + static readonly checkInitConnection = createAction(`${this.PREFIX}/checkInitConnection`); + static readonly setAdministrativeStatus = createAction(`${this.PREFIX}/toggleAdministrativeStatus`); + static readonly setOperationalStatus = createAction(`${this.PREFIX}/setOperationalStatus`); + static readonly updateConnectionStatus = createAction(`${this.PREFIX}/updateConnectionStatus`); } export default NetworkActions; diff --git a/app/src/state/reducers/alertsAndPrompts.ts b/app/src/state/reducers/alertsAndPrompts.ts index 38ce9e25b..666fec052 100644 --- a/app/src/state/reducers/alertsAndPrompts.ts +++ b/app/src/state/reducers/alertsAndPrompts.ts @@ -20,6 +20,12 @@ const initialState: AlertsAndPromptsState = { const filterDuplicates = (key: keyof AlertMessage, matchValue: any, state: AlertMessage[]): any[] => state.filter((entry) => entry[key] !== matchValue); +const addAlert = (state: AlertMessage[], alert: AlertMessage): AlertMessage[] => [...state, { ...alert, id: nanoid() }]; +const addPrompt = (state: PromptAction[], prompt: PromptAction): PromptAction[] => [ + ...state, + { ...prompt, id: nanoid() } +]; + export function createAlertsAndPromptsReducer( configuration: AppConfig ): (AlertsAndPromptsState, AnyAction) => AlertsAndPromptsState { @@ -43,15 +49,15 @@ export function createAlertsAndPromptsReducer( draftState.prompts = []; } else if (RegExp(Prompt.NEW_PROMPT).exec(action.type)) { const newPrompt: PromptAction = action.payload; - draftState.prompts = [...state.prompts, { ...newPrompt, id: nanoid() }]; + draftState.prompts = addPrompt(state.prompts, newPrompt); } else if (RecordCache.requestCaching.fulfilled.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetCacheSuccess, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheSuccess); } else if (RecordCache.requestCaching.rejected.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetCacheFailed, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheFailed); } else if (RecordCache.deleteCache.rejected.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetDeleteCacheFailed, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetDeleteCacheFailed); } else if (RecordCache.deleteCache.fulfilled.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetDeleteCacheSuccess, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetDeleteCacheSuccess); } }); }; diff --git a/app/src/state/reducers/network.ts b/app/src/state/reducers/network.ts index ed2521638..0c57f7d7b 100644 --- a/app/src/state/reducers/network.ts +++ b/app/src/state/reducers/network.ts @@ -2,18 +2,43 @@ import { createNextState } from '@reduxjs/toolkit'; import { Draft } from 'immer'; import NetworkActions from 'state/actions/network/NetworkActions'; -interface Network { +const HEARTBEATS_TO_FAILURE = 4; + +/** + * @desc Network State properties + * @property { boolean } administrativeStatus user opt-in status for connecting to network + * @property { boolean } connected Combination variable for User wanting network connection, and connection being available + * @property { number } consecutiveHeartbeatFailures current number of concurrent failed health checks + * @property { boolean } operationalStatus status of connection to the API + */ +export interface NetworkState { + administrativeStatus: boolean; connected: boolean; + consecutiveHeartbeatFailures: number; + operationalStatus: boolean; } -function createNetworkReducer(initialStatus: Network) { - const initialState: Network = { - ...initialStatus - }; +const initialState: NetworkState = { + administrativeStatus: true, + connected: true, + consecutiveHeartbeatFailures: 0, + operationalStatus: true +}; +function createNetworkReducer() { return (state = initialState, action) => { - return createNextState(state, (draftState: Draft) => { - if (NetworkActions.online.match(action)) { + return createNextState(state, (draftState: Draft) => { + if (NetworkActions.setAdministrativeStatus.match(action)) { + draftState.administrativeStatus = action.payload; + draftState.consecutiveHeartbeatFailures = HEARTBEATS_TO_FAILURE; + } else if (NetworkActions.updateConnectionStatus.match(action)) { + if (action.payload) { + draftState.consecutiveHeartbeatFailures = 0; + } else { + draftState.consecutiveHeartbeatFailures++; + } + draftState.operationalStatus = draftState.consecutiveHeartbeatFailures < HEARTBEATS_TO_FAILURE; + } else if (NetworkActions.online.match(action)) { draftState.connected = true; } else if (NetworkActions.offline.match(action)) { draftState.connected = false; @@ -23,5 +48,5 @@ function createNetworkReducer(initialStatus: Network) { } const selectNetworkConnected: (state) => boolean = (state) => state.Network.connected; - -export { selectNetworkConnected, createNetworkReducer }; +const selectNetworkState: (state) => NetworkState = (state) => state.Network; +export { createNetworkReducer, selectNetworkConnected, selectNetworkState }; diff --git a/app/src/state/reducers/rootReducer.ts b/app/src/state/reducers/rootReducer.ts index adf6199d0..db094f940 100644 --- a/app/src/state/reducers/rootReducer.ts +++ b/app/src/state/reducers/rootReducer.ts @@ -14,7 +14,7 @@ import { createTrainingVideosReducer } from './training_videos'; import { createUserSettingsReducer, UserSettingsState } from './userSettings'; import { createIAPPSiteReducer } from './iappsite'; import { createConfigurationReducerWithDefaultState } from './configuration'; -import { createNetworkReducer } from './network'; +import { createNetworkReducer, NetworkState } from './network'; import { createUserInfoReducer } from './userInfo'; import { errorHandlerReducer } from './error_handler'; import { createOfflineActivityReducer, OfflineActivityState } from './offlineActivity'; @@ -65,7 +65,15 @@ function createRootReducer(config: AppConfig) { createAuthReducer(config) ), UserInfo: createUserInfoReducer({ loaded: false, accessRequested: false, activated: false }), - Network: createNetworkReducer({ connected: true }), + Network: persistReducer( + { + key: 'network', + storage: platformStorage, + stateReconciler: autoMergeLevel1, + whitelist: ['connected', 'administrativeStatus', 'operationalStatus'] + }, + createNetworkReducer() + ), ActivityPage: persistReducer( { key: 'activity', diff --git a/app/src/state/sagas/network.ts b/app/src/state/sagas/network.ts index 8f7132147..7c564b036 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -1,13 +1,87 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { all, cancelled, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; import networkAlertMessages from 'constants/alerts/networkAlerts'; -import { all, put, select, takeEvery } from 'redux-saga/effects'; +import { HEALTH_ENDPOINT } from 'constants/misc'; import Alerts from 'state/actions/alerts/Alerts'; import NetworkActions from 'state/actions/network/NetworkActions'; +import { MOBILE } from 'state/build-time-config'; +import { selectConfiguration } from 'state/reducers/configuration'; import { OfflineActivitySyncState, selectOfflineActivity } from 'state/reducers/offlineActivity'; +import { selectNetworkState } from 'state/reducers/network'; -function* handle_NETWORK_GO_OFFLINE() { - yield put(Alerts.create(networkAlertMessages.userWentOffline)); +/* Utilities */ + +/** + * @desc Targets the API and checks for successful response. + * @param url Path to API Health check + * @returns Connection to API Succeeded + */ +const canConnectToNetwork = async (url: string): Promise => { + return await fetch(url) + .then((res) => res.ok) + .catch(() => false); +}; + +/** + * @desc Get the time between ticks for polling the heartbeat + * @param heartbeatFailures Current number of failed network attempts + * @returns { number } Greater of two delay values + */ +const getTimeBetweenTicks = (heartbeatFailures: number): number => { + const BASE_SECONDS_BETWEEN_FAILURE = 20; + const BASE_SECONDS_BETWEEN_SUCCESS = 60; + return Math.max( + BASE_SECONDS_BETWEEN_FAILURE * 1000 * Math.pow(1.1, heartbeatFailures), + BASE_SECONDS_BETWEEN_SUCCESS * 1000 + ); +}; + +/* Sagas */ + +/** + * @desc If administrative status enabled at launch, begin monitoring heartbeat + */ +function* handle_CHECK_INIT_CONNECTION() { + const { administrativeStatus } = yield select(selectNetworkState); + if (administrativeStatus) { + yield put(NetworkActions.monitorHeartbeat()); + } } +/** + * @desc Handles Manual reconnect attempt by user. Sets their administrative status to true + */ +function* handle_MANUAL_RECONNECT() { + const configuration = yield select(selectConfiguration); + yield put(NetworkActions.setAdministrativeStatus(true)); + if (yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT)) { + yield put(NetworkActions.monitorHeartbeat()); + } else { + yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); + } +} + +/** + * @desc Rolling function that targets the API to determine our online status. + */ +function* handle_MONITOR_HEARTBEAT(cancel: PayloadAction) { + if (!MOBILE || cancel.payload) { + return; + } + const configuration = yield select(selectConfiguration); + + do { + const { consecutiveHeartbeatFailures } = yield select(selectNetworkState); + const networkRequestSuccess = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); + yield put(NetworkActions.updateConnectionStatus(networkRequestSuccess)); + yield delay(getTimeBetweenTicks(consecutiveHeartbeatFailures)); + } while (!(yield cancelled())); +} + +/** + * @desc When user comes online, check for any existing unsychronized Activities. + * Restart the rolling Network status checks. + */ function* handle_NETWORK_GO_ONLINE() { const { serializedActivities } = yield select(selectOfflineActivity); const userHasUnsynchronizedActivities = Object.keys(serializedActivities).some( @@ -20,10 +94,51 @@ function* handle_NETWORK_GO_ONLINE() { } } +/** + * @desc Handler for Administrative status. + * When enabled, begin polling for heartbeat. + * When disabled, notify user they're offline, cancel heartbeat polling, + * @param newStatus new Administrative status + */ +function* handle_SET_ADMINISTRATIVE_STATUS(newStatus: PayloadAction) { + if (newStatus.payload) { + yield put(NetworkActions.monitorHeartbeat()); + } else { + yield all([ + put(NetworkActions.monitorHeartbeat(true)), // Cancel loop + put(Alerts.create(networkAlertMessages.userWentOffline)), + put(NetworkActions.updateConnectionStatus(true)) + ]); + } +} + +/** + * @desc Handler for updating current connection status given boolean flags. + * In case of match, do nothing. + */ +function* handle_UPDATE_CONNECTION_STATUS() { + const { administrativeStatus, operationalStatus, connected } = yield select(selectNetworkState); + + const networkLive = administrativeStatus && operationalStatus; + const disconnected = connected && administrativeStatus && !operationalStatus; + if (disconnected) { + yield put(Alerts.create(networkAlertMessages.userLostConnection)); + } + if (connected && !networkLive) { + yield put(NetworkActions.offline()); + } else if (!connected && networkLive) { + yield put(NetworkActions.online()); + } +} + function* networkSaga() { yield all([ - takeEvery(NetworkActions.offline, handle_NETWORK_GO_OFFLINE), - takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE) + takeEvery(NetworkActions.manualReconnect, handle_MANUAL_RECONNECT), + takeLatest(NetworkActions.monitorHeartbeat, handle_MONITOR_HEARTBEAT), + takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE), + takeEvery(NetworkActions.setAdministrativeStatus, handle_SET_ADMINISTRATIVE_STATUS), + takeEvery(NetworkActions.updateConnectionStatus, handle_UPDATE_CONNECTION_STATUS), + takeEvery(NetworkActions.checkInitConnection, handle_CHECK_INIT_CONNECTION) ]); } diff --git a/app/src/state/store.ts b/app/src/state/store.ts index f11f88de1..27418a3a1 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -1,4 +1,4 @@ -import { configureStore, ThunkDispatch } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; import createSagaMiddleware from 'redux-saga'; import { createLogger } from 'redux-logger'; import { createBrowserHistory } from 'history'; @@ -19,6 +19,7 @@ import userSettingsSaga from './sagas/userSettings'; import { createSagaCrashHandler } from './sagas/error_handler'; import { AppConfig } from './config'; import { DEBUG } from './build-time-config'; +import NetworkActions from './actions/network/NetworkActions'; const historySingleton = createBrowserHistory(); @@ -79,6 +80,7 @@ export function setupStore(configuration: AppConfig) { sagaMiddleware.run(emailTemplatesSaga); sagaMiddleware.run(networkSaga); + store.dispatch(NetworkActions.checkInitConnection()); store.dispatch({ type: AUTH_INITIALIZE_REQUEST }); historySingleton.listen((location) => {