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

mendersoftware / mender-server / 1674252677

17 Feb 2025 10:05AM UTC coverage: 77.683% (+1.0%) from 76.669%
1674252677

Pull #445

gitlab-ci

mzedel
test(gui): also covered phased deployment rendering
+ aligned snapshots accordingly

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #445: Fix/phased deployments more

4392 of 6319 branches covered (69.5%)

Branch coverage included in aggregate %.

8 of 8 new or added lines in 4 files covered. (100.0%)

22 existing lines in 2 files now uncovered.

42854 of 54500 relevant lines covered (78.63%)

23.83 hits per line

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

86.76
/frontend/src/js/components/deployments/CreateDeployment.tsx
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, { useEffect, useRef, useState } from 'react';
15
import { useDispatch, useSelector } from 'react-redux';
16
import { useNavigate } from 'react-router-dom';
17

18
import { ExpandMore } from '@mui/icons-material';
19
import {
20
  Accordion,
21
  AccordionDetails,
22
  AccordionSummary,
23
  Button,
24
  Checkbox,
25
  Divider,
26
  Drawer,
27
  FormControlLabel,
28
  FormGroup,
29
  Typography,
30
  accordionClasses
31
} from '@mui/material';
32
import { makeStyles } from 'tss-react/mui';
33

34
import Confirm from '@northern.tech/common-ui/Confirm';
35
import { DrawerTitle } from '@northern.tech/common-ui/DrawerTitle';
36
import { ALL_DEVICES, onboardingSteps } from '@northern.tech/store/constants';
37
import {
38
  getAcceptedDevices,
39
  getDeviceCountsByStatus,
40
  getDevicesById,
41
  getDocsVersion,
42
  getFeatures,
43
  getGlobalSettings,
44
  getGroupNames,
45
  getGroupsByIdWithoutUngrouped,
46
  getIdAttribute,
47
  getIsEnterprise,
48
  getOnboardingState,
49
  getReleaseListState,
50
  getReleasesById,
51
  getTenantCapabilities
52
} from '@northern.tech/store/selectors';
53
import { advanceOnboarding, createDeployment, getDeploymentsConfig, getGroupDevices, getRelease, getReleases } from '@northern.tech/store/thunks';
54
import { toggle } from '@northern.tech/utils/helpers';
55
import pluralize from 'pluralize';
56

57
import DeltaIcon from '../../../assets/img/deltaicon.svg';
58
import { getOnboardingComponentFor } from '../../utils/onboardingManager';
59
import DeviceLimit from './deployment-wizard/DeviceLimit';
60
import { RolloutPatternSelection, getPhaseStartTime, validatePhases } from './deployment-wizard/PhaseSettings';
61
import { ForceDeploy, Retries, RolloutOptions } from './deployment-wizard/RolloutOptions';
62
import { ScheduleRollout } from './deployment-wizard/ScheduleRollout';
63
import { Devices, ReleasesWarning, Software } from './deployment-wizard/SoftwareDevices';
64

65
const useStyles = makeStyles()(theme => ({
8✔
66
  accordion: {
67
    backgroundColor: theme.palette.grey[400],
68
    marginTop: theme.spacing(4),
69
    '&:before': {
70
      display: 'none'
71
    },
72
    [`&.${accordionClasses.expanded}`]: {
73
      margin: 'unset',
74
      marginTop: theme.spacing(4)
75
    }
76
  },
77
  columns: {
78
    columnGap: 30,
79
    display: 'grid',
80
    gridTemplateColumns: 'max-content max-content',
81
    '&>p': {
82
      marginTop: theme.spacing(3)
83
    }
84
  },
85
  disabled: { color: theme.palette.text.disabled }
86
}));
87

88
const getAnchor = (element, heightAdjustment = 3) => ({
183✔
89
  top: element.offsetTop + element.offsetHeight / heightAdjustment,
90
  left: element.offsetLeft + element.offsetWidth
91
});
92

