Skip to content

Commit

Permalink
feat: added notification about changes to the device offline threshold
Browse files Browse the repository at this point in the history
Ticket: MEN-7288
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
  • Loading branch information
mzedel committed Jun 6, 2024
1 parent 6ad4060 commit 955e86f
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 235 deletions.
21 changes: 18 additions & 3 deletions src/js/actions/appActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import moment from 'moment';
import Cookies from 'universal-cookie';

import GeneralApi from '../api/general-api';
Expand All @@ -26,11 +27,11 @@ import {
TIMEOUTS
} from '../constants/appConstants';
import { DEPLOYMENT_STATES } from '../constants/deploymentConstants';
import { DEVICE_STATES } from '../constants/deviceConstants';
import { DEVICE_STATES, timeUnits } from '../constants/deviceConstants';
import { onboardingSteps } from '../constants/onboardingConstants';
import { SET_TOOLTIPS_STATE, SUCCESSFULLY_LOGGED_IN } from '../constants/userConstants';
import { deepCompare, extractErrorMessage, preformatWithRequestID, stringToBoolean } from '../helpers';
import { getFeatures, getIsEnterprise, getOfflineThresholdSettings, getUserSettings as getUserSettingsSelector } from '../selectors';
import { getFeatures, getIsEnterprise, getOfflineThresholdSettings, getUserCapabilities, getUserSettings as getUserSettingsSelector } from '../selectors';
import { getOnboardingComponentFor } from '../utils/onboardingmanager';
import { getDeploymentsByStatus } from './deploymentActions';
import {
Expand All @@ -46,7 +47,7 @@ import {
import { getOnboardingState, setDemoArtifactPort, setOnboardingComplete } from './onboardingActions';
import { getIntegrations, getUserOrganization } from './organizationActions';
import { getReleases } from './releaseActions';
import { getGlobalSettings, getRoles, getUserSettings, saveGlobalSettings, saveUserSettings } from './userActions';
import { getGlobalSettings, getRoles, getUserSettings, saveGlobalSettings, saveUserSettings, setShowStartupNotification } from './userActions';

const cookies = new Cookies();

Expand Down Expand Up @@ -170,6 +171,20 @@ const interpretAppData = () => (dispatch, getState) => {
dispatch(saveUserSettings(settings))
];
tasks = maybeAddOnboardingTasks({ devicesByStatus: state.devices.byStatus, dispatch, tasks, onboardingState: state.onboarding });

const { canManageUsers } = getUserCapabilities(getState());
const { interval, intervalUnit } = getOfflineThresholdSettings(getState());
if (canManageUsers && intervalUnit && intervalUnit !== timeUnits.days) {
const duration = moment.duration(interval, intervalUnit);
const days = duration.asDays();
if (days < 1) {
tasks.push(Promise.resolve(setTimeout(() => dispatch(setShowStartupNotification(true)), TIMEOUTS.fiveSeconds)));
} else {
const roundedDays = Math.max(1, Math.round(days));
tasks.push(dispatch(saveGlobalSettings({ offlineThreshold: { interval: roundedDays, intervalUnit: timeUnits.days } })));
}
}

// the following is used as a migration and initialization of the stored identity attribute
// changing the default device attribute to the first non-deviceId attribute, unless a stored
// id attribute setting exists
Expand Down
198 changes: 126 additions & 72 deletions src/js/actions/appActions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { act } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import { thunk } from 'redux-thunk';

Expand All @@ -26,7 +27,8 @@ import {
SET_SEARCH_STATE,
SET_SNACKBAR,
SET_VERSION_INFORMATION,
SORTING_OPTIONS
SORTING_OPTIONS,
TIMEOUTS
} from '../constants/appConstants';
import {
RECEIVE_DEPLOYMENTS,
Expand All @@ -49,7 +51,8 @@ import {
SET_PENDING_DEVICES,
SET_PREAUTHORIZED_DEVICES,
SET_REJECTED_DEVICES,
UNGROUPED_GROUP
UNGROUPED_GROUP,
timeUnits
} from '../constants/deviceConstants';
import { SET_DEMO_ARTIFACT_PORT, SET_ONBOARDING_COMPLETE } from '../constants/onboardingConstants';
import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, SET_ORGANIZATION } from '../constants/organizationConstants';
Expand All @@ -58,6 +61,7 @@ import {
RECEIVED_PERMISSION_SETS,
RECEIVED_ROLES,
SET_GLOBAL_SETTINGS,
SET_SHOW_STARTUP_NOTIFICATION,
SET_TOOLTIPS_STATE,
SET_USER_SETTINGS,
SUCCESSFULLY_LOGGED_IN
Expand Down Expand Up @@ -288,6 +292,78 @@ export const deviceInitActions2 = [
}
];

const appInitActions = [
{ type: SUCCESSFULLY_LOGGED_IN, value: { token } },
...commonAppInitActions,
{
type: SET_VERSION_INFORMATION,
docsVersion: '',
value: {
GUI: latestSaasReleaseTag,
Integration: '1.2.3',
'Mender-Artifact': '1.3.7',
'Mender-Client': '3.2.1',
backend: latestSaasReleaseTag,
latestRelease: {
releaseDate: '2022-02-02',
repos: {
integration: '1.2.3',
mender: '3.2.1',
'mender-artifact': '1.3.7',
'other-service': '1.1.0',
service: '3.0.0'
}
}
}
},
{ type: SET_ORGANIZATION, organization: defaultState.organization.organization },
{ type: SET_ANNOUNCEMENT, announcement: tenantDataDivergedMessage },
{
type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS,
value: [
{ connection_string: 'something_else', id: 1, provider: EXTERNAL_PROVIDER['iot-hub'].provider },
{ id: 2, provider: 'iot-core', something: 'new' }
]
},
{ type: RECEIVE_RELEASES, releases: defaultState.releases.byId },
{
type: SET_RELEASES_LIST_STATE,
value: { ...defaultState.releases.releasesList, releaseIds: [defaultState.releases.byId.r1.name], page: 42 }
},
{ type: SET_GLOBAL_SETTINGS, settings: { ...defaultState.users.globalSettings } },

...deviceInitActions,
{ type: SET_TOOLTIPS_STATE, value: {} },
{ type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } },
{ type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings, onboarding: defaultOnboardingState } },
{ type: RECEIVE_DEVICES, devicesById: { [expectedDevice.id]: { ...receivedInventoryDevice, group: 'test' } } },
{
type: SET_ACCEPTED_DEVICES,
deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.a1.id],
status: DEVICE_STATES.accepted,
total: defaultState.devices.byStatus.accepted.total
},
{
type: RECEIVE_DEVICES,
devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } }
},
{
type: SET_DEVICE_LIST_STATE,
state: {
...DEVICE_LIST_DEFAULTS,
deviceIds: ['a1', 'a1'],
isLoading: false,
selectedAttributes: [],
selectedIssues: [],
selection: [],
sort: { direction: SORTING_OPTIONS.desc },
state: 'accepted',
total: 2
}
},
...expectedOnboardingActions
];

