• 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

98.58
/frontend/src/js/components/deployments/ProgressChart.tsx
1
// Copyright 2016 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 React, { useEffect, useRef, useState } from 'react';
2✔
15

2✔
16
import { RotateLeftOutlined, Warning as WarningIcon } from '@mui/icons-material';
2✔
17
import { Tooltip } from '@mui/material';
2✔
18
import { makeStyles } from 'tss-react/mui';
2✔
19

2✔
20
import { mdiDotsHorizontalCircleOutline as QueuedIcon, mdiSleep as SleepIcon } from '@mdi/js';
2✔
21
import MaterialDesignIcon from '@northern.tech/common-ui/MaterialDesignIcon';
2✔
22
import Time from '@northern.tech/common-ui/Time';
2✔
23
import { TIMEOUTS } from '@northern.tech/store/constants';
2✔
24
import { groupDeploymentStats } from '@northern.tech/store/utils';
2✔
25
import dayjs from 'dayjs';
2✔
26
import durationDayJs from 'dayjs/plugin/duration';
2✔
27
import pluralize from 'pluralize';
2✔
28

2✔
29
dayjs.extend(durationDayJs);
18✔
30

2✔
31
const statusMap = {
18✔
32
  complete: {
2✔
33
    icon: <MaterialDesignIcon path={SleepIcon} />,
2✔
UNCOV
34
    description: () => 'Complete, awaiting new devices'
2✔
35
  },
2✔
36
  queued: {
2✔
37
    icon: <MaterialDesignIcon path={QueuedIcon} />,
2✔
38
    description: () => 'Queued to start'
401✔
39
  },
2✔
UNCOV
40
  paused: { icon: <RotateLeftOutlined fontSize="inherit" />, description: window => `Paused until next window ${window}` }
2✔
41
};
2✔
42

2✔
43
const useStyles = makeStyles()(theme => ({
63✔
44
  container: {
2✔
45
    backgroundColor: theme.palette.grey[400],
2✔
46
    padding: '10px 20px',
2✔
47
    borderRadius: theme.spacing(0.5),
2✔
48
    justifyContent: 'center',
2✔
49
    minHeight: 70,
2✔
50
    '.chart-container': { minHeight: 70 },
2✔
51
    '.progress-chart': { minHeight: 45 },
2✔
52
    '.compact .progress-step, .detailed .progress-step': { minHeight: 45 },
2✔
53
    '.progress-step, .detailed .progress-step-total': {
2✔
54
      position: 'absolute',
2✔
55
      borderRightStyle: 'none'
2✔
56
    },
2✔
57
    '.progress-step-total .progress-bar': { backgroundColor: theme.palette.grey[50] },
2✔
58
    '.progress-step-number': { alignSelf: 'flex-start', marginTop: theme.spacing(-0.5) },
2✔
59
    '&.minimal': { padding: 'initial' },
2✔
60
    '&.no-background': { background: 'none' },
2✔
61
    '&.stepped-progress .progress-step-total': { marginLeft: '-0.25%', width: '100.5%' },
2✔
62
    '&.stepped-progress .progress-step-total .progress-bar': {
2✔
63
      backgroundColor: theme.palette.background.default,
2✔
64
      border: `1px solid ${theme.palette.grey[800]}`,
2✔
65
      borderRadius: 2,
2✔
66
      height: 12
2✔
67
    },
2✔
68
    '&.stepped-progress .detailed .progress-step': { minHeight: 20 }
2✔
69
  },
2✔
70
  dualPanel: {
2✔
71
    display: 'grid',
2✔
72
    gridTemplateColumns: '2fr 1fr',
2✔
73
    gridColumnGap: theme.spacing(2),
2✔
74
    '.progress-chart.detailed': {
2✔
75
      minHeight: 20,
2✔
76
      alignItems: 'center'
2✔
77
    }
2✔
78
  },
2✔
79
  defaultDelimiter: { borderRight: '1px dashed', zIndex: 10 },
2✔
80
  phaseDelimiter: {
2✔
81
    display: 'grid',
2✔
82
    rowGap: 4,
2✔
83
    placeItems: 'center',
2✔
84
    position: 'absolute',
2✔
85
    gridTemplateRows: `20px 1.25rem min-content`,
2✔
86
    zIndex: 2,
2✔
87
    ['&.compact']: {
2✔
88
      gridTemplateRows: '45px 1.25rem min-content'
2✔
89
    }
2✔
90
  }
2✔
91
}));
2✔
92

