Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[INV-3715] Implement Heartbeat monitoring for network connectivity #3727

Merged
merged 11 commits into from
Dec 2, 2024
4 changes: 2 additions & 2 deletions app/src/UI/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={'network-state-control'}>
Expand Down
6 changes: 6 additions & 0 deletions app/src/constants/alerts/networkAlerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const networkAlertMessages: Record<string, AlertMessage> = {
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
}
};

Expand Down
2 changes: 2 additions & 0 deletions app/src/constants/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 10 additions & 1 deletion app/src/state/actions/network/NetworkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(`${this.PREFIX}/toggleAdministrativeStatus`);
static readonly setOperationalStatus = createAction<boolean>(`${this.PREFIX}/setOperationalStatus`);
static readonly updateConnectionStatus = createAction<boolean>(`${this.PREFIX}/updateConnectionStatus`);
}
export default NetworkActions;
16 changes: 11 additions & 5 deletions app/src/state/reducers/alertsAndPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
});
};
Expand Down
43 changes: 34 additions & 9 deletions app/src/state/reducers/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Network>) => {
if (NetworkActions.online.match(action)) {
return createNextState(state, (draftState: Draft<NetworkState>) => {
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;
Expand All @@ -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 };
12 changes: 10 additions & 2 deletions app/src/state/reducers/rootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,7 +65,15 @@ function createRootReducer(config: AppConfig) {
createAuthReducer(config)
),
UserInfo: createUserInfoReducer({ loaded: false, accessRequested: false, activated: false }),
Network: createNetworkReducer({ connected: true }),
Network: persistReducer<NetworkState>(
{
key: 'network',
storage: platformStorage,
stateReconciler: autoMergeLevel1,
whitelist: ['connected', 'administrativeStatus', 'operationalStatus']
},
createNetworkReducer()
),
ActivityPage: persistReducer<ActivityState>(
{
key: 'activity',
Expand Down
125 changes: 120 additions & 5 deletions app/src/state/sagas/network.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<boolean>) {
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(
Expand All @@ -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<boolean>) {
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)
]);
}

Expand Down
4 changes: 3 additions & 1 deletion app/src/state/store.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();

Expand Down Expand Up @@ -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) => {
Expand Down
Loading