feat(cicd-statistics): Allow stages to individual status, and added bar charts counts, and some refactoring

Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
Gustaf Räntilä
2022-01-21 16:07:37 +01:00
committed by blam
parent ecaa8e7a15
commit cbbab88d9f
14 changed files with 598 additions and 297 deletions
+18 -2
View File
@@ -63,6 +63,9 @@ export type FilterBranchType = 'master' | 'branch';
export interface Stage {
name: string;
/** The status of the stage */
status: FilterStatusType;
/** Stage duration in milliseconds */
duration: number;
@@ -107,6 +110,16 @@ export type BuildWithRaw<T = any> = Build & {
raw: T;
};
/**
* Chart type.
*
* Values are:
* * `duration`: shows an area chart of the duration over time
* * `count`: shows a bar chart of the number of build per day
*/
export type ChartType = 'duration' | 'count';
export type ChartTypes = Array<ChartType>;
/**
* Default settings for the fetching options and view options.
*
@@ -119,12 +132,15 @@ 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;
/** Chart types per status */
chartTypes: Record<FilterStatusType, ChartTypes>;
}
/**
@@ -1,238 +0,0 @@
/*
* 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 { map } from 'already';
import { Build, Stage, FilterStatusType, statusTypes } from '../apis/types';
import {
Averagify,
ChartableStage,
ChartableStageAnalysis,
ChartableStageDatapoints,
ChartableStagesAnalysis,
} from './types';
function makeStage(name: string): ChartableStage {
return {
analysis: {
unknown: { avg: 0, max: 0, min: 0 },
enqueued: { avg: 0, max: 0, min: 0 },
scheduled: { avg: 0, max: 0, min: 0 },
running: { avg: 0, max: 0, min: 0 },
aborted: { avg: 0, max: 0, min: 0 },
succeeded: { avg: 0, max: 0, min: 0 },
failed: { avg: 0, max: 0, min: 0 },
stalled: { avg: 0, max: 0, min: 0 },
expired: { avg: 0, max: 0, min: 0 },
},
combinedAnalysis: { avg: 0, max: 0, min: 0 },
statusSet: new Set<FilterStatusType>(),
name,
values: [],
stages: new Map(),
};
}
export interface ChartableStagesOptions {
normalizeTimeRange: boolean;
}
/**
* Converts a list of builds, each with a tree of stages (and durations) into a
* merged tree of stages, and calculates {avg, min, max} of each stage.
*/
export async function buildsToChartableStages(
builds: Array<Build>,
options: ChartableStagesOptions,
): Promise<ChartableStagesAnalysis> {
const { normalizeTimeRange } = options;
const total: ChartableStage = makeStage('Total');
const recurseDown = (
status: FilterStatusType,
stageMap: Map<string, ChartableStage>,
stage: Stage,
__epoch: number,
) => {
const { name, duration } = stage;
const subChartableStage = getOrSetStage(stageMap, name);
subChartableStage.statusSet.add(status);
subChartableStage.values.push({
__epoch,
[status]: duration,
[`${status} avg`]: duration,
});
stage.stages?.forEach(subStage => {
recurseDown(status, subChartableStage.stages, subStage, __epoch);
});
};
const stages = new Map<string, ChartableStage>();
await map(builds, { chunk: 'idle' }, build => {
const { duration, requestedAt, status } = build;
const __epoch = requestedAt.getTime();
total.statusSet.add(status);
total.values.push({
__epoch,
[status]: duration,
[`${status} avg`]: duration,
});
build.stages?.forEach(subStage => {
recurseDown(status, stages, subStage, __epoch);
});
});
const allEpochs = normalizeTimeRange
? builds.map(build => build.requestedAt.getTime())
: [];
// Recurse down again and calculate averages
await map([...stages.values()], { chunk: 'idle' }, stage =>
finalizeStage(stage, { allEpochs, averageWidth: 10 }),
);
finalizeStage(total, { allEpochs, averageWidth: 10 });
return { total, stages };
}
function getAnalysis(
values: Array<ChartableStageDatapoints>,
status: FilterStatusType,
): ChartableStageAnalysis {
const analysis: ChartableStageAnalysis = {
max: 0,
min: 0,
avg: 0,
};
const definedValues = values.filter(
value => typeof value[status] !== 'undefined',
);
analysis.max = definedValues.reduce(
(prev, cur) => Math.max(prev, cur[status]!),
0,
);
analysis.min = definedValues.reduce(
(prev, cur) => Math.min(prev, cur[status]!),
analysis.max,
);
analysis.avg =
definedValues.length === 0
? 0
: definedValues.reduce((prev, cur) => prev + cur[status]!, 0) /
values.length;
return analysis;
}
interface FinalizeStageOptions {
averageWidth: number;
allEpochs: Array<number>;
}
/**
* Calculate {avg, min, max} of a stage and its sub stages, recursively.
* This is calculated per status (successful, failed, etc).
*/
function finalizeStage(stage: ChartableStage, options: FinalizeStageOptions) {
const { averageWidth, allEpochs } = options;
const { values, analysis, combinedAnalysis } = stage;
if (allEpochs.length > 0) {
const valueEpochs = new Set(values.map(value => value.__epoch));
allEpochs.forEach(epoch => {
if (!valueEpochs.has(epoch)) {
values.push({ __epoch: epoch });
}
});
}
values.sort((a, b) => a.__epoch - b.__epoch);
const avgDuration: [duration: number, count: number] = [0, 0];
statusTypes.forEach(status => {
analysis[status] = getAnalysis(values, status);
const durationsIndexes = values
.map(value => value[status])
.map((duration, index) => ({ index, duration }))
.filter(({ duration }) => typeof duration !== 'undefined')
.map(({ index }) => index);
const durationsDense = values
.map(value => value[status])
.filter(
(duration): duration is number => typeof duration !== 'undefined',
);
avgDuration[0] += durationsDense.reduce((prev, cur) => prev + cur, 0);
avgDuration[1] += durationsDense.length;
const averages = durationsDense.map((_, i) =>
average(
durationsDense.slice(
Math.max(i - averageWidth, 0),
Math.min(i + averageWidth, durationsDense.length),
),
),
);
averages.forEach((avg, index) => {
const key: Averagify<FilterStatusType> = `${status} avg`;
values[durationsIndexes[index]][key] = avg;
});
});
const analysisValues = Object.values(analysis);
combinedAnalysis.max = analysisValues.reduce(
(prev, cur) => Math.max(prev, cur.max),
0,
);
combinedAnalysis.min = analysisValues.reduce(
(prev, cur) => Math.min(prev, cur.min),
combinedAnalysis.max,
);
combinedAnalysis.avg = !avgDuration[1] ? 0 : avgDuration[0] / avgDuration[1];
stage.stages.forEach(subStage => finalizeStage(subStage, options));
}
function average(values: number[]): number {
return !values.length
? 0
: Math.round(values.reduce((prev, cur) => prev + cur, 0) / values.length);
}
function getOrSetStage(
stages: Map<string, ChartableStage>,
name: string,
): ChartableStage {
const stage = stages.get(name);
if (stage) return stage;
const newStage: ChartableStage = makeStage(name);
stages.set(name, newStage);
return newStage;
}
@@ -0,0 +1,90 @@
/*
* 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 { map } from 'already';
import { Build, Stage } from '../../apis/types';
import { ChartableStage, ChartableStagesAnalysis } from '../types';
import { getOrSetStage, makeStage } from './utils';
import { finalizeStage } from './finalize-stage';
export interface ChartableStagesOptions {
normalizeTimeRange: boolean;
}
/**
* Converts a list of builds, each with a tree of stages (and durations) into a
* merged tree of stages, and calculates {avg, min, max} of each stage.
*/
export async function buildsToChartableStages(
builds: Array<Build>,
options: ChartableStagesOptions,
): Promise<ChartableStagesAnalysis> {
const { normalizeTimeRange } = options;
const total: ChartableStage = makeStage('Total');
const recurseDown = (
stageMap: Map<string, ChartableStage>,
stage: Stage,
__epoch: number,
) => {
const { name, status, duration } = stage;
const subChartableStage = getOrSetStage(stageMap, name);
subChartableStage.statusSet.add(status);
subChartableStage.values.push({
__epoch,
[status]: duration,
[`${status} avg`]: duration,
});
stage.stages?.forEach(subStage => {
recurseDown(subChartableStage.stages, subStage, __epoch);
});
};
const stages = new Map<string, ChartableStage>();
await map(builds, { chunk: 'idle' }, build => {
const { duration, requestedAt, status } = build;
const __epoch = requestedAt.getTime();
total.statusSet.add(status);
total.values.push({
__epoch,
[status]: duration,
[`${status} avg`]: duration,
});
build.stages?.forEach(subStage => {
recurseDown(stages, subStage, __epoch);
});
});
const allEpochs = normalizeTimeRange
? builds.map(build => build.requestedAt.getTime())
: [];
// Recurse down again and calculate averages
await map([...stages.values()], { chunk: 'idle' }, stage =>
finalizeStage(stage, { allEpochs, averageWidth: 10 }),
);
finalizeStage(total, { allEpochs, averageWidth: 10 });
return { total, stages };
}
@@ -0,0 +1,51 @@
/*
* 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 { DateTime } from 'luxon';
import { groupBy } from 'lodash';
import { FilterStatusType, statusTypes } from '../../apis/types';
import { Countify, ChartableStageDatapoints } from '../types';
function startOfDayEpoch(epoch: number) {
return DateTime.fromMillis(epoch).startOf('day').toMillis();
}
export function countBuildsPerDay(
values: ReadonlyArray<ChartableStageDatapoints>,
) {
const days = groupBy(values, value => startOfDayEpoch(value.__epoch));
Object.entries(days).forEach(([_startOfDay, valuesThisDay]) => {
const counts = Object.fromEntries(
statusTypes
.map(
type =>
[
type,
valuesThisDay.map(value => value[type] !== undefined).length,
] as const,
)
.filter(([_type, count]) => count > 0)
.map(([type, count]): [Countify<FilterStatusType>, number] => [
`${type} count`,
count,
]),
);
// Assign the count for this day to the first value this day
Object.assign(valuesThisDay[0], counts);
});
}
@@ -0,0 +1,103 @@
/*
* 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 { FilterStatusType, statusTypes } from '../../apis/types';
import { Averagify, ChartableStage } from '../types';
import { countBuildsPerDay } from './count-builds-per-day';
import { getAnalysis } from './get-analysis';
import { average } from './utils';
interface FinalizeStageOptions {
averageWidth: number;
allEpochs: Array<number>;
}
/**
* Calculate:
* * {avg, min, max}
* * count per day
* of a stage and its sub stages, recursively.
*
* This is calculated per status (successful, failed, etc).
*/
export function finalizeStage(
stage: ChartableStage,
options: FinalizeStageOptions,
) {
const { averageWidth, allEpochs } = options;
const { values, analysis, combinedAnalysis } = stage;
if (allEpochs.length > 0) {
const valueEpochs = new Set(values.map(value => value.__epoch));
allEpochs.forEach(epoch => {
if (!valueEpochs.has(epoch)) {
values.push({ __epoch: epoch });
}
});
}
values.sort((a, b) => a.__epoch - b.__epoch);
countBuildsPerDay(values);
const avgDuration: [duration: number, count: number] = [0, 0];
statusTypes.forEach(status => {
analysis[status] = getAnalysis(values, status);
const durationsIndexes = values
.map(value => value[status])
.map((duration, index) => ({ index, duration }))
.filter(({ duration }) => typeof duration !== 'undefined')
.map(({ index }) => index);
const durationsDense = values
.map(value => value[status])
.filter(
(duration): duration is number => typeof duration !== 'undefined',
);
avgDuration[0] += durationsDense.reduce((prev, cur) => prev + cur, 0);
avgDuration[1] += durationsDense.length;
const averages = durationsDense.map((_, i) =>
average(
durationsDense.slice(
Math.max(i - averageWidth, 0),
Math.min(i + averageWidth, durationsDense.length),
),
),
);
averages.forEach((avg, index) => {
const key: Averagify<FilterStatusType> = `${status} avg`;
values[durationsIndexes[index]][key] = avg;
});
});
const analysisValues = Object.values(analysis);
combinedAnalysis.max = analysisValues.reduce(
(prev, cur) => Math.max(prev, cur.max),
0,
);
combinedAnalysis.min = analysisValues.reduce(
(prev, cur) => Math.min(prev, cur.min),
combinedAnalysis.max,
);
combinedAnalysis.avg = !avgDuration[1] ? 0 : avgDuration[0] / avgDuration[1];
stage.stages.forEach(subStage => finalizeStage(subStage, options));
}
@@ -0,0 +1,49 @@
/*
* 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 { FilterStatusType } from '../../apis/types';
import { ChartableStageAnalysis, ChartableStageDatapoints } from '../types';
export function getAnalysis(
values: Array<ChartableStageDatapoints>,
status: FilterStatusType,
): ChartableStageAnalysis {
const analysis: ChartableStageAnalysis = {
max: 0,
min: 0,
avg: 0,
};
const definedValues = values.filter(
value => typeof value[status] !== 'undefined',
);
analysis.max = definedValues.reduce(
(prev, cur) => Math.max(prev, cur[status]!),
0,
);
analysis.min = definedValues.reduce(
(prev, cur) => Math.min(prev, cur[status]!),
analysis.max,
);
analysis.avg =
definedValues.length === 0
? 0
: definedValues.reduce((prev, cur) => prev + cur[status]!, 0) /
values.length;
return analysis;
}
@@ -0,0 +1,57 @@
/*
* 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 { FilterStatusType } from '../../apis/types';
import { ChartableStage } from '../types';
export function average(values: number[]): number {
return !values.length
? 0
: Math.round(values.reduce((prev, cur) => prev + cur, 0) / values.length);
}
export function getOrSetStage(
stages: Map<string, ChartableStage>,
name: string,
): ChartableStage {
const stage = stages.get(name);
if (stage) return stage;
const newStage: ChartableStage = makeStage(name);
stages.set(name, newStage);
return newStage;
}
export function makeStage(name: string): ChartableStage {
return {
analysis: {
unknown: { avg: 0, max: 0, min: 0 },
enqueued: { avg: 0, max: 0, min: 0 },
scheduled: { avg: 0, max: 0, min: 0 },
running: { avg: 0, max: 0, min: 0 },
aborted: { avg: 0, max: 0, min: 0 },
succeeded: { avg: 0, max: 0, min: 0 },
failed: { avg: 0, max: 0, min: 0 },
stalled: { avg: 0, max: 0, min: 0 },
expired: { avg: 0, max: 0, min: 0 },
},
combinedAnalysis: { avg: 0, max: 0, min: 0 },
statusSet: new Set<FilterStatusType>(),
name,
values: [],
stages: new Map(),
};
}
@@ -17,6 +17,7 @@
import React, { Fragment, useMemo } from 'react';
import {
Area,
Bar,
ComposedChart,
XAxis,
YAxis,
@@ -38,7 +39,7 @@ import {
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { statusTypes } from '../apis/types';
import { CicdDefaults, statusTypes } from '../apis/types';
import { ChartableStage } from './types';
import {
pickElements,
@@ -64,13 +65,14 @@ const transitionProps = { unmountOnExit: true };
export interface StageChartProps {
stage: ChartableStage;
chartTypes: CicdDefaults['chartTypes'];
defaultCollapsed?: number;
zeroYAxis?: boolean;
}
export function StageChart(props: StageChartProps) {
const { stage, ...chartOptions } = props;
const { defaultCollapsed = 0, zeroYAxis = false } = chartOptions;
const { chartTypes, defaultCollapsed = 0, zeroYAxis = false } = chartOptions;
const ticks = useMemo(
() => pickElements(stage.values, 8).map(val => val.__epoch),
@@ -134,49 +136,74 @@ export function StageChart(props: StageChartProps) {
tickFormatter={tickFormatterX}
/>
<YAxis
yAxisId={1}
tickFormatter={tickFormatterY}
type="number"
tickCount={5}
name="Duration"
domain={domainY}
/>
<YAxis
yAxisId={2}
orientation="right"
type="number"
tickCount={5}
name="Count"
/>
<Tooltip
formatter={tooltipValueFormatter}
labelFormatter={labelFormatter}
/>
{statuses.map(status => (
<Fragment key={status}>
<Area
type="monotone"
dataKey={status}
stackId={status}
stroke={
statuses.length > 1
? statusColorMap[status]
: colorStroke
}
fillOpacity={statuses.length > 1 ? 0.5 : 1}
fill={
statuses.length > 1
? statusColorMap[status]
: 'url(#colorDur)'
}
connectNulls
/>
<Line
type="monotone"
dataKey={`${status} avg`}
stroke={
statuses.length > 1
? statusColorMap[status]
: colorStrokeAvg
}
opacity={0.8}
strokeWidth={2}
dot={false}
connectNulls
/>
</Fragment>
{statuses.reverse().map(status => (
<>
{!chartTypes[status].includes('duration') ? null : (
<Fragment key={status}>
<Area
yAxisId={1}
type="monotone"
dataKey={status}
stackId={status}
stroke={
statuses.length > 1
? statusColorMap[status]
: colorStroke
}
fillOpacity={statuses.length > 1 ? 0.5 : 1}
fill={
statuses.length > 1
? statusColorMap[status]
: 'url(#colorDur)'
}
connectNulls
/>
<Line
yAxisId={1}
type="monotone"
dataKey={`${status} avg`}
stroke={
statuses.length > 1
? statusColorMap[status]
: colorStrokeAvg
}
opacity={0.8}
strokeWidth={2}
dot={false}
connectNulls
/>
</Fragment>
)}
{!chartTypes[status].includes('count') ? null : (
<Bar
yAxisId={2}
type="monotone"
dataKey={`${status} count`}
stackId="1"
stroke={statusColorMap[status] ?? ''}
fillOpacity={0.5}
fill={statusColorMap[status] ?? ''}
/>
)}
</>
))}
</ComposedChart>
</ResponsiveContainer>
@@ -17,6 +17,7 @@
import { FilterStatusType } from '../apis/types';
export type Averagify<T extends string> = `${T} avg`;
export type Countify<T extends string> = `${T} count`;
export type ChartableStageDatapoints = {
__epoch: number;
@@ -24,6 +25,8 @@ export type ChartableStageDatapoints = {
[status in FilterStatusType]?: number;
} & {
[status in Averagify<FilterStatusType>]?: number;
} & {
[status in Countify<FilterStatusType>]?: number;
};
export interface ChartableStageAnalysis {
+5 -2
View File
@@ -76,10 +76,13 @@ export function tickFormatterY(duration: number) {
.replace(/year.*/, 'y');
}
export function tooltipValueFormatter(duration: number, name: string) {
export function tooltipValueFormatter(durationOrCount: number, name: string) {
return [
<span style={infoText}>
{name}: {formatDuration(duration)}
{name}:{' '}
{name.endsWith(' count')
? durationOrCount
: formatDuration(durationOrCount)}
</span>,
null,
];
@@ -20,6 +20,7 @@ import { ButtonGroup, Button, Tooltip, Zoom } from '@material-ui/core';
export interface SwitchValueDetails<T extends string> {
value: T;
tooltip?: string;
text?: string | JSX.Element;
}
export type SwitchValue<T extends string> = T | SwitchValueDetails<T>;
@@ -49,12 +50,34 @@ function switchValue<T extends string>(value: SwitchValue<T>): T {
return typeof value === 'object' ? value.value : value;
}
function switchText<T extends string>(
value: SwitchValue<T>,
): string | JSX.Element {
return typeof value === 'object' ? value.text ?? value.value : value;
}
function findParent(tagName: string, elem: HTMLElement): HTMLElement {
let node: HTMLElement | null = elem;
while (node.tagName !== tagName) {
node = node.parentElement;
if (!node) {
throw new Error(`Couldn't find ${tagName} parent`);
}
}
return node;
}
export function ButtonSwitch<T extends string>(props: ButtonSwitchProps<T>) {
const { values, vertical = false } = props;
const onClick = useCallback(
(ev: MouseEvent<HTMLSpanElement>) => {
const value = (ev.target as HTMLSpanElement).textContent!;
const btn = findParent('BUTTON', ev.target as HTMLElement);
const index = [...btn.parentElement!.children].findIndex(
child => child === btn,
);
const value = switchValue(values[index]);
if (props.multi) {
props.onChange(
props.selection.includes(value as T)
@@ -108,7 +131,7 @@ export function ButtonSwitch<T extends string>(props: ButtonSwitchProps<T>) {
}
onClick={onClick}
>
{switchValue(value)}
{switchText(value)}
</Button>,
),
)}
@@ -23,12 +23,15 @@ import {
FormControl,
FormGroup,
FormControlLabel,
Grid,
Switch,
Theme,
Tooltip,
Typography,
makeStyles,
} from '@material-ui/core';
import ShowChartIcon from '@material-ui/icons/ShowChart';
import BarChartIcon from '@material-ui/icons/BarChart';
import {
MuiPickersUtilsProvider,
KeyboardDatePicker,
@@ -37,9 +40,13 @@ import { DateTime } from 'luxon';
import LuxonUtils from '@date-io/luxon';
import {
ChartType,
ChartTypes,
CicdConfiguration,
CicdDefaults,
FilterBranchType,
FilterStatusType,
statusTypes,
} from '../apis/types';
import { ButtonSwitch, SwitchValue } from './button-switch';
import { Toggle } from './toggle';
@@ -68,6 +75,10 @@ const useStyles = makeStyles<Theme>(
margin: theme.spacing(1, 0, 1, 0),
},
},
buttonDescription: {
textTransform: 'uppercase',
margin: theme.spacing(1, 0, 0, 1),
},
}),
{
name: 'CicdStatisticsChartFilters',
@@ -97,7 +108,7 @@ export function getDefaultChartFilter(
status:
cicdConfiguration.defaults?.filterStatus ??
cicdConfiguration.availableStatuses.filter(
status => status === 'succeeded',
status => status === 'succeeded' || status === 'failed',
),
};
}
@@ -114,10 +125,10 @@ function isSameChartFilter(a: ChartFilter, b: ChartFilter): boolean {
);
}
export interface ViewOptions {
lowercaseNames: boolean;
normalizeTimeRange: boolean;
}
export type ViewOptions = Pick<
CicdDefaults,
'lowercaseNames' | 'normalizeTimeRange' | 'chartTypes'
>;
export function getDefaultViewOptions(
cicdConfiguration: CicdConfiguration,
@@ -125,9 +136,36 @@ export function getDefaultViewOptions(
return {
lowercaseNames: cicdConfiguration.defaults?.lowercaseNames ?? false,
normalizeTimeRange: cicdConfiguration.defaults?.normalizeTimeRange ?? true,
chartTypes: {
succeeded: ['duration'],
failed: ['count'],
enqueued: ['count'],
scheduled: ['count'],
running: ['count'],
aborted: ['count'],
stalled: ['count'],
expired: ['count'],
unknown: ['count'],
},
};
}
const branchValues: Array<SwitchValue<BranchSelection>> = [
'master',
'branch',
{
value: 'all',
tooltip:
'NOTE; If the build pipelines are very different between master and branch ' +
'builds, viewing them combined might not result in a very useful chart',
},
];
const chartTypeValues: Array<SwitchValue<ChartType>> = [
{ value: 'duration', text: <ShowChartIcon />, tooltip: 'Duration' },
{ value: 'count', text: <BarChartIcon />, tooltip: 'Count per day' },
];
export interface ChartFiltersProps {
cicdConfiguration: CicdConfiguration;
initialFetchFilter: ChartFilter;
@@ -162,16 +200,6 @@ export function ChartFilters(props: ChartFiltersProps) {
const [toDate, setToDate] = useState(initialFetchFilter.toDate);
const [fromDate, setFromDate] = useState(initialFetchFilter.fromDate);
const branchValues: Array<SwitchValue<BranchSelection>> = [
'master',
'branch',
{
value: 'all',
tooltip:
'NOTE; If the build pipelines are very different between master and branch ' +
'builds, viewing them combined might not result in a very useful chart',
},
];
const [branch, setBranch] = useState(initialFetchFilter.branch);
const statusValues: ReadonlyArray<StatusSelection> =
@@ -196,6 +224,29 @@ export function ChartFilters(props: ChartFiltersProps) {
[setViewOptions],
);
const setChartType = useCallback(
(statusType: FilterStatusType, chartTypes: ChartTypes) => {
setViewOptions(old => ({
...old,
chartTypes: { ...old.chartTypes, [statusType]: chartTypes },
}));
},
[setViewOptions],
);
const setChartTypeSpecific = useMemo(
() =>
Object.fromEntries(
statusTypes.map(
status =>
[
status,
(chartTypes: ChartTypes) => setChartType(status, chartTypes),
] as const,
),
),
[setChartType],
);
useEffect(() => {
onChangeViewOptions(viewOptions);
}, [onChangeViewOptions, viewOptions]);
@@ -380,6 +431,27 @@ export function ChartFilters(props: ChartFiltersProps) {
<span>Normalize time range</span>
</Tooltip>
</Toggle>
<Typography
variant="subtitle2"
className={`${classes.title} ${classes.title}`}
>
Chart styles
</Typography>
{currentFetchFilter?.status.map(status => (
<Grid container spacing={0}>
<Grid item>
<ButtonSwitch<ChartType>
values={chartTypeValues}
selection={viewOptions.chartTypes[status]}
onChange={setChartTypeSpecific[status]}
multi
/>
</Grid>
<Grid item className={classes.buttonDescription}>
<div>{status}</div>
</Grid>
</Grid>
))}
</CardContent>
</Card>
</MuiPickersUtilsProvider>
+15 -3
View File
@@ -25,7 +25,7 @@ import {
UseCicdStatisticsOptions,
} from './hooks/use-cicd-statistics';
import { useCicdConfiguration } from './hooks/use-cicd-configuration';
import { buildsToChartableStages } from './charts/conversions';
import { buildsToChartableStages } from './charts/logic/conversions';
import { StageChart } from './charts/stage-chart';
import { StatusChart } from './charts/status-chart';
import {
@@ -38,6 +38,7 @@ import {
import { CicdConfiguration } from './apis';
import { cleanupBuildTree } from './utils/stage-names';
import { renderFallbacks, useAsyncChain } from './components/progress';
import { sortFilterStatusType } from './utils/api';
export function EntityPageCicdCharts() {
const state = useCicdConfiguration();
@@ -64,6 +65,12 @@ function startOfDay(date: Date) {
function endOfDay(date: Date) {
return DateTime.fromJSDate(date).endOf('day').toJSDate();
}
function cleanChartFilter(filter: ChartFilter): ChartFilter {
return {
...filter,
status: sortFilterStatusType(filter.status),
};
}
interface CicdChartsProps {
cicdConfiguration: CicdConfiguration;
@@ -125,7 +132,7 @@ function CicdCharts(props: CicdChartsProps) {
);
const onFilterChange = useCallback((filter: ChartFilter) => {
setChartFilter(filter);
setChartFilter(cleanChartFilter(filter));
}, []);
const onViewOptionsChange = useCallback(
@@ -166,12 +173,17 @@ function CicdCharts(props: CicdChartsProps) {
{!statisticsState.value?.builds.length ? null : (
<StatusChart builds={statisticsState.value?.builds} />
)}
<StageChart stage={chartableStages.total} defaultCollapsed={0} />
<StageChart
stage={chartableStages.total}
defaultCollapsed={0}
chartTypes={viewOptions.chartTypes}
/>
{[...chartableStages.stages.entries()].map(([name, stage]) => (
<StageChart
key={name}
stage={stage}
defaultCollapsed={collapsedLimit}
chartTypes={viewOptions.chartTypes}
/>
))}
</>
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { FilterStatusType, statusTypes } from '../apis/types';
export function sortFilterStatusType<T extends FilterStatusType>(
statuses: ReadonlyArray<T>,
): Array<T> {
const statusSet = new Set<T>(statuses);
const sorted = (['all', ...statusTypes] as Array<T>).filter((status: T) => {
if (statusSet.has(status)) {
statusSet.delete(status);
return true;
}
return false;
});
return [...sorted, ...statusSet];
}