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

mendersoftware / mender-server / 1622978334

13 Jan 2025 03:51PM UTC coverage: 72.802% (-3.8%) from 76.608%
1622978334

Pull #300

gitlab-ci

alfrunes
fix: Deployment device count should not exceed max devices

Added a condition to skip deployments when the device count reaches max
devices.

Changelog: Title
Ticket: MEN-7847
Signed-off-by: Alf-Rune Siqveland <alf.rune@northern.tech>
Pull Request #300: fix: Deployment device count should not exceed max devices

4251 of 6164 branches covered (68.96%)

Branch coverage included in aggregate %.

0 of 18 new or added lines in 1 file covered. (0.0%)

2544 existing lines in 83 files now uncovered.

42741 of 58384 relevant lines covered (73.21%)

21.49 hits per line

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

84.63
/frontend/src/js/store/devicesSlice/thunks.tsx
1
// Copyright 2024 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14
// @ts-nocheck
15

16
/*eslint import/namespace: ['error', { allowComputed: true }]*/
17
import React from 'react';
18
import { Link } from 'react-router-dom';
19

20
import storeActions from '@northern.tech/store/actions';
21
import GeneralApi from '@northern.tech/store/api/general-api';
22
import {
23
  ALL_DEVICES,
24
  DEVICE_FILTERING_OPTIONS,
25
  DEVICE_LIST_DEFAULTS,
26
  EXTERNAL_PROVIDER,
27
  MAX_PAGE_SIZE,
28
  SORTING_OPTIONS,
29
  TIMEOUTS,
30
  UNGROUPED_GROUP,
31
  auditLogsApiUrl,
32
  defaultReports,
33
  headerNames,
34
  rootfsImageVersion
35
} from '@northern.tech/store/constants';
36
import {
37
  getAttrsEndpoint,
38
  getCurrentUser,
39
  getDeviceTwinIntegrations,
40
  getGlobalSettings,
41
  getIdAttribute,
42
  getSearchEndpoint,
43
  getTenantCapabilities,
44
  getUserCapabilities,
45
  getUserSettings
46
} from '@northern.tech/store/selectors';
47
import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store';
48
import { getDeviceMonitorConfig, getLatestDeviceAlerts, getSingleDeployment, saveGlobalSettings } from '@northern.tech/store/thunks';
49
import {
50
  convertDeviceListStateToFilters,
51
  extractErrorMessage,
52
  filtersFilter,
53
  mapDeviceAttributes,
54
  mapFiltersToTerms,
55
  mapTermsToFilters,
56
  progress
57
} from '@northern.tech/store/utils';
58
import { attributeDuplicateFilter, deepCompare, getSnackbarMessage } from '@northern.tech/utils/helpers';
59
import { createAsyncThunk } from '@reduxjs/toolkit';
60
import { isCancel } from 'axios';
61
import pluralize from 'pluralize';
62
import { v4 as uuid } from 'uuid';
63

64
import { actions, sliceName } from '.';
65
import { routes } from '../../components/devices/base-devices';
66
import { chartColorPalette } from '../../themes/Mender';
67
import {
68
  DEVICE_STATES,
69
  deviceAuthV2,
70
  deviceConfig,
71
  deviceConnect,
72
  emptyFilter,
73
  geoAttributes,
74
  inventoryApiUrl,
75
  inventoryApiUrlV2,
76
  iotManagerBaseURL,
77
  reportingApiUrl
78
} from './constants';
79
import {
80
  getDeviceById as getDeviceByIdSelector,
81
  getDeviceFilters,
82
  getDeviceListState,
83
  getDevicesById,
84
  getGroupsById,
85
  getGroups as getGroupsSelector,
86
  getSelectedGroup
87
} from './selectors';
88

89
const { cleanUpUpload, initUpload, setSnackbar, uploadProgress } = storeActions;
110✔
90
const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS;
110✔
91

92
const defaultAttributes = [
110✔
93
  { scope: 'identity', attribute: 'status' },
94
  { scope: 'inventory', attribute: 'artifact_name' },
95
  { scope: 'inventory', attribute: 'device_type' },
96
  { scope: 'inventory', attribute: 'mender_is_gateway' },
97
  { scope: 'inventory', attribute: 'mender_gateway_system_id' },
98
  { scope: 'inventory', attribute: rootfsImageVersion },
99
  { scope: 'monitor', attribute: 'alerts' },
100
  { scope: 'system', attribute: 'created_ts' },
101
  { scope: 'system', attribute: 'updated_ts' },
102
  { scope: 'system', attribute: 'check_in_time' },
103
  { scope: 'system', attribute: 'group' },
104
  { scope: 'tags', attribute: 'name' }
105
];
106

107
export const getGroups = createAsyncThunk(`${sliceName}/getGroups`, (_, { dispatch, getState }) =>
110✔
108
  GeneralApi.get(`${inventoryApiUrl}/groups`).then(res => {
30✔
109
    const state = getGroupsById(getState());
30✔
110
    const dynamicGroups = Object.entries(state).reduce((accu, [id, group]) => {
30✔
111
      if (group.id || (group.filters?.length && id !== UNGROUPED_GROUP.id)) {
58✔
112
        accu[id] = group;
27✔
113
      }
114
      return accu;
58✔
115
    }, {});
116
    const groups = res.data.reduce((accu, group) => {
30✔
117
      accu[group] = { deviceIds: [], filters: [], total: 0, ...state[group] };
30✔
118
      return accu;
30✔
119
    }, dynamicGroups);
120
    const filters = [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }];
30✔
121
    return Promise.all([
30✔
122
      dispatch(actions.receivedGroups(groups)),
123
      dispatch(getDevicesByStatus({ filterSelection: filters, group: 0, page: 1, perPage: 1, status: undefined }))
124
    ]).then(promises => {
125
      const devicesRetrieval = promises[promises.length - 1] || [];
30!
126
      const { payload } = devicesRetrieval || {};
30!
127
      const result = payload[payload.length - 1] || {};
30!
128
      if (!result.total) {
30!
UNCOV
129
        return Promise.resolve();
×
130
      }
131
      return Promise.resolve(
30✔
132
        dispatch(
133
          actions.addGroup({
134
            groupName: UNGROUPED_GROUP.id,
135
            group: { filters: [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }] }
136
          })
137
        )
138
      );
139
    });
140
  })
141
);
142

143
export const addDevicesToGroup = createAsyncThunk(`${sliceName}/addDevicesToGroup`, ({ group, deviceIds, isCreation }, { dispatch }) =>
110✔
144
  GeneralApi.patch(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds)
2✔
145
    .then(() => dispatch(actions.addToGroup({ group, deviceIds })))
2✔
146
    .finally(() => (isCreation ? Promise.resolve(dispatch(getGroups())) : {}))
2✔
147
);
148

149
export const removeDevicesFromGroup = createAsyncThunk(`${sliceName}/removeDevicesFromGroup`, ({ group, deviceIds }, { dispatch }) =>
110✔
150
  GeneralApi.delete(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds).then(() =>
1✔
151
    Promise.all([
1✔
152
      dispatch(actions.removeFromGroup({ group, deviceIds })),
153
      dispatch(setSnackbar(`The ${pluralize('devices', deviceIds.length)} ${pluralize('were', deviceIds.length)} removed from the group`, TIMEOUTS.fiveSeconds))
154
    ])
155
  )
156
);
157

158
const getGroupNotification = (newGroup, selectedGroup) => {
110✔
159
  const successMessage = 'The group was updated successfully';
3✔
160
  if (newGroup === selectedGroup) {
3✔
161
    return [successMessage, TIMEOUTS.fiveSeconds];
1✔
162
  }
163
  return [
2✔
164
    <>
165
      {successMessage} - <Link to={`/devices?inventory=group:eq:${newGroup}`}>click here</Link> to see it.
166
    </>,
167
    5000,
168
    undefined,
169
    undefined,
170
    () => {}
171
  ];
172
};
173

