• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / gui / 1081664682

22 Nov 2023 02:11PM UTC coverage: 82.798% (-17.2%) from 99.964%
1081664682

Pull #4214

gitlab-ci

tranchitella
fix: Fixed the infinite page redirects when the back button is pressed

Remove the location and navigate from the useLocationParams.setValue callback
dependencies as they change the set function that is presented in other
useEffect dependencies. This happens when the back button is clicked, which
leads to the location changing infinitely.

Changelog: Title
Ticket: MEN-6847
Ticket: MEN-6796

Signed-off-by: Ihor Aleksandrychiev <ihor.aleksandrychiev@northern.tech>
Signed-off-by: Fabio Tranchitella <fabio.tranchitella@northern.tech>
Pull Request #4214: fix: Fixed the infinite page redirects when the back button is pressed

4319 of 6292 branches covered (0.0%)

8332 of 10063 relevant lines covered (82.8%)

191.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.95
/src/js/actions/deviceActions.js
1
/*eslint import/namespace: ['error', { allowComputed: true }]*/
2
import React from 'react';
3
import { Link } from 'react-router-dom';
4

5
import { isCancel } from 'axios';
6
import pluralize from 'pluralize';
7
import { v4 as uuid } from 'uuid';
8

9
import { commonErrorFallback, commonErrorHandler, setSnackbar } from '../actions/appActions';
10
import { getSingleDeployment } from '../actions/deploymentActions';
11
import { auditLogsApiUrl } from '../actions/organizationActions';
12
import { cleanUpUpload, progress } from '../actions/releaseActions';
13
import { saveGlobalSettings } from '../actions/userActions';
14
import GeneralApi, { MAX_PAGE_SIZE, apiUrl, headerNames } from '../api/general-api';
15
import { routes, sortingAlternatives } from '../components/devices/base-devices';
16
import { filtersFilter } from '../components/devices/widgets/filters';
17
import { SORTING_OPTIONS, TIMEOUTS, UPLOAD_PROGRESS, emptyChartSelection, yes } from '../constants/appConstants';
18
import * as DeviceConstants from '../constants/deviceConstants';
19
import { rootfsImageVersion } from '../constants/releaseConstants';
20
import { attributeDuplicateFilter, deepCompare, extractErrorMessage, getSnackbarMessage, mapDeviceAttributes } from '../helpers';
21
import {
22
  getDeviceById as getDeviceByIdSelector,
23
  getDeviceFilters,
24
  getDeviceTwinIntegrations,
25
  getGroups as getGroupsSelector,
26
  getIdAttribute,
27
  getTenantCapabilities,
28
  getUserCapabilities,
29
  getUserSettings
30
} from '../selectors';
31
import { chartColorPalette } from '../themes/Mender';
32
import { getDeviceMonitorConfig, getLatestDeviceAlerts } from './monitorActions';
33

34
const { DEVICE_FILTERING_OPTIONS, DEVICE_STATES, DEVICE_LIST_DEFAULTS, UNGROUPED_GROUP, emptyFilter } = DeviceConstants;
183✔
35
const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS;
183✔
36

37
export const deviceAuthV2 = `${apiUrl.v2}/devauth`;
183✔
38
export const deviceConnect = `${apiUrl.v1}/deviceconnect`;
183✔
39
export const inventoryApiUrl = `${apiUrl.v1}/inventory`;
183✔
40
export const inventoryApiUrlV2 = `${apiUrl.v2}/inventory`;
183✔
41
export const deviceConfig = `${apiUrl.v1}/deviceconfig/configurations/device`;
183✔
42
export const reportingApiUrl = `${apiUrl.v1}/reporting`;
183✔
43
export const iotManagerBaseURL = `${apiUrl.v1}/iot-manager`;
183✔
44

45
const defaultAttributes = [
183✔
46
  { scope: 'identity', attribute: 'status' },
47
  { scope: 'inventory', attribute: 'artifact_name' },
48
  { scope: 'inventory', attribute: 'device_type' },
49
  { scope: 'inventory', attribute: 'mender_is_gateway' },
50
  { scope: 'inventory', attribute: 'mender_gateway_system_id' },
51
  { scope: 'inventory', attribute: rootfsImageVersion },
52
  { scope: 'monitor', attribute: 'alerts' },
53
  { scope: 'system', attribute: 'created_ts' },
54
  { scope: 'system', attribute: 'updated_ts' },
55
  { scope: 'system', attribute: 'check_in_time' },
56
  { scope: 'system', attribute: 'group' },
57
  { scope: 'tags', attribute: 'name' }
58
];
59

60
export const getSearchEndpoint = hasReporting => (hasReporting ? `${reportingApiUrl}/devices/search` : `${inventoryApiUrlV2}/filters/search`);
473!
61

62
const getAttrsEndpoint = hasReporting => (hasReporting ? `${reportingApiUrl}/devices/search/attributes` : `${inventoryApiUrlV2}/filters/attributes`);
183✔
63

64
export const getGroups = () => (dispatch, getState) =>
183✔
65
  GeneralApi.get(`${inventoryApiUrl}/groups`).then(res => {
20✔
66
    const state = getState().devices.groups.byId;
20✔
67
    const dynamicGroups = Object.entries(state).reduce((accu, [id, group]) => {
20✔
68
      if (group.id || (group.filters?.length && id !== UNGROUPED_GROUP.id)) {
40!
69
        accu[id] = group;
20✔
70
      }
71
      return accu;
40✔
72
    }, {});
73
    const groups = res.data.reduce((accu, group) => {
20✔
74
      accu[group] = { deviceIds: [], filters: [], total: 0, ...state[group] };
20✔
75
      return accu;
20✔
76
    }, dynamicGroups);
77
    const filters = [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }];
20✔
78
    return Promise.all([
20✔
79
      dispatch({ type: DeviceConstants.RECEIVE_GROUPS, groups }),
80
      dispatch(getDevicesByStatus(undefined, { filterSelection: filters, group: 0, page: 1, perPage: 1 }))
81
    ]).then(promises => {
82
      const ungroupedDevices = promises[promises.length - 1] || [];
20!
83
      const result = ungroupedDevices[ungroupedDevices.length - 1] || {};
20!
84
      if (!result.total) {
20!
85
        return Promise.resolve();
×
86
      }
87
      return Promise.resolve(
20✔
88
        dispatch({
89
          type: DeviceConstants.ADD_DYNAMIC_GROUP,
90
          groupName: UNGROUPED_GROUP.id,
91
          group: {
92
            deviceIds: [],
93
            total: 0,
94
            ...getState().devices.groups.byId[UNGROUPED_GROUP.id],
95
            filters: [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }]
96
          }
97
        })
98
      );
99
    });
100
  });
101

102
export const addDevicesToGroup = (group, deviceIds, isCreation) => dispatch =>
183✔
103
  GeneralApi.patch(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds)
2✔
104
    .then(() => dispatch({ type: DeviceConstants.ADD_TO_GROUP, group, deviceIds }))
2✔
105
    .finally(() => (isCreation ? Promise.resolve(dispatch(getGroups())) : {}));
2✔
106

107
export const removeDevicesFromGroup = (group, deviceIds) => dispatch =>
183✔
108
  GeneralApi.delete(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds).then(() =>
1✔
109
    Promise.all([
1✔
110
      dispatch({
111
        type: DeviceConstants.REMOVE_FROM_GROUP,
112
        group,
113
        deviceIds
114
      }),
115
      dispatch(setSnackbar(`The ${pluralize('devices', deviceIds.length)} ${pluralize('were', deviceIds.length)} removed from the group`, TIMEOUTS.fiveSeconds))
116
    ])
117
  );
118

119
const getGroupNotification = (newGroup, selectedGroup) => {
183✔
120
  const successMessage = 'The group was updated successfully';
3✔
121
  if (newGroup === selectedGroup) {
3✔
122
    return [successMessage, TIMEOUTS.fiveSeconds];
1✔
123
  }
124
  return [
2✔
125
    <>
126
      {successMessage} - <Link to={`/devices?inventory=group:eq:${newGroup}`}>click here</Link> to see it.
127
    </>,
128
    5000,
129
    undefined,
130
    undefined,
131
    () => {}
132
  ];
133
};
134

