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

mendersoftware / gui / 1350829378

27 Jun 2024 01:46PM UTC coverage: 83.494% (-16.5%) from 99.965%
1350829378

Pull #4465

gitlab-ci

mzedel
chore: test fixes

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4465: MEN-7169 - feat: added multi sorting capabilities to devices view

4506 of 6430 branches covered (70.08%)

81 of 100 new or added lines in 14 files covered. (81.0%)

1661 existing lines in 163 files now uncovered.

8574 of 10269 relevant lines covered (83.49%)

160.6 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 } from '../components/devices/base-devices';
16
import { filtersFilter } from '../components/devices/widgets/filters';
17
import { TIMEOUTS, UPLOAD_PROGRESS, emptyChartSelection, yes } from '../constants/appConstants';
18
import * as DeviceConstants from '../constants/deviceConstants';
19
import { rootfsImageVersion } from '../constants/releaseConstants';
20
import {
21
  attributeDuplicateFilter,
22
  combineSortCriteria,
23
  deepCompare,
24
  extractErrorMessage,
25
  getSnackbarMessage,
26
  mapDeviceAttributes,
27
  sortCriteriaToSortOptions
28
} from '../helpers';
29
import {
30
  getDeviceById as getDeviceByIdSelector,
31
  getDeviceFilters,
32
  getDeviceTwinIntegrations,
33
  getGroups as getGroupsSelector,
34
  getIdAttribute,
35
  getTenantCapabilities,
36
  getUserCapabilities,
37
  getUserSettings
38
} from '../selectors';
39
import { chartColorPalette } from '../themes/Mender';
40
import { getDeviceMonitorConfig, getLatestDeviceAlerts } from './monitorActions';
41

42
const { DEVICE_FILTERING_OPTIONS, DEVICE_STATES, DEVICE_LIST_DEFAULTS, UNGROUPED_GROUP, emptyFilter } = DeviceConstants;
184✔
43
const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS;
184✔
44

45
export const deviceAuthV2 = `${apiUrl.v2}/devauth`;
184✔
46
export const deviceConnect = `${apiUrl.v1}/deviceconnect`;
184✔
47
export const inventoryApiUrl = `${apiUrl.v1}/inventory`;
184✔
48
export const inventoryApiUrlV2 = `${apiUrl.v2}/inventory`;
184✔
49
export const deviceConfig = `${apiUrl.v1}/deviceconfig/configurations/device`;
184✔
50
export const reportingApiUrl = `${apiUrl.v1}/reporting`;
184✔
51
export const iotManagerBaseURL = `${apiUrl.v1}/iot-manager`;
184✔
52

53
const defaultAttributes = [
184✔
54
  { scope: 'identity', attribute: 'status' },
55
  { scope: 'inventory', attribute: 'artifact_name' },
56
  { scope: 'inventory', attribute: 'device_type' },
57
  { scope: 'inventory', attribute: 'mender_is_gateway' },
58
  { scope: 'inventory', attribute: 'mender_gateway_system_id' },
59
  { scope: 'inventory', attribute: rootfsImageVersion },
60
  { scope: 'monitor', attribute: 'alerts' },
61
  { scope: 'system', attribute: 'created_ts' },
62
  { scope: 'system', attribute: 'updated_ts' },
63
  { scope: 'system', attribute: 'check_in_time' },
64
  { scope: 'system', attribute: 'group' },
65
  { scope: 'tags', attribute: 'name' }
66
];
67

68
export const getSearchEndpoint = hasReporting => (hasReporting ? `${reportingApiUrl}/devices/search` : `${inventoryApiUrlV2}/filters/search`);
913!
69

70
const getAttrsEndpoint = hasReporting => (hasReporting ? `${reportingApiUrl}/devices/search/attributes` : `${inventoryApiUrlV2}/filters/attributes`);
184✔
71

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

110
export const addDevicesToGroup = (group, deviceIds, isCreation) => dispatch =>
184✔
111
  GeneralApi.patch(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds)
2✔
112
    .then(() => dispatch({ type: DeviceConstants.ADD_TO_GROUP, group, deviceIds }))
2✔
113
    .finally(() => (isCreation ? Promise.resolve(dispatch(getGroups())) : {}));
