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

mendersoftware / gui / 944676341

pending completion
944676341

Pull #3875

gitlab-ci

mzedel
chore: aligned snapshots with updated design

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

4469 of 6446 branches covered (69.33%)

230 of 266 new or added lines in 43 files covered. (86.47%)

1712 existing lines in 161 files now uncovered.

8406 of 10170 relevant lines covered (82.65%)

196.7 hits per line

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

76.92
/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, { useEffect, useState } from 'react';
15
import { Link } from 'react-router-dom';
16

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

27
import { BENEFITS } from '../../../constants/appConstants';
28
import { DEPLOYMENT_ROUTES } from '../../../constants/deploymentConstants';
29
import { DEVICE_STATES } from '../../../constants/deviceConstants';
30
import { deepCompare, groupDeploymentDevicesStats, groupDeploymentStats, isEmpty, toggle } from '../../../helpers';
31
import Tracking from '../../../tracking';
32
import ConfigurationObject from '../../common/configurationobject';
33
import Confirm from '../../common/confirm';
34
import LogDialog from '../../common/dialogs/log';
35
import { DOCSTIPS, DocsTooltip } from '../../common/docslink';
36
import EnterpriseNotification from '../../common/enterpriseNotification';
37
import KeyValueEditor from '../../common/forms/keyvalueeditor';
38
import { InfoHintContainer } from '../../common/info-hint';
39
import Loader from '../../common/loader';
40
import Time from '../../common/time';
41
import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips';
42
import ConfigImportDialog from './configimportdialog';
43
import DeviceDataCollapse from './devicedatacollapse';
44

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

49
const defaultReportTimeStamp = '0001-01-01T00:00:00Z';
10✔
50

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

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

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

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

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

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

