• 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

94.81
/frontend/src/js/components/dashboard/widgets/Distribution.tsx
1
// Copyright 2020 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, useEffect, useMemo, useRef, useState } from 'react';
2✔
15
import { useDispatch, useSelector } from 'react-redux';
2✔
16
import { useNavigate } from 'react-router-dom';
2✔
17

2✔
18
import { Clear as ClearIcon, Settings, Square } from '@mui/icons-material';
2✔
19
import { IconButton, LinearProgress, linearProgressClasses } from '@mui/material';
2✔
20
import { makeStyles } from 'tss-react/mui';
2✔
21

2✔
22
import Loader from '@northern.tech/common-ui/Loader';
2✔
23
import { ALL_DEVICES, TIMEOUTS, chartTypes, rootfsImageVersion, softwareIndicator, softwareTitleMap } from '@northern.tech/store/constants';
2✔
24
import { getDeviceReports, getGroupsById } from '@northern.tech/store/selectors';
2✔
25
import { getReportDataWithoutBackendSupport, updateReportData } from '@northern.tech/store/thunks';
2✔
26
import { ensureVersionString } from '@northern.tech/store/utils';
2✔
27
import { chartColorPalette } from '@northern.tech/themes/Mender';
2✔
28
import { isEmpty, toggle } from '@northern.tech/utils/helpers';
2✔
29
import { VictoryBar, VictoryContainer, VictoryPie, VictoryStack } from 'victory';
2✔
30

2✔
31
import BaseWidget from './BaseWidget';
2✔
32
import { ChartEditWidget, Header, RemovalWidget } from './ChartAddition';
2✔
33

2✔
34
const seriesOther = '__OTHER__';
8✔
35

2✔
36
const createColorClassName = hexColor => `color-${hexColor.slice(1)}`;
31✔
37

2✔
38
const useStyles = makeStyles()(theme => ({
8✔
39
  indicator: { fontSize: 10, minWidth: 'initial', marginLeft: 4 },
2✔
40
  legendItem: {
2✔
41
    alignItems: 'center',
2✔
42
    display: 'grid',
2✔
43
    gridTemplateColumns: '1fr max-content',
2✔
44
    columnGap: theme.spacing(2),
2✔
45
    '&.indicating': {
2✔
46
      gridTemplateColumns: 'min-content 1fr max-content',
2✔
47
      columnGap: theme.spacing()
2✔
48
    }
2✔
49
  },
2✔
50
  wrapper: {
2✔
51
    display: 'grid',
2✔
52
    gridTemplateColumns: '200px 1fr',
2✔
53
    columnGap: theme.spacing(2),
2✔
54
    marginBottom: theme.spacing(2),
2✔
55
    '&>.flexbox.column > *': {
2✔
56
      height: 20
2✔
57
    },
2✔
58
    '.barchart': {
2✔
59
      [`.${linearProgressClasses.root}`]: {
2✔
60
        backgroundColor: theme.palette.grey[400]
2✔
61
      },
2✔
62
      ...Object.values(chartColorPalette).reduce(
2✔
63
        (accu, color) => ({
20✔
64
          ...accu,
2✔
65
          [`.${createColorClassName(color)} .${linearProgressClasses.barColorPrimary}`]: { backgroundColor: color }
2✔
66
        }),
2✔
67
        {
2✔
68
          [`.${createColorClassName(theme.palette.grey[400])} .${linearProgressClasses.barColorPrimary}`]: { backgroundColor: theme.palette.grey[400] }
2✔
69
        }
2✔
70
      )
2✔
71
    }
2✔
72
  }
2✔
73
}));
2✔
74

2✔
75
const ChartLegend = ({ classes, data, events = [], showIndicators = true }) => {
8✔
76
  const { eventHandlers = {} } = events[0];
10✔
77
  const { onClick } = eventHandlers;
10✔
78
  return (
10✔
79
    <div className="flexbox column">
2✔
80
      {data.map(({ fill, x, title, tip, value }) => (
2✔
81
        <div
12✔
82
          className={`clickable ${classes.legendItem} ${showIndicators ? 'indicating' : ''}`}
2✔
83
          key={x}
2✔
UNCOV
84
          onClick={e => onClick(e, { datum: { x } })}
2✔
85
          title={tip}
2✔
86
        >
2✔
87
          {showIndicators && <Square className={classes.indicator} style={{ fill }} />}
2✔
88
          <div className="text-overflow">{title}</div>
2✔
89
          <div>{value.toLocaleString()}</div>
2✔
90
        </div>
2✔
91
      ))}
2✔
92
    </div>
2✔
93
  );
2✔
94
};
2✔
95

