diff --git a/.changeset/strong-ties-exist.md b/.changeset/strong-ties-exist.md
new file mode 100644
index 0000000000..dae77c2805
--- /dev/null
+++ b/.changeset/strong-ties-exist.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-cicd-statistics': minor
+---
+
+Added new plugin "CI/CD Statistics" which charts pipeline build durations over time
diff --git a/plugins/cicd-statistics/.eslintrc.js b/plugins/cicd-statistics/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/cicd-statistics/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/cicd-statistics/README.md b/plugins/cicd-statistics/README.md
new file mode 100644
index 0000000000..e4db2166a6
--- /dev/null
+++ b/plugins/cicd-statistics/README.md
@@ -0,0 +1,7 @@
+# CI/CD Statistics Plugin
+
+This plugin shows charts of CI/CD pipeline durations over time. It expects to be used on the Software Catalog entity page, as it uses `useEntity` to figure out what component to get the build information for.
+
+To use this plugin, you need to implement an API `CicdStatisticsApi` and bind it to the `cicdStatisticsApiRef`. This API is defined in `src/apis/types.ts` and is an interface with two functions, `getConfiguration()` and `fetchBuilds(options)`. This plugin will call `getConfiguration` to allow the implementation to specify defaults an settings for the UI. First time the UI shows, and each time the user changes filters and clicks `Update` to refresh the data, `fetchBuilds` is invoked with the filter options. The API implementation is the expected to fetch build information from somewhere, format it into a generic and rather simpe type `Build` (also defined in `types.ts`). The API can optionally signal completion for a progress bar in the UI.
+
+When this plugin has fetched the builds, it will transpose the list of builds (and build stages) into a tree of build stages. As build pipelines sometimes change, certain stages might end or begin within the timerange of the view.
diff --git a/plugins/cicd-statistics/api-report.md b/plugins/cicd-statistics/api-report.md
new file mode 100644
index 0000000000..aef376c9b8
--- /dev/null
+++ b/plugins/cicd-statistics/api-report.md
@@ -0,0 +1,182 @@
+## API Report File for "@backstage/plugin-cicd-statistics"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { ApiRef } from '@backstage/core-plugin-api';
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+import { RouteRef } from '@backstage/core-plugin-api';
+
+// Warning: (ae-missing-release-tag) "AbortError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export class AbortError extends Error {}
+
+// Warning: (ae-missing-release-tag) "branchTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const branchTypes: Array;
+
+// Warning: (ae-missing-release-tag) "Build" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface Build {
+ buildType: FilterBranchType;
+ duration: number;
+ id: string;
+ // (undocumented)
+ raw?: unknown;
+ requestedAt: Date;
+ stages: Array;
+ status: FilterStatusType;
+}
+
+// Warning: (ae-missing-release-tag) "BuildWithRaw" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type BuildWithRaw = Build & {
+ raw: T;
+};
+
+// Warning: (ae-missing-release-tag) "CicdConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface CicdConfiguration {
+ availableStatuses: ReadonlyArray;
+ defaults: Partial;
+ formatStageName: (parentNames: Array, stageName: string) => string;
+}
+
+// Warning: (ae-missing-release-tag) "CicdDefaults" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface CicdDefaults {
+ collapsedLimit: number;
+ // (undocumented)
+ filterStatus: Array;
+ // (undocumented)
+ filterType: FilterBranchType<'all'>;
+ lowercaseNames: boolean;
+ normalizeTimeRange: boolean;
+ // (undocumented)
+ timeFrom: Date;
+ // (undocumented)
+ timeTo: Date;
+}
+
+// Warning: (ae-missing-release-tag) "CicdState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface CicdState {
+ // (undocumented)
+ builds: Array;
+}
+
+// Warning: (ae-missing-release-tag) "CicdStatisticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface CicdStatisticsApi {
+ // (undocumented)
+ fetchBuilds(options: FetchBuildsOptions): Promise;
+ // (undocumented)
+ getConfiguration(): Promise>;
+}
+
+// Warning: (ae-missing-release-tag) "cicdStatisticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const cicdStatisticsApiRef: ApiRef;
+
+// Warning: (ae-missing-release-tag) "cicdStatisticsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const cicdStatisticsPlugin: BackstagePlugin<
+ {
+ entityContent: RouteRef;
+ },
+ {}
+>;
+
+// Warning: (ae-forgotten-export) The symbol "EntityPageCicdCharts" needs to be exported by the entry point index.d.ts
+// Warning: (ae-missing-release-tag) "EntityCicdStatisticsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const EntityCicdStatisticsContent: EntityPageCicdCharts;
+
+// Warning: (ae-missing-release-tag) "FetchBuildsOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface FetchBuildsOptions {
+ // (undocumented)
+ abortSignal: AbortSignal;
+ // (undocumented)
+ entity: Entity;
+ // (undocumented)
+ filterStatus: Array>;
+ // (undocumented)
+ filterType: FilterBranchType<'all'>;
+ // (undocumented)
+ timeFrom: Date;
+ // (undocumented)
+ timeTo: Date;
+ // (undocumented)
+ updateProgress: UpdateProgress;
+}
+
+// Warning: (ae-missing-release-tag) "FilterBranchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type FilterBranchType =
+ | Extra
+ | 'master'
+ | 'branch';
+
+// Warning: (ae-missing-release-tag) "FilterStatusType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type FilterStatusType =
+ | Extra
+ | 'unknown'
+ | 'enqueued'
+ | 'scheduled'
+ | 'running'
+ | 'aborted'
+ | 'succeeded'
+ | 'failed'
+ | 'stalled'
+ | 'expired';
+
+// Warning: (ae-missing-release-tag) "rootCatalogCicdStatsRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const rootCatalogCicdStatsRouteRef: RouteRef;
+
+// Warning: (ae-missing-release-tag) "Stage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface Stage {
+ duration: number;
+ // (undocumented)
+ name: string;
+ stages?: Array;
+}
+
+// Warning: (ae-missing-release-tag) "statusTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const statusTypes: Array;
+
+// Warning: (ae-missing-release-tag) "UpdateProgress" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type UpdateProgress = (
+ completed: number,
+ total: number,
+ started?: number,
+) => void;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/cicd-statistics/package.json b/plugins/cicd-statistics/package.json
new file mode 100644
index 0000000000..8fad2b25f3
--- /dev/null
+++ b/plugins/cicd-statistics/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@backstage/plugin-cicd-statistics",
+ "description": "A frontend plugin visualizing CI/CD pipeline statistics (build time)",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/cicd-statistics"
+ },
+ "keywords": [
+ "backstage"
+ ],
+ "scripts": {
+ "build": "backstage-cli build",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "^0.9.8",
+ "@backstage/core-plugin-api": "^0.4.1",
+ "@backstage/plugin-catalog-react": "^0.6.9",
+ "@date-io/date-fns": "^1.3.13",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.11.2",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "@material-ui/pickers": "^3.3.10",
+ "already": "^3.2.0",
+ "date-fns": "^2.27.0",
+ "lodash": "^4.17.21",
+ "react-use": "^17.3.1",
+ "recharts": "^2.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.13.1 || ^17.0.0",
+ "react": "^16.13.1 || ^17.0.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/cicd-statistics/src/apis/cicd-statistics.ts b/plugins/cicd-statistics/src/apis/cicd-statistics.ts
new file mode 100644
index 0000000000..714900a1bd
--- /dev/null
+++ b/plugins/cicd-statistics/src/apis/cicd-statistics.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { createApiRef } from '@backstage/core-plugin-api';
+
+import { CicdStatisticsApi } from './types';
+
+export const cicdStatisticsApiRef = createApiRef({
+ id: 'cicd-statistics-api',
+});
diff --git a/plugins/cicd-statistics/src/apis/index.ts b/plugins/cicd-statistics/src/apis/index.ts
new file mode 100644
index 0000000000..cb2fff3700
--- /dev/null
+++ b/plugins/cicd-statistics/src/apis/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+export * from './types';
+export * from './cicd-statistics';
diff --git a/plugins/cicd-statistics/src/apis/types.ts b/plugins/cicd-statistics/src/apis/types.ts
new file mode 100644
index 0000000000..1f2fa8a5b9
--- /dev/null
+++ b/plugins/cicd-statistics/src/apis/types.ts
@@ -0,0 +1,225 @@
+/*
+ * 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 { Entity } from '@backstage/catalog-model';
+
+/**
+ * This is a generic enum of build statuses.
+ *
+ * If all of these aren't applicable to the underlying CI/CD, these can be
+ * configured to be hidden, using the `availableStatuses` in `CicdConfiguration`.
+ */
+export type FilterStatusType =
+ | Extra
+ | 'unknown'
+ | 'enqueued'
+ | 'scheduled'
+ | 'running'
+ | 'aborted'
+ | 'succeeded'
+ | 'failed'
+ | 'stalled'
+ | 'expired';
+export const statusTypes: Array = [
+ 'succeeded',
+ 'failed',
+ 'enqueued',
+ 'scheduled',
+ 'running',
+ 'aborted',
+ 'stalled',
+ 'expired',
+ 'unknown',
+];
+
+/**
+ * The branch enum of either 'master' or 'branch' (or possibly the meta 'all').
+ *
+ * The concept of what constitues a master branch is generic. It might be called
+ * something like 'release' or 'main' or 'trunk' in the underlying CI/CD system,
+ * which is then up to the Api to map accordingly.
+ */
+export type FilterBranchType =
+ | Extra
+ | 'master'
+ | 'branch';
+export const branchTypes: Array = ['master', 'branch'];
+
+/**
+ * A Stage is a part of either a Build or a parent Stage.
+ *
+ * This may be called things like Stage or Step or Task in CI/CD systems, but is
+ * generic here. There's also no concept of parallelism which might exist within
+ * some stages.
+ */
+export interface Stage {
+ name: string;
+
+ /** Stage duration in milliseconds */
+ duration: number;
+
+ /** Sub stages within this stage */
+ stages?: Array;
+}
+
+/**
+ * Generic Build type.
+ *
+ * A build has e.g. a build type (master/branch), a status and (possibly) sub stages.
+ */
+export interface Build {
+ raw?: unknown;
+
+ /** Build id */
+ id: string;
+
+ /** The status of the build */
+ status: FilterStatusType;
+
+ /** Branch type */
+ buildType: FilterBranchType;
+
+ /** Time when the build started */
+ requestedAt: Date;
+
+ /** The overall duration of the build */
+ duration: number;
+
+ /** Top-level build stages */
+ stages: Array;
+}
+
+/**
+ * Helper type which is a Build with a certain typed 'raw' field.
+ *
+ * This can be useful in an Api to use while mapping internal data structures
+ * (raw) into generic builds.
+ */
+export type BuildWithRaw = Build & {
+ raw: T;
+};
+
+/**
+ * Default settings for the fetching options and view options.
+ *
+ * These are all optional, but can be overridden from the Api to whatever makes
+ * most sense for that implementation.
+ */
+export interface CicdDefaults {
+ timeFrom: Date;
+ timeTo: Date;
+ filterStatus: Array;
+ filterType: FilterBranchType<'all'>;
+
+ /** 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;
+}
+
+/**
+ * A configuration interface which the Api must implement.
+ *
+ * When the UI for the CI/CD Statistics is loaded, it begins with fetching the
+ * configuration before anything else.
+ *
+ * All of these fields are optional though, and will fallback to hard-coded defaults.
+ */
+export interface CicdConfiguration {
+ /**
+ * This field can be used to override what statuses are available
+ */
+ availableStatuses: ReadonlyArray;
+
+ /**
+ * When transposing the list of builds into a tree of stages, the stage names
+ * will be transformed through this function.
+ *
+ * Override this for a custom implementation. The default will try to remove
+ * parent names off of child names, if they are prepended by them.
+ *
+ * For example; if a stage has the name 'Install' and a child stage has the
+ * name 'Install - Fetch dependencies', the child name will be replaced with
+ * 'Fetch dependencies'.
+ */
+ formatStageName: (parentNames: Array, stageName: string) => string;
+
+ /**
+ * Default options for the UI
+ */
+ defaults: Partial;
+}
+
+/**
+ * If the Api implements support for aborting the fetching of builds, throw an
+ * AbortError of this type
+ */
+export class AbortError extends Error {}
+
+/**
+ * The result type for `fetchBuilds`.
+ */
+export interface CicdState {
+ builds: Array;
+}
+
+/**
+ * When fetching, if applicable, the Api can feedback progress back to the UI.
+ *
+ * Use the `updateProgress(completed, total, started?)` to signal that
+ * `completed` builds out of a `total` has finished. Optionally use the
+ * `started` to signal how many builds have been started in total (i.e. at least
+ * the amount of `completed`).
+ *
+ * This can be called at any rate. Rate limiting (debouncing) is implemented in
+ * the UI.
+ */
+export type UpdateProgress = (
+ completed: number,
+ total: number,
+ started?: number,
+) => void;
+
+/**
+ * When fetching, the Api should fetch build information about the `entity` and
+ * respect the `timeFrom`, `timeTo`, `filterStatus` and `filterType`.
+ *
+ * Optionally implement support for `updateProgress` and `abortSignal` if
+ * preferred.
+ *
+ * When the UI re-fetches, it will abort any previous fetching, so polling
+ * `abortSignal.aborted`, and possibly throwing an `AbortError`, can be useful.
+ */
+export interface FetchBuildsOptions {
+ entity: Entity;
+ updateProgress: UpdateProgress;
+ abortSignal: AbortSignal;
+ timeFrom: Date;
+ timeTo: Date;
+ filterStatus: Array>;
+ filterType: FilterBranchType<'all'>;
+}
+
+/**
+ * The interface which is mapped to the `cicdStatisticsApiRef` which is used by
+ * the UI.
+ */
+export interface CicdStatisticsApi {
+ getConfiguration(): Promise>;
+ fetchBuilds(options: FetchBuildsOptions): Promise;
+}
diff --git a/plugins/cicd-statistics/src/charts/colors.ts b/plugins/cicd-statistics/src/charts/colors.ts
new file mode 100644
index 0000000000..e1ccb67fea
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/colors.ts
@@ -0,0 +1,39 @@
+/*
+ * 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';
+
+export const statusColorMap: Record = {
+ unknown: '#3d01a4',
+ enqueued: '#7ad1b9',
+ scheduled: '#0391ce',
+ running: '#f3f318',
+ aborted: '#8600af',
+ succeeded: '#66b032',
+ failed: '#fe2712',
+ stalled: '#fb9904',
+ expired: '#a7194b',
+};
+
+export const fireColors: Array<[percent: string, color: string]> = [
+ ['5%', '#e19678'],
+ ['30%', '#dfe178'],
+ ['50%', '#82ca9d'],
+ ['95%', '#82ca9d'],
+];
+
+export const colorStroke = '#c0c0c0';
+export const colorStrokeAvg = '#788ee1';
diff --git a/plugins/cicd-statistics/src/charts/conversions.ts b/plugins/cicd-statistics/src/charts/conversions.ts
new file mode 100644
index 0000000000..0f2b024d1f
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/conversions.ts
@@ -0,0 +1,238 @@
+/*
+ * 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/stage-chart.tsx b/plugins/cicd-statistics/src/charts/stage-chart.tsx
new file mode 100644
index 0000000000..f407a274b1
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/stage-chart.tsx
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { Fragment, useMemo } from 'react';
+import {
+ Area,
+ ComposedChart,
+ XAxis,
+ YAxis,
+ YAxisProps,
+ CartesianGrid,
+ Legend,
+ LegendProps,
+ Line,
+ Tooltip,
+ ResponsiveContainer,
+} from 'recharts';
+import Alert from '@material-ui/lab/Alert';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Grid,
+ Typography,
+} from '@material-ui/core';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+
+import { statusTypes } from '../apis/types';
+import { ChartableStage } from './types';
+import {
+ pickElements,
+ labelFormatter,
+ tickFormatterX,
+ tickFormatterY,
+ tooltipValueFormatter,
+ formatDuration,
+} from './utils';
+import {
+ statusColorMap,
+ fireColors,
+ colorStroke,
+ colorStrokeAvg,
+} from './colors';
+
+const fullWidth = {
+ width: '100%',
+};
+
+const transitionProps = { unmountOnExit: true };
+
+export interface StageChartProps {
+ stage: ChartableStage;
+
+ defaultCollapsed?: number;
+ zeroYAxis?: boolean;
+}
+
+export function StageChart(props: StageChartProps) {
+ const { stage, ...chartOptions } = props;
+ const { defaultCollapsed = 0, zeroYAxis = false } = chartOptions;
+
+ const ticks = useMemo(
+ () => pickElements(stage.values, 8).map(val => val.__epoch),
+ [stage.values],
+ );
+ const domainY = useMemo(
+ () => [zeroYAxis ? 0 : 'auto', 'auto'] as YAxisProps['domain'],
+ [zeroYAxis],
+ );
+ const statuses = useMemo(
+ () => statusTypes.filter(status => stage.statusSet.has(status)),
+ [stage.statusSet],
+ );
+ const legendPayload = useMemo(
+ (): LegendProps['payload'] =>
+ statuses.map(status => ({
+ value: status,
+ type: 'line',
+ id: status,
+ color: statusColorMap[status],
+ })),
+ [statuses],
+ );
+
+ return (
+ defaultCollapsed}
+ TransitionProps={transitionProps}
+ >
+ }>
+
+ {stage.name} (avg {formatDuration(stage.combinedAnalysis.avg)})
+
+
+
+ {stage.values.length === 0 ? (
+ No data
+ ) : (
+
+
+
+
+
+
+ {fireColors.map(([percent, color]) => (
+
+ ))}
+
+
+ {statuses.length > 1 && }
+
+
+
+
+ {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
+ />
+
+ ))}
+
+
+
+ {stage.stages.size === 0 ? null : (
+
+
+ }>
+ Sub stages ({stage.stages.size})
+
+
+