• 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

92.64
/frontend/src/js/components/devices/device-details/Configuration.tsx
1
// Copyright 2021 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, useRef, useState } from 'react';
2✔
15
import { useDispatch, useSelector } from 'react-redux';
2✔
16
import { Link } from 'react-router-dom';
2✔
17

2✔
18
import { Block as BlockIcon, CheckCircle as CheckCircleIcon, Error as ErrorIcon, Refresh as RefreshIcon, SaveAlt as SaveAltIcon } from '@mui/icons-material';
2✔
19
import { Button, Checkbox, FormControlLabel, Typography } from '@mui/material';
2✔
20

2✔
21
import ConfigurationObject from '@northern.tech/common-ui/ConfigurationObject';
2✔
22
import Confirm, { EditButton } from '@northern.tech/common-ui/Confirm';
2✔
23
import { DOCSTIPS, DocsTooltip } from '@northern.tech/common-ui/DocsLink';
2✔
24
import EnterpriseNotification from '@northern.tech/common-ui/EnterpriseNotification';
2✔
25
import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint';
2✔
26
import Loader from '@northern.tech/common-ui/Loader';
2✔
27
import Time from '@northern.tech/common-ui/Time';
2✔
28
import LogDialog from '@northern.tech/common-ui/dialogs/Log';
2✔
29
import KeyValueEditor from '@northern.tech/common-ui/forms/KeyValueEditor';
2✔
30
import storeActions from '@northern.tech/store/actions';
2✔
31
import { BENEFITS, DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, DEVICE_STATES, TIMEOUTS } from '@northern.tech/store/constants';
2✔
32
import { getDeviceConfigDeployment, getTenantCapabilities, getUserCapabilities } from '@northern.tech/store/selectors';
2✔
33
import {
2✔
34
  abortDeployment,
2✔
35
  applyDeviceConfig,
2✔
36
  getDeviceConfig,
2✔
37
  getDeviceLog,
2✔
38
  getSingleDeployment,
2✔
39
  saveGlobalSettings,
2✔
40
  setDeviceConfig
2✔
41
} from '@northern.tech/store/thunks';
2✔
42
import { groupDeploymentDevicesStats, groupDeploymentStats } from '@northern.tech/store/utils';
2✔
43
import { deepCompare, isEmpty, toggle } from '@northern.tech/utils/helpers';
2✔
44

2✔
45
import Tracking from '../../../tracking';
2✔
46
import { HELPTOOLTIPS } from '../../helptips/HelpTooltips';
2✔
47
import { MenderHelpTooltip } from '../../helptips/MenderTooltip';
2✔
48
import ConfigImportDialog from './ConfigImportDialog';
2✔
49
import DeviceDataCollapse from './DeviceDataCollapse';
2✔
50

2✔
51
const { setSnackbar } = storeActions;
11✔
52

2✔
53
const buttonStyle = { marginLeft: 30 };
11✔
54
const iconStyle = { margin: 12 };
11✔
55
const textStyle = { textTransform: 'capitalize', textAlign: 'left' };
11✔
56

2✔
57
const defaultReportTimeStamp = '0001-01-01T00:00:00Z';
11✔
58

2✔
59
const configHelpTipsMap = {
11✔
60
  'mender-demo-raspberrypi-led': {
2✔
61
    position: 'right',
2✔
UNCOV
62
    component: ({ anchor, ...props }) => <MenderHelpTooltip style={anchor} id={HELPTOOLTIPS.configureRaspberryLedTip.id} contentProps={props} />
2✔
63
  },
2✔
64
  timezone: {
2✔
65
    position: 'right',
2✔
UNCOV
66
    component: ({ anchor, ...props }) => <MenderHelpTooltip style={anchor} id={HELPTOOLTIPS.configureTimezoneTip.id} contentProps={props} />
2✔
67
  }
2✔
68
};
2✔
69

2✔
70
export const ConfigUpToDateNote = ({ updated_ts = defaultReportTimeStamp }) => (
11✔
71
  <div className="flexbox margin-small">
6✔
72
    <CheckCircleIcon className="green" style={iconStyle} />
2✔
73
    <div>
2✔
74
      <Typography variant="subtitle2" style={textStyle}>
2✔
75
        Configuration up-to-date on the device
2✔
76
      </Typography>
2✔
77
      <Typography variant="caption" className="muted" style={textStyle}>
2✔
78
        Updated: {<Time value={updated_ts} />}
2✔
79
      </Typography>
2✔
80
    </div>
2✔
81
  </div>
2✔
82
);
2✔
83