174
export const addStaticGroup = createAsyncThunk(`${sliceName}/addStaticGroup`, ({ group, devices }, { dispatch, getState }) =>
110✔
175
  Promise.resolve(dispatch(addDevicesToGroup({ group, deviceIds: devices.map(({ id }) => id), isCreation: true })))
1✔
176
    .then(() =>
177
      Promise.resolve(
1✔
178
        dispatch(
179
          actions.addGroup({
180
            group: { deviceIds: [], total: 0, filters: [], ...getState().devices.groups.byId[group] },
181
            groupName: group
182
          })
183
        )
184
      ).then(() =>
185
        Promise.all([
1✔
186
          dispatch(setDeviceListState({ setOnly: true })),
187
          dispatch(getGroups()),
188
          dispatch(setSnackbar(...getGroupNotification(group, getState().devices.groups.selectedGroup)))
189
        ])
190
      )
191
    )
UNCOV
192
    .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch))
×
193
);
194

195
export const removeStaticGroup = createAsyncThunk(`${sliceName}/removeStaticGroup`, (groupName, { dispatch }) =>
110✔
196
  GeneralApi.delete(`${inventoryApiUrl}/groups/${groupName}`).then(() =>
1✔
197
    Promise.all([
1✔
198
      dispatch(actions.removeGroup(groupName)),
199
      dispatch(getGroups()),
200
      dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds))
201
    ])
202
  )
203
);
204

205
export const getDynamicGroups = createAsyncThunk(`${sliceName}/getDynamicGroups`, (_, { dispatch, getState }) =>
110✔
206
  GeneralApi.get(`${inventoryApiUrlV2}/filters?per_page=${MAX_PAGE_SIZE}`)
28✔
207
    .then(({ data: filters }) => {
208
      const state = getGroupsById(getState());
28✔
209
      const staticGroups = Object.entries(state).reduce((accu, [id, group]) => {
28✔
210
        if (!(group.id || group.filters?.length)) {
54✔
211
          accu[id] = group;
28✔
212
        }
213
        return accu;
54✔
214
      }, {});
215
      const groups = (filters || []).reduce((accu, filter) => {
28!
216
        accu[filter.name] = {
28✔
217
          deviceIds: [],
218
          total: 0,
219
          ...state[filter.name],
220
          id: filter.id,
221
          filters: mapTermsToFilters(filter.terms)
222
        };
223
        return accu;
28✔
224
      }, staticGroups);
225
      return Promise.resolve(dispatch(actions.receivedGroups(groups)));
28✔
226
    })
UNCOV
227
    .catch(() => console.log('Dynamic group retrieval failed - likely accessing a non-enterprise backend'))
×
228
);
229

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

255
export const updateDynamicGroup = createAsyncThunk(`${sliceName}/updateDynamicGroup`, ({ groupName, filterPredicates }, { dispatch, getState }) => {
110✔
256
  const filterId = getState().devices.groups.byId[groupName].id;
1✔
257
  return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() => Promise.resolve(dispatch(addDynamicGroup({ groupName, filterPredicates }))));
1✔
258
});
259

260
export const removeDynamicGroup = createAsyncThunk(`${sliceName}/removeDynamicGroup`, (groupName, { dispatch, getState }) => {
110✔
261
  const filterId = getState().devices.groups.byId[groupName].id;
1✔
262
  return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() =>
1✔
263
    Promise.all([dispatch(actions.removeGroup(groupName)), dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds))])
1✔
264
  );
265
});
266

267
/*
268
 * Device inventory functions
269
 */
270
const getGroupFilters = (group, groupsState, filters = []) => {
110✔
271
  const groupName = group === UNGROUPED_GROUP.id || group === UNGROUPED_GROUP.name ? UNGROUPED_GROUP.id : group;
11!
272
  const selectedGroup = groupsState.byId[groupName];
11✔
273
  const groupFilterLength = selectedGroup?.filters?.length || 0;
11✔
274
  const cleanedFilters = groupFilterLength ? [...filters, ...selectedGroup.filters].filter(filtersFilter) : filters;
11✔
275
  return { cleanedFilters, groupName, selectedGroup, groupFilterLength };
11✔
276
};
277

278
export const selectGroup = createAsyncThunk(`${sliceName}/selectGroup`, ({ group, filters = [] }, { dispatch, getState }) => {
110✔
279
  const { cleanedFilters, groupName, selectedGroup, groupFilterLength } = getGroupFilters(group, getState().devices.groups, filters);
3✔
280
  if (getSelectedGroup(getState()) === groupName && ((filters.length === 0 && !groupFilterLength) || filters.length === cleanedFilters.length)) {
3!
UNCOV
281
    return Promise.resolve();
×
282
  }
283
  let tasks = [];
3✔
284
  if (groupFilterLength) {
3✔
285
    tasks.push(dispatch(actions.setDeviceFilters(cleanedFilters)));
2✔
286
  } else {
287
    tasks.push(dispatch(actions.setDeviceFilters(filters)));
1✔
288
    tasks.push(dispatch(getGroupDevices({ group: groupName, perPage: 1, shouldIncludeAllStates: true })));
1✔
289
  }
290
  const selectedGroupName = selectedGroup || !Object.keys(getGroupsById(getState())).length ? groupName : undefined;
3!
291
  tasks.push(dispatch(actions.selectGroup(selectedGroupName)));
3✔
292
  return Promise.all(tasks);
3✔
293
});
294

295
const getEarliestTs = (dateA = '', dateB = '') => (!dateA || !dateB ? dateA || dateB : dateA < dateB ? dateA : dateB);
610!
296

297
const reduceReceivedDevices = (devices, ids, state, status) =>
110✔
298
  devices.reduce(
213✔
299
    (accu, device) => {
300
      const stateDevice = getDeviceByIdSelector(state, device.id);
305✔
301
      const {
302
        attributes: storedAttributes = {},
6✔
303
        identity_data: storedIdentity = {},
6✔
304
        monitor: storedMonitor = {},
183✔
305
        tags: storedTags = {},
183✔
306
        group: storedGroup
307
      } = stateDevice;
305✔
308
      const { identity, inventory, monitor, system = {}, tags } = mapDeviceAttributes(device.attributes);
305!
309
      device.tags = { ...storedTags, ...tags };
305✔
310
      device.group = system.group ?? storedGroup;
305✔
311
      device.monitor = { ...storedMonitor, ...monitor };
305✔
312
      device.identity_data = { ...storedIdentity, ...identity, ...(device.identity_data ? device.identity_data : {}) };
305✔
313
      device.status = status ? status : device.status || identity.status;
305✔
314
      device.check_in_time_rounded = system.check_in_time ?? stateDevice.check_in_time_rounded;
305✔
315
      device.check_in_time_exact = device.check_in_time ?? stateDevice.check_in_time_exact;
305✔
316
      device.created_ts = getEarliestTs(getEarliestTs(system.created_ts, device.created_ts), stateDevice.created_ts);
305✔
317
      device.updated_ts = device.attributes ? device.updated_ts : stateDevice.updated_ts;
305✔
318
      device.isNew = new Date(device.created_ts) > new Date(state.app.newThreshold);
305✔
319
      device.isOffline = new Date(device.check_in_time_rounded) < new Date(state.app.offlineThreshold) || device.check_in_time_rounded === undefined;
305✔
320
      // 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
321
      // for device_type and artifact_name, potentially overwriting existing info, so rely on stored information instead if there are no attributes
322
      device.attributes = device.attributes ? { ...storedAttributes, ...inventory } : storedAttributes;
305✔
323
      accu.devicesById[device.id] = { ...stateDevice, ...device };
305✔
324
      accu.ids.push(device.id);
305✔
325
      return accu;
305✔
326
    },
327
    { ids, devicesById: {} }
328
  );