2✔
96
const VictoryBarChart = ({ data, totals, ...remainder }) => (
8✔
97
  <VictoryStack {...remainder} animate={{ duration: 700, onLoad: { duration: 700 } }} horizontal padding={{ left: 0, right: 0, top: 0, bottom: 15 }}>
2✔
98
    <VictoryBar alignment="start" barWidth={16} sortKey={['y']} sortOrder="ascending" data={data} />
2✔
99
    <VictoryBar alignment="start" barWidth={16} sortKey={['y']} sortOrder="descending" data={totals} />
2✔
100
  </VictoryStack>
2✔
101
);
2✔
102

2✔
103
const BarChart = ({ data, events }) => {
8✔
104
  const [chartData, setChartData] = useState([]);
10✔
105
  const timer = useRef();
10✔
106

2✔
107
  useEffect(() => {
10✔
108
    setChartData(data.map(item => ({ ...item, y: 0 })));
7✔
109
    clearTimeout(timer.current);
5✔
110
    timer.current = setTimeout(() => setChartData(data), TIMEOUTS.debounceDefault);
5✔
111
    return () => {
5✔
112
      clearTimeout(timer.current);
5✔
113
    };
2✔
114
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
115
  }, [JSON.stringify(data)]);
2✔
116

2✔
117
  const { eventHandlers = {} } = events[0];
10✔
118
  const { onClick } = eventHandlers;
10✔
119
  return (
10✔
120
    <div className="flexbox column">
2✔
121
      {chartData.map(({ fill, x, y, tip }) => (
2✔
122
        <div className="clickable flexbox column barchart" key={x} onClick={e => onClick(e, { datum: { x } })} title={tip} style={{ justifyContent: 'center' }}>
10✔
123
          <LinearProgress className={createColorClassName(fill)} variant="determinate" value={y} style={{ height: 8 }} />
2✔
124
        </div>
2✔
125
      ))}
2✔
126
    </div>
2✔
127
  );
2✔
128
};
2✔
129

2✔
130
const ChartContainer = ({ className, children, innerRef, style = {} }) => (
8✔
131
  <div className={className} ref={innerRef} style={style}>
10✔
132
    {children}
2✔
133
  </div>
2✔
134
);
2✔
135

2✔
136
const BarChartContainer = ({ classes = {}, data, events, ...remainder }) => (
8✔
137
  <ChartContainer className={classes.wrapper}>
8✔
138
    <ChartLegend classes={classes} data={data} events={events} showIndicators={false} />
2✔
139
    <BarChart {...remainder} data={data} events={events} />
2✔
140
  </ChartContainer>
2✔
141
);
2✔
142

2✔
143
const PieChart = props => <VictoryPie {...props} padding={{ left: 0, right: 0, top: 0, bottom: 15 }} />;
8✔
144

2✔
145
const padding = 10;
8✔
146
const PieChartContainer = ({ classes = {}, ...chartProps }) => {
8✔
147
  const ref = useRef();
4✔
148
  let height;
2✔
149
  if (ref.current) {
4✔
150
    // use the widget height, remove the space the header takes up and account for widget padding (top + padding) + own padding for the chart
2✔
151
    height = ref.current.parentElement.offsetHeight - ref.current.parentElement.children[0].offsetHeight - 3 * padding;
3✔
152
  }
2✔
153
  return (
4✔
154
    <ChartContainer className={classes.wrapper} innerRef={ref} style={{ height }}>
2✔
155
      <ChartLegend {...chartProps} classes={classes} />
2✔
156
      {height && <PieChart {...chartProps} containerComponent={<VictoryContainer style={{ height }} />} />}
2✔
157
    </ChartContainer>
2✔
158
  );
2✔
159
};
2✔
160

2✔
161
const VictoryBarChartContainer = ({ classes = {}, ...chartProps }) => (
8!
162
  <ChartContainer className={classes.wrapper}>
2✔
163
    <ChartLegend {...chartProps} classes={classes} />
2✔
164
    <VictoryBarChart {...chartProps} />
2✔
165
  </ChartContainer>
2✔
166
);
2✔
167