2✔
114

115
export const removeDevicesFromGroup = (group, deviceIds) => dispatch =>
184✔
116
  GeneralApi.delete(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds).then(() =>
1✔
117
    Promise.all([
1✔
118
      dispatch({
119
        type: DeviceConstants.REMOVE_FROM_GROUP,
120
        group,
121
        deviceIds
122
      }),
123
      dispatch(setSnackbar(`The ${pluralize('devices', deviceIds.length)} ${pluralize('were', deviceIds.length)} removed from the group`, TIMEOUTS.fiveSeconds))
124
    ])
125
  );
126

127
const getGroupNotification = (newGroup, selectedGroup) => {
184✔
128
  const successMessage = 'The group was updated successfully';
3✔
129
  if (newGroup === selectedGroup) {
3✔
130
    return [successMessage, TIMEOUTS.fiveSeconds];
1✔
131
  }
132
  return [
2✔
133
    <>
134
      {successMessage} - <Link to={`/devices?inventory=group:eq:${newGroup}`}>click here</Link> to see it.
135
    </>,
136
    5000,
137
    undefined,
138
    undefined,
139
    () => {}
140
  ];
141
};
142

143
export const addStaticGroup = (group, devices) => (dispatch, getState) =>
184✔
144
  Promise.resolve(
1✔
145
    dispatch(
146
      addDevicesToGroup(
147
        group,
148
        devices.map(({ id }) => id),
1✔
149
        true
150
      )
151
    )
152
  )
153
    .then(() =>
154
      Promise.resolve(
1✔
155
        dispatch({
156
          type: DeviceConstants.ADD_STATIC_GROUP,
157
          group: { deviceIds: [], total: 0, filters: [], ...getState().devices.groups.byId[group] },
158
          groupName: group
159
        })
160
      ).then(() =>
161
        Promise.all([
1✔
162
          dispatch(setDeviceListState({ setOnly: true })),
163
          dispatch(getGroups()),
164
          dispatch(setSnackbar(...getGroupNotification(group, getState().devices.groups.selectedGroup)))
165
        ])
166
      )
167
    )
UNCOV
168
    .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch));
×
169

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

187
// for some reason these functions can not be stored in the deviceConstants...
188
const filterProcessors = {
184✔
UNCOV
189
  $gt: val => Number(val) || val,
×
UNCOV
190
  $gte: val => Number(val) || val,
×
UNCOV
191
  $lt: val => Number(val) || val,
×
UNCOV
192
  $lte: val => Number(val) || val,
×
UNCOV
193
  $in: val => ('' + val).split(',').map(i => i.trim()),
×
194
  $nin: val => ('' + val).split(',').map(i => i.trim()),
33✔
195
  $exists: yes,
UNCOV
196
  $nexists: () => false
×
197
};
198
const filterAliases = {
184✔
199
  $nexists: { alias: DEVICE_FILTERING_OPTIONS.$exists.key, value: false }
200
};
201
export const mapFiltersToTerms = (filters = []) =>
184!
202
  filters.map(filter => ({
925✔
203
    scope: filter.scope,
204
    attribute: filter.key,
205
    type: filterAliases[filter.operator]?.alias || filter.operator,
1,850✔
206
    value: filterProcessors.hasOwnProperty(filter.operator) ? filterProcessors[filter.operator](filter.value) : filter.value
925✔
207
  }));
208
export const mapTermsToFilters = (terms = []) =>
184!
209
  terms.map(term => {
30✔
210
    const aliasedFilter = Object.entries(filterAliases).find(
90✔
211
      aliasDefinition => aliasDefinition[1].alias === term.type && aliasDefinition[1].value === term.value
90✔
212
    );
213
    const operator = aliasedFilter ? aliasedFilter[0] : term.type;
90✔
214
    return { scope: term.scope, key: term.attribute, operator, value: term.value };
90✔
215
  });
216

217
export const getDynamicGroups = () => (dispatch, getState) =>
184✔
218
  GeneralApi.get(`${inventoryApiUrlV2}/filters?per_page=${MAX_PAGE_SIZE}`)