329

330
export const getGroupDevices = createAsyncThunk(`${sliceName}/getGroupDevices`, (options, { dispatch, getState }) => {
110✔
331
  const { group, shouldIncludeAllStates, ...remainder } = options;
6✔
332
  const { cleanedFilters: filterSelection } = getGroupFilters(group, getState().devices.groups);
6✔
333
  return Promise.resolve(
6✔
334
    dispatch(getDevicesByStatus({ ...remainder, filterSelection, group, status: shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted }))
6✔
335
  )
336
    .unwrap()
337
    .then(results => {
338
      if (!group) {
6✔
339
        return Promise.resolve();
2✔
340
      }
341
      const { deviceAccu, total } = results[results.length - 1];
4✔
342
      const stateGroup = getState().devices.groups.byId[group];
4✔
343
      if (!stateGroup && !total && !deviceAccu.ids.length) {
4!
UNCOV
344
        return Promise.resolve();
×
345
      }
346
      return Promise.resolve(
4✔
347
        dispatch(
348
          actions.addGroup({
349
            group: {
350
              deviceIds: deviceAccu.ids.length === total || deviceAccu.ids.length > stateGroup?.deviceIds ? deviceAccu.ids : stateGroup.deviceIds,
8!
351
              total
352
            },
353
            groupName: group
354
          })
355
        )
356
      );
357
    });
358
});
359

360
export const getAllGroupDevices = createAsyncThunk(`${sliceName}/getAllGroupDevices`, (group, { dispatch, getState }) => {
110✔
361
  if (!group || (!!group && (!getGroupsById(getState())[group] || getGroupsById(getState())[group].filters.length))) {
13!
UNCOV
362
    return Promise.resolve();
×
363
  }
364
  const { attributes, filterTerms } = prepareSearchArguments({
13✔
365
    filters: [],
366
    group,
367
    state: getState(),
368
    status: DEVICE_STATES.accepted
369
  });
370
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) =>
13✔
371
    GeneralApi.post(getSearchEndpoint(getState()), {
13✔
372
      page,
373
      per_page: perPage,
374
      filters: filterTerms,
375
      attributes
376
    }).then(res => {
377
      const state = getState();
13✔
378
      const deviceAccu = reduceReceivedDevices(res.data, devices, state);
13✔
379
      dispatch(actions.receivedDevices(deviceAccu.devicesById));
13✔
380
      const total = Number(res.headers[headerNames.total]);
13✔
381
      if (total > perPage * page) {
13!
UNCOV
382
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
383
      }
384
      return Promise.resolve(dispatch(actions.addGroup({ group: { deviceIds: deviceAccu.ids, total: deviceAccu.ids.length }, groupName: group })));
13✔
385
    });
386
  return getAllDevices();
13✔
387
});
388

389
export const getAllDynamicGroupDevices = createAsyncThunk(`${sliceName}/getAllDynamicGroupDevices`, (group, { dispatch, getState }) => {
110✔
390
  if (!!group && (!getGroupsById(getState())[group] || !getGroupsById(getState())[group].filters.length)) {
13!
UNCOV
391
    return Promise.resolve();
×
392
  }
393
  const { attributes, filterTerms: filters } = prepareSearchArguments({
13✔
394
    filters: getState().devices.groups.byId[group].filters,
395
    state: getState(),
396
    status: DEVICE_STATES.accepted
397
  });
398
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) =>
13✔
399
    GeneralApi.post(getSearchEndpoint(getState()), { page, per_page: perPage, filters, attributes }).then(res => {
13✔
400
      const state = getState();
13✔
401
      const deviceAccu = reduceReceivedDevices(res.data, devices, state);
13✔
402
      dispatch(actions.receivedDevices(deviceAccu.devicesById));
13✔
403
      const total = Number(res.headers[headerNames.total]);
13✔
404
      if (total > deviceAccu.ids.length) {
13!
UNCOV
405
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
406
      }
407
      return Promise.resolve(dispatch(actions.addGroup({ group: { deviceIds: deviceAccu.ids, total }, groupName: group })));
13✔
408
    });
409
  return getAllDevices();
13✔
410
});
411

412
export const getDeviceById = createAsyncThunk(`${sliceName}/getDeviceById`, (id, { dispatch, getState }) =>
110✔
413
  GeneralApi.get(`${inventoryApiUrl}/devices/${id}`)
15✔
414
    .then(res => {
415
      const device = reduceReceivedDevices([res.data], [], getState()).devicesById[id];
15✔
416
      device.etag = res.headers.etag;
15✔
417
      dispatch(actions.receivedDevice(device));
14✔
418
      return Promise.resolve(device);
14✔
419
    })
420
    .catch(err => {
421
      const errMsg = extractErrorMessage(err);
1✔
422
      if (errMsg.includes('Not Found')) {
1!
UNCOV
423
        console.log(`${id} does not have any inventory information`);
×
424
        const device = reduceReceivedDevices(
×
425
          [
426
            {
427
              id,
428
              attributes: [
429
                { name: 'status', value: 'decomissioned', scope: 'identity' },
430
                { name: 'decomissioned', value: 'true', scope: 'inventory' }
431
              ]
432
            }
433
          ],
434
          [],
435
          getState()
436
        ).devicesById[id];
UNCOV
437
        dispatch(actions.receivedDevice(device));
×
438
      }
439
    })
440
);
441

442
export const getDeviceInfo = createAsyncThunk(`${sliceName}/getDeviceInfo`, (deviceId, { dispatch, getState }) => {
110✔
443
  const device = getDeviceByIdSelector(getState(), deviceId);
2✔
444
  const { hasDeviceConfig, hasDeviceConnect, hasMonitor } = getTenantCapabilities(getState());
2✔
445
  const { canConfigure } = getUserCapabilities(getState());
2✔
446
  const integrations = getDeviceTwinIntegrations(getState());
2✔
447
  let tasks = [dispatch(getDeviceAuth(deviceId)), ...integrations.map(integration => dispatch(getDeviceTwin({ deviceId, integration })))];
2✔
448
  if (hasDeviceConfig && canConfigure && [DEVICE_STATES.accepted, DEVICE_STATES.preauth].includes(device.status)) {
2✔
449
    tasks.push(dispatch(getDeviceConfig(deviceId)));
1✔
450
  }
451
  if (device.status === DEVICE_STATES.accepted) {
2!
452
    // Get full device identity details for single selected device
453
    tasks.push(dispatch(getDeviceById(deviceId)));
2✔
454
    if (hasDeviceConnect) {
2!
455
      tasks.push(dispatch(getDeviceConnect(deviceId)));
2✔
456
    }
457
    if (hasMonitor) {
2!
UNCOV
458
      tasks.push(dispatch(getLatestDeviceAlerts({ id: deviceId })));
×
459
      tasks.push(dispatch(getDeviceMonitorConfig(deviceId)));
×
460
    }
461
  }
462
  return Promise.all(tasks);
2✔
463
});
464