const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

Expand Down Expand Up @@ -328,83 +404,61 @@ describe('app actions', () => {
},
releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } }
});

const expectedActions = [
{ type: SUCCESSFULLY_LOGGED_IN, value: { token } },
...commonAppInitActions,
{
type: SET_VERSION_INFORMATION,
docsVersion: '',
value: {
GUI: latestSaasReleaseTag,
Integration: '1.2.3',
'Mender-Artifact': '1.3.7',
'Mender-Client': '3.2.1',
backend: latestSaasReleaseTag,
latestRelease: {
releaseDate: '2022-02-02',
repos: {
integration: '1.2.3',
mender: '3.2.1',
'mender-artifact': '1.3.7',
'other-service': '1.1.0',
service: '3.0.0'
}
}
await store.dispatch(initializeAppData());
const storeActions = store.getActions();
expect(storeActions.length).toEqual(appInitActions.length);
appInitActions.map((action, index) => Object.keys(action).map(key => expect(storeActions[index][key]).toEqual(action[key])));
});
it('should execute the offline threshold migration for multi day thresholds', async () => {
const store = mockStore({
...defaultState,
app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } },
users: {
...defaultState.users,
currentSession: getSessionInfo(),
globalSettings: {
...defaultState.users.globalSettings,
id_attribute: { attribute: 'mac', scope: 'identity' },
offlineThreshold: { interval: 48, intervalUnit: timeUnits.hours }
}
},
{ type: SET_ORGANIZATION, organization: defaultState.organization.organization },
{ type: SET_ANNOUNCEMENT, announcement: tenantDataDivergedMessage },
{
type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS,
value: [
{ connection_string: 'something_else', id: 1, provider: EXTERNAL_PROVIDER['iot-hub'].provider },
{ id: 2, provider: 'iot-core', something: 'new' }
]
},
{ type: RECEIVE_RELEASES, releases: defaultState.releases.byId },
{
type: SET_RELEASES_LIST_STATE,
value: { ...defaultState.releases.releasesList, releaseIds: [defaultState.releases.byId.r1.name], page: 42 }
},
{ type: SET_GLOBAL_SETTINGS, settings: { ...defaultState.users.globalSettings } },
releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } }
});
await store.dispatch(initializeAppData());