2✔
84
export const ConfigEmptyNote = ({ updated_ts = '' }) => (
11✔
85
  <div className="flexbox column margin-small">
4✔
86
    <Typography variant="subtitle2">The device appears to either have an empty configuration or not to have reported a configuration yet.</Typography>
2✔
87
    <Typography variant="caption" className="muted" style={textStyle}>
2✔
88
      Updated: {<Time value={updated_ts} />}
2✔
89
    </Typography>
2✔
90
  </div>
2✔
91
);
2✔
92

2✔
93
export const ConfigEditingActions = ({ canSetDefault, isSetAsDefault, onSetAsDefaultChange, onSubmit, onCancel }) => (
11✔
94
  <>
33✔
95
    {canSetDefault && (
2✔
96
      <div style={{ maxWidth: 275 }}>
2✔
97
        <FormControlLabel
2✔
98
          control={<Checkbox color="primary" checked={isSetAsDefault} onChange={onSetAsDefaultChange} size="small" />}
2✔
99
          label="Save as default configuration"
2✔
100
          style={{ marginTop: 0 }}
2✔
101
        />
2✔
102
        <div className="muted">You can import these key value pairs when configuring other devices</div>
2✔
103
      </div>
2✔
104
    )}
2✔
105
    <Button variant="contained" onClick={onSubmit} style={buttonStyle}>
2✔
106
      Save and apply to device
2✔
107
    </Button>
2✔
108
    <Button onClick={onCancel} style={buttonStyle}>
2✔
109
      Cancel changes
2✔
110
    </Button>
2✔
111
  </>
2✔
112
);
2✔
113

2✔
114
export const ConfigUpdateNote = ({ isUpdatingConfig, isAccepted }) => (
11✔
115
  <div>
6✔
116
    <Typography variant="subtitle2" style={textStyle}>
2✔
117
      {!isAccepted
2!
118
        ? 'Configuration will be applied once the device is connected'
2✔
119
        : isUpdatingConfig
2!
120
          ? 'Updating configuration on device...'
2✔
121
          : 'Configuration could not be updated on device'}
2✔
122
    </Typography>
2✔
123
    <Typography variant="caption" className="muted" style={textStyle}>
2✔
124
      Status: {isUpdatingConfig || !isAccepted ? 'pending' : 'failed'}
2!
125
    </Typography>
2✔
126
  </div>
2✔
127
);
2✔
128

2✔
129
export const ConfigUpdateFailureActions = ({ hasLog, onSubmit, onCancel, setShowLog }) => (
11✔
130
  <>
3✔
131
    {hasLog && (
2!
132
      <Button onClick={setShowLog} style={buttonStyle}>
2✔
133
        View log
2✔
134
      </Button>
2✔
135
    )}
2✔
136
    <Button onClick={onSubmit} startIcon={<RefreshIcon fontSize="small" />} style={buttonStyle}>
2✔
137
      Retry
2✔
138
    </Button>
2✔
139
    <a className="margin-left-large" onClick={onCancel}>
2✔
140
      cancel changes
2✔
141
    </a>
2✔
142
  </>
2✔
143
);
2✔
144

