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

mendersoftware / gui / 1113439055

19 Dec 2023 09:01PM UTC coverage: 82.752% (-17.2%) from 99.964%
1113439055

Pull #4258

gitlab-ci

mender-test-bot
chore: Types update

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

4326 of 6319 branches covered (0.0%)

8348 of 10088 relevant lines covered (82.75%)

189.39 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 { Block as BlockIcon, CheckCircle as CheckCircleIcon, Error as ErrorIcon, Refresh as RefreshIcon, SaveAlt as SaveAltIcon } from '@mui/icons-material';
19
import { Button, Checkbox, FormControlLabel, Typography } from '@mui/material';
20

21
import { setSnackbar } from '../../../actions/appActions';
22
import { abortDeployment, getDeviceLog, getSingleDeployment } from '../../../actions/deploymentActions';
23
import { applyDeviceConfig, setDeviceConfig } from '../../../actions/deviceActions';
24
import { saveGlobalSettings } from '../../../actions/userActions';
25
import { BENEFITS, TIMEOUTS } from '../../../constants/appConstants';
26
import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES } from '../../../constants/deploymentConstants';
27
import { DEVICE_STATES } from '../../../constants/deviceConstants';
28
import { deepCompare, groupDeploymentDevicesStats, groupDeploymentStats, isEmpty, toggle } from '../../../helpers';
29
import { getDeviceConfigDeployment } from '../../../selectors';
30
import Tracking from '../../../tracking';
31
import ConfigurationObject from '../../common/configurationobject';
32
import Confirm, { EditButton } from '../../common/confirm';
33
import LogDialog from '../../common/dialogs/log';
34
import { DOCSTIPS, DocsTooltip } from '../../common/docslink';
35
import EnterpriseNotification from '../../common/enterpriseNotification';
36
import KeyValueEditor from '../../common/forms/keyvalueeditor';
37
import { InfoHintContainer } from '../../common/info-hint';
38
import Loader from '../../common/loader';
39
import Time from '../../common/time';
40
import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips';
41
import ConfigImportDialog from './configimportdialog';
42
import DeviceDataCollapse from './devicedatacollapse';
43

44
const buttonStyle = { marginLeft: 30 };
9✔
45
const iconStyle = { margin: 12 };
9✔
46
const textStyle = { textTransform: 'capitalize', textAlign: 'left' };
9✔
47

48
const defaultReportTimeStamp = '0001-01-01T00:00:00Z';
9✔
49

50
const configHelpTipsMap = {
9✔
51
  'mender-demo-raspberrypi-led': {
52
    position: 'right',
53
    component: ({ anchor, ...props }) => <MenderHelpTooltip style={anchor} id={HELPTOOLTIPS.configureRaspberryLedTip.id} contentProps={props} />
×
54
  },
55
  timezone: {
56
    position: 'right',
57
    component: ({ anchor, ...props }) => <MenderHelpTooltip style={anchor} id={HELPTOOLTIPS.configureTimezoneTip.id} contentProps={props} />
×
58
  }
59
};
60

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

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

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

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

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

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

154
  useEffect(() => {
38✔
155
    if (!isEmpty(config) && !isEmpty(changedConfig) && !isEditingConfig) {
29!
156
      setIsEditingConfig(isUpdatingConfig || updateFailed);
×
157
    }
158
    // eslint-disable-next-line react-hooks/exhaustive-deps
159
  }, [JSON.stringify(config), JSON.stringify(changedConfig), isEditingConfig, isUpdatingConfig, updateFailed]);
160

161
  useEffect(() => {
38✔
162
    if (deployment.devices && deployment.devices[device.id]?.log) {
2!
163
      setUpdateLog(deployment.devices[device.id].log);
×
164
    }
165

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

169
  useEffect(() => {
38✔
170
    clearInterval(deploymentTimer.current);
2✔
171
    if (isRelevantDeployment && deployment.status !== DEPLOYMENT_STATES.finished) {
2!
172
      deploymentTimer.current = setInterval(() => dispatch(getSingleDeployment(deployment_id)), TIMEOUTS.refreshDefault);
×
173
    } else if (deployment_id && !isRelevantDeployment) {
2!
174
      dispatch(getSingleDeployment(deployment_id));
×
175
    }
176
    return () => {
2✔
177
      clearInterval(deploymentTimer.current);
2✔
178
    };
179
  }, [deployment.status, deployment_id, dispatch, isRelevantDeployment]);
180

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

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

219
  const onConfigImport = ({ config, importType }) => {
38✔
220
    let updatedConfig = config;
×
221
    if (importType === 'default') {
×
222
      updatedConfig = defaultConfig.current;
×
223
    }
224
    setChangedConfig(updatedConfig);
×
225
    setEditableConfig(updatedConfig);
×
226
    setShowConfigImport(false);
×
227
  };
228

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

231
  const onSetAsDefaultChange = () => setIsSetAsDefault(toggle);
38✔
232

233
  const onShowLog = () =>
38✔
234
    dispatch(getDeviceLog(deployment_id, device.id)).then(result => {
×
235
      setShowLog(true);
×
236
      setUpdateLog(result[1]);
×
237
    });
238

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

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

276
  const onStartEdit = e => {
38✔
277
    e.stopPropagation();
1✔
278
    const nextEditableConfig = { ...configured, ...reported };
1✔
279
    setChangedConfig(nextEditableConfig);
1✔
280
    setEditableConfig(nextEditableConfig);
1✔
281
    setIsEditingConfig(true);
1✔
282
  };
283

284
  const onStartImportClick = e => {
38✔
285
    e.stopPropagation();
×
286
    setShowConfigImport(true);
×
287
  };
288

289
  const onAbortClick = () => setIsAborting(toggle);
38✔
290

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

336
  const helpTipsMap = Object.entries(configHelpTipsMap).reduce((accu, [key, value]) => {
38✔
337
    accu[key] = {
76✔
338
      ...value,
339
      props: { deviceId: device.id }
340
    };
341
    return accu;
76✔
342
  }, {});
343

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

388
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