• 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

83.53
/src/js/components/deployments/deployment-wizard/phasesettings.js
1
// Copyright 2019 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, useState } from 'react';
15

16
import { Add as AddIcon, Cancel as CancelIcon } from '@mui/icons-material';
17
import {
18
  Checkbox,
19
  Chip,
20
  Collapse,
21
  FormControl,
22
  FormControlLabel,
23
  IconButton,
24
  Input,
25
  InputAdornment,
26
  ListSubheader,
27
  MenuItem,
28
  Select,
29
  Table,
30
  TableBody,
31
  TableCell,
32
  TableHead,
33
  TableRow
34
} from '@mui/material';
35
import { makeStyles } from 'tss-react/mui';
36

37
import pluralize from 'pluralize';
38

39
import { BENEFITS } from '../../../constants/appConstants';
40
import { getPhaseDeviceCount, getRemainderPercent } from '../../../helpers';
41
import { DOCSTIPS, DocsTooltip } from '../../common/docslink';
42
import EnterpriseNotification from '../../common/enterpriseNotification';
43
import { InfoHintContainer } from '../../common/info-hint';
44
import Time from '../../common/time';
45
import { getPhaseStartTime } from '../createdeployment';
46

47
const useStyles = makeStyles()(theme => ({
11✔
48
  chip: { marginTop: theme.spacing(2) },
49
  delayInputWrapper: { display: 'grid', gridTemplateColumns: 'max-content max-content', columnGap: theme.spacing() },
50
  row: { whiteSpace: 'nowrap' },
51
  input: { minWidth: 400 },
52
  patternSelection: { maxWidth: 515, width: 'min-content' }
53
}));
54

55
const timeframes = ['minutes', 'hours', 'days'];
11✔
56
const tableHeaders = ['', 'Batch size', 'Phase begins', 'Delay before next phase', ''];
11✔
57