2✔
168
const chartTypeComponentMap = {
8✔
169
  [chartTypes.bar.key]: BarChartContainer,
2✔
170
  [`${chartTypes.bar.key}-alternative`]: VictoryBarChartContainer,
2✔
171
  [chartTypes.pie.key]: PieChartContainer
2✔
172
};
2✔
173

2✔
174
const initDistribution = ({ data, theme }) => {
8✔
175
  const { items, otherCount, total } = data;
6✔
176
  const numberOfItems = items.length > chartColorPalette.length ? chartColorPalette.length - 1 : items.length;
6!
177
  const colors = chartColorPalette.slice(0, numberOfItems).reverse();
6✔
178
  const distribution = items.slice(0, colors.length).reduce(
6✔
179
    (accu, { key, count }, index) => [
7✔
180
      {
2✔
181
        x: key || '-',
2!
182
        y: (count / total) * 100, //value,
2✔
183
        title: key || '-',
2!
184
        tip: key || '-',
2!
185
        fill: chartColorPalette[index],
2✔
186
        value: count
2✔
187
      },
2✔
188
      ...accu
2✔
189
    ],
2✔
190
    []
2✔
191
  );
2✔
192
  if (items.length > chartColorPalette.length || otherCount) {
6✔
193
    distribution.splice(0, 0, {
3✔
194
      x: seriesOther,
2✔
195
      title: 'Other',
2✔
196
      tip: 'Other',
2✔
197
      y: (otherCount / total) * 100,
2✔
198
      fill: chartColorPalette[chartColorPalette.length - 1],
2✔
199
      value: otherCount
2✔
200
    });
2✔
201
  }
2✔
202
  distribution.sort((pairA, pairB) => pairB.y - pairA.y);
6✔
203
  // y: formatValue(item.y, total)
2✔
204
  const totals = distribution.map(({ x, y }) => ({ value: total, x, y: 100 - y, fill: theme.palette.grey[400] }));
8✔
205
  return { distribution, totals };
6✔
206
};
2✔
207

2✔
208
interface DistributionReport {
2✔
209
  attribute: string;
2✔
210
  chartType: string;
2✔
211
  group: string;
2✔
212
  index: number;
2✔
213
  software: string;
2✔
214
  type: string;
2✔
215
}
2✔
216

2✔
217
type SoftwareLayer = {
2✔
218
  [key: string]: SoftwareLayer | string;
2✔
219
};
2✔
220

2✔
221
interface DistributionReportProps {
2✔
222
  onClick?: () => void;
2✔
223
  onSave: (selection: Partial<DistributionReport>) => void;
2✔
224
  selection?: Partial<DistributionReport>;
2✔
225
  software?: SoftwareLayer;
2✔
226
}
2✔
227

2✔
228
const cleanSoftwareTitle = (software: string) => {
8✔
229
  let cleanedSoftware = softwareTitleMap[software] ? softwareTitleMap[software].title : software;
10!
230
  cleanedSoftware = cleanedSoftware.endsWith(softwareIndicator)
10!
231
    ? cleanedSoftware.substring(0, cleanedSoftware.lastIndexOf(softwareIndicator))
2✔
232
    : cleanedSoftware;
2✔
233
  return cleanedSoftware;
10✔
234
};
2✔
235