93
export const CreateDeployment = props => {
8✔
94
  const { deploymentObject = {}, onDismiss, onScheduleSubmit, setDeploymentSettings } = props;
91!
95

96
  const { canRetry, canSchedule, hasFullFiltering } = useSelector(getTenantCapabilities);
89✔
97
  const { isHosted } = useSelector(getFeatures);
89✔
98
  const { createdGroup, groups, hasDynamicGroups } = useSelector(state => {
89✔
99
    const groups = getGroupsByIdWithoutUngrouped(state);
222✔
100
    const createdGroup = Object.keys(groups).length ? Object.keys(groups)[0] : undefined;
222!
101
    const hasDynamicGroups = Object.values(groups).some(group => !!group.id);
375✔
102
    return { createdGroup, hasDynamicGroups, groups };
222✔
103
  });
104
  const { hasDelta: hasDeltaEnabled } = useSelector(state => state.deployments.config) ?? {};
222!
105
  const { total: acceptedDeviceCount } = useSelector(getAcceptedDevices);
89✔
106
  const hasDevices = !!acceptedDeviceCount;
89✔
107
  const devicesById = useSelector(getDevicesById);
89✔
108
  const docsVersion = useSelector(getDocsVersion);
89✔
109
  const { pending: hasPending } = useSelector(getDeviceCountsByStatus);
89✔
110
  const idAttribute = useSelector(getIdAttribute);
89✔
111
  const isEnterprise = useSelector(getIsEnterprise);
89✔
112
  const { needsDeploymentConfirmation: needsCheck, previousPhases = [], retries: previousRetries = 0 } = useSelector(getGlobalSettings);
89✔
113
  const onboardingState = useSelector(getOnboardingState) || {};
89!
114
  const { complete: isOnboardingComplete } = onboardingState;
89✔
115
  const { searchedIds: releases } = useSelector(getReleaseListState);
89✔
116
  const releasesById = useSelector(getReleasesById);
89✔
117
  const groupNames = useSelector(getGroupNames);
89✔
118
  const dispatch = useDispatch();
89✔
119

120
  const isCreating = useRef(false);
89✔
121
  const [hasNewRetryDefault, setHasNewRetryDefault] = useState(false);
89✔
122
  const [isChecking, setIsChecking] = useState(false);
89✔
123
  const [isExpanded, setIsExpanded] = useState(false);
89✔
124
  const navigate = useNavigate();
89✔
125
  const releaseRef = useRef();
89✔
126
  const groupRef = useRef();
89✔
127
  const deploymentAnchor = useRef();
89✔
128
  const { classes } = useStyles();
89✔
129

130
  useEffect(() => {
89✔
131
    dispatch(getReleases({ page: 1, perPage: 100, searchOnly: true }));
4✔
132
  }, [dispatch]);
133

134
  useEffect(() => {
89✔
135
    if (isHosted || isEnterprise) {
4✔
136
      dispatch(getDeploymentsConfig());
2✔
137
    }
138
  }, [dispatch, isEnterprise, isHosted]);
139

140
  useEffect(() => {
89✔
141
    const { devices = [], group, release } = deploymentObject;
11✔
142
    if (release) {
11✔
143
      dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_ARTIFACT_SELECTION));
3✔
144
      dispatch(getRelease(release.name));
3✔
145
    }
146
    dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_GROUP_SELECTION));
11✔
147
    let nextDeploymentObject = { deploymentDeviceCount: devices.length ? devices.length : 0 };
11!
148
    if (group === ALL_DEVICES) {
11✔
149
      dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_ALL_DEVICES_SELECTION));
2✔
150
      nextDeploymentObject.deploymentDeviceCount = acceptedDeviceCount;
2✔
151
    }
152
    if (groups[group]) {
11✔
153
      dispatch(getGroupDevices({ group, perPage: 1 }))
3✔
154
        .unwrap()
155
        .then(
156
          ({
157
            payload: {
158
              group: { total: deploymentDeviceCount }
159
            }
160
          }) => setDeploymentSettings({ deploymentDeviceCount })
3✔
161
        );
162
    }
163
    setDeploymentSettings(nextDeploymentObject);
11✔
164
    // eslint-disable-next-line react-hooks/exhaustive-deps
165
  }, [acceptedDeviceCount, deploymentObject.group, deploymentObject.release?.name, dispatch, JSON.stringify(groups), setDeploymentSettings]);
166

167
  useEffect(() => {
89✔
168
    let { deploymentDeviceCount: deviceCount, deploymentDeviceIds: deviceIds = [], devices = [] } = deploymentObject;
25✔
169
    if (devices.length) {
25!
170
      deviceIds = devices.map(({ id }) => id);
×
UNCOV
171
      deviceCount = deviceIds.length;
×
UNCOV
172
      devices = devices.map(({ id }) => ({ id, ...(devicesById[id] ?? {}) }));
×
173
    } else if (deploymentObject.group === ALL_DEVICES) {
25✔
174
      deviceCount = acceptedDeviceCount;
13✔
175
    }
176
    setDeploymentSettings({ deploymentDeviceIds: deviceIds, deploymentDeviceCount: deviceCount, devices });
25✔
177
    // eslint-disable-next-line react-hooks/exhaustive-deps
178
  }, [acceptedDeviceCount, JSON.stringify(deploymentObject), JSON.stringify(devicesById), setDeploymentSettings]);
179

180
  const cleanUpDeploymentsStatus = () => {
89✔
181
    if (!window.location.search) {
3!
182
      return;
3✔
183
    }
UNCOV
184
    const location = window.location.pathname.slice('/ui'.length);
×
UNCOV
185
    navigate(location); // lgtm [js/client-side-unvalidated-url-redirection]
×
186
  };
