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..8d91d74794
--- /dev/null
+++ b/plugins/cicd-statistics/README.md
@@ -0,0 +1,13 @@
+# 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.
+
+## Usage
+
+> This plugin cannot be used as-is; it requires a custom implementation to fetch build information
+
+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(options)` and `fetchBuilds(options)`. This plugin will call `getConfiguration` to allow the implementation to specify defaults and 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 simple 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 date range of the view (when _Normalize time range_ is enabled, which is the default).
diff --git a/plugins/cicd-statistics/api-report.md b/plugins/cicd-statistics/api-report.md
new file mode 100644
index 0000000000..3c7a52cdad
--- /dev/null
+++ b/plugins/cicd-statistics/api-report.md
@@ -0,0 +1,222 @@
+## 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) "Build" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface Build {
+ branchType: FilterBranchType;
+ duration: number;
+ id: string;
+ // (undocumented)
+ raw?: unknown;
+ requestedAt: Date;
+ stages: Array;
+ status: FilterStatusType;
+ triggeredBy?: TriggerReason;
+}
+
+// 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) "ChartType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export type ChartType = 'duration' | 'count';
+
+// Warning: (ae-missing-release-tag) "ChartTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type ChartTypes = Array;
+
+// 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 {
+ chartTypes: Record;
+ collapsedLimit: number;
+ // (undocumented)
+ filterStatus: Array;
+ // (undocumented)
+ filterType: FilterBranchType | 'all';
+ hideLimit: number;
+ 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(
+ options: GetConfigurationOptions,
+ ): 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-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) "EntityPageCicdCharts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export function EntityPageCicdCharts(): JSX.Element;
+
+// 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 = '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 =
+ | 'unknown'
+ | 'enqueued'
+ | 'scheduled'
+ | 'running'
+ | 'aborted'
+ | 'succeeded'
+ | 'failed'
+ | 'stalled'
+ | 'expired';
+
+// Warning: (ae-missing-release-tag) "GetConfigurationOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public
+export interface GetConfigurationOptions {
+ // (undocumented)
+ entity: Entity;
+}
+
+// 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;
+ status: FilterStatusType;
+}
+
+// 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) "TriggerReason" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type TriggerReason =
+ /** Triggered by source code management, e.g. a Github hook */
+ | 'scm'
+ /** Triggered manually */
+ | 'manual'
+ /** Triggered internally (non-scm, or perhaps after being delayed/enqueued) */
+ | 'internal'
+ /** Triggered for some other reason */
+ | 'other';
+
+// Warning: (ae-missing-release-tag) "triggerReasons" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const triggerReasons: 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 interface UpdateProgress {
+ // (undocumented)
+ (completed: number, total: number, started?: number): void;
+ // (undocumented)
+ (
+ steps: Array<{
+ title: string;
+ 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..1c4d8a455e
--- /dev/null
+++ b/plugins/cicd-statistics/package.json
@@ -0,0 +1,57 @@
+{
+ "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"
+ },
+ "devDependencies": {
+ "@types/react": "^16.13.1 || ^17.0.0",
+ "@types/luxon": "^2.0.5"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "^0.9.8",
+ "@backstage/core-plugin-api": "^0.4.1",
+ "@backstage/plugin-catalog-react": "^0.6.9",
+ "@date-io/luxon": "^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",
+ "humanize-duration": "^3.27.0",
+ "already": "^3.2.0",
+ "lodash": "^4.17.21",
+ "luxon": "^2.0.2",
+ "react-use": "^17.3.1",
+ "recharts": "^2.1.5"
+ },
+ "peerDependencies": {
+ "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..0a87b40fc2
--- /dev/null
+++ b/plugins/cicd-statistics/src/apis/types.ts
@@ -0,0 +1,275 @@
+/*
+ * 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 =
+ | '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 = 'master' | 'branch';
+
+export type TriggerReason =
+ /** Triggered by source code management, e.g. a Github hook */
+ | 'scm'
+ /** Triggered manually */
+ | 'manual'
+ /** Triggered internally (non-scm, or perhaps after being delayed/enqueued) */
+ | 'internal'
+ /** Triggered for some other reason */
+ | 'other';
+
+export const triggerReasons: Array = [
+ 'scm',
+ 'manual',
+ 'internal',
+ 'other',
+];
+
+/**
+ * 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;
+
+ /** The status of the stage */
+ status: FilterStatusType;
+
+ /** 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 reason this build was started */
+ triggeredBy?: TriggerReason;
+
+ /** The status of the build */
+ status: FilterStatusType;
+
+ /** Branch type */
+ branchType: 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;
+};
+
+/**
+ * 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.
+ *
+ * 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;
+ /** Default hide stages with a max-duration below this value */
+ hideLimit: number;
+ /** Chart types per status */
+ chartTypes: Record;
+}
+
+/**
+ * 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 (or any other error with name === 'AbortError').
+ */
+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.
+ *
+ * Optionally this can signal multiple progresses in several steps
+ */
+export interface UpdateProgress {
+ (completed: number, total: number, started?: number): void;
+ (
+ steps: Array<{
+ title: string;
+ completed: number;
+ total: number;
+ started?: number;
+ }>,
+ ): void;
+}
+
+/**
+ * When reading configuration, the Api can return a custom settings depending on
+ * the entity being viewed.
+ */
+export interface GetConfigurationOptions {
+ entity: Entity;
+}
+
+/**
+ * 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(
+ options: GetConfigurationOptions,
+ ): 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..670b214665
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/colors.ts
@@ -0,0 +1,46 @@
+/*
+ * 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, TriggerReason } 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 triggerColorMap: Record = {
+ scm: '#0391ce',
+ manual: '#a7194b',
+ internal: '#82ca9d',
+ other: '#f3f318',
+};
+
+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/logic/analysis.ts b/plugins/cicd-statistics/src/charts/logic/analysis.ts
new file mode 100644
index 0000000000..4333ae3a6f
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/analysis.ts
@@ -0,0 +1,79 @@
+/*
+ * 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,
+ med: 0,
+ };
+
+ const definedValues = values
+ .filter(value => typeof value[status] !== 'undefined')
+ .map(value => value[status]!)
+ .sort((a, b) => a - b);
+
+ analysis.max = definedValues[definedValues.length - 1] ?? 0;
+
+ analysis.min = definedValues[0] ?? 0;
+
+ analysis.avg =
+ definedValues.length === 0
+ ? 0
+ : definedValues.reduce((prev, cur) => prev + cur, 0) / values.length;
+
+ analysis.med = definedValues[Math.ceil(definedValues.length / 2)] ?? 0;
+
+ return analysis;
+}
+
+export function makeCombinedAnalysis(
+ analysis: Record,
+ allDurations: Array,
+): ChartableStageAnalysis {
+ if (analysis.succeeded) {
+ // If succeeded is a viewed status, it's probably what's expected to see
+ // overall. Otherwise combine all other.
+ return analysis.succeeded;
+ }
+
+ const analysisValues = Object.values(analysis);
+
+ const max = analysisValues.reduce((prev, cur) => Math.max(prev, cur.max), 0);
+ const min = analysisValues.reduce(
+ (prev, cur) => Math.min(prev, cur.min),
+ max,
+ );
+ const avg = !allDurations.length
+ ? 0
+ : allDurations.reduce((prev, cur) => prev + cur, 0) / allDurations.length;
+ allDurations.sort((a, b) => a - b);
+ const med = allDurations[Math.ceil(allDurations.length / 2)] ?? 0;
+
+ return {
+ max,
+ min,
+ avg,
+ med,
+ };
+}
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..c548b0e2f5
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/conversions.ts
@@ -0,0 +1,120 @@
+/*
+ * 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 } from '../../apis/types';
+import { ChartableStage, ChartableStagesAnalysis } from '../types';
+import { getOrSetStage, makeStage, sortStatuses } from './utils';
+import { finalizeStage } from './finalize-stage';
+import { dailySummary } from './daily-summary';
+
+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 });
+
+ const daily = dailySummary(builds);
+
+ const statuses = findStatuses(total, [...stages.values()]);
+
+ return { daily, total, stages, statuses };
+}
+
+function findStatuses(
+ total: ChartableStage,
+ stages: Array,
+): Array {
+ const statuses = new Set();
+
+ const addStatuses = (set: Set) => {
+ set.forEach(status => {
+ statuses.add(status);
+ });
+ };
+
+ addStatuses(total.statusSet);
+
+ const recurse = (subStages: Array) => {
+ subStages.forEach(stage => {
+ addStatuses(stage.statusSet);
+ recurse([...stage.stages.values()]);
+ });
+ };
+ recurse(stages);
+
+ return sortStatuses([...statuses]);
+}
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..54e47c3f5b
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts
@@ -0,0 +1,47 @@
+/*
+ * 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 { groupBy } from 'lodash';
+
+import { FilterStatusType, statusTypes } from '../../apis/types';
+import { Countify, ChartableStageDatapoints } from '../types';
+import { startOfDay } from './utils';
+
+export function countBuildsPerDay(
+ values: ReadonlyArray,
+) {
+ const days = groupBy(values, value => startOfDay(value.__epoch));
+ Object.entries(days).forEach(([_startOfDay, valuesThisDay]) => {
+ const counts = Object.fromEntries(
+ statusTypes
+ .map(
+ type =>
+ [
+ type,
+ valuesThisDay.filter(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/daily-summary.ts b/plugins/cicd-statistics/src/charts/logic/daily-summary.ts
new file mode 100644
index 0000000000..93c7b07c34
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/daily-summary.ts
@@ -0,0 +1,115 @@
+/*
+ * 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 { groupBy, countBy } from 'lodash';
+
+import { Build } from '../../apis/types';
+import {
+ Epoch,
+ TriggerReasonsDatapoint,
+ StatusesDatapoint,
+ ChartableDaily,
+} from '../types';
+import { sortStatuses, sortTriggerReasons, startOfDay } from './utils';
+
+export function dailySummary(builds: ReadonlyArray): ChartableDaily {
+ const triggersDaily = countTriggersPerDay(builds);
+ const statusesDaily = countStatusesPerDay(builds);
+
+ const { triggerReasons } = triggersDaily;
+ const { statuses } = statusesDaily;
+
+ const reasonMap = new Map(
+ triggersDaily.values.map(value => [value.__epoch, value]),
+ );
+ const statusMap = new Map(
+ statusesDaily.values.map(value => [value.__epoch, value]),
+ );
+
+ const days = Object.keys(
+ groupBy(builds, value => startOfDay(value.requestedAt)),
+ )
+ .map(epoch => parseInt(epoch, 10))
+ .sort();
+
+ return {
+ values: days.map(epoch => ({
+ __epoch: epoch,
+ ...reasonMap.get(epoch),
+ ...statusMap.get(epoch),
+ })),
+ triggerReasons,
+ statuses,
+ };
+}
+
+function countTriggersPerDay(builds: ReadonlyArray) {
+ const days = groupBy(builds, value => startOfDay(value.requestedAt));
+
+ const triggerReasons = sortTriggerReasons([
+ ...new Set(
+ builds
+ .map(({ triggeredBy }) => triggeredBy)
+ .filter((v): v is NonNullable => !!v),
+ ),
+ ]);
+
+ const values = Object.entries(days).map(([epoch, buildsThisDay]) => {
+ const datapoint = Object.fromEntries(
+ triggerReasons
+ .map(reason => [
+ reason,
+ buildsThisDay.filter(build => build.triggeredBy === reason).length,
+ ])
+ .filter(([_type, count]) => count > 0),
+ ) as Omit;
+
+ // Assign the count for this day to the first value this day
+ const value: Epoch & TriggerReasonsDatapoint = Object.assign(datapoint, {
+ __epoch: parseInt(epoch, 10),
+ });
+
+ return value;
+ });
+
+ return { triggerReasons, values };
+}
+
+function countStatusesPerDay(builds: ReadonlyArray) {
+ const days = groupBy(builds, value => startOfDay(value.requestedAt));
+
+ const foundStatuses = new Set();
+
+ const values = Object.entries(days).map(([epoch, buildsThisDay]) => {
+ const byStatus = countBy(buildsThisDay, 'status');
+
+ const value: Epoch & StatusesDatapoint = {
+ __epoch: parseInt(epoch, 10),
+ ...byStatus,
+ };
+
+ Object.keys(byStatus).forEach(status => {
+ foundStatuses.add(status);
+ });
+
+ return value;
+ });
+
+ return {
+ statuses: sortStatuses([...foundStatuses]),
+ values,
+ };
+}
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..bb54fbf41e
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts
@@ -0,0 +1,93 @@
+/*
+ * 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, makeCombinedAnalysis } from './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 allDurations: Array = [];
+
+ 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',
+ );
+
+ durationsDense.forEach(dur => allDurations.push(dur));
+
+ 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;
+ });
+ });
+
+ Object.assign(combinedAnalysis, makeCombinedAnalysis(analysis, allDurations));
+
+ stage.stages.forEach(subStage => finalizeStage(subStage, options));
+}
diff --git a/plugins/cicd-statistics/src/charts/logic/utils.test.ts b/plugins/cicd-statistics/src/charts/logic/utils.test.ts
new file mode 100644
index 0000000000..72e0a22101
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/utils.test.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 { sortTriggerReasons } from './utils';
+
+describe('daily-summary', () => {
+ it('sortTriggerReasons', () => {
+ const values = ['a', 'manual', 'b', 'other', 'c', 'scm', 'd'];
+ const expected = ['manual', 'scm', 'other', 'a', 'b', 'c', 'd'];
+
+ expect(sortTriggerReasons(values)).toStrictEqual(expected);
+ expect(sortTriggerReasons(values.reverse())).toStrictEqual(expected);
+ });
+});
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..f3a0087a3d
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/logic/utils.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 { FilterStatusType, statusTypes } 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, med: 0, max: 0, min: 0 },
+ enqueued: { avg: 0, med: 0, max: 0, min: 0 },
+ scheduled: { avg: 0, med: 0, max: 0, min: 0 },
+ running: { avg: 0, med: 0, max: 0, min: 0 },
+ aborted: { avg: 0, med: 0, max: 0, min: 0 },
+ succeeded: { avg: 0, med: 0, max: 0, min: 0 },
+ failed: { avg: 0, med: 0, max: 0, min: 0 },
+ stalled: { avg: 0, med: 0, max: 0, min: 0 },
+ expired: { avg: 0, med: 0, max: 0, min: 0 },
+ },
+ combinedAnalysis: { avg: 0, med: 0, max: 0, min: 0 },
+ statusSet: new Set(),
+ name,
+ values: [],
+ stages: new Map(),
+ };
+}
+
+export function startOfDay(date: number | Date) {
+ if (typeof date === 'number') {
+ return DateTime.fromMillis(date).startOf('day').toMillis();
+ }
+ return DateTime.fromJSDate(date).startOf('day').toMillis();
+}
+
+export function sortTriggerReasons(reasons: Array): Array {
+ return reasons.sort((a, b) => {
+ if (a === 'manual') return -1;
+ else if (b === 'manual') return 1;
+ else if (a === 'scm') return -1;
+ else if (b === 'scm') return 1;
+ else if (a === 'other') return -1;
+ else if (b === 'other') return 1;
+ return a.localeCompare(b);
+ });
+}
+
+export function sortStatuses(statuses: Array): Array {
+ return [
+ ...statusTypes.filter(status => statuses.includes(status)),
+ ...statuses
+ .filter(status => !(statusTypes as Array).includes(status))
+ .sort((a, b) => a.localeCompare(b)),
+ ];
+}
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..ba3474153c
--- /dev/null
+++ b/plugins/cicd-statistics/src/charts/stage-chart.tsx
@@ -0,0 +1,269 @@
+/*
+ * 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, { CSSProperties, Fragment, useMemo } from 'react';
+import {
+ Area,
+ Bar,
+ 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 { capitalize } from 'lodash';
+
+import { useZoom, useZoomArea } from './zoom';
+import { CicdDefaults, statusTypes } from '../apis/types';
+import { ChartableStage } from './types';
+import {
+ pickElements,
+ labelFormatter,
+ tickFormatterX,
+ tickFormatterY,
+ tooltipValueFormatter,
+ formatDuration,
+} from '../components/utils';
+import {
+ statusColorMap,
+ fireColors,
+ colorStroke,
+ colorStrokeAvg,
+} from './colors';
+
+const fullWidth: CSSProperties = { width: '100%' };
+const noUserSelect: CSSProperties = { userSelect: 'none' };
+
+const transitionProps = { unmountOnExit: true };
+
+export interface StageChartProps {
+ stage: ChartableStage;
+
+ chartTypes: CicdDefaults['chartTypes'];
+ defaultCollapsed?: number;
+ defaultHidden?: number;
+ zeroYAxis?: boolean;
+}
+
+export function StageChart(props: StageChartProps) {
+ const { stage, ...chartOptions } = props;
+ const {
+ chartTypes,
+ defaultCollapsed = 0,
+ defaultHidden = 0,
+ zeroYAxis = false,
+ } = chartOptions;
+
+ const { zoomFilterValues } = useZoom();
+ const { zoomProps, getZoomArea } = useZoomArea();
+
+ 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: capitalize(status),
+ type: 'line',
+ id: status,
+ color: statusColorMap[status],
+ })),
+ [statuses],
+ );
+
+ const subStages = useMemo(
+ () =>
+ new Map(
+ [...stage.stages.entries()].filter(
+ ([_name, subStage]) => subStage.combinedAnalysis.max > defaultHidden,
+ ),
+ ),
+ [stage.stages, defaultHidden],
+ );
+
+ const zoomFilteredValues = useMemo(
+ () => zoomFilterValues(stage.values),
+ [stage.values, zoomFilterValues],
+ );
+
+ return stage.combinedAnalysis.max < defaultHidden ? null : (
+ defaultCollapsed}
+ TransitionProps={transitionProps}
+ >
+ }>
+
+ {stage.name} (med {formatDuration(stage.combinedAnalysis.med)}, avg{' '}
+ {formatDuration(stage.combinedAnalysis.avg)})
+
+
+
+ {stage.values.length === 0 ? (
+ No data
+ ) : (
+
+
+
+
+
+
+ {fireColors.map(([percent, color]) => (
+
+ ))}
+
+
+ {statuses.length > 1 && }
+
+
+
+
+
+ {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 : (
+
+ )}
+
+ ))}
+ {getZoomArea({ yAxisId: 1 })}
+
+
+
+ {subStages.size === 0 ? null : (
+
+
+ }>
+ Sub stages ({subStages.size})
+
+
+