...deviceInitActions,
{ type: SET_TOOLTIPS_STATE, value: {} },
{ type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } },
{ type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings, onboarding: defaultOnboardingState } },
{ type: RECEIVE_DEVICES, devicesById: { [expectedDevice.id]: { ...receivedInventoryDevice, group: 'test' } } },
{
type: SET_ACCEPTED_DEVICES,
deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.a1.id],
status: DEVICE_STATES.accepted,
total: defaultState.devices.byStatus.accepted.total
},
{
type: RECEIVE_DEVICES,
devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } }
},
{
type: SET_DEVICE_LIST_STATE,
state: {
...DEVICE_LIST_DEFAULTS,
deviceIds: ['a1', 'a1'],
isLoading: false,
selectedAttributes: [],
selectedIssues: [],
selection: [],
sort: { direction: SORTING_OPTIONS.desc },
state: 'accepted',
total: 2
const storeActions = store.getActions();
expect(storeActions.length).toEqual(appInitActions.length + 3); // 3 = get settings + set settings + set offline threshold
const settingStorageAction = storeActions.find(action => action.type === SET_GLOBAL_SETTINGS && action.settings.offlineThreshold);
expect(settingStorageAction.settings.offlineThreshold.interval).toEqual(2);
expect(settingStorageAction.settings.offlineThreshold.intervalUnit).toEqual(timeUnits.days);
});
it('should trigger the offline threshold migration dialog', async () => {
const store = mockStore({
...defaultState,
app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } },
users: {
...defaultState.users,
currentSession: getSessionInfo(),
globalSettings: {
...defaultState.users.globalSettings,
id_attribute: { attribute: 'mac', scope: 'identity' },
offlineThreshold: { interval: 15, intervalUnit: 'minutes' }
}
},
...expectedOnboardingActions
];
releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } }
});
await store.dispatch(initializeAppData());
await act(async () => {
jest.advanceTimersByTime(TIMEOUTS.fiveSeconds + TIMEOUTS.oneSecond);
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
const storeActions = store.getActions();
expect(storeActions.length).toEqual(expectedActions.length);
expectedActions.map((action, index) => Object.keys(action).map(key => expect(storeActions[index][key]).toEqual(action[key])));
expect(storeActions.length).toEqual(appInitActions.length + 1);
const notificationAction = storeActions.find(action => action.type === SET_SHOW_STARTUP_NOTIFICATION);
expect(notificationAction.value).toBeTruthy();
});

it('should pass snackbar information', async () => {
const store = mockStore({ ...defaultState });
const expectedActions = [
Expand Down Expand Up @@ -479,7 +533,7 @@ describe('app actions', () => {
});
it('should calculate yesterdays timestamp', async () => {
const store = mockStore({ ...defaultState });
const expectedActions = [{ type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:00.900Z' }];
const expectedActions = [{ type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:06.900Z' }];
await store.dispatch(setOfflineThreshold());
const storeActions = store.getActions();
expect(storeActions.length).toEqual(expectedActions.length);
Expand Down
2 changes: 2 additions & 0 deletions src/js/actions/userActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,8 @@ export const setHideAnnouncement = (shouldHide, userId) => (dispatch, getState)
return Promise.resolve();
};

export const setShowStartupNotification = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_STARTUP_NOTIFICATION, value: Boolean(show) });

export const getTokens = () => (dispatch, getState) =>
GeneralApi.get(`${useradmApiUrl}/settings/tokens`).then(({ data: tokens }) => {
const user = getCurrentUser(getState());
Expand Down
2 changes: 1 addition & 1 deletion src/js/actions/userActions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const settings = { test: true };
// eslint-disable-next-line no-unused-vars
const { attributes, ...expectedDevice } = defaultState.devices.byId.a1;

const offlineThreshold = { type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:00.900Z' };
const offlineThreshold = { type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:06.900Z' };
const appInitActions = [
{ type: RECEIVED_USER, user: defaultState.users.byId[userId] },
{ type: SET_CUSTOM_COLUMNS, value: [] },
Expand Down
3 changes: 3 additions & 0 deletions src/js/components/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { dark as darkTheme, light as lightTheme } from '../themes/Mender';
import Tracking from '../tracking';
import ConfirmDismissHelptips from './common/dialogs/confirmdismisshelptips';
import DeviceConnectionDialog from './common/dialogs/deviceconnectiondialog';
import StartupNotificationDialog from './common/dialogs/startupnotification';
import Footer from './footer';
import Header from './header/header';
import LeftNav from './leftnav';
Expand Down Expand Up @@ -101,6 +102,7 @@ export const AppRoot = () => {
const { id: currentUser } = useSelector(getCurrentUser);
const showDismissHelptipsDialog = useSelector(state => !state.onboarding.complete && state.onboarding.showTipsDialog);
const showDeviceConnectionDialog = useSelector(state => state.users.showConnectDeviceDialog);
const showStartupNotification = useSelector(state => state.users.showStartupNotification);
const snackbar = useSelector(state => state.app.snackbar);
const trackingCode = useSelector(state => state.app.trackerCode);
const { mode } = useSelector(getUserSettings);
Expand Down Expand Up @@ -200,6 +202,7 @@ export const AppRoot = () => {
</div>
{showDismissHelptipsDialog && <ConfirmDismissHelptips />}
{showDeviceConnectionDialog && <DeviceConnectionDialog onCancel={() => dispatch(setShowConnectingDialog(false))} />}
{showStartupNotification && <StartupNotificationDialog />}
</div>
) : (
<div className={classes.public}>
Expand Down
Loading

0 comments on commit 955e86f

Please sign in to comment.