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

mendersoftware / gui / 963002358

pending completion
963002358

Pull #3870

gitlab-ci

mzedel
chore: cleaned up left over onboarding tooltips & aligned with updated design

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #3870: MEN-5413

4348 of 6319 branches covered (68.81%)

95 of 122 new or added lines in 24 files covered. (77.87%)

1734 existing lines in 160 files now uncovered.

8174 of 9951 relevant lines covered (82.14%)

178.12 hits per line

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

56.3
/src/js/components/devices/device-details/configuration.js
1
// Copyright 2021 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
import React, { useCallback, useEffect, useRef, useState } from 'react';
15
import { useDispatch, useSelector } from 'react-redux';
16
import { Link } from 'react-router-dom';
17

18
import {
19
  Block as BlockIcon,
20
  CheckCircle as CheckCircleIcon,
21
  Edit as EditIcon,
22
  Error as ErrorIcon,
23
  Refresh as RefreshIcon,
24
  SaveAlt as SaveAltIcon
25
} from '@mui/icons-material';
26
import { Button, Checkbox, FormControlLabel, Typography } from '@mui/material';
27

28
import { setSnackbar } from '../../../actions/appActions';
29
import { abortDeployment, getDeviceLog, getSingleDeployment } from '../../../actions/deploymentActions';
30
import { applyDeviceConfig, setDeviceConfig } from '../../../actions/deviceActions';
31
import { saveGlobalSettings } from '../../../actions/userActions';
32
import { TIMEOUTS } from '../../../constants/appConstants';
33
import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES } from '../../../constants/deploymentConstants';
34
import { DEVICE_STATES } from '../../../constants/deviceConstants';
35
import { deepCompare, groupDeploymentDevicesStats, groupDeploymentStats, isEmpty, toggle } from '../../../helpers';
36
import { getDeviceConfigDeployment } from '../../../selectors';
37
import Tracking from '../../../tracking';
38
import ConfigurationObject from '../../common/configurationobject';
39
import Confirm from '../../common/confirm';
40
import LogDialog from '../../common/dialogs/log';
41
import KeyValueEditor from '../../common/forms/keyvalueeditor';
42
import Loader from '../../common/loader';
43
import Time from '../../common/time';
44
import { ConfigureAddOnTip, ConfigureRaspberryLedTip, ConfigureTimezoneTip } from '../../helptips/helptooltips';
45
import ConfigImportDialog from './configimportdialog';
46
import DeviceDataCollapse from './devicedatacollapse';
47

48
const buttonStyle = { marginLeft: 30 };
10✔
49
const iconStyle = { margin: 12 };
10✔
50
const textStyle = { textTransform: 'capitalize', textAlign: 'left' };
10✔
51

52
const defaultReportTimeStamp = '0001-01-01T00:00:00Z';
10✔
53

54
const configHelpTipsMap = {
10✔
55
  'mender-demo-raspberrypi-led': {
56
    position: 'right',
57
    component: ConfigureRaspberryLedTip
58
  },
59
  timezone: {
60
    position: 'right',
61
    component: ConfigureTimezoneTip
62
  }
63
};
64

65
export const ConfigUpToDateNote = ({ updated_ts = defaultReportTimeStamp }) => (
10!
66
  <div className="flexbox margin-small">
2✔
67
    <CheckCircleIcon className="green" style={iconStyle} />
68
    <div>
69
      <Typography variant="subtitle2" style={textStyle}>
70
        Configuration up-to-date on the device
71
      </Typography>
72
      <Typography variant="caption" className="muted" style={textStyle}>
73
        Updated: {<Time value={updated_ts} />}
74
      </Typography>
75
    </div>
76
  </div>
77
);
78

79
export const ConfigEmptyNote = ({ updated_ts = '' }) => (
10!
80
  <div className="flexbox column margin-small">
2✔
81
    <Typography variant="subtitle2">The device appears to either have an empty configuration or not to have reported a configuration yet.</Typography>
82
    <Typography variant="caption" className="muted" style={textStyle}>
83
      Updated: {<Time value={updated_ts} />}
84
    </Typography>
85
  </div>
86
);
87

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