2✔
145
export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId } }) => {
11✔
146
  const { device, deviceConfigDeployment: deployment } = useSelector(state => getDeviceConfigDeployment(state, deviceId));
49✔
147
  const { hasDeviceConfig } = useSelector(getTenantCapabilities);
40✔
148
  const { canManageUsers } = useSelector(getUserCapabilities);
40✔
149
  const { config = {}, status } = device;
40✔
150
  const { configured = {}, deployment_id, reported = {}, reported_ts, updated_ts } = config;
40✔
151
  const isRelevantDeployment = deployment.created > updated_ts && (!reported_ts || deployment.finished > reported_ts);
40✔
152
  const [changedConfig, setChangedConfig] = useState();
40✔
153
  const [editableConfig, setEditableConfig] = useState();
40✔
154
  const [isAborting, setIsAborting] = useState(false);
40✔
155
  const [isEditingConfig, setIsEditingConfig] = useState(false);
40✔
156
  const [isSetAsDefault, setIsSetAsDefault] = useState(false);
40✔
157
  const [isUpdatingConfig, setIsUpdatingConfig] = useState(false);
40✔
158
  const [showConfigImport, setShowConfigImport] = useState(false);
40✔
159
  const [showLog, setShowLog] = useState(false);
40✔
160
  const [updateFailed, setUpdateFailed] = useState();
40✔
161
  const [updateLog, setUpdateLog] = useState();
40✔
162
  const dispatch = useDispatch();
40✔
163
  const deploymentTimer = useRef();
40✔
164

2✔
165
  useEffect(() => {
40✔
166
    if (!isEmpty(config) && !isEmpty(changedConfig) && !isEditingConfig) {
27✔
167
      setIsEditingConfig(isUpdatingConfig || updateFailed);
3✔
168
    }
2✔
169
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
170
  }, [JSON.stringify(config), JSON.stringify(changedConfig), isEditingConfig, isUpdatingConfig, updateFailed]);
2✔
171

2✔
172
  useEffect(() => {
40✔
173
    if (deployment.devices && deployment.devices[device.id]?.log) {
5!
174
      setUpdateLog(deployment.devices[device.id].log);
2✔
175
    }
2✔
176

2✔
177
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
178
  }, [JSON.stringify(deployment.devices), device.id]);
2✔
179

2✔
180
  useEffect(() => {
40✔
181
    clearInterval(deploymentTimer.current);
6✔
182
    if (isRelevantDeployment && deployment.status !== DEPLOYMENT_STATES.finished) {
6!
183
      deploymentTimer.current = setInterval(() => dispatch(getSingleDeployment(deployment_id)), TIMEOUTS.refreshDefault);
2✔
184
    } else if (deployment_id && !isRelevantDeployment) {
6✔
185
      dispatch(getSingleDeployment(deployment_id));
3✔
186
    }
2✔
187
    return () => {
6✔
188
      clearInterval(deploymentTimer.current);
6✔
189
    };
2✔
190
  }, [deployment.status, deployment_id, dispatch, isRelevantDeployment]);
2✔
191

2✔
192
  useEffect(() => {
40✔
193
    if (!isRelevantDeployment) {
6✔
194
      return;
5✔
195
    }
2✔
196
    if (deployment.status === DEPLOYMENT_STATES.finished) {
3!
197
      // we have to rely on the device stats here as the state change might not have propagated to the deployment status
2✔
198
      // leaving all stats at 0 and giving a false impression of deployment success
2✔
199
      const stats = groupDeploymentStats(deployment);
3✔
200
      const deviceStats = groupDeploymentDevicesStats(deployment);
3✔
201
      const updateFailed = !!((stats.failures || deviceStats.failures) && deployment.device_count);
3!
202
      setUpdateFailed(updateFailed);
3✔
203
      setIsEditingConfig(updateFailed);
3✔
204
      setIsUpdatingConfig(false);
3✔
205
      dispatch(getDeviceConfig(device.id));
3✔
UNCOV
206
    } else if (deployment.status) {
2!
207
      setChangedConfig(configured);
2✔
208
      setEditableConfig(configured);
2✔
209
      // we can't rely on the deployment.status to be !== 'finished' since `deployment` is initialized as an empty object
2✔
210
      // and thus the undefined status would also point to an ongoing update
2✔
211
      setIsUpdatingConfig(true);
2✔
212
    }
2✔
213
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
214
  }, [JSON.stringify(configured), JSON.stringify(deployment.stats), deployment.created, deployment.status, deployment.finished, isRelevantDeployment]);
2✔
215

2✔
216
  useEffect(() => {
40✔
217
    if (!isRelevantDeployment) {
7✔
218
      return;
6✔
219
    }
2✔
220
    if (!changedConfig && !isEmpty(config) && (!deployment_id || deployment.status)) {
3!
221
      // let currentConfig = reported;
2✔
222
      const stats = groupDeploymentStats(deployment);
2✔
223
      if (deployment.status !== DEPLOYMENT_STATES.finished || stats.failures) {
2!
224
        setEditableConfig(configured);
2✔
225
        setChangedConfig(configured);
2✔
226
      }
2✔
227
    }
2✔
228
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
229
  }, [dispatch, JSON.stringify(config), deployment.status, !changedConfig, JSON.stringify(deployment), isRelevantDeployment]);
2✔
230

2✔
231
  const onConfigImport = ({ config, importType }) => {
40✔
232
    let updatedConfig = config;
2✔
233
    if (importType === 'default') {
2!
234
      updatedConfig = defaultConfig.current;
2✔
235
    }
2✔
236
    setChangedConfig(updatedConfig);
2✔
237
    setEditableConfig(updatedConfig);
2✔
238
    setShowConfigImport(false);
2✔
239
  };
2✔
240

2✔
241
  const onSetSnackbar = useCallback((...args) => dispatch(setSnackbar(...args)), [dispatch]);
40✔
242

2✔
243
  const onSetAsDefaultChange = () => setIsSetAsDefault(toggle);
40✔
244

2✔
245
  const onShowLog = () =>
40✔
246
    dispatch(getDeviceLog({ deploymentId: deployment_id, deviceId: device.id }))
2✔
247
      .unwrap()
2✔
248
      .then(result => {
2✔
249
        setShowLog(true);
2✔
250
        setUpdateLog(result[1]);
2✔
251
      });
2✔
252

2✔
253
  const onClose = () => {
40✔
254
    setIsEditingConfig(false);
2✔
255
    setUpdateFailed(false);
2✔
256
    setIsAborting(false);
2✔
257
  };
2✔
258

2✔
259
  const onCancel = () => {
40✔
260
    if (!isEmpty(reported)) {
2!
261
      setEditableConfig(reported);
2✔
262
      setChangedConfig(reported);
2✔
263
    }
2✔
264
    const requests = [];
2✔
265
    if (deployment_id && deployment.status !== DEPLOYMENT_STATES.finished) {
2!
266
      requests.push(dispatch(abortDeployment(deployment_id)));
2✔
267
    }
2✔
268
    if (deepCompare(reported, changedConfig)) {
2!
269
      requests.push(Promise.resolve());
2✔
270
    } else {
2✔
271
      requests.push(dispatch(setDeviceConfig({ deviceId: device.id, config: reported })));
2✔
272
      if (isSetAsDefault && canManageUsers) {
2!
273
        requests.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: defaultConfig.previous } })));
