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

mendersoftware / mender-server / 10423

11 Nov 2025 04:53PM UTC coverage: 74.435% (-0.1%) from 74.562%
10423

push

gitlab-ci

web-flow
Merge pull request #1071 from mendersoftware/dependabot/npm_and_yarn/frontend/main/development-dependencies-92732187be

3868 of 5393 branches covered (71.72%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

176 existing lines in 95 files now uncovered.

64605 of 86597 relevant lines covered (74.6%)

7.74 hits per line

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

90.53
/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx
1
// Copyright 2019 Northern.tech AS
2✔
2
//
2✔
3
//    Licensed under the Apache License, Version 2.0 (the "License");
2✔
4
//    you may not use this file except in compliance with the License.
2✔
5
//    You may obtain a copy of the License at
2✔
6
//
2✔
7
//        http://www.apache.org/licenses/LICENSE-2.0
2✔
8
//
2✔
9
//    Unless required by applicable law or agreed to in writing, software
2✔
10
//    distributed under the License is distributed on an "AS IS" BASIS,
2✔
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2✔
12
//    See the License for the specific language governing permissions and
2✔
13
//    limitations under the License.
2✔
14
import { useCallback, useEffect, useMemo, useState } from 'react';
2✔
15
import { useDispatch } from 'react-redux';
2✔
16
import { Link } from 'react-router-dom';
2✔
17

2✔
18
import { ErrorOutline as ErrorOutlineIcon } from '@mui/icons-material';
2✔
19
import { Autocomplete, TextField, Tooltip } from '@mui/material';
2✔
20
import { makeStyles } from 'tss-react/mui';
2✔
21

2✔
22
import AsyncAutocomplete from '@northern.tech/common-ui/AsyncAutocomplete';
2✔
23
import { getDeviceIdentityText } from '@northern.tech/common-ui/DeviceIdentity';
2✔
24
import InfoText from '@northern.tech/common-ui/InfoText';
2✔
25
import { ALL_DEVICES, ATTRIBUTE_SCOPES, DEPLOYMENT_TYPES, DEVICE_FILTERING_OPTIONS, DEVICE_STATES } from '@northern.tech/store/constants';
2✔
26
import { formatDeviceSearch } from '@northern.tech/store/locationutils';
2✔
27
import { getReleases, getSystemDevices } from '@northern.tech/store/thunks';
2✔
28
import { stringToBoolean } from '@northern.tech/utils/helpers';
2✔
29
import { useWindowSize } from '@northern.tech/utils/resizehook';
2✔
30
import pluralize from 'pluralize';
2✔
31
import validator from 'validator';
2✔
32

2✔
33
const { isUUID } = validator;
17✔
34

2✔
35
import { HELPTOOLTIPS } from '../../helptips/HelpTooltips';
2✔
36
import { MenderHelpTooltip } from '../../helptips/MenderTooltip';
2✔
37

2✔
38
const useStyles = makeStyles()(theme => ({
17✔
39
  infoStyle: {
2✔
40
    minWidth: 400,
2✔
41
    borderBottom: 'none'
2✔
42
  },
2✔
43
  selection: { minWidth: 'min-content', maxWidth: theme.spacing(50), minHeight: 96, marginBottom: theme.spacing(2) }
2✔
44
}));
2✔
45

2✔
46
const hardCodedStyle = {
17✔
47
  textField: {
2✔
48
    minWidth: 400
2✔
49
  }
2✔
50
};
2✔
51

2✔
52
export const getDevicesLink = ({ devices, filters = [], group, name }) => {
17✔
53
  let devicesLink = '/devices';
139✔
54
  if (filters.length) {
139✔
55
    return `${devicesLink}?${formatDeviceSearch({ pageState: {}, filters, selectedGroup: group })}`;
23✔
56
  }
2✔
57
  // older deployments won't have the filter set so we have to try to guess their targets based on other information
2✔
58
  if (devices.length && (!name || isUUID(name))) {
118!
59
    devicesLink = `${devicesLink}?${devices.map(({ id }) => `id=${id}`).join('&')}`;
2✔
60
    if (devices.length === 1) {
2!
61
      const { systemDeviceIds = [] } = devices[0];
2!
62
      devicesLink = `${devicesLink}${systemDeviceIds.map(id => `&id=${id}`).join('')}`;
2✔
63
    }
2✔
64
  } else if (group) {
118✔
65
    devicesLink = `${devicesLink}?${formatDeviceSearch({ pageState: {}, filters, selectedGroup: group })}`;
49✔
66
  }
2✔
67
  return devicesLink;
118✔
68
};
2✔
69

2✔
70
const deploymentFiltersToTargetText = ({ devicesById, filter, idAttribute }) => {
17✔
71
  const { name, filters = [] } = filter;
1,405✔
72
  if (name) {
1,405✔
73
    return name;
23✔
74
  }
2✔
75
  if (
1,384!
76
    filters.some(
2✔
UNCOV
77
      ({ operator, scope, value }) => scope === ATTRIBUTE_SCOPES.identity && value === DEVICE_STATES.accepted && operator === DEVICE_FILTERING_OPTIONS.$eq.key
2!
78
    )
2✔
79
  ) {
2✔
80
    return ALL_DEVICES;
2✔
81
  }
2✔
82
  const groupFilter = filters.find(
1,384✔
UNCOV
83
    ({ operator, scope, key }) => scope === ATTRIBUTE_SCOPES.system && operator === DEVICE_FILTERING_OPTIONS.$eq.key && key === 'group'
2!
84
  );
2✔
85
  if (groupFilter) {
1,384!
86
    return groupFilter.value;
2✔
87
  }
2✔
88
  return filters
1,384✔
89
    .reduce((accu, { operator, scope, key, value }) => {
2✔
90
      if (!(key === 'id' && scope === ATTRIBUTE_SCOPES.identity)) {
2!
91
        return accu;
2✔
92
      }
2✔
93
      if (operator === DEVICE_FILTERING_OPTIONS.$in.key) {
2!
94
        const devices = value.map(deviceId => getDeviceIdentityText({ device: devicesById[deviceId], idAttribute }));
2✔
95
        return [...accu, ...devices];
2✔
96
      }
2✔
97
      accu.push(getDeviceIdentityText({ device: devicesById[value], idAttribute }));
2✔
98
      return accu;
2✔
99
    }, [])
2✔
100
    .join(', ');
2✔
101
};
2✔
102

2✔
103
export const getDeploymentTargetText = ({ deployment, devicesById, idAttribute }) => {
17✔
104
  const { devices = {}, filter = {}, group = '', name = '', type = DEPLOYMENT_TYPES.software } = deployment;
1,405✔
105
  const text = deploymentFiltersToTargetText({ devicesById, filter, idAttribute });
1,405✔
106
  if (text) {
1,405✔
107
    return text;
23✔
108
  }
2✔
109
  let deviceList = Array.isArray(devices) ? devices : Object.values(devices);
1,384✔
110
  if (isUUID(name) && devicesById[name]) {
1,405!
111
    deviceList = [devicesById[name]];
2✔
112
  }
2✔
113
  if (type !== DEPLOYMENT_TYPES.configuration && (!deviceList.length || group || (deployment.name !== undefined && !isUUID(name)))) {
1,384!
114
    return (group || name) ?? '';
1,384!
115
  }
2✔
116
  return deviceList.map(device => getDeviceIdentityText({ device, idAttribute })).join(', ') || name;
2!
117
};
2✔
118

2✔
119
export const ReleasesWarning = ({ lacksReleases }) => (
17✔
120
  <div className="flexbox center-aligned">
17✔
121
    <ErrorOutlineIcon fontSize="small" style={{ marginRight: 4, top: 4, color: 'rgb(171, 16, 0)' }} />
2✔
122
    <InfoText>
2✔
123
      There are no {lacksReleases ? 'compatible ' : ''}artifacts available.{lacksReleases ? <br /> : ' '}
2✔
124
      <Link to="/releases">Upload one to the repository</Link> to get started.
2✔
125
    </InfoText>
2✔
126
  </div>
2✔
127
);
2✔
128

2✔
129
export const Devices = ({
17✔
130
  deploymentObject,
2✔
131
  groupRef,
2✔
132
  groupNames,
2✔
133
  hasDevices,
2✔
134
  hasDynamicGroups,
2✔
135
  hasFullFiltering,
2✔
136
  hasPending,
2✔
137
  idAttribute,
2✔
138
  setDeploymentSettings
2✔
139
}) => {
2✔
140
  const { classes } = useStyles();
122✔
141
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
2✔
142
  const size = useWindowSize();
122✔
143
  const dispatch = useDispatch();
122✔
144

2✔
145
  const { deploymentDeviceCount = 0, devices = [], filter, group = null } = deploymentObject;
122✔
146
  // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
147
  const device = useMemo(() => (devices.length === 1 ? devices[0] : {}), [devices.length]);
122!
148

2✔
149
  useEffect(() => {
122✔
150
    const { attributes = {} } = device;
7✔
151
    const { mender_is_gateway } = attributes;
7✔
152
    if (!device.id || !stringToBoolean(mender_is_gateway)) {
7!
153
      return;
7✔
154
    }
2✔
155
    dispatch(getSystemDevices({ id: device.id, perPage: 500 }));
2✔
156
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
157
  }, [device.id, device.attributes?.mender_is_gateway, dispatch]);
2✔
158

2✔
159
  const deploymentSettingsUpdate = (e, value, reason) => {
122✔
160
    let update = { group: value };
4✔
161
    if (reason === 'clear') {
4!
162
      update = { ...update, deploymentDeviceCount: 0, devices: [] };
2✔
163
    }
2✔
164
    setDeploymentSettings(update);
4✔
165
  };
2✔
166

2✔
167
  const { deviceText, devicesLink, targetDeviceCount, targetDevicesText } = useMemo(() => {
122✔
168
    const devicesLink = getDevicesLink({ devices, group, hasFullFiltering, filters: filter?.filters });
120✔
169
    let deviceText = getDeploymentTargetText({ deployment: deploymentObject, idAttribute });
120✔
170
    let targetDeviceCount = deploymentDeviceCount;
120✔
171
    let targetDevicesText = `${deploymentDeviceCount} ${pluralize('devices', deploymentDeviceCount)}`;
120✔
172
    if (device?.id) {
120!
173
      const { attributes = {}, systemDeviceIds = [] } = device;
2!
174
      const { mender_is_gateway } = attributes;
2✔
175
      deviceText = `${getDeviceIdentityText({ device, idAttribute })}${stringToBoolean(mender_is_gateway) ? ' (System)' : ''}`;
2!
176
      // here we hope the number of systemDeviceIds doesn't exceed the queried 500 and add the gateway device
2✔
177
      targetDeviceCount = systemDeviceIds.length + 1;
2✔
178
    } else if (group) {
120✔
179
      deviceText = '';
68✔
180
      targetDevicesText = 'All devices';
68✔
181
      targetDeviceCount = 2;
68✔
182
      if (group !== ALL_DEVICES) {
68✔
183
        targetDevicesText = `${targetDevicesText} in this group`;
22✔
184
        targetDeviceCount = deploymentDeviceCount;
22✔
185
      }
2✔
186
    }
2✔
187
    return { deviceText, devicesLink, targetDeviceCount, targetDevicesText };
120✔
188
  }, [devices, filter, group, hasFullFiltering, deploymentObject, idAttribute, deploymentDeviceCount, device]);
2✔
189

2✔
190
  return (
122✔
191
    <>
2✔
192
      <h4 className="margin-top-none">Select a device group to target</h4>
2✔
193
      <div ref={groupRef} className={classes.selection}>
2✔
194
        {deviceText ? (
2!
195
          <TextField value={deviceText} label={pluralize('device', devices.length)} disabled className={classes.infoStyle} />
2✔
196
        ) : (
2✔
197
          <div>
2✔
198
            <Autocomplete
2✔
199
              id="deployment-device-group-selection"
2✔
200
              autoSelect
2✔
201
              autoHighlight
2✔
202
              filterSelectedOptions
2✔
203
              handleHomeEndKeys
2✔
204
              disabled={!(hasDevices || hasDynamicGroups)}
2✔
205
              options={groupNames}
2✔
206
              onChange={deploymentSettingsUpdate}
2✔
207
              renderInput={params => (
2✔
208
                <TextField {...params} placeholder="Select a device group" InputProps={{ ...params.InputProps }} className={classes.textField} />
143✔
209
              )}
2✔
210
              value={group}
2✔
211
            />
2✔
212
            {!(hasDevices || hasDynamicGroups) && (
2!
213
              <InfoText style={{ marginTop: '10px' }}>
2✔
214
                <ErrorOutlineIcon style={{ marginRight: '4px', fontSize: '18px', top: '4px', color: 'rgb(171, 16, 0)', position: 'relative' }} />
2✔
215
                There are no connected devices.{' '}
2✔
216
                {hasPending ? (
2!
217
                  <span>
2✔
218
                    <Link to="/devices/pending">Accept pending devices</Link> to get started.
2✔
219
                  </span>
2✔
220
                ) : (
2✔
221
                  <span>
2✔
222
                    <Link to="/help/get-started">Read the help pages</Link> for help with connecting devices.
2✔
223
                  </span>
2✔
224
                )}
2✔
225
              </InfoText>
2✔
226
            )}
2✔
227
          </div>
2✔
228
        )}
2✔
229
        {!!targetDeviceCount && (
2✔
230
          <InfoText>
2✔
231
            {targetDevicesText} will be targeted. <Link to={devicesLink}>View the {pluralize('devices', targetDeviceCount)}</Link>
2✔
232
          </InfoText>
2✔
233
        )}
2✔
234
      </div>
2✔
235
    </>
2✔
236
  );
2✔
237
};
2✔
238

2✔
239
export const Software = ({ commonClasses, deploymentObject, releaseRef, releases, releasesById, setDeploymentSettings }) => {
17✔
240
  const [isLoadingReleases, setIsLoadingReleases] = useState(!releases.length);
120✔
241
  const dispatch = useDispatch();
120✔
242
  const { classes } = useStyles();
120✔
243
  const { devices = [], release: deploymentRelease = null, releaseSelectionLocked } = deploymentObject;
120✔
244
  const device = devices.length ? devices[0] : undefined;
120!
245

2✔
246
  useEffect(() => {
120✔
247
    setIsLoadingReleases(!releases.length);
8✔
248
  }, [releases.length]);
2✔
249

2✔
250
  const releaseItems = useMemo(() => {
120✔
251
    let releaseItems = releases.map(rel => releasesById[rel]);
304✔
252
    if (device && device.attributes) {
8!
253
      // If single device, don't show incompatible releases
2✔
254
      releaseItems = releaseItems.filter(rel => rel.device_types_compatible.some(type => device.attributes.device_type.includes(type)));
2✔
255
    }
2✔
256
    return releaseItems;
8✔
257
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
258
  }, [device, releases]);
2✔
259

2✔
260
  const onReleaseSelectionChange = useCallback(
120✔
261
    release => {
2✔
262
      if (release !== deploymentObject.release) {
10✔
263
        setDeploymentSettings({ release });
6✔
264
      }
2✔
265
    },
2✔
266
    [deploymentObject.release, setDeploymentSettings]
2✔
267
  );
2✔
268

2✔
269
  const onReleaseInputChange = useCallback(
120✔
270
    inputValue => {
2✔
271
      setIsLoadingReleases(!releases.length);
2✔
272
      return dispatch(getReleases({ page: 1, perPage: 100, searchTerm: inputValue, searchOnly: true })).finally(() => setIsLoadingReleases(false));
2✔
273
    },
2✔
274
    [dispatch, releases.length]
2✔
275
  );
2✔
276

2✔
277
  const releaseDeviceTypes = (deploymentRelease && deploymentRelease.device_types_compatible) ?? [];
120✔
278
  const devicetypesInfo = (
2✔
279
    <Tooltip title={<p>{releaseDeviceTypes.join(', ')}</p>} placement="bottom">
120✔
280
      <span className="link">
2✔
281
        {releaseDeviceTypes.length} device {pluralize('types', releaseDeviceTypes.length)}
2✔
282
      </span>
2✔
283
    </Tooltip>
2✔
284
  );
2✔
285

2✔
286
  return (
120✔
287
    <>
2✔
288
      <h4 className="margin-top-none">Select a Release to deploy</h4>
2✔
289
      <div className={commonClasses.columns}>
2✔
290
        <div ref={releaseRef} className={classes.selection}>
2✔
291
          {releaseSelectionLocked ? (
2!
292
            <TextField value={deploymentRelease?.name} label="Release" disabled className={classes.infoStyle} />
2✔
293
          ) : (
2✔
294
            <AsyncAutocomplete
2✔
295
              id="deployment-release-selection"
2✔
296
              initialValue={deploymentRelease?.name}
2✔
297
              labelAttribute="name"
2✔
298
              placeholder="Select a Release"
2✔
299
              selectionAttribute="name"
2✔
300
              options={releaseItems}
2✔
301
              onChange={onReleaseInputChange}
2✔
302
              onChangeSelection={onReleaseSelectionChange}
2✔
303
              isLoading={isLoadingReleases}
2✔
304
              styles={hardCodedStyle}
2✔
305
            />
2✔
306
          )}
2✔
307
          {!releaseItems.length ? (
2✔
308
            <ReleasesWarning lacksReleases />
2✔
309
          ) : (
2✔
310
            !!releaseDeviceTypes.length && <InfoText style={{ marginBottom: 0 }}>This Release is compatible with {devicetypesInfo}.</InfoText>
2✔
311
          )}
2✔
312
        </div>
2✔
313
        <div className="margin-left-small">
2✔
314
          <MenderHelpTooltip id={HELPTOOLTIPS.groupDeployment.id} />
2✔
315
        </div>
2✔
316
      </div>
2✔
317
    </>
2✔
318
  );
2✔
319
};
2✔
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