2✔
93
export const ProgressChartComponent = ({
18✔
94
  className,
2✔
95
  compact,
2✔
96
  Footer,
2✔
97
  footerProps,
2✔
98
  Header,
2✔
99
  headerProps,
2✔
100
  minimal,
2✔
101
  PhaseDelimiter,
2✔
102
  PhaseLabel,
2✔
103
  phases,
2✔
104
  showDelimiter,
2✔
105
  Side,
2✔
106
  sideProps,
2✔
107
  ...remainder
2✔
108
}) => {
2✔
109
  const { classes } = useStyles();
551✔
110
  return (
551✔
111
    <div className={`relative ${classes.container} ${minimal ? 'minimal' : ''} ${className}`}>
2✔
112
      {!minimal && Header && <Header {...headerProps} />}
2✔
113
      <div className={!minimal && Side ? classes.dualPanel : 'chart-container'}>
2✔
114
        <div className={`progress-chart relative ${compact ? 'compact' : 'detailed'}`}>
2✔
115
          {phases.map((phase, index) => {
2✔
116
            const commonProps = { ...remainder, compact, index, phase, classes };
1,600✔
117
            return (
1,600✔
118
              <React.Fragment key={phase.id ?? `deployment-phase-${index}`}>
2✔
119
                <div className="progress-step" style={{ left: `${phase.offset}%`, width: `${phase.width}%` }}>
2✔
120
                  {!minimal && PhaseLabel && <PhaseLabel {...commonProps} />}
2✔
121
                  {!!phase.progressWidth && <div className="progress-bar" style={{ width: `${phase.progressWidth}%`, backgroundColor: '#aaa' }} />}
2✔
122
                  <div style={{ display: 'contents' }}>
2✔
123
                    <div className="progress-bar green" style={{ width: `${phase.successWidth}%` }} />
2✔
124
                    <div className="progress-bar warning" style={{ left: `${phase.successWidth}%`, width: `${phase.failureWidth}%` }} />
2✔
125
                  </div>
2✔
126
                </div>
2✔
127
                {(showDelimiter || PhaseDelimiter) && index !== phases.length - 1 && (
2✔
128
                  <>
2✔
129
                    {PhaseDelimiter ? (
2!
130
                      <PhaseDelimiter {...commonProps} />
2✔
131
                    ) : (
2✔
132
                      <div className={`absolute ${classes.defaultDelimiter}`} style={{ left: `${phase.offset}%` }} />
2✔
133
                    )}
2✔
134
                  </>
2✔
135
                )}
2✔
136
              </React.Fragment>
2✔
137
            );
2✔
138
          })}
2✔
139
          <div className="progress-step relative flexbox progress-step-total">
2✔
140
            <div className="progress-bar" />
2✔
141
          </div>
2✔
142
        </div>
2✔
143
        {!minimal && Side && <Side compact={compact} {...remainder} {...sideProps} />}
2✔
144
      </div>
2✔
145
      {!minimal && Footer && <Footer {...footerProps} />}
2✔
146
    </div>
2✔
147
  );
2✔
148
};
2✔
149