30✔
219
    .then(({ data: filters }) => {
220
      const state = getState().devices.groups.byId;
30✔
221
      const staticGroups = Object.entries(state).reduce((accu, [id, group]) => {
30✔
222
        if (!(group.id || group.filters?.length)) {
59✔
223
          accu[id] = group;
30✔
224
        }
225
        return accu;
59✔
226
      }, {});
227
      const groups = (filters || []).reduce((accu, filter) => {
30!
228
        accu[filter.name] = {
30✔
229
          deviceIds: [],
230
          total: 0,
231
          ...state[filter.name],
232
          id: filter.id,
233
          filters: mapTermsToFilters(filter.terms)
234
        };
235
        return accu;
30✔
236
      }, staticGroups);
237
      return Promise.resolve(dispatch({ type: DeviceConstants.RECEIVE_DYNAMIC_GROUPS, groups }));
30✔
238
    })
UNCOV
239
    .catch(() => console.log('Dynamic group retrieval failed - likely accessing a non-enterprise backend'));
×
240

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

267
export const updateDynamicGroup = (groupName, filterPredicates) => (dispatch, getState) => {
184✔
268
  const filterId = getState().devices.groups.byId[groupName].id;
1✔
269
  return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() => Promise.resolve(dispatch(addDynamicGroup(groupName, filterPredicates))));
1✔
270
};
271

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

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

318
const getEarliestTs = (dateA = '', dateB = '') => (!dateA || !dateB ? dateA || dateB : dateA < dateB ? dateA : dateB);
542!
319

320
const reduceReceivedDevices = (devices, ids, state, status) =>
184✔
321
  devices.reduce(
222✔
322
    (accu, device) => {
323
      const stateDevice = getDeviceByIdSelector(state, device.id);
271✔
324
      const {
325
        attributes: storedAttributes = {},
4✔
326
        identity_data: storedIdentity = {},
4✔
327
        monitor: storedMonitor = {},
203✔
328
        tags: storedTags = {},
203✔
329
        group: storedGroup
330
      } = stateDevice;
271✔
331
      const { identity, inventory, monitor, system = {}, tags } = mapDeviceAttributes(device.attributes);
271!
332
      device.tags = { ...storedTags, ...tags };
271✔
333
      device.group = system.group ?? storedGroup;
271✔
334
      device.monitor = { ...storedMonitor, ...monitor };
271✔
335
      device.identity_data = { ...storedIdentity, ...identity, ...(device.identity_data ? device.identity_data : {}) };
271✔
336
      device.status = status ? status : device.status || identity.status;
271✔
337
      device.check_in_time_rounded = system.check_in_time ?? stateDevice.check_in_time_rounded;
271✔
338
      device.check_in_time_exact = device.check_in_time ?? stateDevice.check_in_time_exact;
271✔
339
      device.created_ts = getEarliestTs(getEarliestTs(system.created_ts, device.created_ts), stateDevice.created_ts);
271✔
340
      device.updated_ts = device.attributes ? device.updated_ts : stateDevice.updated_ts;
271✔
341
      device.isNew = new Date(device.created_ts) > new Date(state.app.newThreshold);
271✔
342
      device.isOffline = new Date(device.check_in_time_rounded) < new Date(state.app.offlineThreshold) || device.check_in_time_rounded === undefined;
271✔
343
      // 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
344
      // for device_type and artifact_name, potentially overwriting existing info, so rely on stored information instead if there are no attributes
345
      device.attributes = device.attributes ? { ...storedAttributes, ...inventory } : storedAttributes;
271✔
346
      accu.devicesById[device.id] = { ...stateDevice, ...device };
271✔
347
      accu.ids.push(device.id);
271✔
348
      return accu;
271✔
349
    },
350
    { ids, devicesById: {} }
351
  );
352

353
export const getGroupDevices =
354
  (group, options = {}) =>