2✔
236
export const DistributionReport = ({ onClick, onSave, selection = {}, software: softwareTree }: DistributionReportProps) => {
8✔
237
  const { attribute, chartType = chartTypes.bar.key, group, index: reportIndex, software: softwareSelection } = selection;
12✔
238
  const software = softwareSelection || attribute || rootfsImageVersion;
12!
239
  const [editing, setEditing] = useState(false);
12✔
240
  const [removing, setRemoving] = useState(false);
12✔
241
  const navigate = useNavigate();
12✔
242
  const { classes, theme } = useStyles();
12✔
243
  const reportsData = useSelector(getDeviceReports);
12✔
244
  const groupsById = useSelector(getGroupsById);
12✔
245
  const dispatch = useDispatch();
12✔
246
  const hasGroupDefinition = !!groupsById[group];
12✔
247
  const report = reportsData[reportIndex];
12✔
248
  const hasData = !isEmpty(report);
12✔
249

2✔
250
  useEffect(() => {
12✔
251
    if (group && !hasGroupDefinition) {
7!
252
      return;
2✔
253
    }
2✔
254
    // this will retrieve device data and on repeat renders of the widget rely on stored data only - this increases the risk to miss data from the backend or
2✔
255
    // device initiated data changes, but assumes this is preferable over repeat queries to the backend causing rate limiting
2✔
256
    if (!hasData) {
7✔
257
      dispatch(getReportDataWithoutBackendSupport(reportIndex));
4✔
258
      return;
4✔
259
    }
2✔
260
    dispatch(updateReportData(reportIndex));
5✔
261
  }, [dispatch, reportIndex, group, hasData, hasGroupDefinition]);
2✔
262

2✔
263
  const { distribution, totals } = useMemo(() => {
12✔
264
    if (isEmpty(report)) {
8✔
265
      return { distribution: [], totals: [] };
4✔
266
    }
2✔
267
    return initDistribution({ data: report, theme });
6✔
268
    // eslint-disable-next-line react-hooks/exhaustive-deps
2✔
269
  }, [JSON.stringify(report), JSON.stringify(selection)]);
2✔
270

2✔
271
  const onSliceClick = useCallback(
12✔
272
    (e, { datum: { x: target } }) => {
2✔
273
      if (target === seriesOther) {
2!
274
        return;
2✔
275
      }
2✔
276
      navigate(`/devices/accepted?inventory=${group ? `group:eq:${group}&` : ''}${ensureVersionString(software, attribute)}:eq:${target}`);
2!
277
    },
2✔
278
    [attribute, group, navigate, software]
2✔
279
  );
2✔
280

2✔
281
  const toggleRemoving = () => setRemoving(toggle);
12✔
282

2✔
283
  const onToggleEditClick = () => setEditing(toggle);
12✔
284

2✔
285
  const onSaveClick = selection => {
12✔
286
    onSave(selection);
2✔
287
    setEditing(false);
2✔
288
  };
2✔
289

2✔
290
  const Chart = chartTypeComponentMap[chartType];
12✔
291
  const chartProps = {
12✔
292
    classes,
2✔
293
    data: distribution,
2✔
294
    domainPadding: 0,
2✔
295
    events: [{ target: 'data', eventHandlers: { onClick: onSliceClick } }],
2✔
296
    standalone: true,
2✔
297
    style: { data: { fill: ({ datum }) => datum.fill } },
3✔
298
    labels: () => null
3✔
299
  };
2✔
300
  const couldHaveDevices = !group || groupsById[group]?.deviceIds.length;
12!
301
  if (removing) {
12!
302
    return <RemovalWidget onCancel={toggleRemoving} onClick={onClick} />;
2✔
303
  }
2✔
304
  if (editing) {
12!
305
    return <ChartEditWidget groups={groupsById} onSave={onSaveClick} onCancel={onToggleEditClick} selection={selection} software={softwareTree} />;
2✔
306
  }
2✔
307
  if (!report) {
12✔
308
    return <BaseWidget className="chart-widget flexbox centered" main={<Loader show />} />;
4✔
309
  }
2✔
310
  return (
10✔
311
    <div className="widget chart-widget">
2✔
312
      <div className="margin-bottom-small">
2✔
313
        <div className="flexbox space-between margin-bottom-small">
2✔
314
          <Header chartType={chartType} />
2✔
315
          <div className="flexbox center-aligned" style={{ zIndex: 1 }}>
2✔
316
            <IconButton onClick={onToggleEditClick} size="small">
2✔
317
              <Settings fontSize="small" />
2✔
318
            </IconButton>
2✔
319
            <IconButton onClick={toggleRemoving} size="small">
2✔
320
              <ClearIcon fontSize="small" />
2✔
321
            </IconButton>
2✔
322
          </div>
2✔
323
        </div>
2✔
324
        <div className="flexbox space-between slightly-smaller">
2✔
325
          <div>{cleanSoftwareTitle(software)}</div>
2✔
326
          <div>{group || ALL_DEVICES}</div>
2✔
327
        </div>
2✔
328
      </div>
2✔
329
      {distribution.length || totals.length ? (
2!
330
        <Chart {...chartProps} totals={totals} />
2✔
331
      ) : couldHaveDevices ? (
2!
332
        <div className="muted flexbox centered">There are no devices that match the selected criteria.</div>
2✔
333
      ) : (
2✔
334
        <Loader show={true} />
2✔
335
      )}
2✔
336
    </div>
2✔
337
  );
2✔
338
};
2✔
339

2✔
340
export default DistributionReport;
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