2✔
274
      }
2✔
275
    }
2✔
276
    return Promise.all(requests).then(() => {
2✔
277
      setIsUpdatingConfig(false);
2✔
278
      setUpdateFailed(false);
2✔
279
      setIsAborting(false);
2✔
280
    });
2✔
281
  };
2✔
282

2✔
283
  const onSubmit = () => {
40✔
284
    Tracking.event({ category: 'devices', action: 'apply_configuration' });
3✔
285
    setIsUpdatingConfig(true);
3✔
286
    setUpdateFailed(false);
3✔
287
    return dispatch(setDeviceConfig({ deviceId: device.id, config: changedConfig }))
3✔
288
      .unwrap()
2✔
289
      .then(() =>
2✔
290
        dispatch(applyDeviceConfig({ deviceId: device.id, configDeploymentConfiguration: { retries: 0 }, isDefault: isSetAsDefault, config: changedConfig }))
3✔
291
      )
2✔
292
      .catch(() => {
2✔
293
        setIsEditingConfig(true);
2✔
294
        setUpdateFailed(true);
2✔
295
        setIsUpdatingConfig(false);
2✔
296
      });
2✔
297
  };
2✔
298

2✔
299
  const onStartEdit = e => {
40✔
300
    e.stopPropagation();
3✔
301
    const nextEditableConfig = { ...configured, ...reported };
3✔
302
    setChangedConfig(nextEditableConfig);
3✔
303
    setEditableConfig(nextEditableConfig);
3✔
304
    setIsEditingConfig(true);
3✔
305
  };
2✔
306

2✔
307
  const onStartImportClick = e => {
40✔
308
    e.stopPropagation();
2✔
309
    setShowConfigImport(true);
2✔
310
  };
2✔
311

2✔
312
  const onAbortClick = () => setIsAborting(toggle);
40✔
313

2✔
314
  const hasDeviceConfiguration = !isEmpty(reported);
40✔
315
  let footer = hasDeviceConfiguration ? <ConfigUpToDateNote updated_ts={reported_ts} /> : <ConfigEmptyNote updated_ts={updated_ts} />;
40✔
316
  if (isEditingConfig) {
40✔
317
    footer = (
35✔
318
      <ConfigEditingActions
2✔
319
        canSetDefault={canManageUsers}
2✔
320
        isSetAsDefault={isSetAsDefault}
2✔
321
        onSetAsDefaultChange={onSetAsDefaultChange}
2✔
322
        onSubmit={onSubmit}
2✔
323
        onCancel={onClose}
2✔
324
      />
2✔
325
    );
2✔
326
  }