137
export const DeviceConfiguration = ({
10✔
138
  abortDeployment,
139
  applyDeviceConfig,
140
  defaultConfig = {},
57✔
141
  device,
142
  deployment = {},
33✔
143
  getDeviceLog,
144
  getSingleDeployment,
145
  saveGlobalSettings,
146
  setDeviceConfig,
147
  setSnackbar,
148
  tenantCapabilities
149
}) => {
150
  const { config = {}, status } = device;
57!
151
  const { configured, deployment_id, reported = {}, reported_ts, updated_ts } = config;
57!
152
  const { hasDeviceConfig: hasDeviceConfigAddOn } = tenantCapabilities;
57✔
153

154
  const [changedConfig, setChangedConfig] = useState();
57✔
155
  const [isEditDisabled, setIsEditDisabled] = useState(false);
57✔
156
  const [isAborting, setIsAborting] = useState(false);
57✔
157
  const [isEditingConfig, setIsEditingConfig] = useState(false);
57✔
158
  const [isSetAsDefault, setIsSetAsDefault] = useState(false);
57✔
159
  const [isUpdatingConfig, setIsUpdatingConfig] = useState(false);
57✔
160
  const [shouldUpdateEditor, setShouldUpdateEditor] = useState(false);
57✔
161
  const [showConfigImport, setShowConfigImport] = useState(false);
57✔
162
  const [showLog, setShowLog] = useState(false);
57✔
163
  const [updateFailed, setUpdateFailed] = useState();
57✔
164
  const [updateLog, setUpdateLog] = useState();
57✔
165

166
  useEffect(() => {
57✔
167
    setShouldUpdateEditor(toggle);
12✔
168
  }, [isEditingConfig, isUpdatingConfig]);
169

170
  useEffect(() => {
57✔
171
    if (device.config || changedConfig) {
7!
172
      setIsEditDisabled(isUpdatingConfig);
7✔
173
      setIsEditingConfig(isUpdatingConfig || updateFailed);
7✔
174
    }
175
  }, [isUpdatingConfig, updateFailed]);
176

177
  useEffect(() => {
57✔
178
    if (deployment.devices && deployment.devices[device.id]?.log) {
3!
UNCOV
179
      setUpdateLog(deployment.devices[device.id].log);
×
180
    }
181
  }, [deployment.devices]);
182

183
  useEffect(() => {
57✔
184
    if (deployment.status === 'finished') {
3✔
185
      // we have to rely on the device stats here as the state change might not have propagated to the deployment status
186
      // leaving all stats at 0 and giving a false impression of deployment success
187
      const stats = groupDeploymentStats(deployment);
1✔
188
      const deviceStats = groupDeploymentDevicesStats(deployment);
1✔
189
      setUpdateFailed(deployment.created > updated_ts && deployment.finished > reported_ts && (stats.failures || deviceStats.failures));
1!
190
      setIsUpdatingConfig(false);
1✔
191
    } else if (deployment.status) {
2!
UNCOV
192
      setChangedConfig(configured);
×
193
      // we can't rely on the deployment.status to be !== 'finished' since `deployment` is initialized as an empty object
194
      // and thus the undefined status would also point to an ongoing update
UNCOV
195
      setIsUpdatingConfig(true);
×
196
    }
197
  }, [deployment.status]);
198

199
  useEffect(() => {
57✔
200
    const { config = {} } = device;
3!
201
    if (!changedConfig && device.config && (!deployment_id || deployment.status)) {
3!
202
      const { configured = {}, reported = {}, reported_ts } = config;
2!
203
      let currentConfig = reported;
2✔
204
      const stats = groupDeploymentStats(deployment);
2✔
205
      if (deployment.status !== 'finished' || (deployment.finished > reported_ts && stats.failures)) {
2!
206
        currentConfig = configured;
2✔
207
      }
208
      setChangedConfig(currentConfig);
2✔
209
    }
210
    if (deployment.status !== 'finished' && deployment_id) {
3!
UNCOV
211
      getSingleDeployment(deployment_id);
×
212
    }
213
  }, [device.config, deployment.status]);
214

215
  const onConfigImport = ({ config, importType }) => {
57✔
UNCOV
216
    let updatedConfig = config;
×
UNCOV
217
    if (importType === 'default') {
×
UNCOV
218
      updatedConfig = defaultConfig.current;
×
219
    }
UNCOV
220
    setShouldUpdateEditor(toggle);
×
UNCOV
221
    setChangedConfig(updatedConfig);
×
UNCOV
222
    setShowConfigImport(false);
×
223
  };
224

225
  const onSetAsDefaultChange = () => setIsSetAsDefault(toggle);
57✔
226

227
  const onShowLog = () => {
57✔
UNCOV
228
    getDeviceLog(deployment_id, device.id).then(result => {
×
UNCOV
229
      setShowLog(true);
×
UNCOV
230
      setUpdateLog(result[1]);
×
231
    });
232
  };
233

234
  const onCancel = () => {
57✔
235
    setIsEditingConfig(isEmpty(reported));
1✔
236
    setChangedConfig(reported);
1✔
237
    let requests = [];
1✔
238
    if (deployment_id && deployment.status !== 'finished') {
1!
UNCOV
239
      requests.push(abortDeployment(deployment_id));
×
240
    }
241
    if (deepCompare(reported, changedConfig)) {
1!
UNCOV
242
      requests.push(Promise.resolve());
×
243
    } else {
244
      requests.push(setDeviceConfig(device.id, reported));
1✔
245
      if (isSetAsDefault) {
1!
246
        requests.push(saveGlobalSettings({ defaultDeviceConfig: { current: defaultConfig.previous } }));
1✔
247
      }
248
    }
249
    return Promise.all(requests).then(() => {
1✔
250
      setIsUpdatingConfig(false);
1✔
251
      setUpdateFailed(false);
1✔
252
      setIsAborting(false);
1✔
253
    });
254
  };
255

256
  const onSubmit = () => {
57✔
257
    Tracking.event({ category: 'devices', action: 'apply_configuration' });
2✔
258
    setIsUpdatingConfig(true);
2✔
259
    setUpdateFailed(false);
2✔
260
    return setDeviceConfig(device.id, changedConfig)
2✔
261
      .then(() => applyDeviceConfig(device.id, { retries: 0 }, isSetAsDefault, changedConfig))
2✔
262
      .then(() => {
263
        setUpdateFailed(false);
1✔
264
      })
265
      .catch(() => {
266
        setIsEditDisabled(false);
1✔
267
        setIsEditingConfig(true);
1✔
268
        setUpdateFailed(true);
1✔
269
        setIsUpdatingConfig(false);
1✔
270
      });
271
  };
272

273
  const onStartEdit = e => {
57✔
274
    e.stopPropagation();
2✔
275
    setChangedConfig(configured || reported);
2!
276
    setIsEditingConfig(true);
2✔
277
  };
278

279
  const onStartImportClick = e => {
57✔
UNCOV
280
    e.stopPropagation();
×
UNCOV
281
    setShowConfigImport(true);
×
282
  };
283

284
  const onAbortClick = () => setIsAborting(toggle);
57✔
285

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

331
  const helpTipsMap = Object.entries(configHelpTipsMap).reduce((accu, [key, value]) => {
57✔
332
    accu[key] = {
114✔
333
      ...value,
334
      props: { deviceId: device.id }
335
    };
336
    return accu;
114✔
337
  }, {});
338

339
  return (
57✔
340
    <DeviceDataCollapse
341
      isAddOn
342
      title={
343
        <div className="two-columns">
344
          <div className="flexbox center-aligned">
345
            <h4 className="margin-right">Device configuration</h4>
346
            {hasDeviceConfigAddOn && !(isEditingConfig || isUpdatingConfig) && (
191✔
347
              <Button onClick={onStartEdit} startIcon={<EditIcon />} size="small">
348
                Edit
349
              </Button>
350
            )}
351
          </div>
352
          <div className="flexbox center-aligned">
353
            {isEditingConfig ? (
57✔
354
              <Button onClick={onStartImportClick} disabled={isUpdatingConfig} startIcon={<SaveAltIcon />} style={{ justifySelf: 'left' }}>
355
                Import configuration
356
              </Button>
357
            ) : null}
358
            <InfoHintContainer>
359
              <EnterpriseNotification id={BENEFITS.deviceConfiguration.id} />
360
              <MenderHelpTooltip id={HELPTOOLTIPS.configureAddOnTip.id} style={{ marginTop: 5 }} />
361
              <DocsTooltip id={DOCSTIPS.deviceConfig.id} />
362
            </InfoHintContainer>
363
          </div>
364
        </div>
365
      }
366
    >
367
      <div className="relative">
368
        {isEditingConfig ? (
57✔
369
          <KeyValueEditor
370
            disabled={isEditDisabled}
371
            errortext=""
372
            input={changedConfig}
373
            inputHelpTipsMap={helpTipsMap}
374
            onInputChange={setChangedConfig}
375
            reset={shouldUpdateEditor}
376
          />
377
        ) : (
378
          hasDeviceConfig && <ConfigurationObject config={reported} setSnackbar={setSnackbar} />
17✔
379
        )}
380
        <div className="flexbox center-aligned margin-bottom margin-top">{footer}</div>
UNCOV
381
        {showLog && <LogDialog logData={updateLog} onClose={() => setShowLog(false)} type="configUpdateLog" />}
×
UNCOV
382
        {showConfigImport && <ConfigImportDialog onCancel={() => setShowConfigImport(false)} onSubmit={onConfigImport} setSnackbar={setSnackbar} />}
×
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