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) => {