465
export const deriveInactiveDevices = createAsyncThunk(`${sliceName}/deriveInactiveDevices`, (deviceIds, { dispatch, getState }) => {
110✔
466
  const yesterday = new Date();
11✔
467
  yesterday.setDate(yesterday.getDate() - 1);
11✔
468
  const yesterdaysIsoString = yesterday.toISOString();
11✔
469
  // now boil the list down to the ones that were not updated since yesterday
470
  const devices = deviceIds.reduce(
11✔
471
    (accu, id) => {
472
      const device = getDeviceByIdSelector(getState(), id);
22✔
473
      if (device && device.updated_ts > yesterdaysIsoString) {
22!
UNCOV
474
        accu.active.push(id);
×
475
      } else {
476
        accu.inactive.push(id);
22✔
477
      }
478
      return accu;
22✔
479
    },
480
    { active: [], inactive: [] }
481
  );
482
  return dispatch(actions.setInactiveDevices({ activeDeviceTotal: devices.active.length, inactiveDeviceTotal: devices.inactive.length }));
11✔
483
});
484

485
/*
486
    Device Auth + admission
487
  */
488
export const getDeviceCount = createAsyncThunk(`${sliceName}/getDeviceCount`, (status, { dispatch, getState }) =>
110✔
489
  GeneralApi.post(getSearchEndpoint(getState()), {
594✔
490
    page: 1,
491
    per_page: 1,
492
    filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]),
493
    attributes: defaultAttributes
494
  }).then(response => {
495
    const count = Number(response.headers[headerNames.total]);
590✔
496
    if (status) {
590!
497
      return dispatch(actions.setDevicesCountByStatus({ count, status }));
590✔
498
    }
UNCOV
499
    return dispatch(actions.setTotalDevices(count));
×
500
  })
501
);
502

503
export const getAllDeviceCounts = createAsyncThunk(`${sliceName}/getAllDeviceCounts`, (_, { dispatch }) =>
110✔
504
  Promise.all([DEVICE_STATES.accepted, DEVICE_STATES.pending].map(status => dispatch(getDeviceCount(status))))
550✔
505
);
506

507
export const getDeviceLimit = createAsyncThunk(`${sliceName}/getDeviceLimit`, (_, { dispatch }) =>
110✔
508
  GeneralApi.get(`${deviceAuthV2}/limits/max_devices`).then(res => dispatch(actions.setDeviceLimit(res.data.limit)))
11✔
509
);
510

511
export const setDeviceListState = createAsyncThunk(
110✔
512
  `${sliceName}/setDeviceListState`,
513
  ({ shouldSelectDevices = true, forceRefresh, fetchAuth = true, ...selectionState }, { dispatch, getState }) => {
19✔
514
    const currentState = getDeviceListState(getState());
13✔
515
    const refreshTrigger = forceRefresh ? !currentState.refreshTrigger : selectionState.refreshTrigger;
13✔
516
    let nextState = {
13✔
517
      ...currentState,
518
      setOnly: false,
519
      refreshTrigger,
520
      ...selectionState,
521
      sort: { ...currentState.sort, ...selectionState.sort }
522
    };
523
    let tasks = [];
13✔
524
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
525
    const { isLoading: currentLoading, deviceIds: currentDevices, selection: currentSelection, ...currentRequestState } = currentState;
13✔
526
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
527
    const { isLoading: nextLoading, deviceIds: nextDevices, selection: nextSelection, ...nextRequestState } = nextState;
13✔
528
    if (!nextState.setOnly && !deepCompare(currentRequestState, nextRequestState)) {
13✔
529
      const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol, scope: sortScope } = nextState.sort ?? {};
10!
530
      const sortBy = sortCol ? [{ attribute: sortCol, order: sortDown, scope: sortScope }] : undefined;
10!
531
      const applicableSelectedState = nextState.state === routes.allDevices.key ? undefined : nextState.state;
10!
532
      nextState.isLoading = true;
10✔
533
      tasks.push(
10✔
534
        dispatch(getDevicesByStatus({ ...nextState, status: applicableSelectedState, sortOptions: sortBy, fetchAuth }))
535
          .unwrap()
536
          .then(results => {
537
            const { deviceAccu, total } = results[results.length - 1];
10✔
538
            const devicesState = shouldSelectDevices ? { deviceIds: deviceAccu.ids, total, isLoading: false } : { isLoading: false };
10!
539
            return Promise.resolve(dispatch(actions.setDeviceListState(devicesState)));
10✔
540
          })
541
          // whatever happens, change "loading" back to null
UNCOV
542
          .catch(() => Promise.resolve({ isLoading: false }))
×
543
      );
544
    }
545
    tasks.push(dispatch(actions.setDeviceListState(nextState)));
13✔
546
    return Promise.all(tasks);
13✔
547
  }
548
);
549

550
// get devices from inventory
551
export const getDevicesByStatus = createAsyncThunk(`${sliceName}/getDevicesByStatus`, (options, { dispatch, getState }) => {
110✔
552
  const {
553
    status,
554
    fetchAuth = true,
75✔
555
    filterSelection,
556
    group,
557
    selectedIssues = [],
75✔
558
    page = defaultPage,
44✔
559
    perPage = defaultPerPage,
39✔
560
    sortOptions = [],
85✔
561
    selectedAttributes = []
75✔
562
  } = options;
85✔
563
  const state = getState();
85✔
564
  const { applicableFilters, filterTerms } = convertDeviceListStateToFilters({
85✔
565
    filters: filterSelection ?? getDeviceFilters(state),
134✔
566
    group: group ?? getSelectedGroup(state),
136✔
567
    groups: state.devices.groups,
568
    offlineThreshold: state.app.offlineThreshold,
569
    selectedIssues,
570
    status
571
  });
572
  const attributes = [...defaultAttributes, getIdAttribute(getState()), ...selectedAttributes];
85✔
573
  return GeneralApi.post(getSearchEndpoint(getState()), {
85✔
574
    page,
575
    per_page: perPage,
576
    filters: filterTerms,
577
    sort: sortOptions,
578
    attributes
579
  })
580
    .then(response => {
581
      const state = getState();
85✔
582
      const deviceAccu = reduceReceivedDevices(response.data, [], state, status);
85✔
583
      let total = !applicableFilters.length ? Number(response.headers[headerNames.total]) : null;
85✔
584
      if (status && state.devices.byStatus[status].total === deviceAccu.ids.length) {
85✔
585
        total = deviceAccu.ids.length;
45✔
586
      }
587
      let tasks = [dispatch(actions.receivedDevices(deviceAccu.devicesById))];
85✔
588
      if (status) {
85✔
589
        tasks.push(dispatch(actions.setDevicesByStatus({ deviceIds: deviceAccu.ids, status, total })));
54✔
590
      }
591
      // for each device, get device identity info
592
      const receivedDevices = Object.values(deviceAccu.devicesById);
85✔
593
      if (receivedDevices.length && fetchAuth) {
85✔
594
        tasks.push(dispatch(getDevicesWithAuth(receivedDevices)));
62✔
595
      }
596
      tasks.push(Promise.resolve({ deviceAccu, total: Number(response.headers[headerNames.total]) }));
85✔
597
      return Promise.all(tasks);
85✔
598
    })
UNCOV
599
    .catch(err => commonErrorHandler(err, `${status} devices couldn't be loaded.`, dispatch, commonErrorFallback));
×
600
});
601

602
export const getAllDevicesByStatus = createAsyncThunk(`${sliceName}/getAllDevicesByStatus`, (status, { dispatch, getState }) => {
110✔
603
  const attributes = [...defaultAttributes, getIdAttribute(getState())];
14✔
604
  const getAllDevices = (perPage = MAX_PAGE_SIZE, page = 1, devices = []) =>
14✔
605
    GeneralApi.post(getSearchEndpoint(getState()), {
14✔
606
      page,
607
      per_page: perPage,
608
      filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]),
609
      attributes
610
    }).then(res => {
611
      const state = getState();
14✔
612
      const deviceAccu = reduceReceivedDevices(res.data, devices, state, status);
14✔
613
      dispatch(actions.receivedDevices(deviceAccu.devicesById));
14✔
614
      const total = Number(res.headers[headerNames.total]);
14✔
615
      if (total > state.deployments.deploymentDeviceLimit) {
14✔
616
        return Promise.resolve();
3✔
617
      }
618
      if (total > perPage * page) {
11!
UNCOV
619
        return getAllDevices(perPage, page + 1, deviceAccu.ids);
×
620
      }
621
      let tasks = [dispatch(actions.setDevicesByStatus({ deviceIds: deviceAccu.ids, forceUpdate: true, status, total: deviceAccu.ids.length }))];
11✔
622
      if (status === DEVICE_STATES.accepted && deviceAccu.ids.length === total) {
11!
623
        tasks.push(dispatch(deriveInactiveDevices(deviceAccu.ids)));
11✔
624
        tasks.push(dispatch(deriveReportsData()));
11✔
625
      }
626
      return Promise.all(tasks);
11✔
627
    });