184✔
355
  (dispatch, getState) => {
3✔
356
    const { shouldIncludeAllStates, ...remainder } = options;
3✔
357
    const { cleanedFilters: filterSelection } = getGroupFilters(group, getState().devices.groups);
3✔
358
    return Promise.resolve(
3✔
359
      dispatch(getDevicesByStatus(shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted, { ...remainder, filterSelection, group }))
3✔
360
    ).then(results => {
361
      if (!group) {
3✔
362
        return Promise.resolve();
1✔
363
      }
364
      const { deviceAccu, total } = results[results.length - 1];
2✔
365
      const stateGroup = getState().devices.groups.byId[group];
2✔
366
      if (!stateGroup && !total && !deviceAccu.ids.length) {
2!
UNCOV
367
        return Promise.resolve();
×
368
      }
369
      return Promise.resolve(
2✔
370
        dispatch({
371
          type: DeviceConstants.RECEIVE_GROUP_DEVICES,
372
          group: {
373
            filters: [],
374
            ...stateGroup,
375
            deviceIds: deviceAccu.ids.length === total || deviceAccu.ids.length > stateGroup?.deviceIds ? deviceAccu.ids : stateGroup.deviceIds,
6!
376
            total
377
          },
378
          groupName: group
379
        })
380
      );
381
    });
382
  };
383

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

427
export const getAllDynamicGroupDevices = group => (dispatch, getState) => {
184✔
428
  if (!!group && (!getState().devices.groups.byId[group] || !getState().devices.groups.byId[group].filters.length)) {
13✔
429
    return Promise.resolve();
12✔
430
  }
431
  const { attributes, filterTerms: filters } = prepareSearchArguments({
1✔
432
    filters: getState().devices.groups.byId[group].filters,
433
    state: getState(),
434
    status: DEVICE_STATES.accepted
435
  });
436
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) =>
1✔
437
    GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { page, per_page: perPage, filters, attributes }).then(res => {
1✔
438
      const state = getState();
1✔
439
      const deviceAccu = reduceReceivedDevices(res.data, devices, state);
1✔
440
      dispatch({
1✔
441
        type: DeviceConstants.RECEIVE_DEVICES,
442
        devicesById: deviceAccu.devicesById
443
      });
444
      const total = Number(res.headers[headerNames.total]);
1✔
445
      if (total > deviceAccu.ids.length) {
1!
UNCOV
446
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
447
      }
448
      return Promise.resolve(
1✔
449
        dispatch({
450
          type: DeviceConstants.RECEIVE_GROUP_DEVICES,
451
          group: {
452
            ...state.devices.groups.byId[group],
453
            deviceIds: deviceAccu.ids,
454
            total
455
          },
456
          groupName: group
457
        })
458
      );
459
    });
460
  return getAllDevices();
1✔
461
};
462

463
export const setDeviceFilters = filters => (dispatch, getState) => {
184✔
464
  if (deepCompare(filters, getDeviceFilters(getState()))) {
6✔
465
    return Promise.resolve();
2✔
466
  }
467
  return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_FILTERS, filters }));
4✔
468
};
469

470
export const getDeviceById = id => (dispatch, getState) =>
184✔
471
  GeneralApi.get(`${inventoryApiUrl}/devices/${id}`)
12✔
472
    .then(res => {
473
      const device = reduceReceivedDevices([res.data], [], getState()).devicesById[id];
12✔
474
      device.etag = res.headers.etag;
12✔
475
      dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device });
11✔
476
      return Promise.resolve(device);
11✔
477
    })
478
    .catch(err => {
479
      const errMsg = extractErrorMessage(err);
1✔
480
      if (errMsg.includes('Not Found')) {
1!
UNCOV
481
        console.log(`${id} does not have any inventory information`);
×
UNCOV
482
        const device = reduceReceivedDevices(
×
483
          [
484
            {
485
              id,
486
              attributes: [
487
                { name: 'status', value: 'decomissioned', scope: 'identity' },
488
                { name: 'decomissioned', value: 'true', scope: 'inventory' }
489
              ]
490
            }
491
          ],
492
          [],
493
          getState()
494
        ).devicesById[id];
UNCOV
495
        dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device });
×
496
      }
497
    });
498