135
export const addStaticGroup = (group, devices) => (dispatch, getState) =>
183✔
136
  Promise.resolve(
1✔
137
    dispatch(
138
      addDevicesToGroup(
139
        group,
140
        devices.map(({ id }) => id),
1✔
141
        true
142
      )
143
    )
144
  )
145
    .then(() =>
146
      Promise.resolve(
1✔
147
        dispatch({
148
          type: DeviceConstants.ADD_STATIC_GROUP,
149
          group: { deviceIds: [], total: 0, filters: [], ...getState().devices.groups.byId[group] },
150
          groupName: group
151
        })
152
      ).then(() =>
153
        Promise.all([
1✔
154
          dispatch(setDeviceListState({ selectedId: undefined, setOnly: true })),
155
          dispatch(getGroups()),
156
          dispatch(setSnackbar(...getGroupNotification(group, getState().devices.groups.selectedGroup)))
157
        ])
158
      )
159
    )
160
    .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch));
×
161

162
export const removeStaticGroup = groupName => (dispatch, getState) => {
183✔
163
  return GeneralApi.delete(`${inventoryApiUrl}/groups/${groupName}`).then(() => {
1✔
164
    const selectedGroup = getState().devices.groups.selectedGroup === groupName ? undefined : getState().devices.groups.selectedGroup;
1!
165
    // eslint-disable-next-line no-unused-vars
166
    const { [groupName]: removal, ...groups } = getState().devices.groups.byId;
1✔
167
    return Promise.all([
1✔
168
      dispatch({
169
        type: DeviceConstants.REMOVE_STATIC_GROUP,
170
        groups
171
      }),
172
      dispatch(getGroups()),
173
      dispatch(selectGroup(selectedGroup)),
174
      dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds))
175
    ]);
176
  });
177
};
178

179
// for some reason these functions can not be stored in the deviceConstants...
180
const filterProcessors = {
183✔
181
  $gt: val => Number(val) || val,
×
182
  $gte: val => Number(val) || val,
×
183
  $lt: val => Number(val) || val,
5✔
184
  $lte: val => Number(val) || val,
×
185
  $in: val => ('' + val).split(',').map(i => i.trim()),
×
186
  $nin: val => ('' + val).split(',').map(i => i.trim()),
21✔
187
  $exists: yes,
188
  $nexists: () => false
×
189
};
190
const filterAliases = {
183✔
191
  $nexists: { alias: DEVICE_FILTERING_OPTIONS.$exists.key, value: false }
192
};
193
export const mapFiltersToTerms = (filters = []) =>
183!
194
  filters.map(filter => ({
484✔
195
    scope: filter.scope,
196
    attribute: filter.key,
197
    type: filterAliases[filter.operator]?.alias || filter.operator,
968✔
198
    value: filterProcessors.hasOwnProperty(filter.operator) ? filterProcessors[filter.operator](filter.value) : filter.value
484✔
199
  }));
200
export const mapTermsToFilters = (terms = []) =>
183!
201
  terms.map(term => {
16✔
202
    const aliasedFilter = Object.entries(filterAliases).find(
48✔
203
      aliasDefinition => aliasDefinition[1].alias === term.type && aliasDefinition[1].value === term.value
48✔
204
    );
205
    const operator = aliasedFilter ? aliasedFilter[0] : term.type;
48✔
206
    return { scope: term.scope, key: term.attribute, operator, value: term.value };
48✔
207
  });
208

209
export const getDynamicGroups = () => (dispatch, getState) =>
183✔
210
  GeneralApi.get(`${inventoryApiUrlV2}/filters?per_page=${MAX_PAGE_SIZE}`)
16✔
211
    .then(({ data: filters }) => {
212
      const state = getState().devices.groups.byId;
16✔
213
      const staticGroups = Object.entries(state).reduce((accu, [id, group]) => {
16✔
214
        if (!(group.id || group.filters?.length)) {
32✔
215
          accu[id] = group;
16✔
216
        }
217
        return accu;
32✔
218
      }, {});
219
      const groups = (filters || []).reduce((accu, filter) => {
16!
220
        accu[filter.name] = {
16✔
221
          deviceIds: [],
222
          total: 0,
223
          ...state[filter.name],
224
          id: filter.id,
225
          filters: mapTermsToFilters(filter.terms)
226
        };
227
        return accu;
16✔
228
      }, staticGroups);
229
      return Promise.resolve(dispatch({ type: DeviceConstants.RECEIVE_DYNAMIC_GROUPS, groups }));
16✔
230
    })
231
    .catch(() => console.log('Dynamic group retrieval failed - likely accessing a non-enterprise backend'));
×
232

233
export const addDynamicGroup = (groupName, filterPredicates) => (dispatch, getState) =>
183✔
234
  GeneralApi.post(`${inventoryApiUrlV2}/filters`, { name: groupName, terms: mapFiltersToTerms(filterPredicates) })
2✔
235
    .then(res =>
236
      Promise.resolve(
2✔
237
        dispatch({
238
          type: DeviceConstants.ADD_DYNAMIC_GROUP,
239
          groupName,
240
          group: {
241
            deviceIds: [],
242
            total: 0,
243
            ...getState().devices.groups.byId[groupName],
244
            id: res.headers[headerNames.location].substring(res.headers[headerNames.location].lastIndexOf('/') + 1),
245
            filters: filterPredicates
246
          }
247
        })
248
      ).then(() => {
249
        const { cleanedFilters } = getGroupFilters(groupName, getState().devices.groups);
2✔
250
        return Promise.all([
2✔
251
          dispatch(setDeviceFilters(cleanedFilters)),
252
          dispatch(setSnackbar(...getGroupNotification(groupName, getState().devices.groups.selectedGroup))),
253
          dispatch(getDynamicGroups())
254
        ]);
255
      })
256
    )
257
    .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch));
×
258

259
export const updateDynamicGroup = (groupName, filterPredicates) => (dispatch, getState) => {
183✔
260
  const filterId = getState().devices.groups.byId[groupName].id;
1✔
261
  return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() => Promise.resolve(dispatch(addDynamicGroup(groupName, filterPredicates))));
1✔
262
};
263