628
  return getAllDevices();
14✔
629
});
630

631
export const searchDevices = createAsyncThunk(`${sliceName}/searchDevices`, (passedOptions = {}, { dispatch, getState }) => {
110!
632
  const state = getState();
2✔
633
  let options = { ...state.app.searchState, ...passedOptions };
2✔
634
  const { page = defaultPage, searchTerm, sortOptions = [] } = options;
2✔
635
  const { columnSelection = [] } = getUserSettings(state);
2!
636
  const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope }));
2✔
637
  const attributes = attributeDuplicateFilter([...defaultAttributes, getIdAttribute(state), ...selectedAttributes], 'attribute');
2✔
638
  return GeneralApi.post(getSearchEndpoint(getState()), {
2✔
639
    page,
640
    per_page: 10,
641
    filters: [],
642
    sort: sortOptions,
643
    text: searchTerm,
644
    attributes
645
  })
646
    .then(response => {
647
      const deviceAccu = reduceReceivedDevices(response.data, [], getState());
2✔
648
      return Promise.all([
2✔
649
        dispatch(actions.receivedDevices(deviceAccu.devicesById)),
650
        Promise.resolve({ deviceIds: deviceAccu.ids, searchTotal: Number(response.headers[headerNames.total]) })
651
      ]);
652
    })
UNCOV
653
    .catch(err => commonErrorHandler(err, `devices couldn't be searched.`, dispatch, commonErrorFallback));
×
654
});
655

656
const ATTRIBUTE_LIST_CUTOFF = 100;
110✔
657
const attributeReducer = (attributes = []) =>
110!
658
  attributes.slice(0, ATTRIBUTE_LIST_CUTOFF).reduce(
25✔
659
    (accu, { name, scope }) => {
660
      if (!accu[scope]) {
500!
UNCOV
661
        accu[scope] = [];
×
662
      }
663
      accu[scope].push(name);
500✔
664
      return accu;
500✔
665
    },
666
    { identity: [], inventory: [], system: [], tags: [] }
667
  );
668

669
export const getDeviceAttributes = createAsyncThunk(`${sliceName}/getDeviceAttributes`, (_, { dispatch, getState }) =>
110✔
670
  GeneralApi.get(getAttrsEndpoint(getState())).then(({ data }) => {
23✔
671
    // TODO: remove the array fallback once the inventory attributes endpoint is fixed
672
    const { identity: identityAttributes, inventory: inventoryAttributes, system: systemAttributes, tags: tagAttributes } = attributeReducer(data || []);
23!
673
    return dispatch(actions.setFilterAttributes({ identityAttributes, inventoryAttributes, systemAttributes, tagAttributes }));
23✔
674
  })
675
);
676

677
export const getReportingLimits = createAsyncThunk(`${sliceName}/getReportingLimits`, (_, { dispatch }) =>
110✔
678
  GeneralApi.get(`${reportingApiUrl}/devices/attributes`)
2✔
UNCOV
679
    .catch(err => commonErrorHandler(err, `filterable attributes limit & usage could not be retrieved.`, dispatch, commonErrorFallback))
×
680
    .then(({ data }) => {
681
      const { attributes, count, limit } = data;
2✔
682
      const groupedAttributes = attributeReducer(attributes);
2✔
683
      return Promise.resolve(dispatch(actions.setFilterablesConfig({ count, limit, attributes: groupedAttributes })));
2✔
684
    })
685
);
686

687
export const ensureVersionString = (software, fallback) =>
110✔
688
  software.length && software !== 'artifact_name' ? (software.endsWith('.version') ? software : `${software}.version`) : fallback;
1!
689

690
const getSingleReportData = (reportConfig, groups) => {
110✔
691
  const { attribute, group, software = '' } = reportConfig;
1!
692
  const filters = [{ key: 'status', scope: 'identity', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: 'accepted' }];
1✔
693
  if (group) {
1!
UNCOV
694
    const staticGroupFilter = { key: 'group', scope: 'system', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: group };
×
695
    const { cleanedFilters: groupFilters } = getGroupFilters(group, groups);
×
696
    filters.push(...(groupFilters.length ? groupFilters : [staticGroupFilter]));
×
697
  }
698
  const aggregationAttribute = ensureVersionString(software, attribute);
1✔
699
  return GeneralApi.post(`${reportingApiUrl}/devices/aggregate`, {
1✔
700
    aggregations: [{ attribute: aggregationAttribute, name: '*', scope: 'inventory', size: chartColorPalette.length }],
701
    filters: mapFiltersToTerms(filters)
702
  }).then(({ data }) => ({ data, reportConfig }));
1✔
703
};
704

705
export const getReportsData = createAsyncThunk(`${sliceName}/getReportsData`, (_, { dispatch, getState }) => {
110✔
706
  const state = getState();
1✔
707
  const currentUserId = getCurrentUser(state).id;
1✔
708
  const reports =
709
    getUserSettings(state).reports || getGlobalSettings(state)[`${currentUserId}-reports`] || (Object.keys(getDevicesById(state)).length ? defaultReports : []);
1!
710
  return Promise.all(reports.map(report => getSingleReportData(report, getState().devices.groups))).then(results => {
1✔
711
    const devicesState = getState().devices;
1✔
712
    const totalDeviceCount = devicesState.byStatus.accepted.total;
1✔
713
    const newReports = results.map(({ data, reportConfig }) => {
1✔
714
      let { items, other_count } = data[0];
1✔
715
      const { attribute, group, software = '' } = reportConfig;
1!
716
      const dataCount = items.reduce((accu, item) => accu + item.count, 0);
2✔
717
      // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software
718
      const otherCount = !group && (software === rootfsImageVersion || attribute === 'artifact_name') ? totalDeviceCount - dataCount : other_count;
1!
719
      return { items, otherCount, total: otherCount + dataCount };
1✔
720
    });
721
    return Promise.resolve(dispatch(actions.setDeviceReports(newReports)));
1✔
722
  });
723
});
724

725
const initializeDistributionData = (report, groups, devices, totalDeviceCount) => {
110✔
726
  const { attribute, group = '', software = '' } = report;
24!
727
  const effectiveAttribute = software ? software : attribute;
24!
728
  const { deviceIds, total = 0 } = groups[group] || {};
24✔
729
  const relevantDevices = groups[group] ? deviceIds.map(id => devices[id]) : Object.values(devices);
24✔
730
  const distributionByAttribute = relevantDevices.reduce((accu, item) => {
24✔
731
    if (!item.attributes || item.status !== DEVICE_STATES.accepted) return accu;
63✔
732
    if (!accu[item.attributes[effectiveAttribute]]) {
48✔
733
      accu[item.attributes[effectiveAttribute]] = 0;
24✔
734
    }
735
    accu[item.attributes[effectiveAttribute]] = accu[item.attributes[effectiveAttribute]] + 1;
48✔
736
    return accu;
48✔
737
  }, {});
738
  const distributionByAttributeSorted = Object.entries(distributionByAttribute).sort((pairA, pairB) => pairB[1] - pairA[1]);
24✔
739
  const items = distributionByAttributeSorted.map(([key, count]) => ({ key, count }));
24✔
740
  const dataCount = items.reduce((accu, item) => accu + item.count, 0);
24✔
741
  // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software
742
  const otherCount = (groups[group] ? total : totalDeviceCount) - dataCount;
24✔
743
  return { items, otherCount, total: otherCount + dataCount };
24✔
744
};
745

