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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user