109
export const ConfigUpdateNote = ({ isUpdatingConfig, isAccepted }) => (
10✔
110
  <div>
19✔
111
    <Typography variant="subtitle2" style={textStyle}>
112
      {!isAccepted
19!
113
        ? 'Configuration will be applied once the device is connected'
114
        : isUpdatingConfig
19✔
115
        ? 'Updating configuration on device...'
116
        : 'Configuration could not be updated on device'}
117
    </Typography>
118
    <Typography variant="caption" className="muted" style={textStyle}>
119
      Status: {isUpdatingConfig || !isAccepted ? 'pending' : 'failed'}
51✔
120
    </Typography>
121
  </div>
122
);
123

124
export const ConfigUpdateFailureActions = ({ hasLog, onSubmit, onCancel, setShowLog }) => (
10✔
125
  <>
16✔
126
    {hasLog && (
16!
127
      <Button color="secondary" onClick={setShowLog} style={buttonStyle}>
128
        View log
129
      </Button>
130
    )}
131
    <Button color="secondary" onClick={onSubmit} startIcon={<RefreshIcon fontSize="small" />} style={buttonStyle}>
132
      Retry
133
    </Button>
134
    <a className="margin-left-large" onClick={onCancel}>
135
      cancel changes
136
    </a>
137
  </>
138
);
139

140
export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId }, showHelptips }) => {
10✔
141
  const { device, deviceConfigDeployment: deployment } = useSelector(state => getDeviceConfigDeployment(state, deviceId));
42✔
142
  const { config = {}, status } = device;
38!
143
  const { configured = {}, deployment_id, reported = {}, reported_ts, updated_ts } = config;
38!
144
  const isRelevantDeployment = deployment.created > updated_ts && (!reported_ts || deployment.finished > reported_ts);
38!
145

146
  const [changedConfig, setChangedConfig] = useState();
38✔
147
  const [editableConfig, setEditableConfig] = useState();
38✔
148
  const [isAborting, setIsAborting] = useState(false);
38✔
149
  const [isEditingConfig, setIsEditingConfig] = useState(false);
38✔
150
  const [isSetAsDefault, setIsSetAsDefault] = useState(false);
38✔
151
  const [isUpdatingConfig, setIsUpdatingConfig] = useState(false);
38✔
152
  const [showConfigImport, setShowConfigImport] = useState(false);
38✔
153
  const [showLog, setShowLog] = useState(false);
38✔
154
  const [updateFailed, setUpdateFailed] = useState();
38✔
155
  const [updateLog, setUpdateLog] = useState();
38✔
156
  const dispatch = useDispatch();
38✔
157
  const deploymentTimer = useRef();
38✔
158

159
  useEffect(() => {
38✔
160
    if (!isEmpty(config) && !isEmpty(changedConfig) && !isEditingConfig) {
29!
UNCOV
161
      setIsEditingConfig(isUpdatingConfig || updateFailed);
×
162
    }
163
    // eslint-disable-next-line react-hooks/exhaustive-deps
164
  }, [JSON.stringify(config), JSON.stringify(changedConfig), isEditingConfig, isUpdatingConfig, updateFailed]);
165

166
  useEffect(() => {
38✔
167
    if (deployment.devices && deployment.devices[device.id]?.log) {
2!
UNCOV
168
      setUpdateLog(deployment.devices[device.id].log);
×
169
    }
170

171
    // eslint-disable-next-line react-hooks/exhaustive-deps
172
  }, [JSON.stringify(deployment.devices), device.id]);
173

174
  useEffect(() => {
38✔
175
    clearInterval(deploymentTimer.current);
2✔
176
    if (isRelevantDeployment && deployment.status !== DEPLOYMENT_STATES.finished) {
2!
UNCOV
177
      deploymentTimer.current = setInterval(() => dispatch(getSingleDeployment(deployment_id)), TIMEOUTS.refreshDefault);
×
178
    } else if (deployment_id && !isRelevantDeployment) {
2!
UNCOV
179
      dispatch(getSingleDeployment(deployment_id));
×
180
    }
181
    return () => {
2✔
182
      clearInterval(deploymentTimer.current);
2✔
183
    };
184
  }, [deployment.status, deployment_id, dispatch, isRelevantDeployment]);
185

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

209
  useEffect(() => {
38✔
210
    if (!isRelevantDeployment) {
3!
211
      return;
3✔
212
    }
UNCOV
213
    if (!changedConfig && !isEmpty(config) && (!deployment_id || deployment.status)) {
×
214
      // let currentConfig = reported;
UNCOV
215
      const stats = groupDeploymentStats(deployment);
×
UNCOV
216
      if (deployment.status !== DEPLOYMENT_STATES.finished || stats.failures) {
×
UNCOV
217
        setEditableConfig(configured);
×
UNCOV
218
        setChangedConfig(configured);
×
219
      }
220
    }
221
    // eslint-disable-next-line react-hooks/exhaustive-deps
222
  }, [dispatch, JSON.stringify(config), deployment.status, !changedConfig, JSON.stringify(deployment), isRelevantDeployment]);
223

224
  const onConfigImport = ({ config, importType }) => {
38✔
UNCOV
225
    let updatedConfig = config;
×
UNCOV
226
    if (importType === 'default') {
×
UNCOV
227
      updatedConfig = defaultConfig.current;
×
228
    }
UNCOV
229
    setChangedConfig(updatedConfig);
×
UNCOV
230
    setEditableConfig(updatedConfig);
×
UNCOV
231
    setShowConfigImport(false);
×
232
  };
233

234
  const onSetSnackbar = useCallback((...args) => dispatch(setSnackbar(...args)), [dispatch]);
38✔
235

236
  const onSetAsDefaultChange = () => setIsSetAsDefault(toggle);
38✔
237

238
  const onShowLog = () =>
38✔
UNCOV
239
    dispatch(getDeviceLog(deployment_id, device.id)).then(result => {
×
UNCOV
240
      setShowLog(true);
×
UNCOV
241
      setUpdateLog(result[1]);
×
242
    });
243

244
  const onCancel = () => {
38✔
UNCOV
245
    if (!isEmpty(reported)) {
×
UNCOV
246
      setEditableConfig(reported);
×
UNCOV
247
      setChangedConfig(reported);
×
248
    }
UNCOV
249
    let requests = [];
×
UNCOV
250
    if (deployment_id && deployment.status !== DEPLOYMENT_STATES.finished) {
×
UNCOV
251
      requests.push(dispatch(abortDeployment(deployment_id)));
×
252
    }
UNCOV
253
    if (deepCompare(reported, changedConfig)) {
×
UNCOV
254
      requests.push(Promise.resolve());
×
255
    } else {
UNCOV
256
      requests.push(dispatch(setDeviceConfig(device.id, reported)));
×
UNCOV
257
      if (isSetAsDefault) {
×
UNCOV
258
        requests.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: defaultConfig.previous } })));