2✔
327
  if (isUpdatingConfig || updateFailed) {
40✔
328
    const hasLog = deployment.devices && deployment.devices[device.id]?.log;
5✔
329
    footer = (
5✔
330
      <>
2✔
331
        <div className="flexbox">
2✔
332
          {isUpdatingConfig && <Loader show={true} style={{ marginRight: 15, marginTop: -15 }} />}
2✔
333
          {updateFailed && <ErrorIcon className="red" style={iconStyle} />}
2!
334
          <ConfigUpdateNote isUpdatingConfig={isUpdatingConfig} isAccepted={status === DEVICE_STATES.accepted} />
2✔
335
        </div>
2✔
336
        {updateFailed ? (
2!
337
          <ConfigUpdateFailureActions hasLog={hasLog} setShowLog={onShowLog} onSubmit={onSubmit} onCancel={onCancel} />
2✔
338
        ) : isAborting ? (
2!
339
          <Confirm cancel={onAbortClick} action={onCancel} type="abort" classes="margin-left-large" />
2✔
340
        ) : (
2✔
341
          <>
2✔
342
            <Button color="secondary" onClick={onAbortClick} startIcon={<BlockIcon fontSize="small" />} style={buttonStyle}>
2✔
343
              Abort update
2✔
344
            </Button>
2✔
345
            <Button component={Link} to={`/deployments/${deployment.status || DEPLOYMENT_ROUTES.active.key}?open=true&id=${deployment_id}`} style={buttonStyle}>
2✔
346
              View deployment
2✔
347
            </Button>
2✔
348
          </>
2✔
349
        )}
2✔
350
      </>
2✔
351
    );
2✔
352
  }
2✔
353

2✔
354
  const helpTipsMap = Object.entries(configHelpTipsMap).reduce((accu, [key, value]) => {
40✔
355
    accu[key] = {
78✔
356
      ...value,
2✔
357
      props: { deviceId: device.id }
2✔
358
    };
2✔
359
    return accu;
78✔
360
  }, {});
2✔
361

2✔
362
  return (
40✔
363
    <DeviceDataCollapse
2✔
364
      isAddOn
2✔
365
      title={
2✔
366
        <div className="two-columns">
2✔
367
          <div className="flexbox center-aligned">
2✔
368
            <h4 className="margin-right">Device configuration</h4>
2✔
369
            {hasDeviceConfig && !(isEditingConfig || isUpdatingConfig) && <EditButton onClick={onStartEdit} />}
2✔
370
          </div>
2✔
371
          <div className="flexbox center-aligned">
2✔
372
            {isEditingConfig ? (
2✔
373
              <Button onClick={onStartImportClick} disabled={isUpdatingConfig} startIcon={<SaveAltIcon />} style={{ justifySelf: 'left' }}>
2✔
374
                Import configuration
2✔
375
              </Button>
2✔
376
            ) : null}
2✔
377
            <InfoHintContainer>
2✔
378
              <EnterpriseNotification id={BENEFITS.deviceConfiguration.id} />
2✔
379
              <MenderHelpTooltip id={HELPTOOLTIPS.configureAddOnTip.id} style={{ marginTop: 5 }} />
2✔
380
              <DocsTooltip id={DOCSTIPS.deviceConfig.id} />
2✔
381
            </InfoHintContainer>
2✔
382
          </div>
2✔
383
        </div>
2✔
384
      }
2✔
385
    >
2✔
386
      <div className="relative">
2✔
387
        {isEditingConfig ? (
2✔
388
          <KeyValueEditor
2✔
389
            disabled={isUpdatingConfig}
2✔
390
            errortext=""
2✔
391
            initialInput={editableConfig}
2✔
392
            inputHelpTipsMap={helpTipsMap}
2✔
393
            onInputChange={setChangedConfig}
2✔
394
          />
2✔
395
        ) : (
2✔
396
          hasDeviceConfig && <ConfigurationObject config={reported} setSnackbar={onSetSnackbar} />
2✔
397
        )}
2✔
398
        {hasDeviceConfig && <div className="flexbox center-aligned margin-bottom margin-top">{footer}</div>}
2✔
UNCOV
399
        {showLog && <LogDialog logData={updateLog} onClose={() => setShowLog(false)} type="configUpdateLog" />}
2!
UNCOV
400
        {showConfigImport && <ConfigImportDialog onCancel={() => setShowConfigImport(false)} onSubmit={onConfigImport} />}
2!
401
      </div>
2✔
402
    </DeviceDataCollapse>
2✔
403
  );
2✔
404
};
2✔
405

2✔
406
export default DeviceConfiguration;
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