From 463c0d493f0c5bb33c1dbca2d09058d8c2dbad62 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:36:48 -0800 Subject: [PATCH 01/10] Create actions/handlers to trigger API Checks for network connectivity --- app/src/UI/Header/Header.tsx | 2 +- app/src/constants/misc.ts | 2 ++ .../state/actions/network/NetworkActions.ts | 3 +- app/src/state/reducers/network.ts | 2 +- app/src/state/sagas/network.ts | 29 ++++++++++++++++++- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/src/UI/Header/Header.tsx b/app/src/UI/Header/Header.tsx index db5f7a9f4..1314e289e 100644 --- a/app/src/UI/Header/Header.tsx +++ b/app/src/UI/Header/Header.tsx @@ -377,7 +377,7 @@ const LoginOrOutMemo = React.memo(() => { const NetworkStateControl: React.FC = () => { const handleNetworkStateChange = () => { - dispatch(connected ? NetworkActions.offline() : NetworkActions.online()); + dispatch(connected ? NetworkActions.offline() : NetworkActions.checkMobileNetworkStatus()); }; const { connected } = useSelector((state) => state.Network); const dispatch = useDispatch(); 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..f8d07f651 100644 --- a/app/src/state/actions/network/NetworkActions.ts +++ b/app/src/state/actions/network/NetworkActions.ts @@ -2,8 +2,9 @@ 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 checkMobileNetworkStatus = createAction(`${this.PREFIX}/checkMobileNetworkStatus`); + static readonly userLostConnection = createAction(`${this.PREFIX}/userLostConnection`); } export default NetworkActions; diff --git a/app/src/state/reducers/network.ts b/app/src/state/reducers/network.ts index ed2521638..f9268e7ba 100644 --- a/app/src/state/reducers/network.ts +++ b/app/src/state/reducers/network.ts @@ -15,7 +15,7 @@ function createNetworkReducer(initialStatus: Network) { return createNextState(state, (draftState: Draft) => { if (NetworkActions.online.match(action)) { draftState.connected = true; - } else if (NetworkActions.offline.match(action)) { + } else if (NetworkActions.offline.match(action) || NetworkActions.userLostConnection.match(action)) { draftState.connected = false; } }); diff --git a/app/src/state/sagas/network.ts b/app/src/state/sagas/network.ts index 8f7132147..7e2aeb62b 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -1,7 +1,11 @@ import networkAlertMessages from 'constants/alerts/networkAlerts'; +import { HEALTH_ENDPOINT } from 'constants/misc'; import { all, put, select, takeEvery } from 'redux-saga/effects'; 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 { selectNetworkConnected } from 'state/reducers/network'; import { OfflineActivitySyncState, selectOfflineActivity } from 'state/reducers/offlineActivity'; function* handle_NETWORK_GO_OFFLINE() { @@ -20,10 +24,33 @@ function* handle_NETWORK_GO_ONLINE() { } } +function* handle_CHECK_MOBILE_NETWORK_STATUS() { + if (!MOBILE) { + return; + } + const currentOnlineStatus = yield select(selectNetworkConnected); + const configuration = yield select(selectConfiguration); + + const networkCheckPassed = yield fetch(configuration.API_BASE + HEALTH_ENDPOINT) + .then((res) => res.status === 200) + .catch(() => false); + + if (!networkCheckPassed && !currentOnlineStatus) { + yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); + } else if (!networkCheckPassed) { + yield put(NetworkActions.userLostConnection()); + yield put(Alerts.create(networkAlertMessages.userLostConnection)); + } else if (networkCheckPassed && !currentOnlineStatus) { + // Only fire online event if we are not already online + yield put(NetworkActions.online()); + } +} + function* networkSaga() { yield all([ takeEvery(NetworkActions.offline, handle_NETWORK_GO_OFFLINE), - takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE) + takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE), + takeEvery(NetworkActions.checkMobileNetworkStatus, handle_CHECK_MOBILE_NETWORK_STATUS) ]); } From ad0c77448cca97dee5f2ff3aaa8fc41ca9d3d2b9 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 07:09:46 -0800 Subject: [PATCH 02/10] Check network status at app load --- app/src/state/store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/state/store.ts b/app/src/state/store.ts index f11f88de1..f987cf1f8 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.checkMobileNetworkStatus()); store.dispatch({ type: AUTH_INITIALIZE_REQUEST }); historySingleton.listen((location) => { From da52b2c85f1c23063806af3d2831948dfb3e2bb6 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:42:43 -0800 Subject: [PATCH 03/10] update NetworkActions.ts --- app/src/state/actions/network/NetworkActions.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/state/actions/network/NetworkActions.ts b/app/src/state/actions/network/NetworkActions.ts index f8d07f651..360567b6b 100644 --- a/app/src/state/actions/network/NetworkActions.ts +++ b/app/src/state/actions/network/NetworkActions.ts @@ -4,7 +4,13 @@ class NetworkActions { private static readonly PREFIX = 'NetworkActions'; static readonly online = createAction(`${this.PREFIX}/online`); static readonly offline = createAction(`${this.PREFIX}/offline`); - static readonly checkMobileNetworkStatus = createAction(`${this.PREFIX}/checkMobileNetworkStatus`); + static readonly checkMobileNetworkStatus = createAction( + `${this.PREFIX}/checkMobileNetworkStatus`, + (cancel: boolean = false) => ({ payload: cancel }) + ); static readonly userLostConnection = createAction(`${this.PREFIX}/userLostConnection`); + static readonly attemptToReconnectFailed = createAction(`${this.PREFIX}/attemptToReconnectFailed`); + static readonly automaticReconnectFailed = createAction(`${this.PREFIX}/automaticReconnectFailed`); + static readonly checkInitConnection = createAction(`${this.PREFIX}/checkInitConnection`); } export default NetworkActions; From 98a3e2f83bf251bc8c766cabd40a09bdeb880a03 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:43:53 -0800 Subject: [PATCH 04/10] Simplify alertsAndPrompts handling of new entries, add new Alert --- app/src/constants/alerts/networkAlerts.ts | 6 ++++++ app/src/state/reducers/alertsAndPrompts.ts | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) 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/state/reducers/alertsAndPrompts.ts b/app/src/state/reducers/alertsAndPrompts.ts index 38ce9e25b..6d7acf03e 100644 --- a/app/src/state/reducers/alertsAndPrompts.ts +++ b/app/src/state/reducers/alertsAndPrompts.ts @@ -6,6 +6,8 @@ import Prompt from 'state/actions/prompts/Prompt'; import { PromptAction } from 'interfaces/prompt-interfaces'; import RecordCache from 'state/actions/cache/RecordCache'; import cacheAlertMessages from 'constants/alerts/cacheAlerts'; +import NetworkActions from 'state/actions/network/NetworkActions'; +import networkAlertMessages from 'constants/alerts/networkAlerts'; interface AlertsAndPromptsState { alerts: AlertMessage[]; @@ -20,6 +22,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 +51,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); } }); }; From 0c93e6cbc1f8d42c0403dad3de4bbe614facab16 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:45:07 -0800 Subject: [PATCH 05/10] Add workflows for monitoring network connections --- app/src/state/sagas/network.ts | 132 ++++++++++++++++++++++++++++----- app/src/state/store.ts | 2 +- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/app/src/state/sagas/network.ts b/app/src/state/sagas/network.ts index 7e2aeb62b..90f8501ae 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -1,6 +1,7 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { all, cancelled, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; import networkAlertMessages from 'constants/alerts/networkAlerts'; import { HEALTH_ENDPOINT } from 'constants/misc'; -import { all, put, select, takeEvery } from 'redux-saga/effects'; import Alerts from 'state/actions/alerts/Alerts'; import NetworkActions from 'state/actions/network/NetworkActions'; import { MOBILE } from 'state/build-time-config'; @@ -8,10 +9,95 @@ import { selectConfiguration } from 'state/reducers/configuration'; import { selectNetworkConnected } from 'state/reducers/network'; import { OfflineActivitySyncState, selectOfflineActivity } from 'state/reducers/offlineActivity'; +function* handle_ATTEMPT_TO_RECONNECT_FAILED() { + yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); +} + +function* handle_AUTOMATIC_RECONNECT_FAILED() { + yield put(Alerts.create(networkAlertMessages.automaticReconnectFailed)); +} + +/** + * @desc Rolling function that targets the API to determine our online status. + * On failure to reach the API, attempts up to 5 times before determining we cannot proceed + * In event of this, disconnect the user and alert them of the incident. + */ +function* handle_CHECK_MOBILE_NETWORK_STATUS(cancel: PayloadAction) { + if (!MOBILE || cancel.payload) { + return; + } + const MAX_ATTEMPTS = 5; + const SECONDS_BETWEEN_CHECKS = 20; + const SECONDS_BETWEEN_ATTEMPTS = 5; + + while (true) { + const currentOnlineStatus = yield select(selectNetworkConnected); + const configuration = yield select(selectConfiguration); + let attempts: number = 0; + let networkCheckPassed: boolean = false; + + do { + networkCheckPassed = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); + if (!networkCheckPassed) { + attempts++; + yield delay(SECONDS_BETWEEN_ATTEMPTS * 1000); + } + + if (yield cancelled()) { + return; + } + } while (!networkCheckPassed && attempts < MAX_ATTEMPTS); + + if (!networkCheckPassed && !currentOnlineStatus) { + yield put(NetworkActions.attemptToReconnectFailed()); + } else if (!networkCheckPassed) { + yield put(NetworkActions.userLostConnection()); + return; + } else if (networkCheckPassed && !currentOnlineStatus) { + // Only fire online event if we are not already online + yield put(NetworkActions.online()); + } + yield delay(SECONDS_BETWEEN_CHECKS * 1000); + } +} + +/** + * @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.status === 200) + .catch(() => false); +}; + +/** + * Initial Network Connectivity Check, determines to begin application online or offline + */ +function* handle_CHECK_INIT_CONNECTION() { + if (!MOBILE) { + return; + } + const configuration = yield select(selectConfiguration); + if (yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT)) { + yield put(NetworkActions.online()); + } else { + yield put(NetworkActions.offline()); + } +} +/** + * @desc Handler for User manually going offline. Fires a cancellation event to stop rolling api checks. + */ function* handle_NETWORK_GO_OFFLINE() { yield put(Alerts.create(networkAlertMessages.userWentOffline)); + yield put(NetworkActions.checkMobileNetworkStatus(true)); } +/** + * @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( @@ -22,35 +108,47 @@ function* handle_NETWORK_GO_ONLINE() { } else { yield put(Alerts.create(networkAlertMessages.userWentOnline)); } + yield put(NetworkActions.checkMobileNetworkStatus()); } -function* handle_CHECK_MOBILE_NETWORK_STATUS() { - if (!MOBILE) { - return; - } - const currentOnlineStatus = yield select(selectNetworkConnected); +/** + * @desc Attempt to establish connection with the API. Abandons after ~3 minutes of disconnection. + * When this event fires, it cancels the rolling API checks + */ +function* handle_USER_LOST_CONNECTION() { + const MAX_RECONNECT_ATTEMPTS = 18; + const SECONDS_BETWEEN_ATTEMPTS = 10; + const configuration = yield select(selectConfiguration); + let attempts: number = 0; + let networkCheckPassed: boolean = false; - const networkCheckPassed = yield fetch(configuration.API_BASE + HEALTH_ENDPOINT) - .then((res) => res.status === 200) - .catch(() => false); + yield put(Alerts.create(networkAlertMessages.userLostConnection)); - if (!networkCheckPassed && !currentOnlineStatus) { - yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); - } else if (!networkCheckPassed) { - yield put(NetworkActions.userLostConnection()); - yield put(Alerts.create(networkAlertMessages.userLostConnection)); - } else if (networkCheckPassed && !currentOnlineStatus) { - // Only fire online event if we are not already online + do { + networkCheckPassed = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); + if (!networkCheckPassed) { + attempts++; + yield delay(SECONDS_BETWEEN_ATTEMPTS * 1000); + } + } while (!networkCheckPassed && attempts < MAX_RECONNECT_ATTEMPTS); + + if (networkCheckPassed) { yield put(NetworkActions.online()); + } else { + yield put(NetworkActions.automaticReconnectFailed()); } } function* networkSaga() { yield all([ + takeEvery(NetworkActions.attemptToReconnectFailed, handle_ATTEMPT_TO_RECONNECT_FAILED), + takeEvery(NetworkActions.automaticReconnectFailed, handle_AUTOMATIC_RECONNECT_FAILED), + takeEvery(NetworkActions.checkInitConnection, handle_CHECK_INIT_CONNECTION), + takeLatest(NetworkActions.checkMobileNetworkStatus, handle_CHECK_MOBILE_NETWORK_STATUS), takeEvery(NetworkActions.offline, handle_NETWORK_GO_OFFLINE), takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE), - takeEvery(NetworkActions.checkMobileNetworkStatus, handle_CHECK_MOBILE_NETWORK_STATUS) + takeLatest(NetworkActions.userLostConnection, handle_USER_LOST_CONNECTION) ]); } diff --git a/app/src/state/store.ts b/app/src/state/store.ts index f987cf1f8..27418a3a1 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -80,7 +80,7 @@ export function setupStore(configuration: AppConfig) { sagaMiddleware.run(emailTemplatesSaga); sagaMiddleware.run(networkSaga); - store.dispatch(NetworkActions.checkMobileNetworkStatus()); + store.dispatch(NetworkActions.checkInitConnection()); store.dispatch({ type: AUTH_INITIALIZE_REQUEST }); historySingleton.listen((location) => { From 484461979d3a25bd44387d7c97d9cb742582e0a3 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:04:52 -0800 Subject: [PATCH 06/10] Add manualReconnect path, simplify the rest --- app/src/UI/Header/Header.tsx | 2 +- .../state/actions/network/NetworkActions.ts | 2 +- app/src/state/sagas/network.ts | 50 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/src/UI/Header/Header.tsx b/app/src/UI/Header/Header.tsx index 1314e289e..cd28bfdc4 100644 --- a/app/src/UI/Header/Header.tsx +++ b/app/src/UI/Header/Header.tsx @@ -377,7 +377,7 @@ const LoginOrOutMemo = React.memo(() => { const NetworkStateControl: React.FC = () => { const handleNetworkStateChange = () => { - dispatch(connected ? NetworkActions.offline() : NetworkActions.checkMobileNetworkStatus()); + dispatch(connected ? NetworkActions.offline() : NetworkActions.manualReconnect()); }; const { connected } = useSelector((state) => state.Network); const dispatch = useDispatch(); diff --git a/app/src/state/actions/network/NetworkActions.ts b/app/src/state/actions/network/NetworkActions.ts index 360567b6b..d1ff7fa51 100644 --- a/app/src/state/actions/network/NetworkActions.ts +++ b/app/src/state/actions/network/NetworkActions.ts @@ -9,7 +9,7 @@ class NetworkActions { (cancel: boolean = false) => ({ payload: cancel }) ); static readonly userLostConnection = createAction(`${this.PREFIX}/userLostConnection`); - static readonly attemptToReconnectFailed = createAction(`${this.PREFIX}/attemptToReconnectFailed`); + static readonly manualReconnect = createAction(`${this.PREFIX}/manualReconnect`); static readonly automaticReconnectFailed = createAction(`${this.PREFIX}/automaticReconnectFailed`); static readonly checkInitConnection = createAction(`${this.PREFIX}/checkInitConnection`); } diff --git a/app/src/state/sagas/network.ts b/app/src/state/sagas/network.ts index 90f8501ae..4b3e8360e 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -6,13 +6,19 @@ 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 { selectNetworkConnected } from 'state/reducers/network'; import { OfflineActivitySyncState, selectOfflineActivity } from 'state/reducers/offlineActivity'; -function* handle_ATTEMPT_TO_RECONNECT_FAILED() { - yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); +/** + * @desc Handler for a Manual Reconnect attempt by user + */ +function* handle_MANUAL_RECONNECT() { + const configuration = yield select(selectConfiguration); + if (yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT)) { + yield put(NetworkActions.online()); + } else { + yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); + } } - function* handle_AUTOMATIC_RECONNECT_FAILED() { yield put(Alerts.create(networkAlertMessages.automaticReconnectFailed)); } @@ -31,14 +37,13 @@ function* handle_CHECK_MOBILE_NETWORK_STATUS(cancel: PayloadAction) { const SECONDS_BETWEEN_ATTEMPTS = 5; while (true) { - const currentOnlineStatus = yield select(selectNetworkConnected); const configuration = yield select(selectConfiguration); let attempts: number = 0; - let networkCheckPassed: boolean = false; + let canConnect: boolean = false; do { - networkCheckPassed = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); - if (!networkCheckPassed) { + canConnect = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); + if (!canConnect) { attempts++; yield delay(SECONDS_BETWEEN_ATTEMPTS * 1000); } @@ -46,16 +51,11 @@ function* handle_CHECK_MOBILE_NETWORK_STATUS(cancel: PayloadAction) { if (yield cancelled()) { return; } - } while (!networkCheckPassed && attempts < MAX_ATTEMPTS); + } while (!canConnect && attempts < MAX_ATTEMPTS); - if (!networkCheckPassed && !currentOnlineStatus) { - yield put(NetworkActions.attemptToReconnectFailed()); - } else if (!networkCheckPassed) { + if (!canConnect) { yield put(NetworkActions.userLostConnection()); return; - } else if (networkCheckPassed && !currentOnlineStatus) { - // Only fire online event if we are not already online - yield put(NetworkActions.online()); } yield delay(SECONDS_BETWEEN_CHECKS * 1000); } @@ -68,7 +68,7 @@ function* handle_CHECK_MOBILE_NETWORK_STATUS(cancel: PayloadAction) { */ const canConnectToNetwork = async (url: string): Promise => { return await fetch(url) - .then((res) => res.status === 200) + .then((res) => res.ok) .catch(() => false); }; @@ -115,25 +115,25 @@ function* handle_NETWORK_GO_ONLINE() { * @desc Attempt to establish connection with the API. Abandons after ~3 minutes of disconnection. * When this event fires, it cancels the rolling API checks */ -function* handle_USER_LOST_CONNECTION() { +function* handle_ATTEMPT_AUTOMATIC_RECONNECT() { const MAX_RECONNECT_ATTEMPTS = 18; const SECONDS_BETWEEN_ATTEMPTS = 10; const configuration = yield select(selectConfiguration); - let attempts: number = 0; - let networkCheckPassed: boolean = false; + let attempts = 0; + let canReconnect: boolean; yield put(Alerts.create(networkAlertMessages.userLostConnection)); do { - networkCheckPassed = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); - if (!networkCheckPassed) { + canReconnect = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); + if (!canReconnect) { attempts++; yield delay(SECONDS_BETWEEN_ATTEMPTS * 1000); } - } while (!networkCheckPassed && attempts < MAX_RECONNECT_ATTEMPTS); + } while (!canReconnect && attempts < MAX_RECONNECT_ATTEMPTS); - if (networkCheckPassed) { + if (canReconnect) { yield put(NetworkActions.online()); } else { yield put(NetworkActions.automaticReconnectFailed()); @@ -142,13 +142,13 @@ function* handle_USER_LOST_CONNECTION() { function* networkSaga() { yield all([ - takeEvery(NetworkActions.attemptToReconnectFailed, handle_ATTEMPT_TO_RECONNECT_FAILED), + takeEvery(NetworkActions.manualReconnect, handle_MANUAL_RECONNECT), takeEvery(NetworkActions.automaticReconnectFailed, handle_AUTOMATIC_RECONNECT_FAILED), takeEvery(NetworkActions.checkInitConnection, handle_CHECK_INIT_CONNECTION), takeLatest(NetworkActions.checkMobileNetworkStatus, handle_CHECK_MOBILE_NETWORK_STATUS), takeEvery(NetworkActions.offline, handle_NETWORK_GO_OFFLINE), takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE), - takeLatest(NetworkActions.userLostConnection, handle_USER_LOST_CONNECTION) + takeLatest(NetworkActions.userLostConnection, handle_ATTEMPT_AUTOMATIC_RECONNECT) ]); } From cdb73f7fc64c01407079bb34128f84b762df9bcf Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:14:47 -0800 Subject: [PATCH 07/10] Cleanup unused imports --- app/src/state/reducers/alertsAndPrompts.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/state/reducers/alertsAndPrompts.ts b/app/src/state/reducers/alertsAndPrompts.ts index 6d7acf03e..666fec052 100644 --- a/app/src/state/reducers/alertsAndPrompts.ts +++ b/app/src/state/reducers/alertsAndPrompts.ts @@ -6,8 +6,6 @@ import Prompt from 'state/actions/prompts/Prompt'; import { PromptAction } from 'interfaces/prompt-interfaces'; import RecordCache from 'state/actions/cache/RecordCache'; import cacheAlertMessages from 'constants/alerts/cacheAlerts'; -import NetworkActions from 'state/actions/network/NetworkActions'; -import networkAlertMessages from 'constants/alerts/networkAlerts'; interface AlertsAndPromptsState { alerts: AlertMessage[]; From 36327e456a6072c87c96a43740cdd7e6c527b2df Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:36:42 -0800 Subject: [PATCH 08/10] Move configuration yield out of while loop --- app/src/state/sagas/network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/state/sagas/network.ts b/app/src/state/sagas/network.ts index 4b3e8360e..d9fab94a4 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -35,9 +35,9 @@ function* handle_CHECK_MOBILE_NETWORK_STATUS(cancel: PayloadAction) { const MAX_ATTEMPTS = 5; const SECONDS_BETWEEN_CHECKS = 20; const SECONDS_BETWEEN_ATTEMPTS = 5; + const configuration = yield select(selectConfiguration); while (true) { - const configuration = yield select(selectConfiguration); let attempts: number = 0; let canConnect: boolean = false; From b6ec503110cf50c68c86d7df0d470e54711298de Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:01:12 -0800 Subject: [PATCH 09/10] Adjust network connectivity to use two boolean flags --- app/src/state/reducers/network.ts | 45 +++++++++++++++++++++------ app/src/state/reducers/rootReducer.ts | 12 +++++-- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/app/src/state/reducers/network.ts b/app/src/state/reducers/network.ts index f9268e7ba..0c57f7d7b 100644 --- a/app/src/state/reducers/network.ts +++ b/app/src/state/reducers/network.ts @@ -2,20 +2,45 @@ 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) || NetworkActions.userLostConnection.match(action)) { + } 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', From eb37405c4c8a40d5a9ce54f0d019cacb09575816 Mon Sep 17 00:00:00 2001 From: LocalNewsTV <62873746+LocalNewsTV@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:02:18 -0800 Subject: [PATCH 10/10] Incorporate Feedback, follow more consistently with network hardware devices --- app/src/UI/Header/Header.tsx | 4 +- .../state/actions/network/NetworkActions.ts | 10 +- app/src/state/sagas/network.ts | 166 ++++++++---------- 3 files changed, 86 insertions(+), 94 deletions(-) diff --git a/app/src/UI/Header/Header.tsx b/app/src/UI/Header/Header.tsx index cd28bfdc4..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.manualReconnect()); + 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/state/actions/network/NetworkActions.ts b/app/src/state/actions/network/NetworkActions.ts index d1ff7fa51..3ee59c133 100644 --- a/app/src/state/actions/network/NetworkActions.ts +++ b/app/src/state/actions/network/NetworkActions.ts @@ -4,13 +4,15 @@ class NetworkActions { private static readonly PREFIX = 'NetworkActions'; static readonly online = createAction(`${this.PREFIX}/online`); static readonly offline = createAction(`${this.PREFIX}/offline`); - static readonly checkMobileNetworkStatus = createAction( - `${this.PREFIX}/checkMobileNetworkStatus`, - (cancel: boolean = false) => ({ payload: cancel }) - ); + 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/sagas/network.ts b/app/src/state/sagas/network.ts index d9fab94a4..7c564b036 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -7,59 +7,9 @@ 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'; -/** - * @desc Handler for a Manual Reconnect attempt by user - */ -function* handle_MANUAL_RECONNECT() { - const configuration = yield select(selectConfiguration); - if (yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT)) { - yield put(NetworkActions.online()); - } else { - yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); - } -} -function* handle_AUTOMATIC_RECONNECT_FAILED() { - yield put(Alerts.create(networkAlertMessages.automaticReconnectFailed)); -} - -/** - * @desc Rolling function that targets the API to determine our online status. - * On failure to reach the API, attempts up to 5 times before determining we cannot proceed - * In event of this, disconnect the user and alert them of the incident. - */ -function* handle_CHECK_MOBILE_NETWORK_STATUS(cancel: PayloadAction) { - if (!MOBILE || cancel.payload) { - return; - } - const MAX_ATTEMPTS = 5; - const SECONDS_BETWEEN_CHECKS = 20; - const SECONDS_BETWEEN_ATTEMPTS = 5; - const configuration = yield select(selectConfiguration); - - while (true) { - let attempts: number = 0; - let canConnect: boolean = false; - - do { - canConnect = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); - if (!canConnect) { - attempts++; - yield delay(SECONDS_BETWEEN_ATTEMPTS * 1000); - } - - if (yield cancelled()) { - return; - } - } while (!canConnect && attempts < MAX_ATTEMPTS); - - if (!canConnect) { - yield put(NetworkActions.userLostConnection()); - return; - } - yield delay(SECONDS_BETWEEN_CHECKS * 1000); - } -} +/* Utilities */ /** * @desc Targets the API and checks for successful response. @@ -73,25 +23,59 @@ const canConnectToNetwork = async (url: string): Promise => { }; /** - * Initial Network Connectivity Check, determines to begin application online or offline + * @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() { - if (!MOBILE) { - return; + 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.online()); + yield put(NetworkActions.monitorHeartbeat()); } else { - yield put(NetworkActions.offline()); + yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); } } + /** - * @desc Handler for User manually going offline. Fires a cancellation event to stop rolling api checks. + * @desc Rolling function that targets the API to determine our online status. */ -function* handle_NETWORK_GO_OFFLINE() { - yield put(Alerts.create(networkAlertMessages.userWentOffline)); - yield put(NetworkActions.checkMobileNetworkStatus(true)); +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())); } /** @@ -108,47 +92,53 @@ function* handle_NETWORK_GO_ONLINE() { } else { yield put(Alerts.create(networkAlertMessages.userWentOnline)); } - yield put(NetworkActions.checkMobileNetworkStatus()); } /** - * @desc Attempt to establish connection with the API. Abandons after ~3 minutes of disconnection. - * When this event fires, it cancels the rolling API checks + * @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_ATTEMPT_AUTOMATIC_RECONNECT() { - const MAX_RECONNECT_ATTEMPTS = 18; - const SECONDS_BETWEEN_ATTEMPTS = 10; - - const configuration = yield select(selectConfiguration); - let attempts = 0; - let canReconnect: boolean; - - yield put(Alerts.create(networkAlertMessages.userLostConnection)); +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)) + ]); + } +} - do { - canReconnect = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); - if (!canReconnect) { - attempts++; - yield delay(SECONDS_BETWEEN_ATTEMPTS * 1000); - } - } while (!canReconnect && attempts < MAX_RECONNECT_ATTEMPTS); +/** + * @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); - if (canReconnect) { + 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()); - } else { - yield put(NetworkActions.automaticReconnectFailed()); } } function* networkSaga() { yield all([ takeEvery(NetworkActions.manualReconnect, handle_MANUAL_RECONNECT), - takeEvery(NetworkActions.automaticReconnectFailed, handle_AUTOMATIC_RECONNECT_FAILED), - takeEvery(NetworkActions.checkInitConnection, handle_CHECK_INIT_CONNECTION), - takeLatest(NetworkActions.checkMobileNetworkStatus, handle_CHECK_MOBILE_NETWORK_STATUS), - takeEvery(NetworkActions.offline, handle_NETWORK_GO_OFFLINE), + takeLatest(NetworkActions.monitorHeartbeat, handle_MONITOR_HEARTBEAT), takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE), - takeLatest(NetworkActions.userLostConnection, handle_ATTEMPT_AUTOMATIC_RECONNECT) + takeEvery(NetworkActions.setAdministrativeStatus, handle_SET_ADMINISTRATIVE_STATUS), + takeEvery(NetworkActions.updateConnectionStatus, handle_UPDATE_CONNECTION_STATUS), + takeEvery(NetworkActions.checkInitConnection, handle_CHECK_INIT_CONNECTION) ]); }