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

mendersoftware / mender-server / 10423

11 Nov 2025 04:53PM UTC coverage: 74.435% (-0.1%) from 74.562%
10423

push

gitlab-ci

web-flow
Merge pull request #1071 from mendersoftware/dependabot/npm_and_yarn/frontend/main/development-dependencies-92732187be

3868 of 5393 branches covered (71.72%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

176 existing lines in 95 files now uncovered.

64605 of 86597 relevant lines covered (74.6%)

7.74 hits per line

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

96.49
/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx
1
// Copyright 2019 Northern.tech AS
2✔
2
//
2✔
3
//    Licensed under the Apache License, Version 2.0 (the "License");
2✔
4
//    you may not use this file except in compliance with the License.
2✔
5
//    You may obtain a copy of the License at
2✔
6
//
2✔
7
//        http://www.apache.org/licenses/LICENSE-2.0
2✔
8
//
2✔
9
//    Unless required by applicable law or agreed to in writing, software
2✔
10
//    distributed under the License is distributed on an "AS IS" BASIS,
2✔
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2✔
12
//    See the License for the specific language governing permissions and
2✔
13
//    limitations under the License.
2✔
14
import { useCallback, useState } from 'react';
2✔
15

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

2✔
37
import { DOCSTIPS, DocsTooltip } from '@northern.tech/common-ui/DocsLink';
2✔
38
import EnterpriseNotification from '@northern.tech/common-ui/EnterpriseNotification';
2✔
39
import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint';
2✔
40
import Time from '@northern.tech/common-ui/Time';
2✔
41
import { BENEFITS } from '@northern.tech/store/constants';
2✔
42
import dayjs from 'dayjs';
2✔
43
import pluralize from 'pluralize';
2✔
44
import validator from 'validator';
2✔
45

2✔
46
// use this to get remaining percent of final phase so we don't set a hard number
2✔
47
export const getRemainderPercent = phases =>
9✔
48
  phases.reduce((accu, phase, index, source) => {
136✔
49
    // ignore final phase size if set
2✔
50
    if (index === source.length - 1) {
438✔
51
      return accu;
136✔
52
    }
2✔
53
    return phase.batch_size ? accu - phase.batch_size : accu;
304!
54
  }, 100);
2✔
55

2✔
56
export const validatePhases = (phases, deploymentDeviceCount) => {
9✔
57
  if (!phases?.length) {
27✔
58
    return true;
18✔
59
  }
2✔
60
  const remainder = getRemainderPercent(phases);
11✔
61
  const { isValid } = phases.reduce(
11✔
62
    (accu, { batch_size = 0 }) => {
2✔
63
      if (!accu.isValid) {
35✔
64
        return accu;
3✔
65
      }
2✔
66
      const deviceCount = Math.floor((deploymentDeviceCount / 100) * (batch_size || remainder));
34✔
67
      const totalSize = accu.totalSize + batch_size;
35✔
68
      return { isValid: deviceCount >= 1 && totalSize <= 100, totalSize };
35✔
69
    },
2✔
70
    { isValid: true, totalSize: 0 }
2✔
71
  );
2✔
72
  return isValid;
11✔
73
};
2✔
74

2✔
75
export const getPhaseDeviceCount = (numberDevices = 1, batchSize, remainder, isLastPhase) =>
9✔
76
  isLastPhase ? Math.ceil((numberDevices / 100) * (batchSize || remainder)) : Math.floor((numberDevices / 100) * (batchSize || remainder));
175✔
77

2✔
78
const useStyles = makeStyles()(theme => ({
9✔
79
  chip: { marginTop: theme.spacing(2) },
2✔
80
  delayInputWrapper: { display: 'grid', gridTemplateColumns: 'max-content max-content', columnGap: theme.spacing() },
2✔
81
  row: { whiteSpace: 'nowrap' },
2✔
82
  input: { minWidth: 400 },
2✔
83
  patternSelection: { marginTop: theme.spacing(2), maxWidth: 515, width: 'min-content' }
2✔
84
}));
2✔
85

2✔
86
const timeframes = ['minutes', 'hours', 'days'];
9✔
87
const tableHeaders = ['', 'Batch size', 'Phase begins', 'Delay before next phase', ''];
9✔
88

2✔
89
export const getPhaseStartTime = (phases, index, startDate) => {
9✔
90
  const startingDate = typeof startDate === 'string' && validator.isISO8601(startDate) ? startDate : undefined;
174✔
91
  if (index < 1) {
174✔
92
    return startDate?.toISOString ? startDate.toISOString() : startingDate;
66✔
93
  } else if (phases[index].start_ts && typeof phases[index].start_ts === 'string' && validator.isISO8601(phases[index].start_ts)) {
110✔
94
    // if displaying an ongoing deployment we can rely on the timing info from the backend
2✔
95
    return phases[index].start_ts;
32✔
96
  }
2✔
97
  // since we don't want to get stale phase start times when the creation dialog is open for a long time
2✔
98
  // we have to ensure start times are based on delay from previous phases
2✔
99
  // since there likely won't be 1000s of phases this should still be fine to recalculate
2✔
100
  const newStartTime = phases.slice(0, index).reduce((accu, phase) => dayjs(accu).add(phase.delay, phase.delayUnit), startingDate);
140✔
101
  return newStartTime.toISOString();
80✔
102
};
2✔
103

2✔
104
export const PhaseSettings = ({ classNames, deploymentObject, disabled, numberDevices, setDeploymentSettings }) => {
9✔
105
  const { classes } = useStyles();
43✔
106

2✔
107
  const { filter, phases = [] } = deploymentObject;
43✔
108
  const updateDelay = (value, index) => {
43✔
109
    const newPhases = [...phases];
4✔
110
    // value must be at least 1
2✔
111
    value = Math.max(1, value);
4✔
112
    newPhases[index] = { ...newPhases[index], delay: value };
4✔
113

2✔
114
    setDeploymentSettings({ phases: newPhases });
4✔
115
    // logic for updating time stamps should be in parent - only change delays here
2✔
116
  };
2✔
117

2✔
118
  const updateBatchSize = (value, index) => {
43✔
119
    const newPhases = [...phases];
4✔
120
    value = Math.min(100, Math.max(1, value));
4✔
121
    newPhases[index] = {
4✔
122
      ...newPhases[index],
2✔
123
      batch_size: value
2✔
124
    };
2✔
125
    // When phase's batch size changes, check for new 'remainder'
2✔
126
    const remainder = getRemainderPercent(newPhases);
4✔
127
    // if new remainder will be 0 or negative remove phase leave last phase to get remainder
2✔
128
    if (remainder < 1) {
4!
129
      newPhases.pop();
2✔
130
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
2✔
131
      const { batch_size, ...newFinalPhase } = newPhases[newPhases.length - 1];
2✔
132
      newPhases[newPhases.length - 1] = newFinalPhase;
2✔
133
    }
2✔
134
    setDeploymentSettings({ phases: newPhases });
4✔
135
  };
2✔
136

2✔
137
  const addPhase = () => {
43✔
138
    const newPhases = [...phases];
3✔
139
    // assign new batch size to *previous* last batch
2✔
140
    const remainder = getRemainderPercent(newPhases);
3✔
141
    newPhases[newPhases.length - 1] = {
3✔
142
      ...newPhases[newPhases.length - 1],
2✔
143
      // make it default 10, unless remainder is <=10 in which case make it half remainder
2✔
144
      batch_size: remainder > 10 ? 10 : Math.floor(remainder / 2),
2!
145
      // check for previous phase delay or set 2hr default
2✔
146
      delay: newPhases[newPhases.length - 1].delay || 2,
2✔
147
      delayUnit: newPhases[newPhases.length - 1].delayUnit || 'hours'
2✔
148
    };
2✔
149
    newPhases.push({});
3✔
150
    // use function to set new phases incl start time of new phase
2✔
151
    setDeploymentSettings({ phases: newPhases });
3✔
152
  };
2✔
153

2✔
154
  const removePhase = index => {
43✔
155
    const newPhases = [...phases];
2✔
156
    newPhases.splice(index, 1);
2✔
157
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
2✔
158
    const { batch_size, delay, ...newPhase } = newPhases[newPhases.length - 1]; // remove batch size from new last phase, use remainder
2✔
159
    if (newPhases.length > 1) {
2!
160
      newPhase.delay = delay;
2✔
161
    }
2✔
162
    newPhases[newPhases.length - 1] = newPhase;
2✔
163
    setDeploymentSettings({ phases: newPhases });
2✔
164
  };
2✔
165

2✔
166
  const handleDelayToggle = (value, index) => {
43✔
167
    const newPhases = [...phases];
4✔
168
    newPhases[index] = {
4✔
169
      ...newPhases[index],
2✔
170
      delayUnit: value
2✔
171
    };
2✔
172
    setDeploymentSettings({ phases: newPhases });
4✔
173
  };
2✔
174

2✔
175
  const remainder = getRemainderPercent(phases);
43✔
176

2✔
177
  // disable 'add phase' button if last phase/remainder has only 1 device left
2✔
178
  const disableAdd = !filter && (remainder / 100) * numberDevices <= 1;
43✔
179
  const startTime = phases.length ? phases[0].start_ts || new Date() : new Date();
43!
180
  const mappedPhases = phases.map((phase, index) => {
43✔
181
    const max = index > 0 ? 100 - phases[index - 1].batch_size : 100;
148✔
182
    const deviceCount = getPhaseDeviceCount(numberDevices, phase.batch_size, remainder, index === phases.length - 1);
148✔
183
    const isEmptyPhase = deviceCount < 1;
148✔
184
    return (
148✔
185
      <TableRow className={classes.row} key={index}>
2✔
186
        <TableCell component="td" scope="row">
2✔
187
          <Chip size="small" label={`Phase ${index + 1}`} />
2✔
188
        </TableCell>
2✔
189
        <TableCell>
2✔
190
          <div className="flexbox center-aligned">
2✔
191
            {phase.batch_size && phase.batch_size < 100 ? (
2✔
192
              <OutlinedInput
2✔
193
                value={phase.batch_size}
2✔
194
                onChange={event => updateBatchSize(event.target.value, index)}
4✔
195
                endAdornment={
2✔
196
                  <InputAdornment className={isEmptyPhase ? 'warning' : ''} position="end">
2✔
197
                    %
2✔
198
                  </InputAdornment>
2✔
199
                }
2✔
200
                disabled={disabled && deviceCount >= 1}
2!
201
                inputProps={{
2✔
202
                  step: 1,
2✔
203
                  min: 1,
2✔
204
                  max: max,
2✔
205
                  type: 'number'
2✔
206
                }}
2✔
207
              />
2✔
208
            ) : (
2✔
209
              phase.batch_size || remainder
2✔
210
            )}
2✔
211
            <span className={isEmptyPhase ? 'warning info' : 'info'} style={{ marginLeft: '5px' }}>{`(${deviceCount} ${pluralize(
2✔
212
              'device',
2✔
213
              deviceCount
2✔
214
            )})`}</span>
2✔
215
          </div>
2✔
216
          {isEmptyPhase && <div className="warning">Phases must have at least 1 device</div>}
2✔
217
        </TableCell>
2✔
218
        <TableCell>
2✔
219
          <Time value={getPhaseStartTime(phases, index, startTime)} />
2✔
220
        </TableCell>
2✔
221
        <TableCell>
2✔
222
          {phase.delay && index !== phases.length - 1 ? (
2✔
223
            <div className={classes.delayInputWrapper}>
2✔
224
              <OutlinedInput
2✔
225
                value={phase.delay}
2✔
226
                onChange={event => updateDelay(event.target.value, index)}
4✔
227
                inputProps={{ step: 1, min: 1, max: 720, type: 'number' }}
2✔
228
              />
2✔
229
              <Select onChange={event => handleDelayToggle(event.target.value, index)} value={phase.delayUnit || 'hours'}>
4!
230
                {timeframes.map(value => (
2✔
231
                  <MenuItem key={value} value={value}>
317✔
232
                    <div className="capitalized-start">{value}</div>
2✔
233
                  </MenuItem>
2✔
234
                ))}
2✔
235
              </Select>
2✔
236
            </div>
2✔
237
          ) : (
2✔
238
            '-'
2✔
239
          )}
2✔
240
        </TableCell>
2✔
241
        <TableCell>
2✔
242
          {index >= 1 ? (
2✔
UNCOV
243
            <IconButton onClick={() => removePhase(index)} size="large">
2✔
244
              <CancelIcon />
2✔
245
            </IconButton>
2✔
246
          ) : null}
2✔
247
        </TableCell>
2✔
248
      </TableRow>
2✔
249
    );
2✔
250
  });
2✔
251

2✔
252
  return (
43✔
253
    <div className={classNames}>
2✔
254
      <Table size="small">
2✔
255
        <TableHead>
2✔
256
          <TableRow>
2✔
257
            {tableHeaders.map((content, index) => (
2✔
258
              <TableCell key={index}>{content}</TableCell>
207✔
259
            ))}
2✔
260
          </TableRow>
2✔
261
        </TableHead>
2✔
262
        <TableBody>{mappedPhases}</TableBody>
2✔
263
      </Table>
2✔
264

2✔
265
      {!disableAdd ? <Chip className={classes.chip} color="primary" clickable={!disableAdd} icon={<AddIcon />} label="Add a phase" onClick={addPhase} /> : null}
2!
266
    </div>
2✔
267
  );
2✔
268
};
2✔
269

2✔
270
export const RolloutPatternSelection = props => {
9✔
271
  const { setDeploymentSettings, deploymentObject = {}, disableSchedule, isEnterprise, open = false, previousPhases = [] } = props;
121✔
272
  const { deploymentDeviceCount = 0, deploymentDeviceIds = [], filter, phases = [] } = deploymentObject;
121✔
273

2✔
274
  const [usesPattern, setUsesPattern] = useState(open || phases.some(i => i));
121✔
275
  const { classes } = useStyles();
121✔
276

2✔
277
  const handlePatternChange = ({ target: { value } }) => {
121✔
278
    let updatedPhases = [];
3✔
279
    // check if a start time already exists from props and if so, use it
2✔
280
    const phaseStart = phases.length ? { start_ts: phases[0].start_ts } : {};
3!
281
    // if setting new custom pattern we use default 2 phases
2✔
282
    // for small groups get minimum batch size containing at least 1 device
2✔
283
    const minBatch = deploymentDeviceCount < 10 && !filter ? Math.ceil((1 / deploymentDeviceCount) * 100) : 10;
3!
284
    switch (value) {
3!
285
      case 0:
2✔
286
        updatedPhases = [{ batch_size: 100, ...phaseStart }];
2✔
287
        break;
2✔
288
      case 1:
2✔
289
        updatedPhases = [{ batch_size: minBatch, delay: 2, delayUnit: 'hours', ...phaseStart }, {}];
2✔
290
        break;
2✔
291
      default:
2✔
292
        // have to create a deep copy of the array to prevent overwriting, due to nested objects in the array
2✔
293
        updatedPhases = JSON.parse(JSON.stringify(value));
3✔
294
        break;
3✔
295
    }
2✔
296
    setDeploymentSettings({ phases: updatedPhases });
3✔
297
  };
2✔
298

2✔
299
  const onUsesPatternClick = useCallback(() => {
121✔
300
    if (usesPattern) {
3!
301
      setDeploymentSettings({ phases: phases.slice(0, 1) });
2✔
302
    }
2✔
303
    setUsesPattern(!usesPattern);
3✔
304
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
305
  }, [usesPattern, JSON.stringify(phases), setDeploymentSettings, setUsesPattern]);
2✔
306

2✔
307
  const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0;
121!
308
  const customPattern = phases && phases.length > 1 ? 1 : 0;
121✔
309

2✔
310
  const previousPhaseOptions =
2✔
311
    previousPhases.length > 0
121✔
312
      ? previousPhases.map((previousPhaseSetting, index) => {
2✔
313
          const remainder = getRemainderPercent(previousPhaseSetting);
79✔
314
          const phaseDescription = previousPhaseSetting.reduce(
79✔
315
            (accu, phase, _, source) => {
2✔
316
              const phaseDescription = phase.delay
239✔
317
                ? `${phase.batch_size}% > ${phase.delay} ${phase.delayUnit || 'hours'} >`
2!
318
                : `${phase.batch_size || remainder || 100 / source.length}%`;
2!
319
              return `${accu} ${phaseDescription}`;
239✔
320
            },
2✔
321
            `${previousPhaseSetting.length} ${pluralize('phase', previousPhaseSetting.length)}:`
2✔
322
          );
2✔
323
          return (
79✔
324
            <MenuItem key={`previousPhaseSetting-${index}`} value={previousPhaseSetting}>
2✔
325
              {phaseDescription}
2✔
326
            </MenuItem>
2✔
327
          );
2✔
328
        })
2✔
329
      : [
2✔
330
          <MenuItem key="noPreviousPhaseSetting" disabled={true} style={{ opacity: '0.4' }}>
2✔
331
            No recent patterns
2✔
332
          </MenuItem>
2✔
333
        ];
2✔
334
  return (
121✔
335
    <>
2✔
336
      <FormControlLabel
2✔
337
        control={<Checkbox color="primary" checked={usesPattern} disabled={!isEnterprise} onChange={onUsesPatternClick} size="small" />}
2✔
338
        label={
2✔
339
          <div className="flexbox center-aligned">
2✔
340
            <b className="margin-right-small">Select a rollout pattern</b> (optional)
2✔
341
            <InfoHintContainer>
2✔
342
              <EnterpriseNotification id={BENEFITS.phasedDeployments.id} />
2✔
343
              <DocsTooltip id={DOCSTIPS.phasedDeployments.id} />
2✔
344
            </InfoHintContainer>
2✔
345
          </div>
2✔
346
        }
2✔
347
      />
2✔
348
      <Collapse in={usesPattern}>
2✔
349
        <FormControl className={classes.patternSelection}>
2✔
350
          <Select className={classes.input} onChange={handlePatternChange} value={customPattern} disabled={!isEnterprise}>
2✔
351
            <MenuItem value={0}>Single phase: 100%</MenuItem>
2✔
352
            {(numberDevices > 1 || filter) && [
2✔
353
              <MenuItem key="customPhaseSetting" divider={true} value={1}>
2✔
354
                Custom
2✔
355
              </MenuItem>,
2✔
356
              <ListSubheader key="phaseSettingsSubheader">Recent patterns</ListSubheader>,
2✔
357
              ...previousPhaseOptions
2✔
358
            ]}
2✔
359
          </Select>
2✔
360
        </FormControl>
2✔
361
      </Collapse>
2✔
362
      {customPattern ? <PhaseSettings classNames="margin-bottom-small" disabled={disableSchedule} numberDevices={numberDevices} {...props} /> : null}
2✔
363
    </>
2✔
364
  );
2✔
365
};
2✔
366

2✔
367
export default PhaseSettings;
2✔
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