264
export const removeDynamicGroup = groupName => (dispatch, getState) => {
183✔
265
  let groups = { ...getState().devices.groups.byId };
1✔
266
  const filterId = groups[groupName].id;
1✔
267
  const selectedGroup = getState().devices.groups.selectedGroup === groupName ? undefined : getState().devices.groups.selectedGroup;
1!
268
  return Promise.all([GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`), dispatch(selectGroup(selectedGroup))]).then(() => {
1✔
269
    delete groups[groupName];
1✔
270
    return Promise.all([
1✔
271
      dispatch({
272
        type: DeviceConstants.REMOVE_DYNAMIC_GROUP,
273
        groups
274
      }),
275
      dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds))
276
    ]);
277
  });
278
};
279
/*
280
 * Device inventory functions
281
 */
282
const getGroupFilters = (group, groupsState, filters = []) => {
183✔
283
  const groupName = group === UNGROUPED_GROUP.id || group === UNGROUPED_GROUP.name ? UNGROUPED_GROUP.id : group;
10!
284
  const selectedGroup = groupsState.byId[groupName];
10✔
285
  const groupFilterLength = selectedGroup?.filters?.length || 0;
10✔
286
  const cleanedFilters = groupFilterLength ? [...filters, ...selectedGroup.filters].filter(filtersFilter) : filters;
10✔
287
  return { cleanedFilters, groupName, selectedGroup, groupFilterLength };
10✔
288
};
289

290
export const selectGroup =
291
  (group, filters = []) =>
183✔
292
  (dispatch, getState) => {
5✔
293
    const { cleanedFilters, groupName, selectedGroup, groupFilterLength } = getGroupFilters(group, getState().devices.groups, filters);
5✔
294
    const state = getState();
5✔
295
    if (state.devices.groups.selectedGroup === groupName && ((filters.length === 0 && !groupFilterLength) || filters.length === cleanedFilters.length)) {
5!
296
      return Promise.resolve();
2✔
297
    }
298
    let tasks = [];
3✔
299
    if (groupFilterLength) {
3✔
300
      tasks.push(dispatch(setDeviceFilters(cleanedFilters)));
2✔
301
    } else {
302
      tasks.push(dispatch(setDeviceFilters(filters)));
1✔
303
      tasks.push(dispatch(getGroupDevices(groupName, { perPage: 1, shouldIncludeAllStates: true })));
1✔
304
    }
305
    const selectedGroupName = selectedGroup || !Object.keys(state.devices.groups.byId).length ? groupName : undefined;
3!
306
    tasks.push(dispatch({ type: DeviceConstants.SELECT_GROUP, group: selectedGroupName }));
3✔
307
    return Promise.all(tasks);
3✔
308
  };
309

310
const getEarliestTs = (dateA = '', dateB = '') => (!dateA || !dateB ? dateA || dateB : dateA < dateB ? dateA : dateB);
224!
311

312
const reduceReceivedDevices = (devices, ids, state, status) =>
183✔
313
  devices.reduce(
107✔
314
    (accu, device) => {
315
      const stateDevice = getDeviceByIdSelector(state, device.id);
112✔
316
      const {
317
        attributes: storedAttributes = {},
×
318
        identity_data: storedIdentity = {},
×
319
        monitor: storedMonitor = {},
96✔
320
        tags: storedTags = {},
96✔
321
        group: storedGroup
322
      } = stateDevice;
112✔
323
      const { identity, inventory, monitor, system = {}, tags } = mapDeviceAttributes(device.attributes);
112!
324
      device.tags = { ...storedTags, ...tags };
112✔
325
      device.group = system.group ?? storedGroup;
112✔
326
      device.monitor = { ...storedMonitor, ...monitor };
112✔
327
      device.identity_data = { ...storedIdentity, ...identity, ...(device.identity_data ? device.identity_data : {}) };
112✔
328
      device.status = status ? status : device.status || identity.status;
112✔
329
      device.check_in_time = device.check_in_time ?? stateDevice.check_in_time;
112✔
330
      device.created_ts = getEarliestTs(getEarliestTs(system.created_ts, device.created_ts), stateDevice.created_ts);
112✔
331
      device.updated_ts = device.attributes ? device.updated_ts : stateDevice.updated_ts;
112✔
332
      device.isNew = new Date(device.created_ts) > new Date(state.app.newThreshold);
112✔
333
      device.isOffline = new Date(device.check_in_time) < new Date(state.app.offlineThreshold);
112✔
334
      // all the other mapped attributes return as empty objects if there are no attributes to map, but identity will be initialized with an empty state
335
      // for device_type and artifact_name, potentially overwriting existing info, so rely on stored information instead if there are no attributes
336
      device.attributes = device.attributes ? { ...storedAttributes, ...inventory } : storedAttributes;
112✔
337
      accu.devicesById[device.id] = { ...stateDevice, ...device };
112✔
338
      accu.ids.push(device.id);
112✔
339
      return accu;
112✔
340
    },
341
    { ids, devicesById: {} }
342
  );
343

344
export const getGroupDevices =
345
  (group, options = {}) =>
183✔
346
  (dispatch, getState) => {
3✔
347
    const { shouldIncludeAllStates, ...remainder } = options;
3✔
348
    const { cleanedFilters: filterSelection } = getGroupFilters(group, getState().devices.groups);
3✔
349
    return Promise.resolve(
3✔
350
      dispatch(getDevicesByStatus(shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted, { ...remainder, filterSelection, group }))
3✔
351
    ).then(results => {
352
      if (!group) {
3✔
353
        return Promise.resolve();
1✔
354
      }
355
      const { deviceAccu, total } = results[results.length - 1];
2✔
356
      const stateGroup = getState().devices.groups.byId[group];
2✔
357
      if (!stateGroup && !total && !deviceAccu.ids.length) {
2!
358
        return Promise.resolve();
×
359
      }
360
      return Promise.resolve(
2✔
361
        dispatch({
362
          type: DeviceConstants.RECEIVE_GROUP_DEVICES,
363
          group: {
364
            filters: [],
365
            ...stateGroup,
366
            deviceIds: deviceAccu.ids.length === total || deviceAccu.ids.length > stateGroup?.deviceIds ? deviceAccu.ids : stateGroup.deviceIds,
6!
367
            total
368
          },
369
          groupName: group
370
        })
371
      );
372
    });
373
  };
374

375
export const getAllGroupDevices = (group, shouldIncludeAllStates) => (dispatch, getState) => {
183✔
376
  if (!group || (!!group && (!getState().devices.groups.byId[group] || getState().devices.groups.byId[group].filters.length))) {
9✔
377
    return Promise.resolve();
8✔
378
  }
379
  const { attributes, filterTerms } = prepareSearchArguments({
1✔
380
    filters: [],
381
    group,
382
    state: getState(),
383
    status: shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted
1!
384
  });
385
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) =>
1✔
386
    GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), {
1✔
387
      page,
388
      per_page: perPage,
389
      filters: filterTerms,
390
      attributes
391
    }).then(res => {
392
      const state = getState();
1✔
393
      const deviceAccu = reduceReceivedDevices(res.data, devices, state);
1✔
394
      dispatch({
1✔
395
        type: DeviceConstants.RECEIVE_DEVICES,
396
        devicesById: deviceAccu.devicesById
397
      });
398
      const total = Number(res.headers[headerNames.total]);
1✔
399
      if (total > perPage * page) {
1!
400
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
401
      }
402
      return Promise.resolve(
1✔
403
        dispatch({
404
          type: DeviceConstants.RECEIVE_GROUP_DEVICES,
405
          group: {
406
            filters: [],
407
            ...state.devices.groups.byId[group],
408
            deviceIds: deviceAccu.ids,
409
            total: deviceAccu.ids.length
410
          },
411
          groupName: group
412
        })
413
      );
414
    });
415
  return getAllDevices();
1✔
416
};
417

418
export const getAllDynamicGroupDevices = group => (dispatch, getState) => {
183✔
419
  if (!!group && (!getState().devices.groups.byId[group] || !getState().devices.groups.byId[group].filters.length)) {
9✔
420
    return Promise.resolve();
8✔
421
  }
422
  const { attributes, filterTerms: filters } = prepareSearchArguments({
1✔
423
    filters: getState().devices.groups.byId[group].filters,
424
    state: getState(),
425
    status: DEVICE_STATES.accepted
426
  });
427
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) =>
1✔
428
    GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { page, per_page: perPage, filters, attributes }).then(res => {
1✔
429
      const state = getState();
1✔
430
      const deviceAccu = reduceReceivedDevices(res.data, devices, state);
1✔
431
      dispatch({
1✔
432
        type: DeviceConstants.RECEIVE_DEVICES,
433
        devicesById: deviceAccu.devicesById
434
      });
435
      const total = Number(res.headers[headerNames.total]);
1✔
436
      if (total > deviceAccu.ids.length) {
1!
437
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
438
      }
439
      return Promise.resolve(
1✔
440
        dispatch({
441
          type: DeviceConstants.RECEIVE_GROUP_DEVICES,
442
          group: {
443
            ...state.devices.groups.byId[group],
444
            deviceIds: deviceAccu.ids,
445
            total
446
          },
447
          groupName: group
448
        })
449
      );
450
    });
451
  return getAllDevices();
1✔
452
};
453

454
export const setDeviceFilters = filters => (dispatch, getState) => {
183✔
455
  if (deepCompare(filters, getDeviceFilters(getState()))) {
6✔
456
    return Promise.resolve();
2✔
457
  }
458
  return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_FILTERS, filters }));
4✔
459
};
460

461
export const getDeviceById = id => (dispatch, getState) =>
183✔
462
  GeneralApi.get(`${inventoryApiUrl}/devices/${id}`)
6✔
463
    .then(res => {
464
      const device = reduceReceivedDevices([res.data], [], getState()).devicesById[id];
5✔
465
      device.etag = res.headers.etag;
5✔
466
      dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device });
5✔
467
      return Promise.resolve(device);
5✔
468
    })
469
    .catch(err => {
470
      const errMsg = extractErrorMessage(err);
×
471
      if (errMsg.includes('Not Found')) {
×
472
        console.log(`${id} does not have any inventory information`);
×
473
        const device = reduceReceivedDevices(
×
474
          [
475
            {
476
              id,
477
              attributes: [
478
                { name: 'status', value: 'decomissioned', scope: 'identity' },
479
                { name: 'decomissioned', value: 'true', scope: 'inventory' }
480
              ]
481
            }
482
          ],
483
          [],
484
          getState()
485
        ).devicesById[id];
486
        dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device });
×
487
      }
488
    });
489

490
export const getDeviceInfo = deviceId => (dispatch, getState) => {
183✔
491
  const device = getState().devices.byId[deviceId] || {};
2!
492
  const { hasDeviceConfig, hasDeviceConnect, hasMonitor } = getTenantCapabilities(getState());
2✔
493
  const { canConfigure } = getUserCapabilities(getState());
2✔
494
  const integrations = getDeviceTwinIntegrations(getState());
2✔
495
  let tasks = [dispatch(getDeviceAuth(deviceId)), ...integrations.map(integration => dispatch(getDeviceTwin(deviceId, integration)))];
2✔
496
  if (hasDeviceConfig && canConfigure && [DEVICE_STATES.accepted, DEVICE_STATES.preauth].includes(device.status)) {
2✔
497
    tasks.push(dispatch(getDeviceConfig(deviceId)));
1✔
498
  }
499
  if (device.status === DEVICE_STATES.accepted) {
2!
500
    // Get full device identity details for single selected device
501
    tasks.push(dispatch(getDeviceById(deviceId)));
2✔
502
    if (hasDeviceConnect) {
2!
503
      tasks.push(dispatch(getDeviceConnect(deviceId)));
2✔
504
    }
505
    if (hasMonitor) {
2✔
506
      tasks.push(dispatch(getLatestDeviceAlerts(deviceId)));
1✔
507
      tasks.push(dispatch(getDeviceMonitorConfig(deviceId)));
1✔
508
    }
509
  }
510
  return Promise.all(tasks);
2✔
511
};
512

513
const deriveInactiveDevices = deviceIds => (dispatch, getState) => {
183✔
514
  const yesterday = new Date();
1✔
515
  yesterday.setDate(yesterday.getDate() - 1);
1✔
516
  const yesterdaysIsoString = yesterday.toISOString();
1✔
517
  const state = getState().devices;
1✔
518
  // now boil the list down to the ones that were not updated since yesterday
519
  const devices = deviceIds.reduce(
1✔
520
    (accu, id) => {
521
      const device = state.byId[id];
2✔
522
      if (device && device.updated_ts > yesterdaysIsoString) {
2!
523
        accu.active.push(id);
×
524
      } else {
525
        accu.inactive.push(id);
2✔
526
      }
527
      return accu;
2✔
528
    },
529
    { active: [], inactive: [] }
530
  );
531
  return dispatch({
1✔
532
    type: DeviceConstants.SET_INACTIVE_DEVICES,
533
    activeDeviceTotal: devices.active.length,
534
    inactiveDeviceTotal: devices.inactive.length
535
  });
536
};
537

538
/*
539
    Device Auth + admission
540
  */
541
export const getDeviceCount = status => (dispatch, getState) =>
406✔
542
  GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), {
406✔
543
    page: 1,
544
    per_page: 1,
545
    filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]),
546
    attributes: defaultAttributes
547
  }).then(response => {
548
    const count = Number(response.headers[headerNames.total]);
402✔
549
    switch (status) {
402!
550
      case DEVICE_STATES.accepted:
551
      case DEVICE_STATES.pending:
552
      case DEVICE_STATES.preauth:
553
      case DEVICE_STATES.rejected:
554
        return dispatch({ type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], count, status });
402✔
555
      default:
556
        return dispatch({ type: DeviceConstants.SET_TOTAL_DEVICES, count });
×
557
    }
558
  });
559

560
export const getAllDeviceCounts = () => dispatch =>
187✔
561
  Promise.all([DEVICE_STATES.accepted, DEVICE_STATES.pending].map(status => dispatch(getDeviceCount(status))));
374✔
562

563
export const getDeviceLimit = () => dispatch =>
183✔
564
  GeneralApi.get(`${deviceAuthV2}/limits/max_devices`).then(res =>
4✔
565
    dispatch({
4✔
566
      type: DeviceConstants.SET_DEVICE_LIMIT,
567
      limit: res.data.limit
568
    })
569
  );
570

571
export const setDeviceListState =
572
  (selectionState, shouldSelectDevices = true, forceRefresh) =>
183✔
573
  (dispatch, getState) => {
15✔
574
    const currentState = getState().devices.deviceList;
15✔
575
    const refreshTrigger = forceRefresh ? !currentState.refreshTrigger : selectionState.refreshTrigger;
15!
576
    let nextState = {
15✔
577
      ...currentState,
578
      setOnly: false,
579
      refreshTrigger,
580
      ...selectionState,
581
      sort: { ...currentState.sort, ...selectionState.sort }
582
    };
583
    let tasks = [];
15✔
584
    // eslint-disable-next-line no-unused-vars
585
    const { isLoading: currentLoading, deviceIds: currentDevices, selection: currentSelection, ...currentRequestState } = currentState;
15✔
586
    // eslint-disable-next-line no-unused-vars
587
    const { isLoading: nextLoading, deviceIds: nextDevices, selection: nextSelection, ...nextRequestState } = nextState;
15✔
588
    if (!nextState.setOnly && !deepCompare(currentRequestState, nextRequestState)) {
15✔
589
      const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol, scope: sortScope } = nextState.sort ?? {};
12!
590
      const sortBy = sortCol ? [{ attribute: sortCol, order: sortDown, scope: sortScope }] : undefined;
12!
591
      if (sortCol && sortingAlternatives[sortCol]) {
12!
592
        sortBy.push({ ...sortBy[0], attribute: sortingAlternatives[sortCol] });
×
593
      }
594
      const applicableSelectedState = nextState.state === routes.allDevices.key ? undefined : nextState.state;
12!
595
      nextState.isLoading = true;
12✔
596
      tasks.push(
12✔
597
        dispatch(getDevicesByStatus(applicableSelectedState, { ...nextState, sortOptions: sortBy }))
598
          .then(results => {
599
            const { deviceAccu, total } = results[results.length - 1];
10✔
600
            const devicesState = shouldSelectDevices
10!
601
              ? { ...getState().devices.deviceList, deviceIds: deviceAccu.ids, total, isLoading: false }
602
              : { ...getState().devices.deviceList, isLoading: false };
603
            return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: devicesState }));
10✔
604
          })
605
          // whatever happens, change "loading" back to null
606
          .catch(() =>
607
            Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { ...getState().devices.deviceList, isLoading: false } }))
×
608
          )
609
      );
610
    }
611
    tasks.push(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: nextState }));
15✔
612
    return Promise.all(tasks);
15✔
613
  };
614

615
const convertIssueOptionsToFilters = (issuesSelection, filtersState = {}) =>
183!
616
  issuesSelection.map(item => {
64✔
617
    if (typeof DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule.value === 'function') {
11✔
618
      return { ...DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule, value: DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule.value(filtersState) };
5✔
619
    }
620
    return DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule;
6✔
621
  });
622

623
export const convertDeviceListStateToFilters = ({ filters = [], group, groups = { byId: {} }, offlineThreshold, selectedIssues = [], status }) => {
183!
624
  let applicableFilters = [...filters];
64✔
625
  if (typeof group === 'string' && !(groups.byId[group]?.filters || applicableFilters).length) {
64✔
626
    applicableFilters.push({ key: 'group', value: group, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'system' });
4✔
627
  }
628
  const nonMonitorFilters = applicableFilters.filter(
64✔
629
    filter =>
630
      !Object.values(DeviceConstants.DEVICE_ISSUE_OPTIONS).some(
33✔
631
        ({ filterRule }) => filter.scope !== 'inventory' && filterRule.scope === filter.scope && filterRule.key === filter.key
198✔
632
      )
633
  );
634
  const deviceIssueFilters = convertIssueOptionsToFilters(selectedIssues, { offlineThreshold });
64✔
635
  applicableFilters = [...nonMonitorFilters, ...deviceIssueFilters];
64✔
636
  const effectiveFilters = status
64✔
637
    ? [...applicableFilters, { key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]
638
    : applicableFilters;
639
  return { applicableFilters: nonMonitorFilters, filterTerms: mapFiltersToTerms(effectiveFilters) };
64✔
640
};
641

642
// get devices from inventory
643
export const getDevicesByStatus =
644
  (status, options = {}) =>
183✔
645
  (dispatch, getState) => {
50✔
646
    const { filterSelection, group, selectedIssues = [], page = defaultPage, perPage = defaultPerPage, sortOptions = [], selectedAttributes = [] } = options;
50✔
647
    const { applicableFilters, filterTerms } = convertDeviceListStateToFilters({
50✔
648
      filters: filterSelection ?? getDeviceFilters(getState()),
77✔
649
      group: group ?? getState().devices.groups.selectedGroup,
78✔
650
      groups: getState().devices.groups,
651
      offlineThreshold: getState().app.offlineThreshold,
652
      selectedIssues,
653
      status
654
    });
655
    const attributes = [...defaultAttributes, { scope: 'identity', attribute: getIdAttribute(getState()).attribute || 'id' }, ...selectedAttributes];
50!
656
    return GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), {
50✔
657
      page,
658
      per_page: perPage,
659
      filters: filterTerms,
660
      sort: sortOptions,
661
      attributes
662
    })
663
      .then(response => {
664
        const state = getState();
48✔
665
        const deviceAccu = reduceReceivedDevices(response.data, [], state, status);
48✔
666
        let total = !applicableFilters.length ? Number(response.headers[headerNames.total]) : null;
48✔
667
        if (status && state.devices.byStatus[status].total === deviceAccu.ids.length) {
48✔
668
          total = deviceAccu.ids.length;
23✔
669
        }
670
        let tasks = [
48✔
671
          dispatch({
672
            type: DeviceConstants.RECEIVE_DEVICES,
673
            devicesById: deviceAccu.devicesById
674
          })
675
        ];
676
        if (status) {
48✔
677
          tasks.push(
27✔
678
            dispatch({
679
              type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES`],
680
              deviceIds: deviceAccu.ids,
681
              status,
682
              total
683
            })
