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

mendersoftware / gui / 993759026

05 Sep 2023 09:01PM UTC coverage: 82.384% (-17.6%) from 99.964%
993759026

Pull #4020

gitlab-ci

mender-test-bot
chore: Types update

Signed-off-by: Mender Test Bot <mender@northern.tech>
Pull Request #4020: chore: Types update

4346 of 6321 branches covered (0.0%)

8259 of 10025 relevant lines covered (82.38%)

192.76 hits per line

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

61.07
/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 { BENEFITS, 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 { DOCSTIPS, DocsTooltip } from '../../common/docslink';
42
import EnterpriseNotification from '../../common/enterpriseNotification';
43
import KeyValueEditor from '../../common/forms/keyvalueeditor';
44
import { InfoHintContainer } from '../../common/info-hint';
45
import Loader from '../../common/loader';
46
import Time from '../../common/time';
47
import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips';
48
import ConfigImportDialog from './configimportdialog';
49
import DeviceDataCollapse from './devicedatacollapse';
50

51
const buttonStyle = { marginLeft: 30 };
10✔
52
const iconStyle = { margin: 12 };
10✔
53
const textStyle = { textTransform: 'capitalize', textAlign: 'left' };
10✔
54

55
const defaultReportTimeStamp = '0001-01-01T00:00:00Z';
10✔
56

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

238
  const onSetAsDefaultChange = () => setIsSetAsDefault(toggle);
38✔
239

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

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

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

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

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

296
  const onAbortClick = () => setIsAborting(toggle);
38✔
297

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

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

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

399
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