187

188
  const onSaveRetriesSetting = hasNewRetryDefault => setHasNewRetryDefault(hasNewRetryDefault);
89✔
189

190
  const closeWizard = () => {
89✔
191
    cleanUpDeploymentsStatus();
1✔
192
    onDismiss();
1✔
193
  };
194

195
  const onDeltaToggle = ({ target: { checked } }) => setDeploymentSettings({ delta: checked });
89✔
196

197
  const onScheduleSubmitClick = settings => {
89✔
198
    if (needsCheck && !isChecking) {
2!
UNCOV
199
      return setIsChecking(true);
×
200
    }
201
    isCreating.current = true;
2✔
202
    const { delta, deploymentDeviceIds, devices, filter, forceDeploy = false, group, maxDevices, phases, release, retries, update_control_map } = settings;
2!
203
    const startTime = phases?.length ? phases[0].start_ts : undefined;
2✔
204
    const retrySetting = canRetry && retries ? { retries } : {};
2✔
205
    const newDeployment = {
2✔
206
      artifact_name: release.name,
207
      autogenerate_delta: delta,
208
      devices: (filter || group) && !devices.length ? undefined : deploymentDeviceIds,
7!
209
      filter_id: filter?.id,
210
      all_devices: !filter && group === ALL_DEVICES,
3✔
211
      group: group === ALL_DEVICES || devices.length ? undefined : group,
5✔
212
      max_devices: maxDevices ? maxDevices : undefined,
2✔
213
      name: devices[0]?.id || (group ? decodeURIComponent(group) : ALL_DEVICES),
6!
214
      phases: phases
2✔
215
        ? phases.map((phase, i, origPhases) => {
216
            phase.start_ts = getPhaseStartTime(origPhases, i, startTime);
3✔
217
            return phase;
3✔
218
          })
219
        : phases,
220
      ...retrySetting,
221
      force_installation: forceDeploy,
222
      update_control_map
223
    };
224
    if (!isOnboardingComplete) {
2!
225
      dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_RELEASE_TO_DEVICES));
2✔
226
    }
227
    return dispatch(createDeployment({ newDeployment, hasNewRetryDefault }))
2✔
228
      .then(() => {
229
        // successfully retrieved new deployment
230
        cleanUpDeploymentsStatus();
2✔
231
        onScheduleSubmit();
2✔
232
      })
233
      .finally(() => {
234
        isCreating.current = false;
2✔
235
        setIsChecking(false);
2✔
236
      });
237
  };
238

239
  const { delta, deploymentDeviceCount, group, phases } = deploymentObject;
89✔
240

241
  const deploymentSettings = {
89✔
242
    ...deploymentObject,
243
    filter: groups[group]?.id ? groups[group] : undefined
89✔
244
  };
245
  const disabled =
246
    isCreating.current ||
89✔
247
    !(deploymentSettings.release && (deploymentSettings.deploymentDeviceCount || !!deploymentSettings.filter || deploymentSettings.group)) ||
122!
248
    !validatePhases(phases, deploymentSettings.deploymentDeviceCount);
249

250
  const sharedProps = {
89✔
251
    ...props,
252
    canRetry,
253
    canSchedule,
254
    docsVersion,
255
    groupNames,
256
    groupRef,
257
    groups,
258
    hasDevices,
259
    hasDynamicGroups,
260
    hasFullFiltering,
261
    hasPending,
262
    idAttribute,
263
    isEnterprise,
264
    previousPhases,
265
    previousRetries,
266
    releaseRef,
267
    releases,
268
    releasesById,
269
    commonClasses: classes,
270
    deploymentObject: deploymentSettings,
271
    hasNewRetryDefault,
272
    onSaveRetriesSetting,
273
    open: false,
274
    setDeploymentSettings
275
  };
276
  const hasReleases = !!Object.keys(releasesById).length;