2✔
150
// to display failures per phase we have to approximate the failure count per phase by keeping track of the failures we display in previous phases and
2✔
151
// deduct the phase failures from the remainder - so if we have a total of 5 failures reported and are in the 3rd phase, with each phase before reporting
2✔
152
// 3 successful deployments -> the 3rd phase should end up with 1 failure so far
2✔
153
export const getDisplayablePhases = ({ currentPhase, currentProgressCount, phases, totalDeviceCount, totalFailureCount, totalSuccessCount }) =>
18✔
154
  phases.reduce(
548✔
155
    (accu, phase, index) => {
2✔
156
      const displayablePhase = { ...phase };
1,588✔
157
      // ongoing phases might not have a device_count yet - so we calculate it
2✔
158
      let expectedDeviceCountInPhase = Math.floor((totalDeviceCount / 100) * displayablePhase.batch_size) || displayablePhase.batch_size;
1,588✔
159
      // for phases with more successes than phase.device_count or more failures than phase.device_count we have to guess what phase to put them in =>
2✔
160
      // because of that we have to limit per phase success/ failure counts to the phase.device_count and split the progress between those with a bias for success,
2✔
161
      // therefore we have to track the remaining width and work with it - until we get per phase success & failure information
2✔
162
      let leftoverDevices = expectedDeviceCountInPhase;
1,588✔
163
      const possiblePhaseSuccesses = Math.max(Math.min(displayablePhase.device_count, totalSuccessCount - accu.countedSuccesses), 0);
1,588✔
164
      leftoverDevices -= possiblePhaseSuccesses;
1,588✔
165
      const possiblePhaseFailures = Math.max(Math.min(leftoverDevices, totalFailureCount - accu.countedFailures), 0);
1,588✔
166
      leftoverDevices -= possiblePhaseFailures;
1,588✔
167
      const possiblePhaseProgress = Math.max(Math.min(leftoverDevices, currentProgressCount - accu.countedProgress), 0);
1,588✔
168
      // if there are too few devices in a phase to register, fallback to occured deployments, as those have definitely happened
2✔
169
      expectedDeviceCountInPhase = Math.max(expectedDeviceCountInPhase, possiblePhaseSuccesses + possiblePhaseProgress + possiblePhaseFailures, 0);
1,588✔
170
      displayablePhase.successWidth = (possiblePhaseSuccesses / expectedDeviceCountInPhase) * 100 || 0;
1,588✔
171
      displayablePhase.failureWidth = (possiblePhaseFailures / expectedDeviceCountInPhase) * 100 || 0;
1,588✔
172
      if (displayablePhase.id === currentPhase.id || leftoverDevices > 0) {
1,588!
173
        displayablePhase.progressWidth = (possiblePhaseProgress / expectedDeviceCountInPhase) * 100;
1,588✔
174
        accu.countedProgress += possiblePhaseProgress;
1,588✔
175
      }
2✔
176
      displayablePhase.offset = accu.countedBatch;
1,588✔
177
      const remainingWidth = 100 - accu.countedBatch; // countedBatch should be the summarized percentages of the phases so far
1,588✔
178
      displayablePhase.width = index === phases.length - 1 ? remainingWidth : displayablePhase.batch_size;
1,588✔
179
      accu.countedBatch += displayablePhase.batch_size;
1,588✔
180
      accu.countedFailures += possiblePhaseFailures;
1,588✔
181
      accu.countedSuccesses += possiblePhaseSuccesses;
1,588✔
182
      accu.displayablePhases.push(displayablePhase);
1,588✔
183
      return accu;
1,588✔
184
    },
2✔
185
    { countedBatch: 0, countedFailures: 0, countedSuccesses: 0, countedProgress: 0, displayablePhases: [] }
2✔
186
  ).displayablePhases;
2✔
187

2✔
188
export const DeploymentStatusNotification = ({ status }) => (
18✔
189
  <div className="flexbox center-aligned">
401✔
190
    {statusMap[status].icon}
2✔
191
    <span className="margin-left-small">{statusMap[status].description()}</span>
2✔
192
  </div>
2✔
193
);
2✔
194

2✔
195
const Header = ({ status }) => (
18✔
196
  <>
425✔
197
    {statusMap[status] && (
2!
198
      <span className="flexbox center-aligned small muted">
2✔
199
        {statusMap[status].icon}
2✔
200
        <span className="margin-left-small">{statusMap[status].description()}</span>
2✔
201
      </span>
2✔
202
    )}
2✔
203
  </>
2✔
204
);
2✔
205