684
          );
685
        }
686
        // for each device, get device identity info
687
        const receivedDevices = Object.values(deviceAccu.devicesById);
48✔
688
        if (receivedDevices.length) {
48✔
689
          tasks.push(dispatch(getDevicesWithAuth(receivedDevices)));
42✔
690
        }
691
        tasks.push(Promise.resolve({ deviceAccu, total: Number(response.headers[headerNames.total]) }));
48✔
692
        return Promise.all(tasks);
48✔
693
      })
694
      .catch(err => commonErrorHandler(err, `${status} devices couldn't be loaded.`, dispatch, commonErrorFallback));
×
695
  };
696

697
export const getAllDevicesByStatus = status => (dispatch, getState) => {
183✔
698
  const attributes = [...defaultAttributes, { scope: 'identity', attribute: getIdAttribute(getState()).attribute || 'id' }];
1!
699
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = 1, devices = []) =>
1✔
700
    GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), {
1✔
701
      page,
702
      per_page: perPage,
703
      filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]),
704
      attributes
705
    }).then(res => {
706
      const state = getState();
1✔
707
      const deviceAccu = reduceReceivedDevices(res.data, devices, state, status);
1✔
708
      dispatch({
1✔
709
        type: DeviceConstants.RECEIVE_DEVICES,
710
        devicesById: deviceAccu.devicesById
711
      });
712
      const total = Number(res.headers[headerNames.total]);
1✔
713
      if (total > state.deployments.deploymentDeviceLimit) {
1!
714
        return Promise.resolve();
×
715
      }
716
      if (total > perPage * page) {
1!
717
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
718
      }
719
      let tasks = [
1✔
720
        dispatch({
721
          type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES`],
722
          deviceIds: deviceAccu.ids,
723
          forceUpdate: true,
724
          status,
725
          total: deviceAccu.ids.length
726
        })
727
      ];
728
      if (status === DEVICE_STATES.accepted && deviceAccu.ids.length === total) {
1!
729
        tasks.push(dispatch(deriveInactiveDevices(deviceAccu.ids)));
1✔
730
      }
731
      return Promise.all(tasks);
1✔
732
    });
733
  return getAllDevices();
1✔
734
};
735

736
export const searchDevices =
737
  (passedOptions = {}) =>
183!
738
  (dispatch, getState) => {
2✔
739
    const state = getState();
2✔
740
    let options = { ...state.app.searchState, ...passedOptions };
2✔
741
    const { page = defaultPage, searchTerm, sortOptions = [] } = options;
2✔
742
    const { columnSelection = [] } = getUserSettings(state);
2!
743
    const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope }));
2✔
744
    const attributes = attributeDuplicateFilter(
2✔
745
      [...defaultAttributes, { scope: 'identity', attribute: getIdAttribute(state).attribute }, ...selectedAttributes],
746
      'attribute'
747
    );
748
    return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), {
2✔
749
      page,
750
      per_page: 10,
751
      filters: [],
752
      sort: sortOptions,
753
      text: searchTerm,
754
      attributes
755
    })
756
      .then(response => {
757
        const deviceAccu = reduceReceivedDevices(response.data, [], getState());
2✔
758
        return Promise.all([
2✔
759
          dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById: deviceAccu.devicesById }),
760
          Promise.resolve({ deviceIds: deviceAccu.ids, searchTotal: Number(response.headers[headerNames.total]) })
761
        ]);
762
      })
763
      .catch(err => commonErrorHandler(err, `devices couldn't be searched.`, dispatch, commonErrorFallback));
×
764
  };
765

766
const ATTRIBUTE_LIST_CUTOFF = 100;
183✔
767
const attributeReducer = (attributes = []) =>
183!
768
  attributes.slice(0, ATTRIBUTE_LIST_CUTOFF).reduce(
15✔
769
    (accu, { name, scope }) => {
770
      if (!accu[scope]) {
300!
771
        accu[scope] = [];
×
772
      }
773
      accu[scope].push(name);
300✔
774
      return accu;
300✔
775
    },
776
    { identity: [], inventory: [], system: [], tags: [] }
777
  );
778

779
export const getDeviceAttributes = () => (dispatch, getState) =>
183✔
780
  GeneralApi.get(getAttrsEndpoint(getState().app.features.hasReporting)).then(({ data }) => {
14✔
781
    // TODO: remove the array fallback once the inventory attributes endpoint is fixed
782
    const { identity: identityAttributes, inventory: inventoryAttributes, system: systemAttributes, tags: tagAttributes } = attributeReducer(data || []);
13!
783
    return dispatch({
13✔
784
      type: DeviceConstants.SET_FILTER_ATTRIBUTES,
785
      attributes: { identityAttributes, inventoryAttributes, systemAttributes, tagAttributes }
786
    });
787
  });
788

789
export const getReportingLimits = () => dispatch =>
183✔
790
  GeneralApi.get(`${reportingApiUrl}/devices/attributes`)
2✔
791
    .catch(err => commonErrorHandler(err, `filterable attributes limit & usage could not be retrieved.`, dispatch, commonErrorFallback))
×
792
    .then(({ data }) => {
793
      const { attributes, count, limit } = data;
2✔
794
      const groupedAttributes = attributeReducer(attributes);
2✔
795
      return Promise.resolve(dispatch({ type: DeviceConstants.SET_FILTERABLES_CONFIG, count, limit, attributes: groupedAttributes }));
2✔
796
    });
797

798
export const ensureVersionString = (software, fallback) =>
183✔
799
  software.length && software !== 'artifact_name' ? (software.endsWith('.version') ? software : `${software}.version`) : fallback;
1!
800

801
const getSingleReportData = (reportConfig, groups) => {
183✔
802
  const { attribute, group, software = '' } = reportConfig;
1!
803
  const filters = [{ key: 'status', scope: 'identity', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: 'accepted' }];
1✔
804
  if (group) {
1!
805
    const staticGroupFilter = { key: 'group', scope: 'system', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: group };
×
806
    const { cleanedFilters: groupFilters } = getGroupFilters(group, groups);
×
807
    filters.push(...(groupFilters.length ? groupFilters : [staticGroupFilter]));
×
808
  }
809
  const aggregationAttribute = ensureVersionString(software, attribute);
1✔
810
  return GeneralApi.post(`${reportingApiUrl}/devices/aggregate`, {
1✔
811
    aggregations: [{ attribute: aggregationAttribute, name: '*', scope: 'inventory', size: chartColorPalette.length }],
812
    filters: mapFiltersToTerms(filters)
813
  }).then(({ data }) => ({ data, reportConfig }));
1✔
814
};
815

816
export const defaultReportType = 'distribution';
183✔
817
export const defaultReports = [{ ...emptyChartSelection, group: null, attribute: 'artifact_name', type: defaultReportType }];
183✔
818

819
export const getReportsData = () => (dispatch, getState) => {
183✔
820
  const state = getState();
1✔
821
  const reports =
822
    getUserSettings(state).reports ||
1✔
823
    state.users.globalSettings[`${state.users.currentUser}-reports`] ||
824
    (Object.keys(state.devices.byId).length ? defaultReports : []);
1!
825
  return Promise.all(reports.map(report => getSingleReportData(report, getState().devices.groups))).then(results => {
1✔
826
    const devicesState = getState().devices;
1✔
827
    const totalDeviceCount = devicesState.byStatus.accepted.total;
1✔
828
    const newReports = results.map(({ data, reportConfig }) => {
1✔
829
      let { items, other_count } = data[0];
1✔
830
      const { attribute, group, software = '' } = reportConfig;
1!
831
      const dataCount = items.reduce((accu, item) => accu + item.count, 0);
2✔
832
      // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software
833
      const otherCount = !group && (software === rootfsImageVersion || attribute === 'artifact_name') ? totalDeviceCount - dataCount : other_count;
1!
834
      return { items, otherCount, total: otherCount + dataCount };
1✔
835
    });
836
    return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_REPORTS, reports: newReports }));
1✔
837
  });
838
};
839

840
const initializeDistributionData = (report, groups, devices, totalDeviceCount) => {
183✔
841
  const { attribute, group = '', software = '' } = report;
8!
842
  const effectiveAttribute = software ? software : attribute;
8!
843
  const { deviceIds, total = 0 } = groups[group] || {};
8✔
844
  const relevantDevices = groups[group] ? deviceIds.map(id => devices[id]) : Object.values(devices);
8!
845
  const distributionByAttribute = relevantDevices.reduce((accu, item) => {
8✔
846
    if (!item.attributes || item.status !== DEVICE_STATES.accepted) return accu;
24✔
847
    if (!accu[item.attributes[effectiveAttribute]]) {
16!
848
      accu[item.attributes[effectiveAttribute]] = 0;
16✔
849
    }
850
    accu[item.attributes[effectiveAttribute]] = accu[item.attributes[effectiveAttribute]] + 1;
16✔
851
    return accu;
16✔
852
  }, {});
853
  const distributionByAttributeSorted = Object.entries(distributionByAttribute).sort((pairA, pairB) => pairB[1] - pairA[1]);
8✔
854
  const items = distributionByAttributeSorted.map(([key, count]) => ({ key, count }));
16✔
855
  const dataCount = items.reduce((accu, item) => accu + item.count, 0);
16✔
856
  // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software
857
  const otherCount = (groups[group] ? total : totalDeviceCount) - dataCount;
8!
858
  return { items, otherCount, total: otherCount + dataCount };
8✔
859
};
860

861
export const deriveReportsData = () => (dispatch, getState) =>
183✔
862
  Promise.all([dispatch(getGroups()), dispatch(getDynamicGroups())]).then(() => {
8✔
863
    const { dynamic: dynamicGroups, static: staticGroups } = getGroupsSelector(getState());
8✔
864
    return Promise.all([
8✔
865
      ...staticGroups.map(group => dispatch(getAllGroupDevices(group))),
8✔
866
      ...dynamicGroups.map(group => dispatch(getAllDynamicGroupDevices(group)))
8✔
867
    ]).then(() => {
868
      const state = getState();
8✔
869
      const {
870
        groups: { byId: groupsById },
871
        byId,
872
        byStatus: {
873
          accepted: { total }
874
        }
875
      } = state.devices;
8✔
876
      const reports =
877
        getUserSettings(state).reports || state.users.globalSettings[`${state.users.currentUser}-reports`] || (Object.keys(byId).length ? defaultReports : []);
8!
878
      const newReports = reports.map(report => initializeDistributionData(report, groupsById, byId, total));
8✔
879
      return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_REPORTS, reports: newReports }));
8✔
880
    });