746
export const deriveReportsData = createAsyncThunk(`${sliceName}/deriveReportsData`, (_, { dispatch, getState }) => {
110✔
747
  const state = getState();
24✔
748
  const {
749
    groups: { byId: groupsById },
750
    byId,
751
    byStatus: {
752
      accepted: { total }
753
    }
754
  } = state.devices;
24✔
755
  const reports =
756
    getUserSettings(state).reports || state.users.globalSettings[`${state.users.currentUser}-reports`] || (Object.keys(byId).length ? defaultReports : []);
24!
757
  const newReports = reports.map(report => initializeDistributionData(report, groupsById, byId, total));
24✔
758
  return Promise.resolve(dispatch(actions.setDeviceReports(newReports)));
24✔
759
});
760

761
export const getReportsDataWithoutBackendSupport = createAsyncThunk(`${sliceName}/getReportsDataWithoutBackendSupport`, (_, { dispatch, getState }) =>
110✔
762
  Promise.all([dispatch(getAllDevicesByStatus(DEVICE_STATES.accepted)), dispatch(getGroups()), dispatch(getDynamicGroups())]).then(() => {
13✔
763
    const { dynamic: dynamicGroups, static: staticGroups } = getGroupsSelector(getState());
13✔
764
    return Promise.all([
13✔
765
      ...staticGroups.map(({ groupId }) => dispatch(getAllGroupDevices(groupId))),
12✔
766
      ...dynamicGroups.map(({ groupId }) => dispatch(getAllDynamicGroupDevices(groupId)))
12✔
767
    ]).then(() => dispatch(deriveReportsData()));
13✔
768
  })
769
);
770

771
export const getDeviceConnect = createAsyncThunk(`${sliceName}/getDeviceConnect`, (id, { dispatch }) =>
110✔
772
  GeneralApi.get(`${deviceConnect}/devices/${id}`).then(({ data }) =>
2✔
773
    Promise.all([dispatch(actions.receivedDevice({ connect_status: data.status, connect_updated_ts: data.updated_ts, id })), Promise.resolve(data)])
1✔
774
  )
775
);
776

777
const updateTypeMap = { deploymentUpdate: 'check-update', inventoryUpdate: 'send-inventory' };
110✔
778
export const triggerDeviceUpdate = createAsyncThunk(`${sliceName}/triggerDeviceUpdate`, ({ id, type }, { dispatch }) =>
110✔
779
  GeneralApi.post(`${deviceConnect}/devices/${id}/${updateTypeMap[type] ?? updateTypeMap.deploymentUpdate}`).then(
3!
780
    () => new Promise(resolve => setTimeout(() => resolve(dispatch(getDeviceById(id))), TIMEOUTS.threeSeconds))
3✔
781
  )
782
);
783

784
export const getSessionDetails = createAsyncThunk(`${sliceName}/getSessionDetails`, ({ sessionId, deviceId, userId, startDate, endDate }) => {
110✔
785
  const createdAfter = startDate ? `&created_after=${Math.round(Date.parse(startDate) / 1000)}` : '';
4✔
786
  const createdBefore = endDate ? `&created_before=${Math.round(Date.parse(endDate) / 1000)}` : '';
4✔
787
  const objectSearch = `&object_id=${deviceId}`;
4✔
788
  return GeneralApi.get(`${auditLogsApiUrl}/logs?per_page=500${createdAfter}${createdBefore}&actor_id=${userId}${objectSearch}`).then(
4✔
789
    ({ data: auditLogEntries }) => {
790
      const { start, end } = auditLogEntries.reduce(
4✔
791
        (accu, item) => {
792
          if (item.meta?.session_id?.includes(sessionId)) {
4!
793
            accu.start = new Date(item.action.startsWith('open') ? item.time : accu.start);
4!
794
            accu.end = new Date(item.action.startsWith('close') ? item.time : accu.end);
4!
795
          }
796
          return accu;
4✔
797
        },
798
        { start: startDate || endDate, end: endDate || startDate }
12✔
799
      );
800
      return Promise.resolve({ start, end });
4✔
801
    }
802
  );
803
});
804

805
export const getDeviceFileDownloadLink = createAsyncThunk(`${sliceName}/getDeviceFileDownloadLink`, ({ deviceId, path }) =>
110✔
806
  Promise.resolve(`${window.location.origin}${deviceConnect}/devices/${deviceId}/download?path=${encodeURIComponent(path)}`)
1✔
807
);
808

809
export const deviceFileUpload = createAsyncThunk(`${sliceName}/deviceFileUpload`, ({ deviceId, path, file }, { dispatch }) => {
110✔
810
  let formData = new FormData();
1✔
811
  formData.append('path', path);
1✔
812
  formData.append('file', file);
1✔
813
  const uploadId = uuid();
1✔
814
  const cancelSource = new AbortController();
1✔
815
  return Promise.all([
1✔
816
    dispatch(setSnackbar('Uploading file')),
817
    dispatch(initUpload({ id: uploadId, upload: { inprogress: true, uploadProgress: 0, cancelSource } })),
818
    GeneralApi.uploadPut(
819
      `${deviceConnect}/devices/${deviceId}/upload`,
820
      formData,
821
      e => dispatch(uploadProgress({ id: uploadId, progress: progress(e) })),
1✔
822
      cancelSource.signal
823
    )
824
  ])
825
    .then(() => Promise.resolve(dispatch(setSnackbar('Upload successful', TIMEOUTS.fiveSeconds))))
1✔
826
    .catch(err => {
UNCOV
827
      if (isCancel(err)) {
×
828
        return dispatch(setSnackbar('The upload has been cancelled', TIMEOUTS.fiveSeconds));
×
829
      }
UNCOV
830
      return commonErrorHandler(err, `Error uploading file to device.`, dispatch);
×
831
    })
832
    .finally(() => dispatch(cleanUpUpload(uploadId)));
1✔
833
});
834

835
export const getDeviceAuth = createAsyncThunk(`${sliceName}/getDeviceAuth`, (id, { dispatch }) =>
110✔
836
  dispatch(getDevicesWithAuth([{ id }]))
6✔
837
    .unwrap()
838
    .then(results => {
839
      if (results[results.length - 1]) {
6!
840
        return Promise.resolve(results[results.length - 1][0]);
6✔
841
      }
UNCOV
842
      return Promise.resolve();
×
843
    })
844
);
845

846
export const getDevicesWithAuth = createAsyncThunk(`${sliceName}/getDevicesWithAuth`, (devices, { dispatch, getState }) =>
110✔
847
  devices.length
70✔
848
    ? GeneralApi.get(`${deviceAuthV2}/devices?id=${devices.map(device => device.id).join('&id=')}`)
121✔
849
        .then(({ data: receivedDevices }) => {
850
          const { devicesById } = reduceReceivedDevices(receivedDevices, [], getState());
69✔
851
          return Promise.all([dispatch(actions.receivedDevices(devicesById)), Promise.resolve(receivedDevices)]);
69✔
852
        })
UNCOV
853
        .catch(err => commonErrorHandler(err, `Error: ${err}`, dispatch))
×
854
    : Promise.resolve([[], []])
855
);
856