2✔
206
const Footer = ({ currentPhaseIndex, duration, nextPhaseStart, phasesCount }) => (
18✔
207
  <div className="flexbox space-between muted">
425✔
208
    <div>Devices in progress</div>
2✔
209
    {phasesCount > 1 && phasesCount > currentPhaseIndex + 1 ? (
2✔
210
      <div>
2✔
211
        <span>Time until next phase: </span>
2✔
212
        <Tooltip title={<Time value={nextPhaseStart.toDate()} />} placement="top">
2✔
213
          <span>{duration}</span>
2✔
214
        </Tooltip>
2✔
215
      </div>
2✔
216
    ) : (
2✔
217
      <div>{`Current phase: ${currentPhaseIndex + 1} of ${phasesCount}`}</div>
2✔
218
    )}
2✔
219
  </div>
2✔
220
);
2✔
221

2✔
222
const Side = ({ totalFailureCount }) => (
18✔
223
  <div className={`flexbox center-aligned ${totalFailureCount ? 'warning' : 'muted'}`} style={{ justifyContent: 'flex-end' }}>
425!
224
    {!!totalFailureCount && <WarningIcon style={{ fontSize: 16, marginRight: 10 }} />}
2!
225
    {`${totalFailureCount} ${pluralize('failure', totalFailureCount)}`}
2✔
226
  </div>
2✔
227
);
2✔
228

2✔
229
export const getDeploymentPhasesInfo = deployment => {
18✔
230
  const { created, device_count = 0, id, phases: deploymentPhases = [], max_devices = 0 } = deployment;
548✔
231
  const {
2✔
232
    inprogress: currentProgressCount,
2✔
233
    successes: totalSuccessCount,
2✔
234
    failures: totalFailureCount
2✔
235
  } = groupDeploymentStats(deployment, deploymentPhases.length < 2);
548✔
236
  const totalDeviceCount = Math.max(device_count, max_devices);
548✔
237

2✔
238
  const phases = deploymentPhases.length ? deploymentPhases : [{ id, device_count: totalSuccessCount, batch_size: 100, start_ts: created }];
548✔
239
  return {
548✔
240
    currentProgressCount,
2✔
241
    phases,
2✔
242
    reversedPhases: phases.slice().reverse(),
2✔
243
    totalDeviceCount,
2✔
244
    totalFailureCount,
2✔
245
    totalSuccessCount
2✔
246
  };
2✔
247
};
2✔
248

2✔
249
export const ProgressDisplay = ({ className = '', deployment, minimal = false, status }) => {
18✔
250
  const [time, setTime] = useState(dayjs());
530✔
251
  const timer = useRef();
530✔
252

2✔
253
  useEffect(() => {
530✔
254
    timer.current = setInterval(() => setTime(dayjs()), TIMEOUTS.oneSecond);
210✔
255
    return () => {
42✔
256
      clearInterval(timer.current);
42✔
257
    };
2✔
258
  }, []);
2✔
259

2✔
260
  const { reversedPhases, totalFailureCount, phases, ...remainder } = getDeploymentPhasesInfo(deployment);
530✔
261

2✔
262
  const currentPhase = reversedPhases.find(phase => dayjs(phase.start_ts) < time) || phases[0];
1,566✔
263
  const currentPhaseIndex = phases.findIndex(phase => phase.id === currentPhase.id);
530✔
264
  const nextPhaseStart = phases.length > currentPhaseIndex + 1 ? dayjs(phases[currentPhaseIndex + 1].start_ts) : dayjs(time);
530✔
265

2✔
266
  const displayablePhases = getDisplayablePhases({
530✔
267
    currentPhase,
2✔
268
    totalFailureCount,
2✔
269
    phases,
2✔
270
    ...remainder
2✔
271
  });
2✔
272

2✔
273
  const duration = dayjs.duration(nextPhaseStart.diff(time)).format('DD [days] HH [h] mm [m] ss [s]');
530✔
274

2✔
275
  return (
530✔
276
    <ProgressChartComponent
2✔
277
      className={className}
2✔
278
      Footer={Footer}
2✔
279
      footerProps={{ currentPhaseIndex, duration, nextPhaseStart, phasesCount: phases.length }}
2✔
280
      Header={Header}
2✔
281
      headerProps={{ status }}
2✔
282
      minimal={minimal}
2✔
283
      phases={displayablePhases}
2✔
284
      Side={Side}
2✔
285
      sideProps={{ totalFailureCount }}
2✔
286
    />
2✔
287
  );
2✔
288
};
2✔
289

2✔
290
export default ProgressDisplay;
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