881
  });
882

883
export const getDeviceConnect = id => dispatch =>
183✔
884
  GeneralApi.get(`${deviceConnect}/devices/${id}`).then(({ data }) => {
2✔
885
    let tasks = [
1✔
886
      dispatch({
887
        type: DeviceConstants.RECEIVE_DEVICE_CONNECT,
888
        device: { connect_status: data.status, connect_updated_ts: data.updated_ts, id }
889
      })
890
    ];
891
    tasks.push(Promise.resolve(data));
1✔
892
    return Promise.all(tasks);
1✔
893
  });
894

895
export const getSessionDetails = (sessionId, deviceId, userId, startDate, endDate) => () => {
183✔
896
  const createdAfter = startDate ? `&created_after=${Math.round(Date.parse(startDate) / 1000)}` : '';
7✔
897
  const createdBefore = endDate ? `&created_before=${Math.round(Date.parse(endDate) / 1000)}` : '';
7✔
898
  const objectSearch = `&object_id=${deviceId}`;
7✔
899
  return GeneralApi.get(`${auditLogsApiUrl}/logs?per_page=500${createdAfter}${createdBefore}&actor_id=${userId}${objectSearch}`).then(
7✔
900
    ({ data: auditLogEntries }) => {
901
      const { start, end } = auditLogEntries.reduce(
7✔
902
        (accu, item) => {
903
          if (item.meta?.session_id?.includes(sessionId)) {
7!
904
            accu.start = new Date(item.action.startsWith('open') ? item.time : accu.start);
7!
905
            accu.end = new Date(item.action.startsWith('close') ? item.time : accu.end);
7!
906
          }
907
          return accu;
7✔
908
        },
909
        { start: startDate || endDate, end: endDate || startDate }
21✔
910
      );
911
      return Promise.resolve({ start, end });
7✔
912
    }
913
  );
914
};
915

