Skip to content

Commit

Permalink
[INV-3715] Implement Heartbeat monitoring for network connectivity (#…
Browse files Browse the repository at this point in the history
…3727)

* Create actions/handlers to trigger API Checks for network connectivity

* Check network status at app load

* update NetworkActions.ts

* Simplify alertsAndPrompts handling of new entries, add new Alert

* Add workflows for monitoring network connections

* Add manualReconnect path, simplify the rest

* Cleanup unused imports

* Move configuration yield out of while loop

* Adjust network connectivity to use two boolean flags

* Incorporate Feedback, follow more consistently with network hardware devices
  • Loading branch information
LocalNewsTV authored Dec 2, 2024
1 parent 7d780df commit d29e3a4
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 25 deletions.
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

0 comments on commit d29e3a4

Please sign in to comment.