feat(cicd-statistics): Allow custom hiding/collapsing under certain thresholds
Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
@@ -132,13 +132,14 @@ export interface CicdDefaults {
|
||||
filterStatus: Array<FilterStatusType>;
|
||||
filterType: FilterBranchType | 'all';
|
||||
|
||||
/** Default collapse the stages with a max-duration below this value */
|
||||
collapsedLimit: number;
|
||||
|
||||
/** Lower-case all stage names (to potentially merge stages with different cases) */
|
||||
lowercaseNames: boolean;
|
||||
/** Normalize the from-to date range in all charts */
|
||||
normalizeTimeRange: boolean;
|
||||
/** Default collapse the stages with a max-duration below this value */
|
||||
collapsedLimit: number;
|
||||
/** Default hide stages with a max-duration below this value */
|
||||
hideLimit: number;
|
||||
/** Chart types per status */
|
||||
chartTypes: Record<FilterStatusType, ChartTypes>;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
tickFormatterY,
|
||||
tooltipValueFormatter,
|
||||
formatDuration,
|
||||
} from './utils';
|
||||
} from '../components/utils';
|
||||
import {
|
||||
statusColorMap,
|
||||
fireColors,
|
||||
@@ -67,12 +67,18 @@ export interface StageChartProps {
|
||||
|
||||
chartTypes: CicdDefaults['chartTypes'];
|
||||
defaultCollapsed?: number;
|
||||
defaultHidden?: number;
|
||||
zeroYAxis?: boolean;
|
||||
}
|
||||
|
||||
export function StageChart(props: StageChartProps) {
|
||||
const { stage, ...chartOptions } = props;
|
||||
const { chartTypes, defaultCollapsed = 0, zeroYAxis = false } = chartOptions;
|
||||
const {
|
||||
chartTypes,
|
||||
defaultCollapsed = 0,
|
||||
defaultHidden = 0,
|
||||
zeroYAxis = false,
|
||||
} = chartOptions;
|
||||
|
||||
const ticks = useMemo(
|
||||
() => pickElements(stage.values, 8).map(val => val.__epoch),
|
||||
@@ -97,7 +103,17 @@ export function StageChart(props: StageChartProps) {
|
||||
[statuses],
|
||||
);
|
||||
|
||||
return (
|
||||
const subStages = useMemo(
|
||||
() =>
|
||||
new Map<string, ChartableStage>(
|
||||
[...stage.stages.entries()].filter(
|
||||
([_name, subStage]) => subStage.combinedAnalysis.max > defaultHidden,
|
||||
),
|
||||
),
|
||||
[stage.stages, defaultHidden],
|
||||
);
|
||||
|
||||
return stage.combinedAnalysis.max < defaultHidden ? null : (
|
||||
<Accordion
|
||||
defaultExpanded={stage.combinedAnalysis.max > defaultCollapsed}
|
||||
TransitionProps={transitionProps}
|
||||
@@ -209,18 +225,18 @@ export function StageChart(props: StageChartProps) {
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Grid>
|
||||
{stage.stages.size === 0 ? null : (
|
||||
{subStages.size === 0 ? null : (
|
||||
<Grid item>
|
||||
<Accordion
|
||||
defaultExpanded={false}
|
||||
TransitionProps={transitionProps}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Sub stages ({stage.stages.size})</Typography>
|
||||
<Typography>Sub stages ({subStages.size})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<div style={fullWidth}>
|
||||
{[...stage.stages.values()].map(subStage => (
|
||||
{[...subStages.values()].map(subStage => (
|
||||
<StageChart
|
||||
key={subStage.name}
|
||||
{...chartOptions}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { countBy } from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { Build, FilterStatusType, statusTypes } from '../apis/types';
|
||||
import { labelFormatterWithoutTime, tickFormatterX } from './utils';
|
||||
import { labelFormatterWithoutTime, tickFormatterX } from '../components/utils';
|
||||
import { statusColorMap } from './colors';
|
||||
|
||||
export interface StatusChartProps {
|
||||
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Switch,
|
||||
Theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Theme,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import ShowChartIcon from '@material-ui/icons/ShowChart';
|
||||
@@ -50,8 +50,10 @@ import {
|
||||
} from '../apis/types';
|
||||
import { ButtonSwitch, SwitchValue } from './button-switch';
|
||||
import { Toggle } from './toggle';
|
||||
import { DurationSlider } from './duration-slider';
|
||||
import { Label } from './label';
|
||||
|
||||
const useStyles = makeStyles<Theme>(
|
||||
export const useStyles = makeStyles<Theme>(
|
||||
theme => ({
|
||||
rootCard: {
|
||||
padding: theme.spacing(0, 0, 0, 0),
|
||||
@@ -81,7 +83,7 @@ const useStyles = makeStyles<Theme>(
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatisticsChartFilters',
|
||||
name: 'CicdStatistics',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -127,7 +129,11 @@ function isSameChartFilter(a: ChartFilter, b: ChartFilter): boolean {
|
||||
|
||||
export type ViewOptions = Pick<
|
||||
CicdDefaults,
|
||||
'lowercaseNames' | 'normalizeTimeRange' | 'chartTypes'
|
||||
| 'lowercaseNames'
|
||||
| 'normalizeTimeRange'
|
||||
| 'collapsedLimit'
|
||||
| 'hideLimit'
|
||||
| 'chartTypes'
|
||||
>;
|
||||
|
||||
export function getDefaultViewOptions(
|
||||
@@ -136,6 +142,8 @@ export function getDefaultViewOptions(
|
||||
return {
|
||||
lowercaseNames: cicdConfiguration.defaults?.lowercaseNames ?? false,
|
||||
normalizeTimeRange: cicdConfiguration.defaults?.normalizeTimeRange ?? true,
|
||||
collapsedLimit: 60 * 1000, // 1m
|
||||
hideLimit: 20 * 1000, // 20s
|
||||
chartTypes: {
|
||||
succeeded: ['duration'],
|
||||
failed: ['count'],
|
||||
@@ -224,6 +232,20 @@ export function ChartFilters(props: ChartFiltersProps) {
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
const setHideLimit = useCallback(
|
||||
(value: number) => {
|
||||
setViewOptions(old => ({ ...old, hideLimit: value }));
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
const setCollapseLimit = useCallback(
|
||||
(value: number) => {
|
||||
setViewOptions(old => ({ ...old, collapsedLimit: value }));
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
const setChartType = useCallback(
|
||||
(statusType: FilterStatusType, chartTypes: ChartTypes) => {
|
||||
setViewOptions(old => ({
|
||||
@@ -351,7 +373,7 @@ export function ChartFilters(props: ChartFiltersProps) {
|
||||
onChange={toggleUseNowAsDate}
|
||||
/>
|
||||
}
|
||||
label="To today"
|
||||
label={<Label>To today</Label>}
|
||||
/>
|
||||
{useNowAsToDate ? null : (
|
||||
<KeyboardDatePicker
|
||||
@@ -413,7 +435,7 @@ export function ChartFilters(props: ChartFiltersProps) {
|
||||
'when stage names have changed casing'
|
||||
}
|
||||
>
|
||||
<span>Lowercase names</span>
|
||||
<Label>Lowercase names</Label>
|
||||
</Tooltip>
|
||||
</Toggle>
|
||||
<Toggle
|
||||
@@ -428,9 +450,19 @@ export function ChartFilters(props: ChartFiltersProps) {
|
||||
'and only appear in a part of the time range.'
|
||||
}
|
||||
>
|
||||
<span>Normalize time range</span>
|
||||
<Label>Normalize time range</Label>
|
||||
</Tooltip>
|
||||
</Toggle>
|
||||
<DurationSlider
|
||||
header="Hide under peak"
|
||||
value={viewOptions.hideLimit}
|
||||
setValue={setHideLimit}
|
||||
/>
|
||||
<DurationSlider
|
||||
header="Collapse under peak"
|
||||
value={viewOptions.collapsedLimit}
|
||||
setValue={setCollapseLimit}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Slider } from '@material-ui/core';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { formatDuration, formatDurationFromSeconds } from './utils';
|
||||
import { Label } from './label';
|
||||
|
||||
const marks = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
60,
|
||||
2 * 60,
|
||||
3 * 60,
|
||||
4 * 60,
|
||||
5 * 60,
|
||||
10 * 60,
|
||||
15 * 60,
|
||||
30 * 60,
|
||||
45 * 60,
|
||||
60 * 60,
|
||||
].map((value, index) => ({
|
||||
value: index,
|
||||
label: formatDurationFromSeconds(value),
|
||||
seconds: value,
|
||||
}));
|
||||
|
||||
function findMarkIndex(seconds: number): number {
|
||||
if (marks[0].seconds > seconds) {
|
||||
return 0;
|
||||
} else if (marks[marks.length - 1].seconds < seconds) {
|
||||
return marks.length - 1;
|
||||
}
|
||||
for (let i = 0; i < marks.length - 1; ++i) {
|
||||
const a = marks[i];
|
||||
const b = marks[i + 1];
|
||||
if (seconds === a.seconds) {
|
||||
return i;
|
||||
} else if (seconds === b.seconds) {
|
||||
return i + 1;
|
||||
} else if (a.seconds < seconds && b.seconds > seconds) {
|
||||
return seconds - a.seconds < b.seconds - seconds ? i : i - 1;
|
||||
}
|
||||
}
|
||||
return 0; // Won't happen
|
||||
}
|
||||
|
||||
function formatDurationFromIndex(index: number) {
|
||||
return formatDurationFromSeconds(marks[index].seconds);
|
||||
}
|
||||
|
||||
export interface DurationSliderProps {
|
||||
header: string;
|
||||
value: number;
|
||||
setValue: (value: number) => void;
|
||||
}
|
||||
|
||||
export function DurationSlider(props: DurationSliderProps) {
|
||||
const { header, value, setValue } = props;
|
||||
|
||||
const [curValue, setCurValue] = useState(value);
|
||||
|
||||
const debouncedSetValue = useMemo(() => debounce(setValue, 1000), [setValue]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(_: any, index: number | number[]) => {
|
||||
const millis = marks[index as number].seconds * 1000;
|
||||
setCurValue(millis);
|
||||
debouncedSetValue(millis);
|
||||
},
|
||||
[debouncedSetValue],
|
||||
);
|
||||
|
||||
const indexValue = useMemo(() => findMarkIndex(curValue / 1000), [curValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>
|
||||
{header} {formatDuration(curValue)}
|
||||
</Label>
|
||||
<Slider
|
||||
value={indexValue}
|
||||
min={0}
|
||||
step={1}
|
||||
max={marks.length - 1}
|
||||
marks
|
||||
getAriaValueText={formatDurationFromIndex}
|
||||
valueLabelFormat={formatDurationFromIndex}
|
||||
onChange={onChange}
|
||||
valueLabelDisplay="auto"
|
||||
aria-labelledby="slider-hide-limit"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Typography, Theme, makeStyles } from '@material-ui/core';
|
||||
|
||||
export const useStyles = makeStyles<Theme>(
|
||||
theme => ({
|
||||
label: {
|
||||
fontWeight: 'normal',
|
||||
margin: theme.spacing(0),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatisticsLabel',
|
||||
},
|
||||
);
|
||||
|
||||
export function Label({ children }: PropsWithChildren<{}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
+4
@@ -117,3 +117,7 @@ export function formatDuration(millis: number) {
|
||||
|
||||
return dur.toHuman({ unitDisplay: 'narrow' }).replace(/, /g, '');
|
||||
}
|
||||
|
||||
export function formatDurationFromSeconds(seconds: number) {
|
||||
return formatDuration(seconds * 1000);
|
||||
}
|
||||
@@ -152,8 +152,6 @@ function CicdCharts(props: CicdChartsProps) {
|
||||
errorApi.post(chartableStagesState.error);
|
||||
}, [errorApi, chartableStagesState.error]);
|
||||
|
||||
const collapsedLimit = cicdConfiguration.defaults.collapsedLimit ?? 60 * 1000; // 1m
|
||||
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid item lg={2} className={classes.pane}>
|
||||
@@ -176,13 +174,15 @@ function CicdCharts(props: CicdChartsProps) {
|
||||
<StageChart
|
||||
stage={chartableStages.total}
|
||||
defaultCollapsed={0}
|
||||
defaultHidden={viewOptions.hideLimit}
|
||||
chartTypes={viewOptions.chartTypes}
|
||||
/>
|
||||
{[...chartableStages.stages.entries()].map(([name, stage]) => (
|
||||
<StageChart
|
||||
key={name}
|
||||
stage={stage}
|
||||
defaultCollapsed={collapsedLimit}
|
||||
defaultCollapsed={viewOptions.collapsedLimit}
|
||||
defaultHidden={viewOptions.hideLimit}
|
||||
chartTypes={viewOptions.chartTypes}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user