58
export const PhaseSettings = ({ classNames, deploymentObject, disabled, numberDevices, setDeploymentSettings }) => {
11✔
59
  const { classes } = useStyles();
43✔
60

61
  const { filter, phases = [] } = deploymentObject;
43!
62
  const updateDelay = (value, index) => {
43✔
63
    let newPhases = phases;
2✔
64
    // value must be at least 1
65
    value = Math.max(1, value);
2✔
66
    newPhases[index].delay = value;
2✔
67

68
    setDeploymentSettings({ phases: newPhases });
2✔
69
    // logic for updating time stamps should be in parent - only change delays here
70
  };
71

72
  const updateBatchSize = (value, index) => {
43✔
73
    let newPhases = [...phases];
2✔
74
    value = Math.min(100, Math.max(1, value));
2✔
75
    newPhases[index].batch_size = value;
2✔
76
    // When phase's batch size changes, check for new 'remainder'
77
    const remainder = getRemainderPercent(newPhases);
2✔
78
    // if new remainder will be 0 or negative remove phase leave last phase to get remainder
79
    if (remainder < 1) {
2!
80
      newPhases.pop();
×
81
      newPhases[newPhases.length - 1].batch_size = null;
×
82
    }
83
    setDeploymentSettings({ phases: newPhases });
2✔
84
  };
85

86
  const addPhase = () => {
43✔
87
    let newPhases = [...phases];
1✔
88
    let newPhase = {};
1✔
89

90
    // assign new batch size to *previous* last batch
91
    const remainder = getRemainderPercent(newPhases);
1✔
92
    // make it default 10, unless remainder is <=10 in which case make it half remainder
93
    let batch_size = remainder > 10 ? 10 : Math.floor(remainder / 2);
1!
94
    newPhases[newPhases.length - 1].batch_size = batch_size;
1✔
95

96
    // check for previous phase delay or set 2hr default
97
    const delay = newPhases[newPhases.length - 1].delay || 2;
1✔
98
    newPhases[newPhases.length - 1].delay = delay;
1✔
99
    const delayUnit = newPhases[newPhases.length - 1].delayUnit || 'hours';
1✔
100
    newPhases[newPhases.length - 1].delayUnit = delayUnit;
1✔
101

102
    newPhases.push(newPhase);
1✔
103
    // use function to set new phases incl start time of new phase
104
    setDeploymentSettings({ phases: newPhases });
1✔
105
  };
106

107
  const removePhase = index => {
43✔
108
    let newPhases = phases;
×
109
    newPhases.splice(index, 1);
×
110

111
    // remove batch size from new last phase, use remainder
112
    delete newPhases[newPhases.length - 1].batch_size;
×
113

114
    if (newPhases.length === 1) {
×
115
      delete newPhases[0].delay;
×
116
    }
117
    setDeploymentSettings({ phases: newPhases });
×
118
  };
119

120
  const handleDelayToggle = (value, index) => {
43✔
121
    let newPhases = phases;
2✔
122
    newPhases[index].delayUnit = value;
2✔
123
    setDeploymentSettings({ phases: newPhases });
2✔
124
  };
125

126
  const remainder = getRemainderPercent(phases);
43✔
127

128
  // disable 'add phase' button if last phase/remainder has only 1 device left
129
  const disableAdd = !filter && (remainder / 100) * numberDevices <= 1;
43✔
130
  const startTime = phases.length ? phases[0].start_ts || new Date() : new Date();
43!
131
  const mappedPhases = phases.map((phase, index) => {
43✔
132
    let max = index > 0 ? 100 - phases[index - 1].batch_size : 100;
120✔
133
    const deviceCount = getPhaseDeviceCount(numberDevices, phase.batch_size, remainder, index === phases.length - 1);
120✔
134
    return (
120✔
135
      <TableRow className={classes.row} key={index}>
136
        <TableCell component="td" scope="row">
137
          <Chip size="small" label={`Phase ${index + 1}`} />
138
        </TableCell>
139
        <TableCell>
140
          <div className="flexbox center-aligned">
141
            {phase.batch_size && phase.batch_size < 100 ? (
317✔
142
              <Input
143
                value={phase.batch_size}
144
                onChange={event => updateBatchSize(event.target.value, index)}
2✔
145
                endAdornment={
146
                  <InputAdornment className={deviceCount < 1 ? 'warning' : ''} position="end">
77✔
147
                    %
148
                  </InputAdornment>
149
                }
150
                disabled={disabled && deviceCount >= 1}
77!
151
                inputProps={{
152
                  step: 1,
153
                  min: 1,
154
                  max: max,
155
                  type: 'number'
156
                }}
157
              />
158
            ) : (
159
              phase.batch_size || remainder
86✔
160
            )}
161
            {!filter && (
240✔
162
              <span className={deviceCount < 1 ? 'warning info' : 'info'} style={{ marginLeft: '5px' }}>{`(${deviceCount} ${pluralize(
120✔
163
                'device',
164
                deviceCount
165
              )})`}</span>
166
            )}
167
          </div>
168
          {!filter && deviceCount < 1 && <div className="warning">Phases must have at least 1 device</div>}
245✔
169
        </TableCell>
170
        <TableCell>
171
          <Time value={getPhaseStartTime(phases, index, startTime)} />
172
        </TableCell>
173
        <TableCell>
174
          {phase.delay && index !== phases.length - 1 ? (
317✔
175
            <div className={classes.delayInputWrapper}>
176
              <Input
177
                value={phase.delay}
178
                onChange={event => updateDelay(event.target.value, index)}
2✔
179
                inputProps={{ step: 1, min: 1, max: 720, type: 'number' }}
180
              />
181
              <Select onChange={event => handleDelayToggle(event.target.value, index)} value={phase.delayUnit || 'hours'}>
2!
182
                {timeframes.map(value => (
183
                  <MenuItem key={value} value={value}>
231✔
184
                    <div className="capitalized-start">{value}</div>
185
                  </MenuItem>
186
                ))}
187
              </Select>
188
            </div>
189
          ) : (
190
            '-'
191
          )}
192
        </TableCell>
193
        <TableCell>
194
          {index >= 1 ? (
120✔
195
            <IconButton onClick={() => removePhase(index)} size="large">
×
196
              <CancelIcon />
197
            </IconButton>
198
          ) : null}
199
        </TableCell>
200
      </TableRow>
201
    );
202
  });
203

204
  return (
43✔
205
    <div className={classNames}>
206
      <Table size="small">
207
        <TableHead>
208
          <TableRow>
209
            {tableHeaders.map((content, index) => (
210
              <TableCell key={index}>{content}</TableCell>
215✔
211
            ))}
212
          </TableRow>
213
        </TableHead>
214
        <TableBody>{mappedPhases}</TableBody>
215
      </Table>
216

217
      {!disableAdd ? <Chip className={classes.chip} color="primary" clickable={!disableAdd} icon={<AddIcon />} label="Add a phase" onClick={addPhase} /> : null}
43!
218
    </div>
219
  );
220
};
221