×
259
      }
260
    }
UNCOV
261
    return Promise.all(requests).then(() => {
×
UNCOV
262
      setIsUpdatingConfig(false);
×
UNCOV
263
      setUpdateFailed(false);
×
UNCOV
264
      setIsAborting(false);
×
265
    });
266
  };
267

268
  const onSubmit = () => {
38✔
269
    Tracking.event({ category: 'devices', action: 'apply_configuration' });
3✔
270
    setIsUpdatingConfig(true);
3✔
271
    setUpdateFailed(false);
3✔
272
    return dispatch(setDeviceConfig(device.id, changedConfig))
3✔
UNCOV
273
      .then(() => dispatch(applyDeviceConfig(device.id, { retries: 0 }, isSetAsDefault, changedConfig)))
×
274
      .catch(() => {
275
        setIsEditingConfig(true);
2✔
276
        setUpdateFailed(true);
2✔
277
        setIsUpdatingConfig(false);
2✔
278
      });
279
  };
280

281
  const onStartEdit = e => {
38✔
282
    e.stopPropagation();
1✔
283
    const nextEditableConfig = { ...configured, ...reported };
1✔
284
    setChangedConfig(nextEditableConfig);
1✔
285
    setEditableConfig(nextEditableConfig);
1✔
286
    setIsEditingConfig(true);
1✔
287
  };
288

289
  const onStartImportClick = e => {
38✔
UNCOV
290
    e.stopPropagation();
×
UNCOV
291
    setShowConfigImport(true);
×
292
  };
293

294
  const onAbortClick = () => setIsAborting(toggle);
38✔
295

296
  const hasDeviceConfig = !isEmpty(reported);
38✔
297
  let footer = hasDeviceConfig ? <ConfigUpToDateNote updated_ts={reported_ts} /> : <ConfigEmptyNote updated_ts={device.updated_ts} />;