499
export const getDeviceInfo = deviceId => (dispatch, getState) => {
184✔
500
  const device = getState().devices.byId[deviceId] || {};
2!
501
  const { hasDeviceConfig, hasDeviceConnect, hasMonitor } = getTenantCapabilities(getState());
2✔
502
  const { canConfigure } = getUserCapabilities(getState());
2✔
503
  const integrations = getDeviceTwinIntegrations(getState());
2✔
504
  let tasks = [dispatch(getDeviceAuth(deviceId)), ...integrations.map(integration => dispatch(getDeviceTwin(deviceId, integration)))];
2✔
505
  if (hasDeviceConfig && canConfigure && [DEVICE_STATES.accepted, DEVICE_STATES.preauth].includes(device.status)) {
2!
UNCOV
506
    tasks.push(dispatch(getDeviceConfig(deviceId)));
×
507
  }
508
  if (device.status === DEVICE_STATES.accepted) {
2!
509
    // Get full device identity details for single selected device
510
    tasks.push(dispatch(getDeviceById(deviceId)));
2✔
511
    if (hasDeviceConnect) {
2!
512
      tasks.push(dispatch(getDeviceConnect(deviceId)));
2✔
513
    }
514
    if (hasMonitor) {
2!
UNCOV
515
      tasks.push(dispatch(getLatestDeviceAlerts(deviceId)));
×
UNCOV
516
      tasks.push(dispatch(getDeviceMonitorConfig(deviceId)));
×
517
    }
518
  }
519
  return Promise.all(tasks);
2✔
520
};
521

522
const deriveInactiveDevices = deviceIds => (dispatch, getState) => {
184✔
523
  const yesterday = new Date();
12✔
524
  yesterday.setDate(yesterday.getDate() - 1);
12✔
525
  const yesterdaysIsoString = yesterday.toISOString();
12✔
526
  const state = getState().devices;
12✔
527
  // now boil the list down to the ones that were not updated since yesterday
528
  const devices = deviceIds.reduce(
12✔
529
    (accu, id) => {
530
      const device = state.byId[id];
24✔
531
      if (device && device.updated_ts > yesterdaysIsoString) {
24!
UNCOV
532
        accu.active.push(id);
×
533
      } else {
534
        accu.inactive.push(id);
24✔
535
      }
536
      return accu;
24✔
537
    },
538
    { active: [], inactive: [] }
539
  );
540
  return dispatch({
12✔
541
    type: DeviceConstants.SET_INACTIVE_DEVICES,
542
    activeDeviceTotal: devices.active.length,
543
    inactiveDeviceTotal: devices.inactive.length
544
  });
545
};
546

547
/*
548
    Device Auth + admission
549
  */
550
export const getDeviceCount = status => (dispatch, getState) =>
780✔
551
  GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), {
780✔
552
    page: 1,
553
    per_page: 1,
554
    filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]),
555
    attributes: defaultAttributes
556
  }).then(response => {
557
    const count = Number(response.headers[headerNames.total]);
776✔
558
    switch (status) {
776!
559
      case DEVICE_STATES.accepted:
560
      case DEVICE_STATES.pending:
561
      case DEVICE_STATES.preauth:
562
      case DEVICE_STATES.rejected:
563
        return dispatch({ type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], count, status });
776✔
564
      default:
UNCOV
565
        return dispatch({ type: DeviceConstants.SET_TOTAL_DEVICES, count });
×
566
    }
567
  });
568

569
export const getAllDeviceCounts = () => dispatch =>
368✔
570
  Promise.all([DEVICE_STATES.accepted, DEVICE_STATES.pending].map(status => dispatch(getDeviceCount(status))));
736✔
571

572
export const getDeviceLimit = () => dispatch =>
184✔
573
  GeneralApi.get(`${deviceAuthV2}/limits/max_devices`).then(res =>
12✔
574
    dispatch({
12✔
575
      type: DeviceConstants.SET_DEVICE_LIMIT,
576
      limit: res.data.limit
577
    })
578
  );
579

580
export const setDeviceListState =
581
  (selectionState, shouldSelectDevices = true, forceRefresh, fetchAuth = true) =>