222
export const RolloutPatternSelection = props => {
11✔
223
  const { setDeploymentSettings, deploymentObject = {}, disableSchedule, isEnterprise, open = false, previousPhases = [] } = props;
243!
224
  const { deploymentDeviceCount = 0, deploymentDeviceIds = [], filter, phases = [] } = deploymentObject;
243✔
225

226
  const [usesPattern, setUsesPattern] = useState(open || phases.some(i => i));
243✔
227
  const { classes } = useStyles();
243✔
228

229
  const handlePatternChange = ({ target: { value } }) => {
243✔
230
    let updatedPhases = [];
1✔
231
    // check if a start time already exists from props and if so, use it
232
    const phaseStart = phases.length ? { start_ts: phases[0].start_ts } : {};
1!
233
    // if setting new custom pattern we use default 2 phases
234
    // for small groups get minimum batch size containing at least 1 device
235
    const minBatch = deploymentDeviceCount < 10 && !filter ? Math.ceil((1 / deploymentDeviceCount) * 100) : 10;
1!
236
    switch (value) {
1!
237
      case 0:
238
        updatedPhases = [{ batch_size: 100, ...phaseStart }];
×
239
        break;
×
240
      case 1:
241
        updatedPhases = [{ batch_size: minBatch, delay: 2, delayUnit: 'hours', ...phaseStart }, {}];
1✔
242
        break;
1✔
243
      default:
244
        // have to create a deep copy of the array to prevent overwriting, due to nested objects in the array
245
        updatedPhases = JSON.parse(JSON.stringify(value));
×
246
        break;
×
247
    }
248
    setDeploymentSettings({ phases: updatedPhases });
1✔
249
  };
250

251
  const onUsesPatternClick = useCallback(() => {
243✔
252
    if (usesPattern) {
1!
253
      setDeploymentSettings({ phases: phases.slice(0, 1) });
×
254
    }
255
    setUsesPattern(!usesPattern);
1✔
256
    // eslint-disable-next-line react-hooks/exhaustive-deps
257
  }, [usesPattern, JSON.stringify(phases), setDeploymentSettings, setUsesPattern]);
258

259
  const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0;
243!
260
  const customPattern = phases && phases.length > 1 ? 1 : 0;
243✔
261

262
  const previousPhaseOptions =
263
    previousPhases.length > 0
243✔
264
      ? previousPhases.map((previousPhaseSetting, index) => (
265
          <MenuItem key={`previousPhaseSetting-${index}`} value={previousPhaseSetting}>
110✔
266
            {previousPhaseSetting.reduce(
267
              (accu, phase, _, source) => {
268
                const phaseDescription = phase.delay
227✔
269
                  ? `${phase.batch_size}% > ${phase.delay} ${phase.delayUnit || 'hours'} >`
117!
270
                  : `${phase.batch_size || 100 / source.length}%`;
117✔
271
                return `${accu} ${phaseDescription}`;
227✔
272
              },
273
              `${previousPhaseSetting.length} ${pluralize('phase', previousPhaseSetting.length)}:`
274
            )}
275
          </MenuItem>
276
        ))
277
      : [
278
          <MenuItem key="noPreviousPhaseSetting" disabled={true} style={{ opacity: '0.4' }}>
279
            No recent patterns
280
          </MenuItem>
281
        ];
282
  return (
243✔
283
    <>
284
      <FormControlLabel
285
        control={<Checkbox color="primary" checked={usesPattern} disabled={!isEnterprise} onChange={onUsesPatternClick} size="small" />}
286
        label={
287
          <div className="flexbox center-aligned">
288
            <b className="margin-right-small">Select a rollout pattern</b> (optional)
289
            <InfoHintContainer>
290
              <EnterpriseNotification id={BENEFITS.phasedDeployments.id} />
291
              <DocsTooltip id={DOCSTIPS.phasedDeployments.id} />
292
            </InfoHintContainer>
293
          </div>
294
        }
295
      />
296
      <Collapse in={usesPattern}>
297
        <FormControl className={classes.patternSelection}>
298
          <Select className={classes.input} onChange={handlePatternChange} value={customPattern} disabled={!isEnterprise}>
299
            <MenuItem value={0}>Single phase: 100%</MenuItem>
300
            {(numberDevices > 1 || filter) && [
486✔
301
              <MenuItem key="customPhaseSetting" divider={true} value={1}>
302
                Custom
303
              </MenuItem>,
304
              <ListSubheader key="phaseSettingsSubheader">Recent patterns</ListSubheader>,
305
              ...previousPhaseOptions
306
            ]}
307
          </Select>
308
        </FormControl>
309
      </Collapse>
310
      {customPattern ? <PhaseSettings classNames="margin-bottom-small" disabled={disableSchedule} numberDevices={numberDevices} {...props} /> : null}
243✔
311
    </>
312
  );
313
};
314

315
export default PhaseSettings;
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