916
export const getDeviceFileDownloadLink = (deviceId, path) => () =>
183✔
917
  Promise.resolve(`${deviceConnect}/devices/${deviceId}/download?path=${encodeURIComponent(path)}`);
1✔
918

919
export const deviceFileUpload = (deviceId, path, file) => (dispatch, getState) => {
183✔
920
  var formData = new FormData();
1✔
921
  formData.append('path', path);
1✔
922
  formData.append('file', file);
1✔
923
  const uploadId = uuid();
1✔
924
  const cancelSource = new AbortController();
1✔
925
  const uploads = { ...getState().app.uploads, [uploadId]: { inprogress: true, uploadProgress: 0, cancelSource } };
1✔
926
  return Promise.all([
1✔
927
    dispatch(setSnackbar('Uploading file')),
928
    dispatch({ type: UPLOAD_PROGRESS, uploads }),
929
    GeneralApi.uploadPut(`${deviceConnect}/devices/${deviceId}/upload`, formData, e => dispatch(progress(e, uploadId)), cancelSource.signal)
×
930
  ])
931
    .then(() => Promise.resolve(dispatch(setSnackbar('Upload successful', TIMEOUTS.fiveSeconds))))
1✔
932
    .catch(err => {
933
      if (isCancel(err)) {
×
934
        return dispatch(setSnackbar('The upload has been cancelled', TIMEOUTS.fiveSeconds));
×
935
      }
936
      return commonErrorHandler(err, `Error uploading file to device.`, dispatch);
×
937
    })
938
    .finally(() => dispatch(cleanUpUpload(uploadId)));
1✔
939
};
940

941
export const getDeviceAuth = id => dispatch =>
183✔
942
  Promise.resolve(dispatch(getDevicesWithAuth([{ id }]))).then(results => {
6✔
943
    if (results[results.length - 1]) {
5!
944
      return Promise.resolve(results[results.length - 1][0]);
5✔
945
    }
946
    return Promise.resolve();
×
947
  });