38✔
298
  if (isEditingConfig) {
38✔
299
    footer = (
36✔
300
      <ConfigEditingActions
301
        hasDeviceConfig={hasDeviceConfig}
302
        isSetAsDefault={isSetAsDefault}
303
        onSetAsDefaultChange={onSetAsDefaultChange}
304
        onSubmit={onSubmit}
305
        onCancel={onCancel}
306
      />
307
    );
308
  }
309
  if (isUpdatingConfig || updateFailed) {
38✔
310
    const hasLog = deployment.devices && deployment.devices[device.id]?.log;
18!
311
    footer = (
18✔
312
      <>
313
        <div className="flexbox">
314
          {isUpdatingConfig && <Loader show={true} style={{ marginRight: 15, marginTop: -15 }} />}
23✔
315
          {updateFailed && <ErrorIcon className="red" style={iconStyle} />}
33✔
316
          <ConfigUpdateNote isUpdatingConfig={isUpdatingConfig} isAccepted={status === DEVICE_STATES.accepted} />
317
        </div>
318
        {updateFailed ? (
18✔
319
          <ConfigUpdateFailureActions hasLog={hasLog} setShowLog={onShowLog} onSubmit={onSubmit} onCancel={onCancel} />
320
        ) : isAborting ? (
3!
321
          <Confirm cancel={onAbortClick} action={onCancel} type="abort" classes="margin-left-large" />
322
        ) : (
323
          <>
324
            <Button color="secondary" onClick={onAbortClick} startIcon={<BlockIcon fontSize="small" />} style={buttonStyle}>
325
              Abort update
326
            </Button>
327
            <Button
328
              color="secondary"
329
              component={Link}
330
              to={`/deployments/${deployment.status || DEPLOYMENT_ROUTES.active.key}?open=true&id=${deployment_id}`}
6✔
331
              style={buttonStyle}
332
            >
333
              View deployment
334
            </Button>
335
          </>
336
        )}
337
      </>
338
    );
339
  }
340

341
  const helpTipsMap = Object.entries(configHelpTipsMap).reduce((accu, [key, value]) => {
38✔
342
    accu[key] = {
76✔
343
      ...value,
344
      props: { deviceId: device.id }
345
    };
346
    return accu;
76✔
347
  }, {});
348

349
  return (
38✔
350
    <DeviceDataCollapse
351
      isAddOn
352
      title={
353
        <div className="two-columns">
354
          <div className="flexbox center-aligned">
355
            <h4 className="margin-right">Device configuration</h4>
356
            {!(isEditingConfig || isUpdatingConfig) && (
80✔
357
              <Button onClick={onStartEdit} startIcon={<EditIcon />} size="small">
358
                Edit
359
              </Button>
360
            )}
361
          </div>
362
          {isEditingConfig ? (
38✔
363
            <Button onClick={onStartImportClick} disabled={isUpdatingConfig} startIcon={<SaveAltIcon />} style={{ justifySelf: 'left', alignSelf: 'center' }}>
364
              Import configuration
365
            </Button>
366
          ) : null}
367
        </div>
368
      }
369
    >
370
      <div className="relative">
371
        {isEditingConfig ? (
38✔
372
          <KeyValueEditor
373
            disabled={isUpdatingConfig}
374
            errortext=""
375
            initialInput={editableConfig}
376
            inputHelpTipsMap={helpTipsMap}
377
            onInputChange={setChangedConfig}
378
            showHelptips={showHelptips}
379
          />
380
        ) : (
381
          hasDeviceConfig && <ConfigurationObject config={reported} setSnackbar={onSetSnackbar} />
3✔
382
        )}
383
        {showHelptips && <ConfigureAddOnTip />}
38!
384
        <div className="flexbox center-aligned margin-bottom margin-top">{footer}</div>
UNCOV
385
        {showLog && <LogDialog logData={updateLog} onClose={() => setShowLog(false)} type="configUpdateLog" />}
×
UNCOV
386
        {showConfigImport && <ConfigImportDialog onCancel={() => setShowConfigImport(false)} onSubmit={onConfigImport} />}
×
387
      </div>
388
    </DeviceDataCollapse>
389
  );
390
};
391

392
export default DeviceConfiguration;
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