89✔
277
  return (
89✔
278
    <Drawer anchor="right" open onClose={closeWizard} PaperProps={{ style: { minWidth: '50vw' } }}>
279
      <DrawerTitle title="Create a deployment" onClose={closeWizard} />
280
      <Divider className="margin-bottom" />
281
      <FormGroup>
282
        {!hasReleases ? (
89!
283
          <ReleasesWarning />
284
        ) : (
285
          <>
286
            <Devices {...sharedProps} groupRef={groupRef} />
287
            <Software {...sharedProps} releaseRef={releaseRef} />
288
          </>
289
        )}
290
        <ScheduleRollout {...sharedProps} />
291
        <Accordion className={classes.accordion} square expanded={isExpanded} onChange={() => setIsExpanded(toggle)}>
2✔
292
          <AccordionSummary expandIcon={<ExpandMore />}>
293
            <Typography className={classes.disabled} variant="subtitle2">
294
              {isExpanded ? 'Hide' : 'Show'} advanced options
89✔
295
            </Typography>
296
          </AccordionSummary>
297
          <AccordionDetails>
298
            <DeviceLimit {...sharedProps} />
299
            <RolloutPatternSelection {...sharedProps} />
300
            <RolloutOptions {...sharedProps} />
301
            <Retries {...sharedProps} />
302
            <ForceDeploy {...sharedProps} />
303
            {hasDeltaEnabled && (
135✔
304
              <FormControlLabel
305
                control={<Checkbox color="primary" checked={delta} onChange={onDeltaToggle} size="small" />}
306
                label={
307
                  <>
308
                    Generate and deploy Delta Artifacts (where available) <DeltaIcon />
309
                  </>
310
                }
311
              />
312
            )}
313
          </AccordionDetails>
314
        </Accordion>
315
      </FormGroup>
316
      <div className="margin-top">
317
        {isChecking && (
89!
318
          <Confirm
319
            classes="confirmation-overlay"
UNCOV
320
            cancel={() => setIsChecking(false)}
×
UNCOV
321
            action={() => onScheduleSubmitClick(deploymentSettings)}
×
322
            message={`This will deploy ${deploymentSettings.release?.name} to ${deploymentDeviceCount} ${pluralize(
323
              'device',
324
              deploymentDeviceCount
325
            )}. Are you sure?`}
326
            style={{ paddingLeft: 12, justifyContent: 'flex-start', maxHeight: 44 }}
327
          />
328
        )}
329
        <Button onClick={closeWizard} style={{ marginRight: 10 }}>
330
          Cancel
331
        </Button>
332
        <Button variant="contained" color="primary" ref={deploymentAnchor} disabled={disabled} onClick={() => onScheduleSubmitClick(deploymentSettings)}>
2✔
333
          Create deployment
334
        </Button>
335
      </div>
336
      <OnboardingComponent
337
        releaseRef={releaseRef}
338
        groupRef={groupRef}
339
        deploymentObject={deploymentObject}
340
        deploymentAnchor={deploymentAnchor}
341
        onboardingState={onboardingState}
342
        createdGroup={createdGroup}
343
        releasesById={releasesById}
344
        releases={releases}
345
        hasDevices={hasDevices}
346
      />
347
    </Drawer>
348
  );
349
};
350

351
export default CreateDeployment;
352

353
const OnboardingComponent = ({
8✔
354
  releaseRef,
355
  groupRef,
356
  deploymentAnchor,
357
  deploymentObject,
358
  onboardingState,
359
  createdGroup,
360
  releasesById,
361
  releases,
362
  hasDevices
363
}) => {
364
  const { deploymentDeviceCount, devices, group, release: deploymentRelease = null } = deploymentObject;
86✔
365

366
  let onboardingComponent = null;
86✔
367
  if (releaseRef.current && groupRef.current && deploymentAnchor.current) {
86✔
368
    const anchor = getAnchor(releaseRef.current);
82✔
369
    const groupAnchor = getAnchor(groupRef.current);
82✔
370
    onboardingComponent = getOnboardingComponentFor(onboardingSteps.SCHEDULING_ALL_DEVICES_SELECTION, onboardingState, { anchor: groupAnchor, place: 'right' });
82✔
371
    if (createdGroup) {
82!
372
      onboardingComponent = getOnboardingComponentFor(
82✔
373
        onboardingSteps.SCHEDULING_GROUP_SELECTION,
374
        { ...onboardingState, createdGroup },
375
        { anchor: groupAnchor, place: 'right' },
376
        onboardingComponent
377
      );
378
    }
379
    if (deploymentDeviceCount && !deploymentRelease) {
82✔
380
      onboardingComponent = getOnboardingComponentFor(
22✔
381
        onboardingSteps.SCHEDULING_ARTIFACT_SELECTION,
382
        { ...onboardingState, selectedRelease: releasesById[releases[0]] || {} },
22!
383
        { anchor, place: 'right' },
384
        onboardingComponent
385
      );
386
    }
387
    if (hasDevices && (deploymentDeviceCount || devices?.length) && deploymentRelease) {
82✔
388
      const buttonAnchor = getAnchor(deploymentAnchor.current, 2);
19✔
389
      onboardingComponent = getOnboardingComponentFor(
19✔
390
        onboardingSteps.SCHEDULING_RELEASE_TO_DEVICES,
391
        { ...onboardingState, selectedDevice: devices.length ? devices[0] : undefined, selectedGroup: group, selectedRelease: deploymentRelease },
19!
392
        { anchor: buttonAnchor, place: 'right' },
393
        onboardingComponent
394
      );
395
    }
396
  }
397
  return onboardingComponent;
86✔
398
};
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