184✔
582
  (dispatch, getState) => {
36✔
583
    const currentState = getState().devices.deviceList;
36✔
584
    const refreshTrigger = forceRefresh ? !currentState.refreshTrigger : selectionState.refreshTrigger;
36✔
585
    let nextState = {
36✔
586
      ...currentState,
587
      setOnly: false,
588
      refreshTrigger,
589
      ...selectionState,
590
      sort: combineSortCriteria(currentState.sort, selectionState.sort)
591
    };
592
    let tasks = [];
36✔
593
    // eslint-disable-next-line no-unused-vars
594
    const { isLoading: currentLoading, deviceIds: currentDevices, selection: currentSelection, ...currentRequestState } = currentState;
36✔
595
    // eslint-disable-next-line no-unused-vars
596
    const { isLoading: nextLoading, deviceIds: nextDevices, selection: nextSelection, ...nextRequestState } = nextState;
36✔
597
    if (!nextState.setOnly && !deepCompare(currentRequestState, nextRequestState)) {
36✔
598
      const applicableSelectedState = nextState.state === routes.allDevices.key ? undefined : nextState.state;
22!
599
      nextState.isLoading = true;
22✔
600
      tasks.push(
22✔
601
        dispatch(getDevicesByStatus(applicableSelectedState, { ...nextState, sortOptions: sortCriteriaToSortOptions(nextState.sort) }, fetchAuth))
602
          .then(results => {
603
            const { deviceAccu, total } = results[results.length - 1];
22✔
604
            const devicesState = shouldSelectDevices
22!
605
              ? { ...getState().devices.deviceList, deviceIds: deviceAccu.ids, total, isLoading: false }
606
              : { ...getState().devices.deviceList, isLoading: false };
607
            return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: devicesState }));
22✔
608
          })
609
          // whatever happens, change "loading" back to null
610
          .catch(() =>
UNCOV
611
            Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { ...getState().devices.deviceList, isLoading: false } }))
×
612
          )
613
      );
614
    }
615
    tasks.push(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: nextState }));
36✔
616
    return Promise.all(tasks);
36✔
617
  };
618

619
const convertIssueOptionsToFilters = (issuesSelection, filtersState = {}) =>
184!
620
  issuesSelection.map(item => {
117✔
621
    if (typeof DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule.value === 'function') {
11✔
622
      return { ...DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule, value: DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule.value(filtersState) };
5✔
623
    }
624
    return DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule;
6✔
625
  });
626

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

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

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

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

771
const ATTRIBUTE_LIST_CUTOFF = 100;
184✔
772
const attributeReducer = (attributes = []) =>
184!
773
  attributes.slice(0, ATTRIBUTE_LIST_CUTOFF).reduce(
27✔
774
    (accu, { name, scope }) => {
775
      if (!accu[scope]) {
540!
UNCOV
776
        accu[scope] = [];
×
777
      }
778
      accu[scope].push(name);
540✔
779
      return accu;
540✔
780
    },
781
    { identity: [], inventory: [], system: [], tags: [] }
782
  );
783

784
export const getDeviceAttributes = () => (dispatch, getState) =>
184✔
785
  GeneralApi.get(getAttrsEndpoint(getState().app.features.hasReporting)).then(({ data }) => {
25✔
786
    // TODO: remove the array fallback once the inventory attributes endpoint is fixed
787
    const { identity: identityAttributes, inventory: inventoryAttributes, system: systemAttributes, tags: tagAttributes } = attributeReducer(data || []);
25!
788
    return dispatch({
25✔
789
      type: DeviceConstants.SET_FILTER_ATTRIBUTES,
790
      attributes: { identityAttributes, inventoryAttributes, systemAttributes, tagAttributes }
791
    });
792
  });
793

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

803
export const ensureVersionString = (software, fallback) =>
184✔
804
  software.length && software !== 'artifact_name' ? (software.endsWith('.version') ? software : `${software}.version`) : fallback;
1!
805

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

821
export const defaultReportType = 'distribution';
184✔
822
export const defaultReports = [{ ...emptyChartSelection, group: null, attribute: 'artifact_name', type: defaultReportType }];
184✔
823

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

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

866
const deriveReportsData = () => (dispatch, getState) => {
184✔
867
  const state = getState();
25✔
868
  const {
869
    groups: { byId: groupsById },
870
    byId,
871
    byStatus: {
872
      accepted: { total }
873
    }
874
  } = state.devices;
25✔
875
  const reports =
876
    getUserSettings(state).reports || state.users.globalSettings[`${state.users.currentUser}-reports`] || (Object.keys(byId).length ? defaultReports : []);
25!
877
  const newReports = reports.map(report => initializeDistributionData(report, groupsById, byId, total));
25✔
878
  return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_REPORTS, reports: newReports }));