857
export const updateDeviceAuth = createAsyncThunk(`${sliceName}/updateDeviceAuth`, ({ deviceId, authId, status }, { dispatch, getState }) =>
110✔
858
  GeneralApi.put(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}/status`, { status })
2✔
859
    .then(() => Promise.all([dispatch(getDeviceAuth(deviceId)), dispatch(setSnackbar('Device authorization status was updated successfully'))]))
2✔
UNCOV
860
    .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch))
×
861
    .then(() => Promise.resolve(dispatch(actions.maybeUpdateDevicesByStatus({ deviceId, authId }))))
2✔
862
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getDeviceListState(getState()).refreshTrigger })))
2✔
863
);
864

865
export const updateDevicesAuth = createAsyncThunk(`${sliceName}/updateDevicesAuth`, ({ deviceIds, status }, { dispatch, getState }) => {
110✔
866
  let devices = getDevicesById(getState());
1✔
867
  const deviceIdsWithoutAuth = deviceIds.reduce((accu, id) => (devices[id].auth_sets ? accu : [...accu, { id }]), []);
2!
868
  return dispatch(getDevicesWithAuth(deviceIdsWithoutAuth)).then(() => {
1✔
869
    devices = getDevicesById(getState());
1✔
870
    // for each device, get id and id of authset & make api call to accept
871
    // if >1 authset, skip instead
872
    const deviceAuthUpdates = deviceIds.map(id => {
1✔
873
      const device = devices[id];
2✔
874
      if (device.auth_sets.length !== 1) {
2✔
875
        return Promise.reject();
1✔
876
      }
877
      // api call device.id and device.authsets[0].id
878
      return dispatch(updateDeviceAuth({ authId: device.auth_sets[0].id, deviceId: device.id, status }))
1✔
879
        .unwrap()
UNCOV
880
        .catch(err => commonErrorHandler(err, 'The action was stopped as there was a problem updating a device authorization status: ', dispatch, '', false));
×
881
    });
882
    return Promise.allSettled(deviceAuthUpdates).then(results => {
1✔
883
      const { skipped, count } = results.reduce(
1✔
884
        (accu, item) => {
885
          if (item.status === 'rejected') {
2✔
886
            accu.skipped = accu.skipped + 1;
1✔
887
          } else {
888
            accu.count = accu.count + 1;
1✔
889
          }
890
          return accu;
2✔
891
        },
892
        { skipped: 0, count: 0 }
893
      );
894
      const message = getSnackbarMessage(skipped, count);
1✔
895
      // break if an error occurs, display status up til this point before error message
896
      return dispatch(setSnackbar(message));
1✔
897
    });
898
  });
899
});
900

901
export const deleteAuthset = createAsyncThunk(`${sliceName}/deleteAuthset`, ({ deviceId, authId }, { dispatch, getState }) =>
110✔
902
  GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}`)
1✔
903
    .then(() => Promise.all([dispatch(setSnackbar('Device authorization status was updated successfully'))]))
1✔
UNCOV
904
    .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch))
×
905
    .then(() => Promise.resolve(dispatch(actions.maybeUpdateDevicesByStatus({ deviceId, authId }))))
1✔
906
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })))
1✔
907
);
908

909
export const preauthDevice = createAsyncThunk(`${sliceName}/preauthDevice`, (authset, { dispatch, rejectWithValue }) =>
110✔
910
  GeneralApi.post(`${deviceAuthV2}/devices`, authset)
4✔
911
    .then(() => Promise.resolve(dispatch(setSnackbar('Device was successfully added to the preauthorization list', TIMEOUTS.fiveSeconds))))
3✔
912
    .catch(err => {
913
      if (err.response.status === 409) {
1!
914
        return rejectWithValue('A device with a matching identity data set already exists');
1✔
915
      }
UNCOV
916
      return commonErrorHandler(err, 'The device could not be added:', dispatch);
×
917
    })
918
);
919

