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

mendersoftware / gui / 1081664682

22 Nov 2023 02:11PM UTC coverage: 82.798% (-17.2%) from 99.964%
1081664682

Pull #4214

gitlab-ci

tranchitella
fix: Fixed the infinite page redirects when the back button is pressed

Remove the location and navigate from the useLocationParams.setValue callback
dependencies as they change the set function that is presented in other
useEffect dependencies. This happens when the back button is clicked, which
leads to the location changing infinitely.

Changelog: Title
Ticket: MEN-6847
Ticket: MEN-6796

Signed-off-by: Ihor Aleksandrychiev <ihor.aleksandrychiev@northern.tech>
Signed-off-by: Fabio Tranchitella <fabio.tranchitella@northern.tech>
Pull Request #4214: fix: Fixed the infinite page redirects when the back button is pressed

4319 of 6292 branches covered (0.0%)

8332 of 10063 relevant lines covered (82.8%)

191.0 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