From cbbab88d9f58ee76d4381088086c2354955bbd11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Fri, 21 Jan 2022 16:07:37 +0100 Subject: [PATCH] feat(cicd-statistics): Allow stages to individual status, and added bar charts counts, and some refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- plugins/cicd-statistics/src/apis/types.ts | 20 +- .../cicd-statistics/src/charts/conversions.ts | 238 ------------------ .../src/charts/logic/conversions.ts | 90 +++++++ .../src/charts/logic/count-builds-per-day.ts | 51 ++++ .../src/charts/logic/finalize-stage.ts | 103 ++++++++ .../src/charts/logic/get-analysis.ts | 49 ++++ .../cicd-statistics/src/charts/logic/utils.ts | 57 +++++ .../src/charts/stage-chart.tsx | 97 ++++--- plugins/cicd-statistics/src/charts/types.ts | 3 + plugins/cicd-statistics/src/charts/utils.tsx | 7 +- .../src/components/button-switch.tsx | 27 +- .../src/components/chart-filters.tsx | 102 ++++++-- plugins/cicd-statistics/src/entity-page.tsx | 18 +- plugins/cicd-statistics/src/utils/api.ts | 33 +++ 14 files changed, 598 insertions(+), 297 deletions(-) delete mode 100644 plugins/cicd-statistics/src/charts/conversions.ts create mode 100644 plugins/cicd-statistics/src/charts/logic/conversions.ts create mode 100644 plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts create mode 100644 plugins/cicd-statistics/src/charts/logic/finalize-stage.ts create mode 100644 plugins/cicd-statistics/src/charts/logic/get-analysis.ts create mode 100644 plugins/cicd-statistics/src/charts/logic/utils.ts create mode 100644 plugins/cicd-statistics/src/utils/api.ts diff --git a/plugins/cicd-statistics/src/apis/types.ts b/plugins/cicd-statistics/src/apis/types.ts index d89b0e1f0c..b185a4a50e 100644 --- a/plugins/cicd-statistics/src/apis/types.ts +++ b/plugins/cicd-statistics/src/apis/types.ts @@ -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 = 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; + /** * Default settings for the fetching options and view options. * @@ -119,12 +132,15 @@ export interface CicdDefaults { filterStatus: Array; 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; } /** diff --git a/plugins/cicd-statistics/src/charts/conversions.ts b/plugins/cicd-statistics/src/charts/conversions.ts deleted file mode 100644 index 0f2b024d1f..0000000000 --- a/plugins/cicd-statistics/src/charts/conversions.ts +++ /dev/null @@ -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(), - 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, - options: ChartableStagesOptions, -): Promise { - const { normalizeTimeRange } = options; - - const total: ChartableStage = makeStage('Total'); - - const recurseDown = ( - status: FilterStatusType, - stageMap: Map, - 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(); - - 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, - 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; -} - -/** - * 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 = `${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, - name: string, -): ChartableStage { - const stage = stages.get(name); - if (stage) return stage; - - const newStage: ChartableStage = makeStage(name); - stages.set(name, newStage); - return newStage; -} diff --git a/plugins/cicd-statistics/src/charts/logic/conversions.ts b/plugins/cicd-statistics/src/charts/logic/conversions.ts new file mode 100644 index 0000000000..b2e990d9e4 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/conversions.ts @@ -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, + options: ChartableStagesOptions, +): Promise { + const { normalizeTimeRange } = options; + + const total: ChartableStage = makeStage('Total'); + + const recurseDown = ( + stageMap: Map, + 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(); + + 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 }; +} diff --git a/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts b/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts new file mode 100644 index 0000000000..c7c7ce1c21 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts @@ -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, +) { + 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, number] => [ + `${type} count`, + count, + ]), + ); + + // Assign the count for this day to the first value this day + Object.assign(valuesThisDay[0], counts); + }); +} diff --git a/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts b/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts new file mode 100644 index 0000000000..e56c5649ff --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts @@ -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; +} + +/** + * 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 = `${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)); +} diff --git a/plugins/cicd-statistics/src/charts/logic/get-analysis.ts b/plugins/cicd-statistics/src/charts/logic/get-analysis.ts new file mode 100644 index 0000000000..493355991d --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/get-analysis.ts @@ -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, + 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; +} diff --git a/plugins/cicd-statistics/src/charts/logic/utils.ts b/plugins/cicd-statistics/src/charts/logic/utils.ts new file mode 100644 index 0000000000..eaed81ac89 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/utils.ts @@ -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, + 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(), + name, + values: [], + stages: new Map(), + }; +} diff --git a/plugins/cicd-statistics/src/charts/stage-chart.tsx b/plugins/cicd-statistics/src/charts/stage-chart.tsx index f407a274b1..eceb37a35c 100644 --- a/plugins/cicd-statistics/src/charts/stage-chart.tsx +++ b/plugins/cicd-statistics/src/charts/stage-chart.tsx @@ -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} /> + - {statuses.map(status => ( - - 1 - ? statusColorMap[status] - : colorStroke - } - fillOpacity={statuses.length > 1 ? 0.5 : 1} - fill={ - statuses.length > 1 - ? statusColorMap[status] - : 'url(#colorDur)' - } - connectNulls - /> - 1 - ? statusColorMap[status] - : colorStrokeAvg - } - opacity={0.8} - strokeWidth={2} - dot={false} - connectNulls - /> - + {statuses.reverse().map(status => ( + <> + {!chartTypes[status].includes('duration') ? null : ( + + 1 + ? statusColorMap[status] + : colorStroke + } + fillOpacity={statuses.length > 1 ? 0.5 : 1} + fill={ + statuses.length > 1 + ? statusColorMap[status] + : 'url(#colorDur)' + } + connectNulls + /> + 1 + ? statusColorMap[status] + : colorStrokeAvg + } + opacity={0.8} + strokeWidth={2} + dot={false} + connectNulls + /> + + )} + {!chartTypes[status].includes('count') ? null : ( + + )} + ))} diff --git a/plugins/cicd-statistics/src/charts/types.ts b/plugins/cicd-statistics/src/charts/types.ts index 6355343ee0..42b3cf4f04 100644 --- a/plugins/cicd-statistics/src/charts/types.ts +++ b/plugins/cicd-statistics/src/charts/types.ts @@ -17,6 +17,7 @@ import { FilterStatusType } from '../apis/types'; export type Averagify = `${T} avg`; +export type Countify = `${T} count`; export type ChartableStageDatapoints = { __epoch: number; @@ -24,6 +25,8 @@ export type ChartableStageDatapoints = { [status in FilterStatusType]?: number; } & { [status in Averagify]?: number; +} & { + [status in Countify]?: number; }; export interface ChartableStageAnalysis { diff --git a/plugins/cicd-statistics/src/charts/utils.tsx b/plugins/cicd-statistics/src/charts/utils.tsx index 4a7131b7c9..6086e84ade 100644 --- a/plugins/cicd-statistics/src/charts/utils.tsx +++ b/plugins/cicd-statistics/src/charts/utils.tsx @@ -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 [ - {name}: {formatDuration(duration)} + {name}:{' '} + {name.endsWith(' count') + ? durationOrCount + : formatDuration(durationOrCount)} , null, ]; diff --git a/plugins/cicd-statistics/src/components/button-switch.tsx b/plugins/cicd-statistics/src/components/button-switch.tsx index 03c7066171..65f55eee41 100644 --- a/plugins/cicd-statistics/src/components/button-switch.tsx +++ b/plugins/cicd-statistics/src/components/button-switch.tsx @@ -20,6 +20,7 @@ import { ButtonGroup, Button, Tooltip, Zoom } from '@material-ui/core'; export interface SwitchValueDetails { value: T; tooltip?: string; + text?: string | JSX.Element; } export type SwitchValue = T | SwitchValueDetails; @@ -49,12 +50,34 @@ function switchValue(value: SwitchValue): T { return typeof value === 'object' ? value.value : value; } +function switchText( + value: SwitchValue, +): 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(props: ButtonSwitchProps) { const { values, vertical = false } = props; const onClick = useCallback( (ev: MouseEvent) => { - 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(props: ButtonSwitchProps) { } onClick={onClick} > - {switchValue(value)} + {switchText(value)} , ), )} diff --git a/plugins/cicd-statistics/src/components/chart-filters.tsx b/plugins/cicd-statistics/src/components/chart-filters.tsx index e14ff366d5..3c5f10d7e9 100644 --- a/plugins/cicd-statistics/src/components/chart-filters.tsx +++ b/plugins/cicd-statistics/src/components/chart-filters.tsx @@ -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( 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> = [ + '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> = [ + { value: 'duration', text: , tooltip: 'Duration' }, + { value: 'count', text: , 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> = [ - '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 = @@ -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) { Normalize time range + + Chart styles + + {currentFetchFilter?.status.map(status => ( + + + + values={chartTypeValues} + selection={viewOptions.chartTypes[status]} + onChange={setChartTypeSpecific[status]} + multi + /> + + +
{status}
+
+
+ ))} diff --git a/plugins/cicd-statistics/src/entity-page.tsx b/plugins/cicd-statistics/src/entity-page.tsx index e12b75fe65..c007e355a9 100644 --- a/plugins/cicd-statistics/src/entity-page.tsx +++ b/plugins/cicd-statistics/src/entity-page.tsx @@ -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 : ( )} - + {[...chartableStages.stages.entries()].map(([name, stage]) => ( ))} diff --git a/plugins/cicd-statistics/src/utils/api.ts b/plugins/cicd-statistics/src/utils/api.ts new file mode 100644 index 0000000000..8af21b3507 --- /dev/null +++ b/plugins/cicd-statistics/src/utils/api.ts @@ -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( + statuses: ReadonlyArray, +): Array { + const statusSet = new Set(statuses); + + const sorted = (['all', ...statusTypes] as Array).filter((status: T) => { + if (statusSet.has(status)) { + statusSet.delete(status); + return true; + } + return false; + }); + + return [...sorted, ...statusSet]; +}