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

mendersoftware / gui / 1350829378

27 Jun 2024 01:46PM UTC coverage: 83.494% (-16.5%) from 99.965%
1350829378

Pull #4465

gitlab-ci

mzedel
chore: test fixes

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4465: MEN-7169 - feat: added multi sorting capabilities to devices view

4506 of 6430 branches covered (70.08%)

81 of 100 new or added lines in 14 files covered. (81.0%)

1661 existing lines in 163 files now uncovered.

8574 of 10269 relevant lines covered (83.49%)

160.6 hits per line

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

61.36
/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, getTenantCapabilities } 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',
UNCOV
53
    component: ({ anchor, ...props }) => <MenderHelpTooltip style={anchor} id={HELPTOOLTIPS.configureRaspberryLedTip.id} contentProps={props} />
×
54
  },
55
  timezone: {
56
    position: 'right',
UNCOV
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>
17✔
107
    <Typography variant="subtitle2" style={textStyle}>
108
      {!isAccepted
17!
109
        ? 'Configuration will be applied once the device is connected'
110
        : isUpdatingConfig
17✔
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'}
47✔
116
    </Typography>
117
  </div>
118
);
119

120
export const ConfigUpdateFailureActions = ({ hasLog, onSubmit, onCancel, setShowLog }) => (
9✔
121
  <>
14✔
122
    {hasLog && (
14!
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));
40✔
138
  const { hasDeviceConfig } = useSelector(state => getTenantCapabilities(state));
40✔
139
  const { config = {}, status } = device;
36!
140
  const { configured = {}, deployment_id, reported = {}, reported_ts, updated_ts } = config;
36!
141
  const isRelevantDeployment = deployment.created > updated_ts && (!reported_ts || deployment.finished > reported_ts);
36!
142
  const [changedConfig, setChangedConfig] = useState();
36✔
143
  const [editableConfig, setEditableConfig] = useState();
36✔
144
  const [isAborting, setIsAborting] = useState(false);
36✔
145
  const [isEditingConfig, setIsEditingConfig] = useState(false);
36✔
146
  const [isSetAsDefault, setIsSetAsDefault] = useState(false);
36✔
147
  const [isUpdatingConfig, setIsUpdatingConfig] = useState(false);
36✔
148
  const [showConfigImport, setShowConfigImport] = useState(false);
36✔
149
  const [showLog, setShowLog] = useState(false);
36✔
150
  const [updateFailed, setUpdateFailed] = useState();
36✔
151
  const [updateLog, setUpdateLog] = useState();
36✔
152
  const dispatch = useDispatch();
36✔
153
  const deploymentTimer = useRef();
36✔
154

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

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

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

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

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

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

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

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

232
  const onSetAsDefaultChange = () => setIsSetAsDefault(toggle);
36✔
233

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

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

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

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

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

290
  const onAbortClick = () => setIsAborting(toggle);
36✔
291

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

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

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

389
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