25✔
879
};
880

881
export const getReportsDataWithoutBackendSupport = () => (dispatch, getState) =>
184✔
882
  Promise.all([dispatch(getAllDevicesByStatus(DEVICE_STATES.accepted)), dispatch(getGroups()), dispatch(getDynamicGroups())]).then(() => {
13✔
883
    const { dynamic: dynamicGroups, static: staticGroups } = getGroupsSelector(getState());
13✔
884
    return Promise.all([
13✔
885
      ...staticGroups.map(group => dispatch(getAllGroupDevices(group))),
12✔
886
      ...dynamicGroups.map(group => dispatch(getAllDynamicGroupDevices(group)))
12✔
887
    ]).then(() => dispatch(deriveReportsData()));
13✔
888
  });
889

890
export const getDeviceConnect = id => dispatch =>
184✔
891
  GeneralApi.get(`${deviceConnect}/devices/${id}`).then(({ data }) => {
2✔
892
    let tasks = [
1✔
893
      dispatch({
894
        type: DeviceConstants.RECEIVE_DEVICE_CONNECT,
895
        device: { connect_status: data.status, connect_updated_ts: data.updated_ts, id }
896
      })
897
    ];
898
    tasks.push(Promise.resolve(data));
1✔
899
    return Promise.all(tasks);
1✔
900
  });
901

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

923
export const getDeviceFileDownloadLink = (deviceId, path) => () =>
184✔
924
  Promise.resolve(`${deviceConnect}/devices/${deviceId}/download?path=${encodeURIComponent(path)}`);
1✔
925

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

948
export const getDeviceAuth = id => dispatch =>
184✔
949
  Promise.resolve(dispatch(getDevicesWithAuth([{ id }]))).then(results => {
6✔
950
    if (results[results.length - 1]) {
6!
951
      return Promise.resolve(results[results.length - 1][0]);
6✔
952
    }
UNCOV
953
    return Promise.resolve();
×
954
  });
955

956
export const getDevicesWithAuth = devices => (dispatch, getState) =>
184✔
957
  devices.length
87✔
958
    ? GeneralApi.get(`${deviceAuthV2}/devices?id=${devices.map(device => device.id).join('&id=')}`)
117✔
959
        .then(({ data: receivedDevices }) => {
960
          const { devicesById } = reduceReceivedDevices(receivedDevices, [], getState());
86✔
961
          return Promise.all([dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById }), Promise.resolve(receivedDevices)]);
86✔
962
        })
UNCOV
963
        .catch(err => commonErrorHandler(err, `Error: ${err}`, dispatch))
×
964
    : Promise.resolve([[], []]);
965

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

985
export const updateDeviceAuth = (deviceId, authId, status) => (dispatch, getState) =>
184✔
986
  GeneralApi.put(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}/status`, { status })
2✔
987
    .then(() => Promise.all([dispatch(getDeviceAuth(deviceId)), dispatch(setSnackbar('Device authorization status was updated successfully'))]))
2✔
UNCOV
988
    .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch))
×
989
    .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId))))
2✔
990
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })));
2✔
991

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

1028
export const deleteAuthset = (deviceId, authId) => (dispatch, getState) =>
184✔
1029
  GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}`)
1✔
1030
    .then(() => Promise.all([dispatch(setSnackbar('Device authorization status was updated successfully'))]))
1✔
UNCOV
1031
    .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch))
×
1032
    .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId))))
1✔
1033
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })));
1✔
1034

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

1046
export const decommissionDevice = (deviceId, authId) => (dispatch, getState) =>
184✔
1047
  GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}`)
1✔
1048
    .then(() => Promise.resolve(dispatch(setSnackbar('Device was decommissioned successfully'))))
1✔
UNCOV
1049
    .catch(err => commonErrorHandler(err, 'There was a problem decommissioning the device:', dispatch))
×
1050
    .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId))))
1✔
1051
    // trigger reset of device list list!
1052
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })));
1✔
1053

1054
export const getDeviceConfig = deviceId => dispatch =>
184✔
1055
  GeneralApi.get(`${deviceConfig}/${deviceId}`)
3✔
1056
    .then(({ data }) => {
1057
      let tasks = [
2✔
1058
        dispatch({
1059
          type: DeviceConstants.RECEIVE_DEVICE_CONFIG,
1060
          device: { id: deviceId, config: data }
1061
        })
1062
      ];
1063
      tasks.push(Promise.resolve(data));
2✔
1064
      return Promise.all(tasks);
2✔
1065
    })
1066
    .catch(err => {
1067
      // 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
1068
      if (err.response?.data?.error.status_code !== 404) {
1!
UNCOV
1069
        return commonErrorHandler(err, `There was an error retrieving the configuration for device ${deviceId}.`, dispatch, commonErrorFallback);
×
1070
      }
1071
    });
1072

1073
export const setDeviceConfig = (deviceId, config) => dispatch =>
184✔
1074
  GeneralApi.put(`${deviceConfig}/${deviceId}`, config)
4✔
1075
    .catch(err => commonErrorHandler(err, `There was an error setting the configuration for device ${deviceId}.`, dispatch, commonErrorFallback))
2✔
1076
    .then(() => Promise.resolve(dispatch(getDeviceConfig(deviceId))));
1✔
1077

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

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

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

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

1165
const prepareSearchArguments = ({ filters, group, state, status }) => {
184✔
1166
  const { filterTerms } = convertDeviceListStateToFilters({ filters, group, offlineThreshold: state.app.offlineThreshold, selectedIssues: [], status });
4✔
1167
  const { columnSelection = [] } = getUserSettings(state);
4!
1168
  const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope }));
4✔
1169
  const attributes = [...defaultAttributes, { scope: 'identity', attribute: getIdAttribute(state).attribute }, ...selectedAttributes];
4✔
1170
  return { attributes, filterTerms };
4✔
1171
};
1172

1173
export const getSystemDevices =
1174
  (id, options = {}) =>
184✔
1175
  (dispatch, getState) => {
1✔
1176
    const { page = defaultPage, perPage = defaultPerPage, sortOptions = [] } = options;
1✔
1177
    const state = getState();
1✔
1178
    let device = getDeviceByIdSelector(state, id);
1✔
1179
    const { attributes: deviceAttributes = {} } = device;
1!
1180
    const { mender_gateway_system_id = '' } = deviceAttributes;
1✔
1181
    const { hasFullFiltering } = getTenantCapabilities(state);
1✔
1182
    if (!hasFullFiltering) {
1!
UNCOV
1183
      return Promise.resolve();
×
1184
    }
1185
    const filters = [
1✔
1186
      { ...emptyFilter, key: 'mender_is_gateway', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: 'true', scope: 'inventory' },
1187
      { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' }
1188
    ];
1189
    const { attributes, filterTerms } = prepareSearchArguments({ filters, state });
1✔
1190

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

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

1243
export const geoAttributes = ['geo-lat', 'geo-lon'].map(attribute => ({ attribute, scope: 'inventory' }));
368✔
1244
export const getDevicesInBounds = (bounds, group) => (dispatch, getState) => {
184✔
UNCOV
1245
  const state = getState();
×
UNCOV
1246
  const { filterTerms } = convertDeviceListStateToFilters({
×
1247
    group: group === DeviceConstants.ALL_DEVICES ? undefined : group,
×
1248
    groups: state.devices.groups,
1249
    status: DEVICE_STATES.accepted
1250
  });
UNCOV
1251
  return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), {
×
1252
    page: 1,
1253
    per_page: MAX_PAGE_SIZE,
1254
    filters: filterTerms,
1255
    attributes: geoAttributes,
1256
    geo_bounding_box_filter: {
1257
      geo_bounding_box: {
1258
        location: {
1259
          top_left: { lat: bounds._northEast.lat, lon: bounds._southWest.lng },
1260
          bottom_right: { lat: bounds._southWest.lat, lon: bounds._northEast.lng }
1261
        }
1262
      }
1263
    }
1264
  }).then(({ data }) => {
UNCOV
1265
    const { devicesById } = reduceReceivedDevices(data, [], getState());
×
UNCOV
1266
    return Promise.resolve(dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById }));
×
1267
  });
1268
};
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