948

949
export const getDevicesWithAuth = devices => (dispatch, getState) =>
183✔
950
  devices.length
50✔
951
    ? GeneralApi.get(`${deviceAuthV2}/devices?id=${devices.map(device => device.id).join('&id=')}`)
50✔
952
        .then(({ data: receivedDevices }) => {
953
          const { devicesById } = reduceReceivedDevices(receivedDevices, [], getState());
47✔
954
          return Promise.all([dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById }), Promise.resolve(receivedDevices)]);
47✔
955
        })
956
        .catch(err => commonErrorHandler(err, `Error: ${err}`, dispatch))
×
957
    : Promise.resolve([[], []]);
958

959
const maybeUpdateDevicesByStatus = (deviceId, authId) => (dispatch, getState) => {
183✔
960
  const devicesState = getState().devices;
4✔
961
  const device = devicesState.byId[deviceId];
4✔
962
  const hasMultipleAuthSets = authId ? device.auth_sets.filter(authset => authset.id !== authId).length > 0 : false;
4✔
963
  if (!hasMultipleAuthSets && Object.values(DEVICE_STATES).includes(device.status)) {
4!
964
    const deviceIds = devicesState.byStatus[device.status].deviceIds.filter(id => id !== deviceId);
8✔
965
    return Promise.resolve(
4✔
966
      dispatch({
967
        type: DeviceConstants[`SET_${device.status.toUpperCase()}_DEVICES`],
968
        deviceIds,
969
        forceUpdate: true,
970
        status: device.status,
971
        total: Math.max(0, devicesState.byStatus[device.status].total - 1)
972
      })
973
    );
974
  }
975
  return Promise.resolve();
×
976
};
977

978
export const updateDeviceAuth = (deviceId, authId, status) => (dispatch, getState) =>
183✔
979
  GeneralApi.put(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}/status`, { status })
2✔
980
    .then(() => Promise.all([dispatch(getDeviceAuth(deviceId)), dispatch(setSnackbar('Device authorization status was updated successfully'))]))
2✔
981
    .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch))
×
982
    .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId))))
2✔
983
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })));
2✔
984

985
export const updateDevicesAuth = (deviceIds, status) => (dispatch, getState) => {
183✔
986
  let devices = getState().devices.byId;
1✔
987
  const deviceIdsWithoutAuth = deviceIds.reduce((accu, id) => (devices[id].auth_sets ? accu : [...accu, { id }]), []);
2!
988
  return dispatch(getDevicesWithAuth(deviceIdsWithoutAuth)).then(() => {
1✔
989
    devices = getState().devices.byId;
1✔
990
    // for each device, get id and id of authset & make api call to accept
991
    // if >1 authset, skip instead
992
    const deviceAuthUpdates = deviceIds.map(id => {
1✔
993
      const device = devices[id];
2✔
994
      if (device.auth_sets.length !== 1) {
2✔
995
        return Promise.reject();
1✔
996
      }
997
      // api call device.id and device.authsets[0].id
998
      return dispatch(updateDeviceAuth(device.id, device.auth_sets[0].id, status)).catch(err =>
1✔
999
        commonErrorHandler(err, 'The action was stopped as there was a problem updating a device authorization status: ', dispatch)
×
1000
      );
1001
    });
1002
    return Promise.allSettled(deviceAuthUpdates).then(results => {
1✔
1003
      const { skipped, count } = results.reduce(
1✔
1004
        (accu, item) => {
1005
          if (item.status === 'rejected') {
2✔
1006
            accu.skipped = accu.skipped + 1;
1✔
1007
          } else {
1008
            accu.count = accu.count + 1;
1✔
1009
          }
1010
          return accu;
2✔
1011
        },
1012
        { skipped: 0, count: 0 }
1013
      );
1014
      const message = getSnackbarMessage(skipped, count);
1✔
1015
      // break if an error occurs, display status up til this point before error message
1016
      return dispatch(setSnackbar(message));
1✔
1017
    });
1018
  });
1019
};
1020

1021
export const deleteAuthset = (deviceId, authId) => (dispatch, getState) =>
183✔
1022
  GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}`)
1✔
1023
    .then(() => Promise.all([dispatch(setSnackbar('Device authorization status was updated successfully'))]))
1✔
1024
    .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch))
×
1025
    .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId))))
1✔
1026
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })));
1✔
1027

1028
export const preauthDevice = authset => dispatch =>
183✔
1029
  GeneralApi.post(`${deviceAuthV2}/devices`, authset)
4✔
1030
    .catch(err => {
1031
      if (err.response.status === 409) {
1!
1032
        return Promise.reject('A device with a matching identity data set already exists');
1✔
1033
      }
1034
      commonErrorHandler(err, 'The device could not be added:', dispatch);
×
1035
      return Promise.reject();
×
1036
    })
1037
    .then(() => Promise.resolve(dispatch(setSnackbar('Device was successfully added to the preauthorization list', TIMEOUTS.fiveSeconds))));
3✔
1038