920
export const decommissionDevice = createAsyncThunk(`${sliceName}/decommissionDevice`, ({ deviceId, authId }, { dispatch, getState }) =>
110✔
921
  GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}`)
1✔
922
    .then(() => Promise.resolve(dispatch(setSnackbar('Device was decommissioned successfully'))))
1✔
UNCOV
923
    .catch(err => commonErrorHandler(err, 'There was a problem decommissioning the device:', dispatch))
×
924
    .then(() => Promise.resolve(dispatch(actions.maybeUpdateDevicesByStatus({ deviceId, authId }))))
1✔
925
    // trigger reset of device list list!
926
    .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger })))
1✔
927
);
928

929
export const getDeviceConfig = createAsyncThunk(`${sliceName}/getDeviceConfig`, (deviceId, { dispatch }) =>
110✔
930
  GeneralApi.get(`${deviceConfig}/${deviceId}`)
6✔
931
    .then(({ data }) => Promise.all([dispatch(actions.receivedDevice({ id: deviceId, config: data })), Promise.resolve(data)]))
5✔
932
    .catch(err => {
933
      // 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
934
      if (err.response?.data?.error.status_code !== 404) {
1!
UNCOV
935
        return commonErrorHandler(err, `There was an error retrieving the configuration for device ${deviceId}.`, dispatch, commonErrorFallback);
×
936
      }
937
    })
938
);
939

940
export const setDeviceConfig = createAsyncThunk(`${sliceName}/setDeviceConfig`, ({ deviceId, config }, { dispatch }) =>
110✔
941
  GeneralApi.put(`${deviceConfig}/${deviceId}`, config)
2✔
942
    .catch(err => commonErrorHandler(err, `There was an error setting the configuration for device ${deviceId}.`, dispatch, commonErrorFallback))
1✔
943
    .then(() => Promise.resolve(dispatch(getDeviceConfig(deviceId))))
1✔
944
);
945

946
export const applyDeviceConfig = createAsyncThunk(
110✔
947
  `${sliceName}/applyDeviceConfig`,
948
  ({ deviceId, configDeploymentConfiguration, isDefault, config }, { dispatch, getState }) =>
949
    GeneralApi.post(`${deviceConfig}/${deviceId}/deploy`, configDeploymentConfiguration)
2✔
UNCOV
950
      .catch(err => commonErrorHandler(err, `There was an error deploying the configuration to device ${deviceId}.`, dispatch, commonErrorFallback))
×
951
      .then(({ data }) => {
952
        const device = getDeviceByIdSelector(getState(), deviceId);
2✔
953
        const { canManageUsers } = getUserCapabilities(getState());
2✔
954
        let tasks = [
2✔
955
          dispatch(actions.receivedDevice({ ...device, config: { ...device.config, deployment_id: data.deployment_id } })),
956
          new Promise(resolve => setTimeout(() => resolve(dispatch(getSingleDeployment(data.deployment_id))), TIMEOUTS.oneSecond))
2✔
957
        ];
958
        if (isDefault && canManageUsers) {
2✔
959
          const { previous } = getGlobalSettings(getState()).defaultDeviceConfig ?? {};
1✔
960
          tasks.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: config, previous } })));
1✔
961
        }
962
        return Promise.all(tasks);
2✔
963
      })
964
);
965

966
export const setDeviceTags = createAsyncThunk(`${sliceName}/setDeviceTags`, ({ deviceId, tags }, { dispatch }) =>
110✔
967
  // to prevent tag set failures, retrieve the device & use the freshest etag we can get
968
  Promise.resolve(dispatch(getDeviceById(deviceId))).then(device => {
2✔
969
    const headers = device.etag ? { 'If-Match': device.etag } : {};
2!
970
    return GeneralApi.put(
2✔
971
      `${inventoryApiUrl}/devices/${deviceId}/tags`,
972
      Object.entries(tags).map(([name, value]) => ({ name, value })),
2✔
973
      { headers }
974
    )
UNCOV
975
      .catch(err => commonErrorHandler(err, `There was an error setting tags for device ${deviceId}.`, dispatch, 'Please check your connection.'))
×
976
      .then(() => Promise.all([dispatch(actions.receivedDevice({ ...device, id: deviceId, tags })), dispatch(setSnackbar('Device name changed'))]));
2✔
977
  })
978
);
979

980
export const getDeviceTwin = createAsyncThunk(`${sliceName}/getDeviceTwin`, ({ deviceId, integration }, { dispatch, getState }) => {
110✔
981
  let providerResult = {};
3✔
982
  return GeneralApi.get(`${iotManagerBaseURL}/devices/${deviceId}/state`)
3✔
983
    .then(({ data }) => {
984
      providerResult = { ...data, twinError: '' };
2✔
985
    })
986
    .catch(err => {
UNCOV
987
      providerResult = {
×
988
        twinError: `There was an error getting the ${EXTERNAL_PROVIDER[integration.provider].twinTitle.toLowerCase()} for device ${deviceId}. ${err}`
989
      };
990
    })
991
    .finally(() => {
992
      const device = getDeviceByIdSelector(getState(), deviceId);
2✔
993
      Promise.resolve(dispatch(actions.receivedDevice({ ...device, twinsByIntegration: { ...device.twinsByIntegration, ...providerResult } })));
2✔
994
    });
995
});
996

997
export const setDeviceTwin = createAsyncThunk(`${sliceName}/setDeviceTwin`, ({ deviceId, integration, settings }, { dispatch, getState }) =>
110✔
998
  GeneralApi.put(`${iotManagerBaseURL}/devices/${deviceId}/state/${integration.id}`, { desired: settings })
1✔
999
    .catch(err =>
UNCOV
1000
      commonErrorHandler(
×
1001
        err,
1002
        `There was an error updating the ${EXTERNAL_PROVIDER[integration.provider].twinTitle.toLowerCase()} for device ${deviceId}.`,
1003
        dispatch
1004
      )
1005
    )
1006
    .then(() => {
1007
      const device = getDeviceByIdSelector(getState(), deviceId);
1✔
1008
      const { twinsByIntegration = {} } = device;
1✔
1009
      const { [integration.id]: currentState = {} } = twinsByIntegration;
1✔
1010
      return Promise.resolve(
1✔
1011
        dispatch(actions.receivedDevice({ ...device, twinsByIntegration: { ...twinsByIntegration, [integration.id]: { ...currentState, desired: settings } } }))
1012
      );
1013
    })
1014
);
1015

1016
const prepareSearchArguments = ({ filters, group, state, status }) => {
110✔
1017
  const { filterTerms } = convertDeviceListStateToFilters({ filters, group, offlineThreshold: state.app.offlineThreshold, selectedIssues: [], status });
28✔
1018
  const { columnSelection = [] } = getUserSettings(state);
28!
1019
  const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope }));
28✔
1020
  const attributes = [...defaultAttributes, getIdAttribute(state), ...selectedAttributes];
28✔
1021
  return { attributes, filterTerms };
28✔
1022
};
1023

1024
export const getSystemDevices = createAsyncThunk(`${sliceName}/getSystemDevices`, (options, { dispatch, getState }) => {
110✔
1025
  const { id, page = defaultPage, perPage = defaultPerPage, sortOptions = [] } = options;
1✔
1026
  const state = getState();
1✔
1027
  const { hasFullFiltering } = getTenantCapabilities(state);
1✔
1028
  if (!hasFullFiltering) {
1!
UNCOV
1029
    return Promise.resolve();
×
1030
  }
1031
  const { attributes: deviceAttributes = {} } = getDeviceByIdSelector(state, id);
1!
1032
  const { mender_gateway_system_id = '' } = deviceAttributes;
1✔
1033
  const filters = [
1✔
1034
    { ...emptyFilter, key: 'mender_is_gateway', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: 'true', scope: 'inventory' },
1035
    { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' }
1036
  ];
1037
  const { attributes, filterTerms } = prepareSearchArguments({ filters, state });
1✔
1038
  return GeneralApi.post(getSearchEndpoint(getState()), {
1✔
1039
    page,
1040
    per_page: perPage,
1041
    filters: filterTerms,
1042
    sort: sortOptions,
1043
    attributes
1044
  })
UNCOV
1045
    .catch(err => commonErrorHandler(err, `There was an error getting system devices device ${id}.`, dispatch, 'Please check your connection.'))
×
1046
    .then(({ data, headers }) => {
1047
      const state = getState();
1✔
1048
      const { devicesById, ids } = reduceReceivedDevices(data, [], state);
1✔
1049
      const device = {
1✔
1050
        ...getDeviceByIdSelector(state, id),
1051
        systemDeviceIds: ids,
1052
        systemDeviceTotal: Number(headers[headerNames.total])
1053
      };
1054
      return Promise.resolve(dispatch(actions.receivedDevices({ ...devicesById, [id]: device })));
1✔
1055
    });
1056
});
1057

1058
export const getGatewayDevices = createAsyncThunk(`${sliceName}/getGatewayDevices`, (deviceId, { dispatch, getState }) => {
110✔
1059
  const state = getState();
1✔
1060
  const { attributes = {} } = getDeviceByIdSelector(state, deviceId);
1!
1061
  const { mender_gateway_system_id = '' } = attributes;
1!
1062
  const filters = [
1✔
1063
    { ...emptyFilter, key: 'id', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: deviceId, scope: 'identity' },
1064
    { ...emptyFilter, key: 'mender_is_gateway', value: 'true', scope: 'inventory' },
1065
    { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' }
1066
  ];
1067
  const { attributes: attributeSelection, filterTerms } = prepareSearchArguments({ filters, state });
1✔
1068
  return GeneralApi.post(getSearchEndpoint(getState()), {
1✔
1069
    page: 1,
1070
    per_page: MAX_PAGE_SIZE,
1071
    filters: filterTerms,
1072
    attributes: attributeSelection
1073
  }).then(({ data }) => {
1074
    const { ids } = reduceReceivedDevices(data, [], getState());
1✔
1075
    let tasks = ids.map(deviceId => dispatch(getDeviceInfo(deviceId)));
1✔
1076
    tasks.push(dispatch(actions.receivedDevice({ id: deviceId, gatewayIds: ids })));
1✔
1077
    return Promise.all(tasks);
1✔
1078
  });
1079
});
1080

1081
export const getDevicesInBounds = createAsyncThunk(`${sliceName}/getDevicesInBounds`, ({ bounds, group }, { dispatch, getState }) => {
110✔
UNCOV
1082
  const state = getState();
×
1083
  const { filterTerms } = convertDeviceListStateToFilters({
×
1084
    group: group === ALL_DEVICES ? undefined : group,
×
1085
    groups: state.devices.groups,
1086
    status: DEVICE_STATES.accepted
1087
  });
UNCOV
1088
  return GeneralApi.post(getSearchEndpoint(getState()), {
×
1089
    page: 1,
1090
    per_page: MAX_PAGE_SIZE,
1091
    filters: filterTerms,
1092
    attributes: geoAttributes,
1093
    geo_bounding_box_filter: {
1094
      geo_bounding_box: {
1095
        location: {
1096
          top_left: { lat: bounds._northEast.lat, lon: bounds._southWest.lng },
1097
          bottom_right: { lat: bounds._southWest.lat, lon: bounds._northEast.lng }
1098
        }
1099
      }
1100
    }
1101
  }).then(({ data }) => {
UNCOV
1102
    const { devicesById } = reduceReceivedDevices(data, [], getState());
×
1103
    return Promise.resolve(dispatch(actions.receivedDevices(devicesById)));
×
1104
  });
1105
});
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