1039
export const decommissionDevice = (deviceId, authId) => (dispatch, getState) =>
183✔
1040
  GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}`)
1✔
1041
    .then(() => Promise.resolve(dispatch(setSnackbar('Device was decommissioned successfully'))))
1✔
1042
    .catch(err => commonErrorHandler(err, 'There was a problem decommissioning the device:', dispatch))
×
1043
    .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId))))
1✔
1044
    // trigger reset of device list list!
1045
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })));
1✔
1046

1047
export const getDeviceConfig = deviceId => dispatch =>
183✔
1048
  GeneralApi.get(`${deviceConfig}/${deviceId}`)
5✔
1049
    .then(({ data }) => {
1050
      let tasks = [
2✔
1051
        dispatch({
1052
          type: DeviceConstants.RECEIVE_DEVICE_CONFIG,
1053
          device: { id: deviceId, config: data }
1054
        })
1055
      ];
1056
      tasks.push(Promise.resolve(data));
2✔
1057
      return Promise.all(tasks);
2✔
1058
    })
1059
    .catch(err => {
1060
      // if we get a proper error response we most likely queried a device without an existing config check-in and we can just ignore the call
1061
      if (err.response?.data?.error.status_code !== 404) {
1!
1062
        return commonErrorHandler(err, `There was an error retrieving the configuration for device ${deviceId}.`, dispatch, commonErrorFallback);
×
1063
      }
1064
    });
1065

1066
export const setDeviceConfig = (deviceId, config) => dispatch =>
183✔
1067
  GeneralApi.put(`${deviceConfig}/${deviceId}`, config)
4✔
1068
    .catch(err => commonErrorHandler(err, `There was an error setting the configuration for device ${deviceId}.`, dispatch, commonErrorFallback))
2✔
1069
    .then(() => Promise.resolve(dispatch(getDeviceConfig(deviceId))));
2✔
1070

1071
export const applyDeviceConfig = (deviceId, configDeploymentConfiguration, isDefault, config) => (dispatch, getState) =>
183✔
1072
  GeneralApi.post(`${deviceConfig}/${deviceId}/deploy`, configDeploymentConfiguration)
2✔
1073
    .catch(err => commonErrorHandler(err, `There was an error deploying the configuration to device ${deviceId}.`, dispatch, commonErrorFallback))
×
1074
    .then(({ data }) => {
1075
      const device = getDeviceByIdSelector(getState(), deviceId);
2✔
1076
      let tasks = [
2✔
1077
        dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device: { ...device, config: { ...device.config, deployment_id: '' } } }),
1078
        new Promise(resolve => setTimeout(() => resolve(dispatch(getSingleDeployment(data.deployment_id))), TIMEOUTS.oneSecond))
2✔
1079
      ];
1080
      if (isDefault) {
2!
1081
        const { previous } = getState().users.globalSettings.defaultDeviceConfig ?? {};
×
1082
        tasks.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: config, previous } })));
×
1083
      }
1084
      return Promise.all(tasks);
2✔
1085
    });
1086

1087
export const setDeviceTags = (deviceId, tags) => dispatch =>
183✔
1088
  // to prevent tag set failures, retrieve the device & use the freshest etag we can get
1089
  Promise.resolve(dispatch(getDeviceById(deviceId))).then(device => {
3✔
1090
    const headers = device.etag ? { 'If-Match': device.etag } : {};
3!
1091
    return GeneralApi.put(
3✔
1092
      `${inventoryApiUrl}/devices/${deviceId}/tags`,
1093
      Object.entries(tags).map(([name, value]) => ({ name, value })),
3✔
1094
      { headers }
1095
    )
1096
      .catch(err => commonErrorHandler(err, `There was an error setting tags for device ${deviceId}.`, dispatch, 'Please check your connection.'))
×
1097
      .then(() => Promise.all([dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device: { ...device, tags } }), dispatch(setSnackbar('Device name changed'))]));
2✔
1098
  });
1099

1100
export const getDeviceTwin = (deviceId, integration) => (dispatch, getState) => {
183✔
1101
  let providerResult = {};
3✔
1102
  return GeneralApi.get(`${iotManagerBaseURL}/devices/${deviceId}/state`)
3✔
1103
    .then(({ data }) => {
1104
      providerResult = { ...data, twinError: '' };
2✔
1105
    })
1106
    .catch(err => {
1107
      providerResult = {
×
1108
        twinError: `There was an error getting the ${DeviceConstants.EXTERNAL_PROVIDER[
1109
          integration.provider
1110
        ].twinTitle.toLowerCase()} for device ${deviceId}. ${err}`
1111
      };
1112
    })
1113
    .finally(() =>
1114
      Promise.resolve(
2✔
1115
        dispatch({
1116
          type: DeviceConstants.RECEIVE_DEVICE,
1117
          device: {
1118
            ...getState().devices.byId[deviceId],
1119
            twinsByIntegration: {
1120
              ...getState().devices.byId[deviceId].twinsByIntegration,
1121
              ...providerResult
1122
            }
1123
          }
1124
        })
1125
      )
1126
    );
1127
};
1128

1129
export const setDeviceTwin = (deviceId, integration, settings) => (dispatch, getState) =>
183✔
1130
  GeneralApi.put(`${iotManagerBaseURL}/devices/${deviceId}/state/${integration.id}`, { desired: settings })
1✔
1131
    .catch(err =>
1132
      commonErrorHandler(
×
1133
        err,
1134
        `There was an error updating the ${DeviceConstants.EXTERNAL_PROVIDER[integration.provider].twinTitle.toLowerCase()} for device ${deviceId}.`,
1135
        dispatch
1136
      )
1137
    )
1138
    .then(() => {
1139
      const { twinsByIntegration = {} } = getState().devices.byId[deviceId];
1✔
1140
      const { [integration.id]: currentState = {} } = twinsByIntegration;
1✔
1141
      return Promise.resolve(
1✔
1142
        dispatch({
1143
          type: DeviceConstants.RECEIVE_DEVICE,
1144
          device: {
1145
            ...getState().devices.byId[deviceId],
1146
            twinsByIntegration: {
1147
              ...twinsByIntegration,
1148
              [integration.id]: {
1149
                ...currentState,
1150
                desired: settings
1151
              }
1152
            }
1153
          }
1154
        })
1155
      );
1156
    });
1157

1158
const prepareSearchArguments = ({ filters, group, state, status }) => {
183✔
1159
  const { filterTerms } = convertDeviceListStateToFilters({ filters, group, offlineThreshold: state.app.offlineThreshold, selectedIssues: [], status });
4✔
1160
  const { columnSelection = [] } = getUserSettings(state);
4!
1161
  const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope }));
4✔
1162
  const attributes = [...defaultAttributes, { scope: 'identity', attribute: getIdAttribute(state).attribute }, ...selectedAttributes];
4✔
1163
  return { attributes, filterTerms };
4✔
1164
};
1165

1166
export const getSystemDevices =
1167
  (id, options = {}) =>
183✔
1168
  (dispatch, getState) => {
1✔
1169
    const { page = defaultPage, perPage = defaultPerPage, sortOptions = [] } = options;
1✔
1170
    const state = getState();
1✔
1171
    let device = getDeviceByIdSelector(state, id);
1✔
1172
    const { attributes: deviceAttributes = {} } = device;
1!
1173
    const { mender_gateway_system_id = '' } = deviceAttributes;
1✔
1174
    const { hasFullFiltering } = getTenantCapabilities(state);
1✔
1175
    if (!hasFullFiltering) {
1!
1176
      return Promise.resolve();
×
1177
    }
1178
    const filters = [
1✔
1179
      { ...emptyFilter, key: 'mender_is_gateway', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: 'true', scope: 'inventory' },
1180
      { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' }
1181
    ];
1182
    const { attributes, filterTerms } = prepareSearchArguments({ filters, state });
1✔
1183

1184
    return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), {
1✔
1185
      page,
1186
      per_page: perPage,
1187
      filters: filterTerms,
1188
      sort: sortOptions,
1189
      attributes
1190
    })
1191
      .catch(err => commonErrorHandler(err, `There was an error getting system devices device ${id}.`, dispatch, 'Please check your connection.'))
×
1192
      .then(({ data, headers }) => {
1193
        const state = getState();
1✔
1194
        const { devicesById, ids } = reduceReceivedDevices(data, [], state);
1✔
1195
        const device = {
1✔
1196
          ...state.devices.byId[id],
1197
          systemDeviceIds: ids,
1198
          systemDeviceTotal: Number(headers[headerNames.total])
1199
        };
1200
        return Promise.resolve(
1✔
1201
          dispatch({
1202
            type: DeviceConstants.RECEIVE_DEVICES,
1203
            devicesById: {
1204
              ...devicesById,
1205
              [id]: device
1206
            }
1207
          })
1208
        );
1209
      });
1210
  };
1211

1212
export const getGatewayDevices = deviceId => (dispatch, getState) => {
183✔
1213
  const state = getState();
1✔
1214
  let device = getDeviceByIdSelector(state, deviceId);
1✔
1215
  const { attributes = {} } = device;
1!
1216
  const { mender_gateway_system_id = '' } = attributes;
1!
1217
  const filters = [
1✔
1218
    { ...emptyFilter, key: 'id', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: deviceId, scope: 'identity' },
1219
    { ...emptyFilter, key: 'mender_is_gateway', value: 'true', scope: 'inventory' },
1220
    { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' }
1221
  ];
1222
  const { attributes: attributeSelection, filterTerms } = prepareSearchArguments({ filters, state });
1✔
1223
  return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), {
1✔
1224
    page: 1,
1225
    per_page: MAX_PAGE_SIZE,
1226
    filters: filterTerms,
1227
    attributes: attributeSelection
1228
  }).then(({ data }) => {
1229
    const { ids } = reduceReceivedDevices(data, [], getState());
1✔
1230
    let tasks = ids.map(deviceId => dispatch(getDeviceInfo(deviceId)));
1✔
1231
    tasks.push(dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device: { ...getState().devices.byId[deviceId], gatewayIds: ids } }));
1✔
1232
    return Promise.all(tasks);
1✔
1233
  });
1234
};
1235

1236
export const geoAttributes = ['geo-lat', 'geo-lon'].map(attribute => ({ attribute, scope: 'inventory' }));
366✔
1237
export const getDevicesInBounds = (bounds, group) => (dispatch, getState) => {
183✔
1238
  const state = getState();
×
1239
  const { filterTerms } = convertDeviceListStateToFilters({
×
1240
    group: group === DeviceConstants.ALL_DEVICES ? undefined : group,
×
1241
    groups: state.devices.groups,
1242
    status: DEVICE_STATES.accepted
1243
  });
1244
  return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), {
×
1245
    page: 1,
1246
    per_page: MAX_PAGE_SIZE,
1247
    filters: filterTerms,
1248
    attributes: geoAttributes,
1249
    geo_bounding_box_filter: {
1250
      geo_bounding_box: {
1251
        location: {
1252
          top_left: { lat: bounds._northEast.lat, lon: bounds._southWest.lng },
1253
          bottom_right: { lat: bounds._southWest.lat, lon: bounds._northEast.lng }
1254
        }
1255
      }
1256
    }
1257
  }).then(({ data }) => {
1258
    const { devicesById } = reduceReceivedDevices(data, [], getState());
×
1259
    return Promise.resolve(dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById }));
×
1260
  });
1261
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc