Merge pull request #8912 from backstage/grantila/cicd-statistics
Added CI/CD statistics plugin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cicd-statistics': minor
|
||||
---
|
||||
|
||||
Added new plugin "CI/CD Statistics" which charts pipeline build durations over time
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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).
|
||||
@@ -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
|
||||
/// <reference types="react" />
|
||||
|
||||
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<Stage>;
|
||||
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<T = any> = 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<ChartType>;
|
||||
|
||||
// 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<FilterStatusType>;
|
||||
defaults: Partial<CicdDefaults>;
|
||||
formatStageName: (parentNames: Array<string>, 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<FilterStatusType, ChartTypes>;
|
||||
collapsedLimit: number;
|
||||
// (undocumented)
|
||||
filterStatus: Array<FilterStatusType>;
|
||||
// (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<Build>;
|
||||
}
|
||||
|
||||
// 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<CicdState>;
|
||||
// (undocumented)
|
||||
getConfiguration(
|
||||
options: GetConfigurationOptions,
|
||||
): Promise<Partial<CicdConfiguration>>;
|
||||
}
|
||||
|
||||
// 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<CicdStatisticsApi>;
|
||||
|
||||
// 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<undefined>;
|
||||
},
|
||||
{}
|
||||
>;
|
||||
|
||||
// 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<FilterStatusType | 'all'>;
|
||||
// (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<Stage>;
|
||||
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<FilterStatusType>;
|
||||
|
||||
// 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<TriggerReason>;
|
||||
|
||||
// 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)
|
||||
```
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<CicdStatisticsApi>({
|
||||
id: 'cicd-statistics-api',
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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<FilterStatusType> = [
|
||||
'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<TriggerReason> = [
|
||||
'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<Stage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Stage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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.
|
||||
*
|
||||
* 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<FilterStatusType>;
|
||||
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<FilterStatusType, ChartTypes>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FilterStatusType>;
|
||||
|
||||
/**
|
||||
* 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<string>, stageName: string) => string;
|
||||
|
||||
/**
|
||||
* Default options for the UI
|
||||
*/
|
||||
defaults: Partial<CicdDefaults>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Build>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FilterStatusType | 'all'>;
|
||||
filterType: FilterBranchType | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface which is mapped to the `cicdStatisticsApiRef` which is used by
|
||||
* the UI.
|
||||
*/
|
||||
export interface CicdStatisticsApi {
|
||||
getConfiguration(
|
||||
options: GetConfigurationOptions,
|
||||
): Promise<Partial<CicdConfiguration>>;
|
||||
fetchBuilds(options: FetchBuildsOptions): Promise<CicdState>;
|
||||
}
|
||||
@@ -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<FilterStatusType, string> = {
|
||||
unknown: '#3d01a4',
|
||||
enqueued: '#7ad1b9',
|
||||
scheduled: '#0391ce',
|
||||
running: '#f3f318',
|
||||
aborted: '#8600af',
|
||||
succeeded: '#66b032',
|
||||
failed: '#fe2712',
|
||||
stalled: '#fb9904',
|
||||
expired: '#a7194b',
|
||||
};
|
||||
|
||||
export const triggerColorMap: Record<TriggerReason, string> = {
|
||||
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';
|
||||
@@ -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<ChartableStageDatapoints>,
|
||||
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<FilterStatusType, ChartableStageAnalysis>,
|
||||
allDurations: Array<number>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<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 });
|
||||
|
||||
const daily = dailySummary(builds);
|
||||
|
||||
const statuses = findStatuses(total, [...stages.values()]);
|
||||
|
||||
return { daily, total, stages, statuses };
|
||||
}
|
||||
|
||||
function findStatuses(
|
||||
total: ChartableStage,
|
||||
stages: Array<ChartableStage>,
|
||||
): Array<string> {
|
||||
const statuses = new Set<string>();
|
||||
|
||||
const addStatuses = (set: Set<FilterStatusType>) => {
|
||||
set.forEach(status => {
|
||||
statuses.add(status);
|
||||
});
|
||||
};
|
||||
|
||||
addStatuses(total.statusSet);
|
||||
|
||||
const recurse = (subStages: Array<ChartableStage>) => {
|
||||
subStages.forEach(stage => {
|
||||
addStatuses(stage.statusSet);
|
||||
recurse([...stage.stages.values()]);
|
||||
});
|
||||
};
|
||||
recurse(stages);
|
||||
|
||||
return sortStatuses([...statuses]);
|
||||
}
|
||||
@@ -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<ChartableStageDatapoints>,
|
||||
) {
|
||||
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<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,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<Build>): 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<Build>) {
|
||||
const days = groupBy(builds, value => startOfDay(value.requestedAt));
|
||||
|
||||
const triggerReasons = sortTriggerReasons([
|
||||
...new Set(
|
||||
builds
|
||||
.map(({ triggeredBy }) => triggeredBy)
|
||||
.filter((v): v is NonNullable<typeof v> => !!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<TriggerReasonsDatapoint, '__epoch'>;
|
||||
|
||||
// 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<Build>) {
|
||||
const days = groupBy(builds, value => startOfDay(value.requestedAt));
|
||||
|
||||
const foundStatuses = new Set<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<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 allDurations: Array<number> = [];
|
||||
|
||||
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<FilterStatusType> = `${status} avg`;
|
||||
values[durationsIndexes[index]][key] = avg;
|
||||
});
|
||||
});
|
||||
|
||||
Object.assign(combinedAnalysis, makeCombinedAnalysis(analysis, allDurations));
|
||||
|
||||
stage.stages.forEach(subStage => finalizeStage(subStage, options));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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, 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<FilterStatusType>(),
|
||||
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<string>): Array<string> {
|
||||
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<string>): Array<string> {
|
||||
return [
|
||||
...statusTypes.filter(status => statuses.includes(status)),
|
||||
...statuses
|
||||
.filter(status => !(statusTypes as Array<string>).includes(status))
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
];
|
||||
}
|
||||
@@ -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<string, ChartableStage>(
|
||||
[...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 : (
|
||||
<Accordion
|
||||
defaultExpanded={stage.combinedAnalysis.max > defaultCollapsed}
|
||||
TransitionProps={transitionProps}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>
|
||||
{stage.name} (med {formatDuration(stage.combinedAnalysis.med)}, avg{' '}
|
||||
{formatDuration(stage.combinedAnalysis.avg)})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{stage.values.length === 0 ? (
|
||||
<Alert severity="info">No data</Alert>
|
||||
) : (
|
||||
<Grid container direction="column">
|
||||
<Grid item style={noUserSelect}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart data={zoomFilteredValues} {...zoomProps}>
|
||||
<defs>
|
||||
<linearGradient id="colorDur" x1="0" y1="0" x2="0" y2="1">
|
||||
{fireColors.map(([percent, color]) => (
|
||||
<stop
|
||||
key={percent}
|
||||
offset={percent}
|
||||
stopColor={color}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{statuses.length > 1 && <Legend payload={legendPayload} />}
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="__epoch"
|
||||
type="category"
|
||||
ticks={ticks}
|
||||
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.reverse().map(status => (
|
||||
<Fragment key={status}>
|
||||
{!chartTypes[status].includes('duration') ? null : (
|
||||
<>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
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
|
||||
isAnimationActive={false}
|
||||
yAxisId={1}
|
||||
type="monotone"
|
||||
dataKey={`${status} avg`}
|
||||
stroke={
|
||||
statuses.length > 1
|
||||
? statusColorMap[status]
|
||||
: colorStrokeAvg
|
||||
}
|
||||
opacity={0.8}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!chartTypes[status].includes('count') ? null : (
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
yAxisId={2}
|
||||
type="monotone"
|
||||
dataKey={`${status} count`}
|
||||
stackId="1"
|
||||
stroke={statusColorMap[status] ?? ''}
|
||||
fillOpacity={0.5}
|
||||
fill={statusColorMap[status] ?? ''}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{getZoomArea({ yAxisId: 1 })}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Grid>
|
||||
{subStages.size === 0 ? null : (
|
||||
<Grid item>
|
||||
<Accordion
|
||||
defaultExpanded={false}
|
||||
TransitionProps={transitionProps}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Sub stages ({subStages.size})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<div style={fullWidth}>
|
||||
{[...subStages.values()].map(subStage => (
|
||||
<StageChart
|
||||
key={subStage.name}
|
||||
{...chartOptions}
|
||||
stage={subStage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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,
|
||||
Bar,
|
||||
ComposedChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
LegendProps,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { useZoom, useZoomArea } from './zoom';
|
||||
import { FilterStatusType, TriggerReason } from '../apis/types';
|
||||
import { labelFormatterWithoutTime, tickFormatterX } from '../components/utils';
|
||||
import { statusColorMap, triggerColorMap } from './colors';
|
||||
import { ChartableStagesAnalysis } from './types';
|
||||
|
||||
export interface StatusChartProps {
|
||||
analysis: ChartableStagesAnalysis;
|
||||
}
|
||||
|
||||
export function StatusChart(props: StatusChartProps) {
|
||||
const { analysis } = props;
|
||||
|
||||
const { zoomFilterValues } = useZoom();
|
||||
const { zoomProps, getZoomArea } = useZoomArea();
|
||||
|
||||
const values = useMemo(() => {
|
||||
return analysis.daily.values.map(value => {
|
||||
const totTriggers = analysis.daily.triggerReasons.reduce(
|
||||
(prev, cur) => prev + (value[cur as TriggerReason] ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (!totTriggers) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
analysis.daily.triggerReasons.map(reason => [
|
||||
reason,
|
||||
(value[reason as TriggerReason] ?? 0) / totTriggers,
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [analysis.daily]);
|
||||
|
||||
const triggerReasonLegendPayload = useMemo(
|
||||
(): NonNullable<LegendProps['payload']> =>
|
||||
analysis.daily.triggerReasons.map(reason => ({
|
||||
value: humanTriggerReason(reason),
|
||||
type: 'line',
|
||||
id: reason,
|
||||
color: triggerColorMap[reason as TriggerReason] ?? '',
|
||||
})),
|
||||
[analysis.daily.triggerReasons],
|
||||
);
|
||||
|
||||
const statusesLegendPayload = useMemo(
|
||||
(): NonNullable<LegendProps['payload']> =>
|
||||
analysis.daily.statuses.map(status => ({
|
||||
value: capitalize(status),
|
||||
type: 'line',
|
||||
id: status,
|
||||
color: statusColorMap[status as FilterStatusType] ?? '',
|
||||
})),
|
||||
[analysis.daily.statuses],
|
||||
);
|
||||
|
||||
const legendPayload = useMemo(
|
||||
(): NonNullable<LegendProps['payload']> => [
|
||||
...triggerReasonLegendPayload,
|
||||
...statusesLegendPayload,
|
||||
],
|
||||
[statusesLegendPayload, triggerReasonLegendPayload],
|
||||
);
|
||||
|
||||
const tooltipFormatter = useMemo(() => {
|
||||
const reasonSet = new Set(analysis.daily.triggerReasons);
|
||||
|
||||
return (percentOrCount: number, name: string) => {
|
||||
const label = reasonSet.has(name)
|
||||
? humanTriggerReason(name)
|
||||
: capitalize(name);
|
||||
const valueText = reasonSet.has(name)
|
||||
? `${(percentOrCount * 100).toFixed(0)}%`
|
||||
: percentOrCount;
|
||||
|
||||
return [
|
||||
<span>
|
||||
{label}: {valueText}
|
||||
</span>,
|
||||
null,
|
||||
];
|
||||
};
|
||||
}, [analysis.daily.triggerReasons]);
|
||||
|
||||
const zoomFilteredValues = useMemo(
|
||||
() => zoomFilterValues(values),
|
||||
[values, zoomFilterValues],
|
||||
);
|
||||
|
||||
const barSize = getBarSize(analysis.daily.values.length);
|
||||
|
||||
return (
|
||||
<Accordion defaultExpanded={analysis.daily.statuses.length > 1}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>
|
||||
Build count per status over build trigger reason
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{values.length === 0 ? (
|
||||
<Alert severity="info">No data</Alert>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart data={zoomFilteredValues} {...zoomProps}>
|
||||
<Legend payload={legendPayload} />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="__epoch"
|
||||
type="category"
|
||||
tickFormatter={tickFormatterX}
|
||||
/>
|
||||
<YAxis yAxisId={1} type="number" tickCount={5} name="Count" />
|
||||
<YAxis yAxisId={2} type="number" name="Triggers" hide />
|
||||
<Tooltip
|
||||
labelFormatter={labelFormatterWithoutTime}
|
||||
formatter={tooltipFormatter}
|
||||
/>
|
||||
{triggerReasonLegendPayload.map(reason => (
|
||||
<Fragment key={reason.id}>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey={reason.id!}
|
||||
stackId="triggers"
|
||||
yAxisId={2}
|
||||
stroke={triggerColorMap[reason.id as TriggerReason] ?? ''}
|
||||
fillOpacity={0.5}
|
||||
fill={triggerColorMap[reason.id as TriggerReason] ?? ''}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{[...analysis.daily.statuses].reverse().map(status => (
|
||||
<Fragment key={status}>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
barSize={barSize}
|
||||
dataKey={status}
|
||||
stackId="statuses"
|
||||
yAxisId={1}
|
||||
stroke={statusColorMap[status as FilterStatusType] ?? ''}
|
||||
fillOpacity={0.8}
|
||||
fill={statusColorMap[status as FilterStatusType] ?? ''}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
{getZoomArea({ yAxisId: 1 })}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
function humanTriggerReason(reason: string): string {
|
||||
if ((reason as TriggerReason) === 'manual') {
|
||||
return 'Triggered manually';
|
||||
} else if ((reason as TriggerReason) === 'scm') {
|
||||
return 'Triggered by SCM';
|
||||
} else if ((reason as TriggerReason) === 'internal') {
|
||||
return 'Triggered internally';
|
||||
} else if ((reason as TriggerReason) === 'other') {
|
||||
return 'Triggered by another reason';
|
||||
}
|
||||
return `Triggered by ${reason}`;
|
||||
}
|
||||
|
||||
function getBarSize(count: number): number {
|
||||
if (count < 20) {
|
||||
return 10;
|
||||
} else if (count < 40) {
|
||||
return 8;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 type Averagify<T extends string> = `${T} avg`;
|
||||
export type Countify<T extends string> = `${T} count`;
|
||||
|
||||
export type Epoch = { __epoch: number };
|
||||
|
||||
export type ChartableStageDatapoints = Epoch & {
|
||||
[status in FilterStatusType]?: number;
|
||||
} & {
|
||||
[status in Averagify<FilterStatusType>]?: number;
|
||||
} & {
|
||||
[status in Countify<FilterStatusType>]?: number;
|
||||
};
|
||||
|
||||
export interface ChartableStageAnalysis {
|
||||
/** Maximum duration */
|
||||
max: number;
|
||||
/** Minimum duration */
|
||||
min: number;
|
||||
/** Average duration */
|
||||
avg: number;
|
||||
/** Median duration */
|
||||
med: number;
|
||||
}
|
||||
|
||||
export interface ChartableStage {
|
||||
analysis: Record<FilterStatusType, ChartableStageAnalysis>;
|
||||
combinedAnalysis: ChartableStageAnalysis;
|
||||
name: string;
|
||||
values: Array<ChartableStageDatapoints>;
|
||||
statusSet: Set<FilterStatusType>;
|
||||
|
||||
stages: Map<string, ChartableStage>;
|
||||
}
|
||||
|
||||
export type TriggerReasonsDatapoint = {
|
||||
[K in TriggerReason]?: number;
|
||||
};
|
||||
export type StatusesDatapoint = { [status in FilterStatusType]?: number };
|
||||
|
||||
export type ChartableDailyDatapoint = Epoch &
|
||||
TriggerReasonsDatapoint &
|
||||
StatusesDatapoint;
|
||||
|
||||
export interface ChartableDaily {
|
||||
values: Array<ChartableDailyDatapoint>;
|
||||
|
||||
/**
|
||||
* The build trigger reasons
|
||||
*/
|
||||
triggerReasons: Array<string>;
|
||||
|
||||
/**
|
||||
* The top-level (build) statuses
|
||||
*/
|
||||
statuses: Array<string>;
|
||||
}
|
||||
|
||||
export interface ChartableStagesAnalysis {
|
||||
/**
|
||||
* Summary of statuses and trigger reasons per day
|
||||
*/
|
||||
daily: ChartableDaily;
|
||||
|
||||
/**
|
||||
* Total aggregates of sub stages
|
||||
*/
|
||||
total: ChartableStage;
|
||||
|
||||
/**
|
||||
* Top-level stages {name -> stage}
|
||||
*/
|
||||
stages: Map<string, ChartableStage>;
|
||||
|
||||
/**
|
||||
* All statuses found deeper in the stage tree. A stage might have been
|
||||
* _aborted_ although the build actually _failed_, e.g.
|
||||
*/
|
||||
statuses: Array<string>;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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 { throttle } from 'lodash';
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
Fragment,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { ReferenceArea } from 'recharts';
|
||||
|
||||
import type { Epoch } from './types';
|
||||
|
||||
interface ZoomState {
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
interface ZoomContext {
|
||||
registerSelection(setter: Dispatch<ZoomState>): void;
|
||||
setSelectState: Dispatch<SetStateAction<ZoomState>>;
|
||||
|
||||
zoomState: ZoomState;
|
||||
setZoomState: Dispatch<SetStateAction<ZoomState>>;
|
||||
|
||||
resetZoom: () => void;
|
||||
}
|
||||
|
||||
const context = React.createContext<ZoomContext>(undefined as any);
|
||||
|
||||
export function ZoomProvider({ children }: PropsWithChildren<{}>) {
|
||||
const [registeredSelectors, setRegisteredSelectors] = useState<
|
||||
Array<Dispatch<ZoomState>>
|
||||
>([]);
|
||||
const [selectState, setSelectState] = useState<ZoomState>({});
|
||||
const [zoomState, setZoomState] = useState<ZoomState>({});
|
||||
|
||||
const registerSelection = useCallback(
|
||||
(selector: Dispatch<ZoomState>) => {
|
||||
setRegisteredSelectors(old => [...old, selector]);
|
||||
|
||||
return () => {
|
||||
setRegisteredSelectors(old => old.filter(sel => sel === selector));
|
||||
};
|
||||
},
|
||||
[setRegisteredSelectors],
|
||||
);
|
||||
|
||||
const callSelectors = useCallback(
|
||||
(state: ZoomState) => {
|
||||
registeredSelectors.forEach(selector => {
|
||||
selector(state);
|
||||
});
|
||||
},
|
||||
[registeredSelectors],
|
||||
);
|
||||
|
||||
const throttledCallSelectors = useMemo(
|
||||
() => throttle(callSelectors, 200),
|
||||
[callSelectors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
throttledCallSelectors({
|
||||
left: selectState.left,
|
||||
right: selectState.right,
|
||||
});
|
||||
}, [selectState.left, selectState.right, throttledCallSelectors]);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setSelectState({});
|
||||
setZoomState({});
|
||||
}, [setSelectState, setZoomState]);
|
||||
|
||||
const value = useMemo(
|
||||
(): ZoomContext => ({
|
||||
registerSelection,
|
||||
setSelectState,
|
||||
|
||||
zoomState,
|
||||
setZoomState,
|
||||
|
||||
resetZoom,
|
||||
}),
|
||||
[registerSelection, setSelectState, zoomState, setZoomState, resetZoom],
|
||||
);
|
||||
|
||||
return <context.Provider value={value} children={children} />;
|
||||
}
|
||||
|
||||
export function useZoom() {
|
||||
const { zoomState, resetZoom } = useContext(context);
|
||||
|
||||
const zoomFilterValues = useCallback(
|
||||
<T extends Epoch>(values: Array<T>): Array<T> => {
|
||||
const { left, right } = zoomState;
|
||||
return left === undefined || right === undefined
|
||||
? values
|
||||
: values.filter(({ __epoch }) => __epoch > left && __epoch < right);
|
||||
},
|
||||
[zoomState],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
resetZoom,
|
||||
zoomState,
|
||||
zoomFilterValues,
|
||||
}),
|
||||
[resetZoom, zoomState, zoomFilterValues],
|
||||
);
|
||||
}
|
||||
|
||||
export interface ZoomAreaProps {
|
||||
yAxisId?: number | string | undefined;
|
||||
}
|
||||
|
||||
export function useZoomArea() {
|
||||
const [showSelection, setShowSelection] = useState(false);
|
||||
const [state, setState] = useState<ZoomState>({});
|
||||
const { setSelectState, setZoomState, registerSelection } =
|
||||
useContext(context);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: any) => {
|
||||
if (!e?.activeLabel) return;
|
||||
|
||||
setSelectState({ left: e.activeLabel });
|
||||
setShowSelection(true);
|
||||
},
|
||||
[setSelectState, setShowSelection],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(e: any) => {
|
||||
if (!e?.activeLabel) return;
|
||||
|
||||
setSelectState(area => {
|
||||
if (!area.left) {
|
||||
return area;
|
||||
}
|
||||
return { ...area, right: e.activeLabel };
|
||||
});
|
||||
},
|
||||
[setSelectState],
|
||||
);
|
||||
|
||||
const doZoom = useCallback(() => {
|
||||
setSelectState(old => {
|
||||
const { left, right } = old;
|
||||
|
||||
if (left === undefined || right === undefined || left === right) {
|
||||
// Either is undefined or both are same - zoom out
|
||||
setZoomState({});
|
||||
} else if (left < right) {
|
||||
setZoomState({ left, right });
|
||||
} else if (left > right) {
|
||||
setZoomState({ left: right, right: left });
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
setShowSelection(false);
|
||||
}, [setSelectState, setZoomState, setShowSelection]);
|
||||
|
||||
const zoomProps = useMemo(
|
||||
() => ({
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp: doZoom,
|
||||
}),
|
||||
[onMouseDown, onMouseMove, doZoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSelection) {
|
||||
return undefined;
|
||||
}
|
||||
return registerSelection(setState);
|
||||
}, [registerSelection, setState, showSelection]);
|
||||
|
||||
const getZoomArea = useCallback(
|
||||
(props?: ZoomAreaProps) => (
|
||||
<Fragment key="zoom-area">
|
||||
{showSelection && state.left && state.right ? (
|
||||
<ReferenceArea
|
||||
x1={state.left}
|
||||
x2={state.right}
|
||||
strokeOpacity={0.5}
|
||||
{...props}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
),
|
||||
[showSelection, state.left, state.right],
|
||||
);
|
||||
|
||||
return {
|
||||
zoomProps,
|
||||
getZoomArea,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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, { useCallback, MouseEvent } from 'react';
|
||||
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>;
|
||||
|
||||
export interface ButtonSwitchPropsBase<T extends string> {
|
||||
values: ReadonlyArray<SwitchValue<T>>;
|
||||
vertical?: boolean;
|
||||
}
|
||||
export interface ButtonSwitchPropsSingle<T extends string>
|
||||
extends ButtonSwitchPropsBase<T> {
|
||||
multi?: false;
|
||||
selection: T;
|
||||
onChange: (selected: T) => void;
|
||||
}
|
||||
export interface ButtonSwitchPropsMulti<T extends string>
|
||||
extends ButtonSwitchPropsBase<T> {
|
||||
multi: true;
|
||||
selection: ReadonlyArray<T>;
|
||||
onChange: (selected: Array<T>) => void;
|
||||
}
|
||||
|
||||
export type ButtonSwitchProps<T extends string> =
|
||||
| ButtonSwitchPropsSingle<T>
|
||||
| ButtonSwitchPropsMulti<T>;
|
||||
|
||||
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 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)
|
||||
? props.selection.filter(val => val !== value)
|
||||
: [...props.selection, value as T],
|
||||
);
|
||||
} else {
|
||||
props.onChange(value as T);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[values, props.selection, props.multi, props.onChange],
|
||||
);
|
||||
|
||||
const hasSelection = (value: T) => {
|
||||
if (props.multi) {
|
||||
return props.selection.includes(value);
|
||||
}
|
||||
return props.selection === value;
|
||||
};
|
||||
|
||||
const tooltipify = (value: SwitchValue<T>, elem: JSX.Element) =>
|
||||
typeof value === 'object' && value.tooltip ? (
|
||||
<Tooltip
|
||||
key={value.value}
|
||||
TransitionComponent={Zoom}
|
||||
title={value.tooltip}
|
||||
arrow
|
||||
>
|
||||
{elem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
elem
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
disableElevation
|
||||
orientation={vertical ? 'vertical' : 'horizontal'}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{values.map(value =>
|
||||
tooltipify(
|
||||
value,
|
||||
<Button
|
||||
key={switchValue(value)}
|
||||
color={hasSelection(switchValue(value)) ? 'primary' : 'default'}
|
||||
variant={
|
||||
hasSelection(switchValue(value)) ? 'contained' : 'outlined'
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
{switchText(value)}
|
||||
</Button>,
|
||||
),
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
* 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, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Theme,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import ShowChartIcon from '@material-ui/icons/ShowChart';
|
||||
import BarChartIcon from '@material-ui/icons/BarChart';
|
||||
import {
|
||||
MuiPickersUtilsProvider,
|
||||
KeyboardDatePicker,
|
||||
} from '@material-ui/pickers';
|
||||
import { DateTime } from 'luxon';
|
||||
import LuxonUtils from '@date-io/luxon';
|
||||
|
||||
import {
|
||||
ChartType,
|
||||
ChartTypes,
|
||||
CicdConfiguration,
|
||||
CicdDefaults,
|
||||
FilterBranchType,
|
||||
FilterStatusType,
|
||||
statusTypes,
|
||||
} from '../apis/types';
|
||||
import { ChartableStagesAnalysis } from '../charts/types';
|
||||
import { ButtonSwitch, SwitchValue } from './button-switch';
|
||||
import { Toggle } from './toggle';
|
||||
import { DurationSlider } from './duration-slider';
|
||||
import { Label } from './label';
|
||||
|
||||
export const useStyles = makeStyles<Theme>(
|
||||
theme => ({
|
||||
rootCard: {
|
||||
padding: theme.spacing(0, 0, 0, 0),
|
||||
margin: theme.spacing(0, 0, 2, 0),
|
||||
},
|
||||
updateButton: {
|
||||
margin: theme.spacing(1, 0, 0, 0),
|
||||
},
|
||||
header: {
|
||||
margin: theme.spacing(0, 0, 0, 0),
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
title: {
|
||||
margin: theme.spacing(3, 0, 1, 0),
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
'&:first-child': {
|
||||
margin: theme.spacing(1, 0, 1, 0),
|
||||
},
|
||||
},
|
||||
buttonDescription: {
|
||||
textTransform: 'uppercase',
|
||||
margin: theme.spacing(1, 0, 0, 1),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatistics',
|
||||
},
|
||||
);
|
||||
|
||||
export type BranchSelection = FilterBranchType | 'all';
|
||||
export type StatusSelection = FilterStatusType;
|
||||
|
||||
export interface ChartFilter {
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
branch: string;
|
||||
status: Array<string>;
|
||||
}
|
||||
|
||||
export function getDefaultChartFilter(
|
||||
cicdConfiguration: CicdConfiguration,
|
||||
): ChartFilter {
|
||||
const toDate = cicdConfiguration.defaults?.timeTo ?? new Date();
|
||||
return {
|
||||
fromDate:
|
||||
cicdConfiguration.defaults?.timeFrom ??
|
||||
DateTime.fromJSDate(toDate).minus({ months: 1 }).toJSDate(),
|
||||
toDate,
|
||||
branch: cicdConfiguration.defaults?.filterType ?? 'branch',
|
||||
status:
|
||||
cicdConfiguration.defaults?.filterStatus ??
|
||||
cicdConfiguration.availableStatuses.filter(
|
||||
status => status === 'succeeded' || status === 'failed',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isSameChartFilter(a: ChartFilter, b: ChartFilter): boolean {
|
||||
return (
|
||||
a.branch === b.branch &&
|
||||
[...a.status].sort().join(' ') === [...b.status].sort().join(' ') &&
|
||||
DateTime.fromJSDate(a.fromDate).hasSame(
|
||||
DateTime.fromJSDate(b.fromDate),
|
||||
'day',
|
||||
) &&
|
||||
DateTime.fromJSDate(a.toDate).hasSame(DateTime.fromJSDate(b.toDate), 'day')
|
||||
);
|
||||
}
|
||||
|
||||
export type ViewOptions = Pick<
|
||||
CicdDefaults,
|
||||
| 'lowercaseNames'
|
||||
| 'normalizeTimeRange'
|
||||
| 'collapsedLimit'
|
||||
| 'hideLimit'
|
||||
| 'chartTypes'
|
||||
>;
|
||||
|
||||
export function getDefaultViewOptions(
|
||||
cicdConfiguration: CicdConfiguration,
|
||||
): ViewOptions {
|
||||
return {
|
||||
lowercaseNames: cicdConfiguration.defaults?.lowercaseNames ?? false,
|
||||
normalizeTimeRange: cicdConfiguration.defaults?.normalizeTimeRange ?? true,
|
||||
collapsedLimit: 60 * 1000, // 1m
|
||||
hideLimit: 20 * 1000, // 20s
|
||||
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 {
|
||||
analysis?: ChartableStagesAnalysis;
|
||||
|
||||
cicdConfiguration: CicdConfiguration;
|
||||
initialFetchFilter: ChartFilter;
|
||||
currentFetchFilter?: ChartFilter;
|
||||
onChangeFetchFilter(filter: ChartFilter): void;
|
||||
updateFetchFilter(filter: ChartFilter): void;
|
||||
|
||||
initialViewOptions: ViewOptions;
|
||||
onChangeViewOptions(filter: ViewOptions): void;
|
||||
}
|
||||
|
||||
interface InternalRef {
|
||||
first: boolean;
|
||||
}
|
||||
|
||||
export function ChartFilters(props: ChartFiltersProps) {
|
||||
const {
|
||||
analysis,
|
||||
cicdConfiguration,
|
||||
initialFetchFilter,
|
||||
currentFetchFilter,
|
||||
onChangeFetchFilter,
|
||||
updateFetchFilter,
|
||||
initialViewOptions,
|
||||
onChangeViewOptions,
|
||||
} = props;
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const [internalRef] = useState<InternalRef>({ first: true });
|
||||
|
||||
const [useNowAsToDate, setUseNowAsToDate] = useState(true);
|
||||
const [toDate, setToDate] = useState(initialFetchFilter.toDate);
|
||||
const [fromDate, setFromDate] = useState(initialFetchFilter.fromDate);
|
||||
|
||||
const [branch, setBranch] = useState(initialFetchFilter.branch);
|
||||
|
||||
const statusValues: ReadonlyArray<StatusSelection> =
|
||||
cicdConfiguration.availableStatuses;
|
||||
const [selectedStatus, setSelectedStatus] = useState(
|
||||
initialFetchFilter.status,
|
||||
);
|
||||
|
||||
const [viewOptions, setViewOptions] = useState(initialViewOptions);
|
||||
|
||||
const setLowercaseNames = useCallback(
|
||||
(lowercaseNames: boolean) => {
|
||||
setViewOptions(old => ({ ...old, lowercaseNames }));
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
const setNormalizeTimeRange = useCallback(
|
||||
(normalizeTimeRange: boolean) => {
|
||||
setViewOptions(old => ({ ...old, normalizeTimeRange }));
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
const setHideLimit = useCallback(
|
||||
(value: number) => {
|
||||
setViewOptions(old => ({ ...old, hideLimit: value }));
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
const setCollapseLimit = useCallback(
|
||||
(value: number) => {
|
||||
setViewOptions(old => ({ ...old, collapsedLimit: value }));
|
||||
},
|
||||
[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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (internalRef.first) {
|
||||
// Skip calling onChangeFetchFilter first time
|
||||
internalRef.first = false;
|
||||
return;
|
||||
}
|
||||
onChangeFetchFilter({
|
||||
toDate,
|
||||
fromDate,
|
||||
branch,
|
||||
status: selectedStatus,
|
||||
});
|
||||
}, [
|
||||
internalRef,
|
||||
toDate,
|
||||
fromDate,
|
||||
branch,
|
||||
selectedStatus,
|
||||
onChangeFetchFilter,
|
||||
]);
|
||||
|
||||
const toggleUseNowAsDate = useCallback(() => {
|
||||
setUseNowAsToDate(!useNowAsToDate);
|
||||
if (!DateTime.fromJSDate(toDate).hasSame(DateTime.now(), 'day')) {
|
||||
setToDate(new Date());
|
||||
}
|
||||
}, [useNowAsToDate, toDate]);
|
||||
|
||||
const hasFetchFilterChanges = useMemo(
|
||||
() =>
|
||||
!currentFetchFilter ||
|
||||
!isSameChartFilter(
|
||||
{
|
||||
toDate,
|
||||
fromDate,
|
||||
branch,
|
||||
status: selectedStatus,
|
||||
},
|
||||
currentFetchFilter,
|
||||
),
|
||||
[toDate, fromDate, branch, selectedStatus, currentFetchFilter],
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(() => {
|
||||
updateFetchFilter({
|
||||
toDate,
|
||||
fromDate,
|
||||
branch,
|
||||
status: selectedStatus,
|
||||
});
|
||||
}, [toDate, fromDate, branch, selectedStatus, updateFetchFilter]);
|
||||
|
||||
const inrefferedStatuses = analysis?.statuses ?? selectedStatus;
|
||||
|
||||
return (
|
||||
<MuiPickersUtilsProvider utils={LuxonUtils}>
|
||||
<Card className={classes.rootCard}>
|
||||
<CardHeader
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
onClick={updateFilter}
|
||||
disabled={!hasFetchFilterChanges}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
title={
|
||||
<Typography variant="subtitle2" className={classes.header}>
|
||||
Fetching options
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
>
|
||||
Date range
|
||||
</Typography>
|
||||
<KeyboardDatePicker
|
||||
autoOk
|
||||
variant="inline"
|
||||
inputVariant="outlined"
|
||||
label="From date"
|
||||
format="yyyy-MM-dd"
|
||||
value={fromDate}
|
||||
InputAdornmentProps={{ position: 'start' }}
|
||||
onChange={date => setFromDate(date?.toJSDate() ?? new Date())}
|
||||
/>
|
||||
<br />
|
||||
<FormControl component="fieldset">
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={useNowAsToDate}
|
||||
onChange={toggleUseNowAsDate}
|
||||
/>
|
||||
}
|
||||
label={<Label>To today</Label>}
|
||||
/>
|
||||
{useNowAsToDate ? null : (
|
||||
<KeyboardDatePicker
|
||||
autoOk
|
||||
variant="inline"
|
||||
inputVariant="outlined"
|
||||
label="To date"
|
||||
format="yyyy-MM-dd"
|
||||
value={toDate}
|
||||
InputAdornmentProps={{ position: 'start' }}
|
||||
onChange={date => setToDate(date?.toJSDate() ?? new Date())}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
>
|
||||
Branch
|
||||
</Typography>
|
||||
<ButtonSwitch<string>
|
||||
values={branchValues}
|
||||
selection={branch}
|
||||
onChange={setBranch}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
>
|
||||
Status
|
||||
</Typography>
|
||||
<ButtonSwitch<string>
|
||||
values={statusValues}
|
||||
multi
|
||||
vertical
|
||||
selection={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={classes.rootCard}>
|
||||
<CardHeader
|
||||
title={
|
||||
<Typography variant="subtitle2" className={classes.header}>
|
||||
View options
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<Toggle
|
||||
checked={viewOptions.lowercaseNames}
|
||||
setChecked={setLowercaseNames}
|
||||
>
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
'Lowercasing names can reduce duplications ' +
|
||||
'when stage names have changed casing'
|
||||
}
|
||||
>
|
||||
<Label>Lowercase names</Label>
|
||||
</Tooltip>
|
||||
</Toggle>
|
||||
<Toggle
|
||||
checked={viewOptions.normalizeTimeRange}
|
||||
setChecked={setNormalizeTimeRange}
|
||||
>
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
'All charts will use the same x-axis. ' +
|
||||
'This reduces confusion when stages have been altered over time ' +
|
||||
'and only appear in a part of the time range.'
|
||||
}
|
||||
>
|
||||
<Label>Normalize time range</Label>
|
||||
</Tooltip>
|
||||
</Toggle>
|
||||
<DurationSlider
|
||||
header="Hide under peak"
|
||||
value={viewOptions.hideLimit}
|
||||
setValue={setHideLimit}
|
||||
/>
|
||||
<DurationSlider
|
||||
header="Collapse under peak"
|
||||
value={viewOptions.collapsedLimit}
|
||||
setValue={setCollapseLimit}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
>
|
||||
Chart styles
|
||||
</Typography>
|
||||
{inrefferedStatuses.map(status => (
|
||||
<Grid key={status} container spacing={0}>
|
||||
<Grid item>
|
||||
<ButtonSwitch<ChartType>
|
||||
values={chartTypeValues}
|
||||
selection={viewOptions.chartTypes[status as FilterStatusType]}
|
||||
onChange={setChartTypeSpecific[status]}
|
||||
multi
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item className={classes.buttonDescription}>
|
||||
<div>{status}</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</MuiPickersUtilsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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, { useCallback, useMemo, useState } from 'react';
|
||||
import { Slider } from '@material-ui/core';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { formatDuration, formatDurationFromSeconds } from './utils';
|
||||
import { Label } from './label';
|
||||
|
||||
const marks = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
60,
|
||||
2 * 60,
|
||||
3 * 60,
|
||||
4 * 60,
|
||||
5 * 60,
|
||||
10 * 60,
|
||||
15 * 60,
|
||||
30 * 60,
|
||||
45 * 60,
|
||||
60 * 60,
|
||||
].map((value, index) => ({
|
||||
value: index,
|
||||
label: formatDurationFromSeconds(value),
|
||||
seconds: value,
|
||||
}));
|
||||
|
||||
function findMarkIndex(seconds: number): number {
|
||||
if (marks[0].seconds > seconds) {
|
||||
return 0;
|
||||
} else if (marks[marks.length - 1].seconds < seconds) {
|
||||
return marks.length - 1;
|
||||
}
|
||||
for (let i = 0; i < marks.length - 1; ++i) {
|
||||
const a = marks[i];
|
||||
const b = marks[i + 1];
|
||||
if (seconds === a.seconds) {
|
||||
return i;
|
||||
} else if (seconds === b.seconds) {
|
||||
return i + 1;
|
||||
} else if (a.seconds < seconds && b.seconds > seconds) {
|
||||
return seconds - a.seconds < b.seconds - seconds ? i : i - 1;
|
||||
}
|
||||
}
|
||||
return 0; // Won't happen
|
||||
}
|
||||
|
||||
function formatDurationFromIndex(index: number) {
|
||||
return formatDurationFromSeconds(marks[index].seconds);
|
||||
}
|
||||
|
||||
export interface DurationSliderProps {
|
||||
header: string;
|
||||
value: number;
|
||||
setValue: (value: number) => void;
|
||||
}
|
||||
|
||||
export function DurationSlider(props: DurationSliderProps) {
|
||||
const { header, value, setValue } = props;
|
||||
|
||||
const [curValue, setCurValue] = useState(value);
|
||||
|
||||
const debouncedSetValue = useMemo(() => debounce(setValue, 1000), [setValue]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(_: any, index: number | number[]) => {
|
||||
const millis = marks[index as number].seconds * 1000;
|
||||
setCurValue(millis);
|
||||
debouncedSetValue(millis);
|
||||
},
|
||||
[debouncedSetValue],
|
||||
);
|
||||
|
||||
const indexValue = useMemo(() => findMarkIndex(curValue / 1000), [curValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>
|
||||
{header} {formatDuration(curValue)}
|
||||
</Label>
|
||||
<Slider
|
||||
value={indexValue}
|
||||
min={0}
|
||||
step={1}
|
||||
max={marks.length - 1}
|
||||
marks
|
||||
getAriaValueText={formatDurationFromIndex}
|
||||
valueLabelFormat={formatDurationFromIndex}
|
||||
onChange={onChange}
|
||||
valueLabelDisplay="auto"
|
||||
aria-labelledby="slider-hide-limit"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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, { PropsWithChildren } from 'react';
|
||||
import { Typography, Theme, makeStyles } from '@material-ui/core';
|
||||
|
||||
export const useStyles = makeStyles<Theme>(
|
||||
theme => ({
|
||||
label: {
|
||||
fontWeight: 'normal',
|
||||
margin: theme.spacing(0),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatisticsLabel',
|
||||
},
|
||||
);
|
||||
|
||||
export function Label({ children }: PropsWithChildren<{}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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, DependencyList } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { Box, LinearProgress } from '@material-ui/core';
|
||||
import Timeline from '@material-ui/lab/Timeline';
|
||||
import TimelineItem from '@material-ui/lab/TimelineItem';
|
||||
import TimelineSeparator from '@material-ui/lab/TimelineSeparator';
|
||||
import TimelineConnector from '@material-ui/lab/TimelineConnector';
|
||||
import TimelineContent from '@material-ui/lab/TimelineContent';
|
||||
import TimelineOppositeContent from '@material-ui/lab/TimelineOppositeContent';
|
||||
import TimelineDot, { TimelineDotProps } from '@material-ui/lab/TimelineDot';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useApp } from '@backstage/core-plugin-api';
|
||||
|
||||
const stepProgressStyle: CSSProperties = {
|
||||
marginTop: 6,
|
||||
};
|
||||
|
||||
// Matching react-use, only has loading/error/value
|
||||
type AsyncState<T> =
|
||||
| {
|
||||
loading: boolean;
|
||||
error?: undefined;
|
||||
value?: undefined;
|
||||
}
|
||||
| {
|
||||
loading: true;
|
||||
error?: Error | undefined;
|
||||
value?: T;
|
||||
}
|
||||
| {
|
||||
loading: false;
|
||||
error: Error;
|
||||
value?: undefined;
|
||||
}
|
||||
| {
|
||||
loading: false;
|
||||
error?: undefined;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export interface ProgressStep extends ProgessAsSingle {
|
||||
title: string;
|
||||
}
|
||||
export interface ProgessAsSteps {
|
||||
steps: Array<ProgressStep>;
|
||||
}
|
||||
export interface ProgessAsSingle<T = number> {
|
||||
progress?: T;
|
||||
progressBuffer?: T;
|
||||
}
|
||||
|
||||
export type ProgessState<T = number> =
|
||||
| ProgessAsSingle<T>
|
||||
| (T extends number ? ProgessAsSteps : { steps?: undefined });
|
||||
|
||||
export type ProgressAsLoading = ProgessState & {
|
||||
loading: true;
|
||||
error?: undefined;
|
||||
value?: undefined;
|
||||
};
|
||||
export type ProgressAsError = ProgessState<undefined> & {
|
||||
loading?: false | undefined;
|
||||
error: Error;
|
||||
value?: undefined;
|
||||
};
|
||||
export type ProgressAsValue<T> = ProgessState<undefined> & {
|
||||
loading?: false | undefined;
|
||||
error?: undefined;
|
||||
value: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* An AsyncState but with the addition of progress (decimal 0-1) to allow
|
||||
* rendering a progress bar while waiting.
|
||||
*/
|
||||
export type ProgressType<T> =
|
||||
| ProgressAsLoading
|
||||
| ProgressAsError
|
||||
| ProgressAsValue<T>;
|
||||
|
||||
const sentry = Symbol();
|
||||
|
||||
/**
|
||||
* Casts an AsyncState or Progress into its non-succeeded sub types
|
||||
*/
|
||||
type Unsuccessful<S extends ProgressType<any> | AsyncState<any>> =
|
||||
S extends ProgressType<any>
|
||||
? ProgressAsLoading | ProgressAsError
|
||||
: Omit<AsyncState<any>, 'value'>;
|
||||
|
||||
/**
|
||||
* Similar to useAsync except it "waits" for a dependent (upstream) async state
|
||||
* to finish first, otherwise it forwards the dependent pending state.
|
||||
*
|
||||
* When/if the dependent state has settled successfully, the callback will be
|
||||
* invoked for a new layer of async state with the dependent (upstream) success
|
||||
* result as argument.
|
||||
*/
|
||||
export function useAsyncChain<S extends ProgressType<any> | AsyncState<any>, R>(
|
||||
parentState: S,
|
||||
fn: (value: NonNullable<S['value']>) => Promise<R>,
|
||||
deps: DependencyList,
|
||||
): Unsuccessful<S> | AsyncState<R> {
|
||||
const childState = useAsync(
|
||||
async () => (!parentState.value ? sentry : fn(parentState.value)),
|
||||
[!parentState.error, !parentState.loading, parentState.value, ...deps],
|
||||
);
|
||||
|
||||
if (!parentState.value) {
|
||||
return parentState as Unsuccessful<S>;
|
||||
} else if (childState.value === sentry) {
|
||||
return { loading: true };
|
||||
}
|
||||
return childState as AsyncState<R>;
|
||||
}
|
||||
|
||||
export function renderFallbacks<T>(
|
||||
state: ProgressType<T> | AsyncState<T>,
|
||||
success: (value: T) => JSX.Element,
|
||||
): JSX.Element {
|
||||
if (state.loading) {
|
||||
return <ViewProgress state={state} />;
|
||||
} else if (state.error) {
|
||||
return <Alert severity="error">{state.error.stack}</Alert>;
|
||||
}
|
||||
|
||||
return success(state.value!);
|
||||
}
|
||||
|
||||
export function ViewProgress({
|
||||
state,
|
||||
}: {
|
||||
state: ProgressAsLoading | { loading: boolean };
|
||||
}) {
|
||||
const { Progress } = useApp().getComponents();
|
||||
|
||||
const stateAsSingleProgress = state as ProgessAsSingle;
|
||||
const stateAsStepProgress = state as ProgessAsSteps;
|
||||
|
||||
if (
|
||||
!stateAsSingleProgress.progress &&
|
||||
!stateAsSingleProgress.progressBuffer &&
|
||||
!stateAsStepProgress.steps
|
||||
) {
|
||||
// Simple spinner
|
||||
return <Progress />;
|
||||
} else if (stateAsSingleProgress.progress !== undefined) {
|
||||
// Simple _single_ progress
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress
|
||||
variant="buffer"
|
||||
value={(stateAsSingleProgress.progress ?? 0) * 100}
|
||||
valueBuffer={(stateAsSingleProgress.progressBuffer ?? 0) * 100}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-step progresses
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Timeline>
|
||||
{stateAsStepProgress.steps.map((step, index) => (
|
||||
<TimelineItem key={index}>
|
||||
<TimelineOppositeContent>{step.title}</TimelineOppositeContent>
|
||||
<TimelineSeparator>
|
||||
<TimelineDot color={getDotColor(step)} />
|
||||
{index < stateAsStepProgress.steps.length - 1 ? (
|
||||
<TimelineConnector />
|
||||
) : null}
|
||||
</TimelineSeparator>
|
||||
<TimelineContent>
|
||||
{!step.progress && !step.progressBuffer ? null : (
|
||||
<LinearProgress
|
||||
style={stepProgressStyle}
|
||||
variant="buffer"
|
||||
value={(step.progress ?? 0) * 100}
|
||||
valueBuffer={(step.progressBuffer ?? 0) * 100}
|
||||
/>
|
||||
)}
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getDotColor(step: ProgressStep): TimelineDotProps['color'] {
|
||||
const progress = step.progress ?? 0;
|
||||
|
||||
if (progress >= 1) {
|
||||
return 'primary';
|
||||
} else if (progress > 0) {
|
||||
return 'secondary';
|
||||
}
|
||||
return 'grey';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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, { useCallback, PropsWithChildren } from 'react';
|
||||
import { FormControlLabel, Switch } from '@material-ui/core';
|
||||
|
||||
export interface ToggleProps {
|
||||
checked: boolean;
|
||||
setChecked: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
setChecked,
|
||||
children,
|
||||
}: PropsWithChildren<ToggleProps>) {
|
||||
const toggler = useCallback(() => {
|
||||
setChecked(!checked);
|
||||
}, [checked, setChecked]);
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={checked} onChange={toggler} />}
|
||||
label={children}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
const infoText: CSSProperties = { color: 'InfoText' };
|
||||
|
||||
/**
|
||||
* Picks {num} elements from {arr} evenly, from the first to the last
|
||||
*/
|
||||
export function pickElements<T>(arr: ReadonlyArray<T>, num: number): Array<T> {
|
||||
if (arr.length <= num) {
|
||||
return [...arr];
|
||||
}
|
||||
|
||||
if (num < 2) {
|
||||
return [arr[arr.length / 2]];
|
||||
}
|
||||
|
||||
const step = arr.length / (num - 1);
|
||||
return [
|
||||
...Array.from(Array(num - 1)).map(
|
||||
(_, index) => arr[Math.round(index * step)],
|
||||
),
|
||||
arr[arr.length - 1],
|
||||
];
|
||||
}
|
||||
|
||||
function formatDateShort(milliseconds: number) {
|
||||
if ((milliseconds as any) === 'auto') {
|
||||
// When recharts gets confused (empty data)
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(milliseconds).toLocaleString(DateTime.DATE_SHORT);
|
||||
}
|
||||
function formatDateTimeShort(milliseconds: number) {
|
||||
if ((milliseconds as any) === 'auto') {
|
||||
// When recharts gets confused (empty data)
|
||||
return '';
|
||||
}
|
||||
return DateTime.fromMillis(milliseconds).toLocaleString(
|
||||
DateTime.DATETIME_SHORT,
|
||||
);
|
||||
}
|
||||
|
||||
export function labelFormatter(epoch: number) {
|
||||
return <span style={infoText}>{formatDateTimeShort(epoch)}</span>;
|
||||
}
|
||||
|
||||
export function labelFormatterWithoutTime(epoch: number) {
|
||||
return <span style={infoText}>{formatDateShort(epoch)}</span>;
|
||||
}
|
||||
|
||||
export function tickFormatterX(epoch: number) {
|
||||
return formatDateShort(epoch);
|
||||
}
|
||||
|
||||
export function tickFormatterY(duration: number) {
|
||||
if (duration === 0) {
|
||||
return '0';
|
||||
} else if (duration < 500) {
|
||||
return `${duration} ms`;
|
||||
}
|
||||
return formatDuration(duration)
|
||||
.replace(/second.*/, 'sec')
|
||||
.replace(/minute.*/, 'min')
|
||||
.replace(/hour.*/, 'h')
|
||||
.replace(/day.*/, 'd')
|
||||
.replace(/month.*/, 'm')
|
||||
.replace(/year.*/, 'y');
|
||||
}
|
||||
|
||||
export function tooltipValueFormatter(durationOrCount: number, name: string) {
|
||||
return [
|
||||
<span style={infoText}>
|
||||
{capitalize(name)}:{' '}
|
||||
{name.endsWith(' count')
|
||||
? durationOrCount
|
||||
: formatDuration(durationOrCount)}
|
||||
</span>,
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
export function formatDuration(millis: number) {
|
||||
let rest = Math.round(millis);
|
||||
const days = Math.floor(rest / (1000 * 60 * 60 * 24));
|
||||
rest -= days * (1000 * 60 * 60 * 24);
|
||||
const hours = Math.floor(rest / (1000 * 60 * 60));
|
||||
rest -= hours * (1000 * 60 * 60);
|
||||
const minutes = Math.floor(rest / (1000 * 60));
|
||||
rest -= minutes * (1000 * 60);
|
||||
const seconds = Math.floor(rest / 1000);
|
||||
rest -= seconds * 1000;
|
||||
const milliseconds = rest;
|
||||
|
||||
if (!days && !hours && !minutes) {
|
||||
if (seconds < 1) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (seconds < 2) {
|
||||
return `${((milliseconds + seconds * 1000) / 1000).toFixed(1)}s`;
|
||||
}
|
||||
}
|
||||
|
||||
const dur = Duration.fromObject({
|
||||
...(days && { days }),
|
||||
...(hours && { hours }),
|
||||
...(minutes && !days && { minutes }),
|
||||
...(seconds && !days && !hours && { seconds }),
|
||||
});
|
||||
|
||||
return humanizeDuration(dur.toMillis(), { round: true });
|
||||
}
|
||||
|
||||
export function formatDurationFromSeconds(seconds: number) {
|
||||
return formatDuration(seconds * 1000);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { Grid, makeStyles, Theme } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import {
|
||||
useCicdStatistics,
|
||||
UseCicdStatisticsOptions,
|
||||
} from './hooks/use-cicd-statistics';
|
||||
import { useCicdConfiguration } from './hooks/use-cicd-configuration';
|
||||
import { buildsToChartableStages } from './charts/logic/conversions';
|
||||
import { ZoomProvider, useZoom } from './charts/zoom';
|
||||
import { StageChart } from './charts/stage-chart';
|
||||
import { StatusChart } from './charts/status-chart';
|
||||
import {
|
||||
ChartFilter,
|
||||
ChartFilters,
|
||||
getDefaultChartFilter,
|
||||
getDefaultViewOptions,
|
||||
ViewOptions,
|
||||
} from './components/chart-filters';
|
||||
import {
|
||||
CicdConfiguration,
|
||||
FilterStatusType,
|
||||
FilterBranchType,
|
||||
} from './apis/types';
|
||||
import { cleanupBuildTree } from './utils/stage-names';
|
||||
import { renderFallbacks, useAsyncChain } from './components/progress';
|
||||
import { sortFilterStatusType } from './utils/api';
|
||||
|
||||
export function EntityPageCicdCharts() {
|
||||
const state = useCicdConfiguration();
|
||||
|
||||
return renderFallbacks(state, value => (
|
||||
<ZoomProvider>
|
||||
<CicdCharts cicdConfiguration={value} />
|
||||
</ZoomProvider>
|
||||
));
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<Theme>(
|
||||
theme => ({
|
||||
pane: {
|
||||
padding: theme.spacing(1, 1, 1, 1),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatisticsView',
|
||||
},
|
||||
);
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
return DateTime.fromJSDate(date).startOf('day').toJSDate();
|
||||
}
|
||||
function endOfDay(date: Date) {
|
||||
return DateTime.fromJSDate(date).endOf('day').toJSDate();
|
||||
}
|
||||
function cleanChartFilter(filter: ChartFilter): ChartFilter {
|
||||
return {
|
||||
...filter,
|
||||
status: sortFilterStatusType(filter.status as FilterStatusType[]),
|
||||
};
|
||||
}
|
||||
|
||||
interface CicdChartsProps {
|
||||
cicdConfiguration: CicdConfiguration;
|
||||
}
|
||||
|
||||
function CicdCharts(props: CicdChartsProps) {
|
||||
const { cicdConfiguration } = props;
|
||||
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const { entity } = useEntity();
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const { resetZoom } = useZoom();
|
||||
|
||||
const [chartFilter, setChartFilter] = useState(
|
||||
getDefaultChartFilter(cicdConfiguration),
|
||||
);
|
||||
const [fetchedChartData, setFetchedChartData] = useState({
|
||||
abortController: null as null | AbortController,
|
||||
chartFilter,
|
||||
});
|
||||
|
||||
const [viewOptions, setViewOptions] = useState(
|
||||
getDefaultViewOptions(cicdConfiguration),
|
||||
);
|
||||
|
||||
const fetchStatisticsOptions = useMemo((): UseCicdStatisticsOptions => {
|
||||
const abortController = new AbortController();
|
||||
fetchedChartData.abortController = abortController;
|
||||
return {
|
||||
abortController,
|
||||
entity,
|
||||
timeFrom: startOfDay(fetchedChartData.chartFilter.fromDate),
|
||||
timeTo: endOfDay(fetchedChartData.chartFilter.toDate),
|
||||
filterStatus: fetchedChartData.chartFilter.status as FilterStatusType[],
|
||||
filterType: fetchedChartData.chartFilter.branch as FilterBranchType,
|
||||
};
|
||||
}, [entity, fetchedChartData]);
|
||||
|
||||
const statisticsState = useCicdStatistics(fetchStatisticsOptions);
|
||||
|
||||
const updateFilter = useCallback(() => {
|
||||
// Abort previous fetch
|
||||
fetchedChartData.abortController?.abort();
|
||||
|
||||
setFetchedChartData({ abortController: null, chartFilter });
|
||||
}, [fetchedChartData, setFetchedChartData, chartFilter]);
|
||||
|
||||
const chartableStagesState = useAsyncChain(
|
||||
statisticsState,
|
||||
async value =>
|
||||
buildsToChartableStages(
|
||||
await cleanupBuildTree(value.builds, {
|
||||
formatStageName: cicdConfiguration.formatStageName,
|
||||
lowerCase: viewOptions.lowercaseNames,
|
||||
}),
|
||||
{ normalizeTimeRange: viewOptions.normalizeTimeRange },
|
||||
),
|
||||
[statisticsState, cicdConfiguration, viewOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetZoom();
|
||||
}, [resetZoom, statisticsState.value]);
|
||||
|
||||
const onFilterChange = useCallback((filter: ChartFilter) => {
|
||||
setChartFilter(cleanChartFilter(filter));
|
||||
}, []);
|
||||
|
||||
const onViewOptionsChange = useCallback(
|
||||
(options: ViewOptions) => {
|
||||
setViewOptions(options);
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!chartableStagesState.error ||
|
||||
chartableStagesState.error?.name === 'AbortError'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
errorApi.post(chartableStagesState.error);
|
||||
}, [errorApi, chartableStagesState.error]);
|
||||
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid item lg={2} className={classes.pane}>
|
||||
<ChartFilters
|
||||
analysis={chartableStagesState.value}
|
||||
cicdConfiguration={cicdConfiguration}
|
||||
initialFetchFilter={chartFilter}
|
||||
currentFetchFilter={fetchedChartData.chartFilter}
|
||||
onChangeFetchFilter={onFilterChange}
|
||||
updateFetchFilter={updateFilter}
|
||||
initialViewOptions={viewOptions}
|
||||
onChangeViewOptions={onViewOptionsChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={10} className={classes.pane}>
|
||||
{renderFallbacks(chartableStagesState, chartableStages => (
|
||||
<>
|
||||
{chartableStages.stages.size > 0 ? null : (
|
||||
<Alert severity="info">No data</Alert>
|
||||
)}
|
||||
{!statisticsState.value?.builds?.length ||
|
||||
!chartableStagesState.value?.daily?.values?.length ? null : (
|
||||
<StatusChart analysis={chartableStagesState.value} />
|
||||
)}
|
||||
<StageChart
|
||||
stage={chartableStages.total}
|
||||
defaultCollapsed={0}
|
||||
defaultHidden={viewOptions.hideLimit}
|
||||
chartTypes={viewOptions.chartTypes}
|
||||
/>
|
||||
{[...chartableStages.stages.entries()].map(([name, stage]) => (
|
||||
<StageChart
|
||||
key={name}
|
||||
stage={stage}
|
||||
defaultCollapsed={viewOptions.collapsedLimit}
|
||||
defaultHidden={viewOptions.hideLimit}
|
||||
chartTypes={viewOptions.chartTypes}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { CicdConfiguration, statusTypes } from '../apis';
|
||||
import { ProgressType } from '../components/progress';
|
||||
import { defaultFormatStageName } from '../utils/stage-names';
|
||||
import { useCicdStatisticsApi } from './use-cicd-statistics-api';
|
||||
|
||||
export function useCicdConfiguration(): ProgressType<CicdConfiguration> {
|
||||
const cicdStatisticsApi = useCicdStatisticsApi();
|
||||
const { entity } = useEntity();
|
||||
|
||||
const [state, setState] = useState<ProgressType<CicdConfiguration>>({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!cicdStatisticsApi) {
|
||||
setState({ error: new Error('No CI/CD Statistics API installed') });
|
||||
return;
|
||||
}
|
||||
|
||||
cicdStatisticsApi
|
||||
.getConfiguration({ entity })
|
||||
.then(configuration => {
|
||||
const {
|
||||
availableStatuses = statusTypes,
|
||||
formatStageName = defaultFormatStageName,
|
||||
defaults = {},
|
||||
} = configuration;
|
||||
setState({
|
||||
value: {
|
||||
availableStatuses,
|
||||
formatStageName,
|
||||
defaults,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
setState({ error });
|
||||
});
|
||||
}, [cicdStatisticsApi, entity]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -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 { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
import { cicdStatisticsApiRef } from '../apis';
|
||||
|
||||
export function useCicdStatisticsApi() {
|
||||
try {
|
||||
return useApi(cicdStatisticsApiRef);
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { throttle } from 'lodash';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
import {
|
||||
CicdState,
|
||||
FetchBuildsOptions,
|
||||
AbortError,
|
||||
FilterStatusType,
|
||||
FilterBranchType,
|
||||
UpdateProgress,
|
||||
} from '../apis';
|
||||
import { ProgressType } from '../components/progress';
|
||||
import { useCicdStatisticsApi } from './use-cicd-statistics-api';
|
||||
|
||||
export interface UseCicdStatisticsOptions {
|
||||
entity: Entity;
|
||||
abortController: AbortController;
|
||||
timeFrom: Date;
|
||||
timeTo: Date;
|
||||
filterStatus: Array<FilterStatusType | 'all'>;
|
||||
filterType: FilterBranchType | 'all';
|
||||
}
|
||||
|
||||
export function useCicdStatistics(
|
||||
options: UseCicdStatisticsOptions,
|
||||
): ProgressType<CicdState> {
|
||||
const {
|
||||
entity,
|
||||
abortController,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
filterStatus,
|
||||
filterType,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<ProgressType<CicdState>>({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const cicdStatisticsApi = useCicdStatisticsApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!cicdStatisticsApi) {
|
||||
setState({ error: new Error('No CI/CD Statistics API installed') });
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let completed = false; // successfully or failed
|
||||
|
||||
const updateProgressImpl: UpdateProgress = (_count, _total?, _started?) => {
|
||||
if (!mounted || completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(_count)) {
|
||||
// Multi-progress
|
||||
setState({
|
||||
loading: true,
|
||||
steps: _count.map(step => ({
|
||||
title: step.title,
|
||||
progress: !step.total ? 0 : step.completed / step.total,
|
||||
progressBuffer: !step.total ? 0 : (step.started ?? 0) / step.total,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
// Single-progress
|
||||
const count = _count;
|
||||
const total = _total as number;
|
||||
const started = (_started as number) ?? 0;
|
||||
setState({
|
||||
loading: true,
|
||||
progress: !total ? 0 : count / total,
|
||||
progressBuffer: !total ? 0 : started / total,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateProgress = throttle(
|
||||
updateProgressImpl,
|
||||
200,
|
||||
// throttle doesn't handle types of multi-signature functions
|
||||
) as any as UpdateProgress;
|
||||
|
||||
const fetchOptions: FetchBuildsOptions = {
|
||||
entity,
|
||||
updateProgress,
|
||||
abortSignal: abortController.signal,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
filterStatus,
|
||||
filterType,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
return cicdStatisticsApi.fetchBuilds(fetchOptions);
|
||||
})()
|
||||
.then(builds => {
|
||||
completed = true;
|
||||
if (mounted) {
|
||||
setState({
|
||||
value: builds,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
completed = true;
|
||||
if (mounted) {
|
||||
setState({
|
||||
error: abortController.signal.aborted ? new AbortError() : err,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [
|
||||
abortController,
|
||||
entity,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
filterStatus,
|
||||
filterType,
|
||||
cicdStatisticsApi,
|
||||
]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -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 './plugin';
|
||||
export * from './apis';
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 {
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
createRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export type { EntityPageCicdCharts } from './entity-page';
|
||||
|
||||
const rootCatalogCicdStatsRouteRef = createRouteRef({
|
||||
id: 'cicd-statistics',
|
||||
});
|
||||
|
||||
export const cicdStatisticsPlugin = createPlugin({
|
||||
id: 'cicd-statistics',
|
||||
routes: {
|
||||
entityContent: rootCatalogCicdStatsRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const EntityCicdStatisticsContent = cicdStatisticsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./entity-page').then(m => m.EntityPageCicdCharts),
|
||||
mountPoint: rootCatalogCicdStatsRouteRef,
|
||||
name: 'EntityCicdStatisticsContent',
|
||||
}),
|
||||
);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
export function defaultFormatStageName(
|
||||
parentNames: Array<string>,
|
||||
stageName: string,
|
||||
): string {
|
||||
let name = stageName;
|
||||
|
||||
// Cut off parent names (if they are prefixed to the stage name)
|
||||
parentNames.forEach(parentName => {
|
||||
if (name.startsWith(parentName)) {
|
||||
const newName = name
|
||||
.slice(parentName.length)
|
||||
// Remove things like ' - '
|
||||
.replace(/^[^\w\d]+/g, '');
|
||||
if (newName) {
|
||||
name = newName;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cut off anything after colon in what looks like pulling docker images
|
||||
return name.replace(/((pulling|(running)) [^:/]*\/.*?):.*/g, '$1');
|
||||
}
|
||||
|
||||
export interface CleanupBuildTreeOptions {
|
||||
formatStageName: typeof defaultFormatStageName;
|
||||
lowerCase: boolean;
|
||||
}
|
||||
|
||||
export async function cleanupBuildTree(
|
||||
builds: Build[],
|
||||
opts: CleanupBuildTreeOptions,
|
||||
): Promise<Build[]> {
|
||||
const { formatStageName, lowerCase } = opts;
|
||||
|
||||
const recurseStage = (stage: Stage, parentNames: Array<string>): Stage => {
|
||||
const name = formatStageName(
|
||||
parentNames,
|
||||
lowerCase ? stage.name.toLocaleLowerCase('en-US') : stage.name,
|
||||
);
|
||||
const ancestry = [...parentNames, name];
|
||||
|
||||
return {
|
||||
...stage,
|
||||
name,
|
||||
stages: stage.stages?.map(subStage => recurseStage(subStage, ancestry)),
|
||||
};
|
||||
};
|
||||
|
||||
return map(builds, { chunk: 'idle' }, build => ({
|
||||
...build,
|
||||
stages: build.stages.map(stage => recurseStage(stage, [])),
|
||||
}));
|
||||
}
|
||||
@@ -1409,7 +1409,7 @@
|
||||
zen-observable "^0.8.15"
|
||||
zod "^3.11.6"
|
||||
|
||||
"@backstage/core-plugin-api@^0.4.0":
|
||||
"@backstage/core-plugin-api@^0.4.0", "@backstage/core-plugin-api@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-0.4.1.tgz#c0a13504bdfa61ae3d0db96934cd6c32a7574446"
|
||||
integrity sha512-IIb7XTcquTPaSIlamMKUgeTs5uLqkKN0Nw32QdTZhKgFkFFVzWC0AwN+henkaMNBZFdGb0ttPzrvNXGj5E6dGg==
|
||||
@@ -1440,7 +1440,7 @@
|
||||
"@material-ui/lab" "4.0.0-alpha.57"
|
||||
react-use "^17.2.4"
|
||||
|
||||
"@backstage/plugin-catalog-react@^0.6.13", "@backstage/plugin-catalog-react@^0.6.5":
|
||||
"@backstage/plugin-catalog-react@^0.6.13", "@backstage/plugin-catalog-react@^0.6.5", "@backstage/plugin-catalog-react@^0.6.9":
|
||||
version "0.6.13"
|
||||
resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.6.13.tgz#b325eae501d3edeb8b7caef5d9615f2e632f5430"
|
||||
integrity sha512-XBwop7PwAZqfongx3KP6jAJar+MEscLSp8nLuHYX5XxA+suQNiBgi96uO3SEQmvtae+hvsRM7c0WHSxbYiXsDA==
|
||||
@@ -1842,6 +1842,13 @@
|
||||
dependencies:
|
||||
"@date-io/core" "^1.3.13"
|
||||
|
||||
"@date-io/luxon@^1.3.13":
|
||||
version "1.3.13"
|
||||
resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a"
|
||||
integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw==
|
||||
dependencies:
|
||||
"@date-io/core" "^1.3.13"
|
||||
|
||||
"@elastic/elasticsearch-mock@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.npmjs.org/@elastic/elasticsearch-mock/-/elasticsearch-mock-0.3.0.tgz#6b1d8448aad3ca20f760fa01c0206b733c9c1e54"
|
||||
@@ -3622,7 +3629,7 @@
|
||||
react-beautiful-dnd "^13.0.0"
|
||||
react-double-scrollbar "0.0.15"
|
||||
|
||||
"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2":
|
||||
"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2", "@material-ui/core@^4.9.13":
|
||||
version "4.12.3"
|
||||
resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz#80d665caf0f1f034e52355c5450c0e38b099d3ca"
|
||||
integrity sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==
|
||||
@@ -5309,6 +5316,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.0.2.tgz#53f2d6325f66ee79afd707c05ac849e8ae0edbb0"
|
||||
integrity sha512-WVx6zBiz4sWlboCy7TCgjeyHpNjMsoF36yaagny1uXfbadc9f+5BeBf7U+lRmQqY3EHbGQpP8UdW8AC+cywSwQ==
|
||||
|
||||
"@types/d3-color@^2":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.3.tgz#8bc4589073c80e33d126345542f588056511fe82"
|
||||
integrity sha512-+0EtEjBfKEDtH9Rk3u3kLOUXM5F+iZK+WvASPb0MhIZl8J8NUvGeZRwKCXl+P3HkYx5TdU4YtcibpqHkSR9n7w==
|
||||
|
||||
"@types/d3-force@^2.1.1":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-2.1.1.tgz#a18b6f029d056eb0f8f84a09471e6228e4469b14"
|
||||
@@ -5321,6 +5333,13 @@
|
||||
dependencies:
|
||||
"@types/d3-color" "*"
|
||||
|
||||
"@types/d3-interpolate@^2.0.0":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.2.tgz#78eddf7278b19e48e8652603045528d46897aba0"
|
||||
integrity sha512-lElyqlUfIPyWG/cD475vl6msPL4aMU7eJvx1//Q177L8mdXoVPFl1djIESF2FKnc0NyaHvQlJpWwKJYwAhUoCw==
|
||||
dependencies:
|
||||
"@types/d3-color" "^2"
|
||||
|
||||
"@types/d3-path@*":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b"
|
||||
@@ -5331,6 +5350,18 @@
|
||||
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c"
|
||||
integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==
|
||||
|
||||
"@types/d3-path@^2":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.1.tgz#ca03dfa8b94d8add97ad0cd97e96e2006b4763cb"
|
||||
integrity sha512-6K8LaFlztlhZO7mwsZg7ClRsdLg3FJRzIIi6SZXDWmmSJc2x8dd2VkESbLXdk3p8cuvz71f36S0y8Zv2AxqvQw==
|
||||
|
||||
"@types/d3-scale@^3.0.0":
|
||||
version "3.3.2"
|
||||
resolved "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.2.tgz#18c94e90f4f1c6b1ee14a70f14bfca2bd1c61d06"
|
||||
integrity sha512-gGqr7x1ost9px3FvIfUMi5XA/F/yAf4UkUDtdQhpH92XCT0Oa7zkkRzY61gPVJq+DxpHn/btouw5ohWkbBsCzQ==
|
||||
dependencies:
|
||||
"@types/d3-time" "^2"
|
||||
|
||||
"@types/d3-selection@*", "@types/d3-selection@^3.0.1":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.2.tgz#23e48a285b24063630bbe312cc0cfe2276de4a59"
|
||||
@@ -5343,6 +5374,13 @@
|
||||
dependencies:
|
||||
"@types/d3-path" "^1"
|
||||
|
||||
"@types/d3-shape@^2.0.0":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz#35d397b9e687abaa0de82343b250b9897b8cacf3"
|
||||
integrity sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==
|
||||
dependencies:
|
||||
"@types/d3-path" "^2"
|
||||
|
||||
"@types/d3-shape@^3.0.1":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.0.2.tgz#4b1ca4ddaac294e76b712429726d40365cd1e8ca"
|
||||
@@ -5350,6 +5388,11 @@
|
||||
dependencies:
|
||||
"@types/d3-path" "*"
|
||||
|
||||
"@types/d3-time@^2":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.1.tgz#743fdc821c81f86537cbfece07093ac39b4bc342"
|
||||
integrity sha512-9MVYlmIgmRR31C5b4FVSWtuMmBHh2mOWQYfl7XAYOa8dsnb7iEmUmRSWSFgXFtkjxO65d7hTUHQC+RhR/9IWFg==
|
||||
|
||||
"@types/d3-zoom@^3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826"
|
||||
@@ -6095,6 +6138,11 @@
|
||||
"@types/tough-cookie" "*"
|
||||
form-data "^2.5.0"
|
||||
|
||||
"@types/resize-observer-browser@^0.1.6":
|
||||
version "0.1.7"
|
||||
resolved "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz#294aaadf24ac6580b8fbd1fe3ab7b59fe85f9ef3"
|
||||
integrity sha512-G9eN0Sn0ii9PWQ3Vl72jDPgeJwRWhv2Qk/nQkJuWmRmOB4HX3/BhD5SE1dZs/hzPZL/WKnvF0RHdTSG54QJFyg==
|
||||
|
||||
"@types/resolve@1.17.1":
|
||||
version "1.17.1"
|
||||
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
|
||||
@@ -6897,6 +6945,11 @@ alphanum-sort@^1.0.2:
|
||||
resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
|
||||
integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
|
||||
|
||||
already@^3.2.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.npmjs.org/already/-/already-3.3.0.tgz#a5e5becd167cf537b45f8f1c23d331488ed77003"
|
||||
integrity sha512-ADGyKddqEp8t/Wu4ITc0y9GGsgZDgyMeMk38AM5qrPK7VEjNAYD87QGTGGgNhSQahmjw76V3mi+3fJRwPJXcTw==
|
||||
|
||||
anafanafo@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/anafanafo/-/anafanafo-2.0.0.tgz#43f56274680bc553dd67a9625a920f88d0057b5c"
|
||||
@@ -9635,7 +9688,7 @@ css-tree@^1.1.3:
|
||||
mdn-data "2.0.14"
|
||||
source-map "^0.6.1"
|
||||
|
||||
css-unit-converter@^1.1.2:
|
||||
css-unit-converter@^1.1.1, css-unit-converter@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21"
|
||||
integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==
|
||||
@@ -9850,6 +9903,13 @@ cypress@^7.3.0:
|
||||
url "^0.11.0"
|
||||
yauzl "^2.10.0"
|
||||
|
||||
d3-array@2, d3-array@^2.3.0:
|
||||
version "2.12.1"
|
||||
resolved "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81"
|
||||
integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==
|
||||
dependencies:
|
||||
internmap "^1.0.0"
|
||||
|
||||
d3-array@^1.2.0:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
|
||||
@@ -9865,6 +9925,11 @@ d3-color@1:
|
||||
resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
|
||||
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
|
||||
|
||||
"d3-color@1 - 2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e"
|
||||
integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==
|
||||
|
||||
"d3-color@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.0.1.tgz#03316e595955d1fcd39d9f3610ad41bb90194d0a"
|
||||
@@ -9907,6 +9972,11 @@ d3-format@1:
|
||||
resolved "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
|
||||
integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
|
||||
|
||||
"d3-format@1 - 2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767"
|
||||
integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==
|
||||
|
||||
d3-interpolate@1, d3-interpolate@^1.3.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
|
||||
@@ -9921,11 +9991,23 @@ d3-interpolate@1, d3-interpolate@^1.3.0:
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
|
||||
"d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163"
|
||||
integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==
|
||||
dependencies:
|
||||
d3-color "1 - 2"
|
||||
|
||||
d3-path@1:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
|
||||
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
|
||||
|
||||
"d3-path@1 - 2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8"
|
||||
integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==
|
||||
|
||||
"d3-path@1 - 3":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e"
|
||||
@@ -9948,6 +10030,17 @@ d3-scale@^2.1.0:
|
||||
d3-time "1"
|
||||
d3-time-format "2"
|
||||
|
||||
d3-scale@^3.0.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3"
|
||||
integrity sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==
|
||||
dependencies:
|
||||
d3-array "^2.3.0"
|
||||
d3-format "1 - 2"
|
||||
d3-interpolate "1.2.0 - 2"
|
||||
d3-time "^2.1.1"
|
||||
d3-time-format "2 - 3"
|
||||
|
||||
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
|
||||
@@ -9960,6 +10053,13 @@ d3-shape@^1.2.0:
|
||||
dependencies:
|
||||
d3-path "1"
|
||||
|
||||
d3-shape@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz#3b6a82ccafbc45de55b57fcf956c584ded3b666f"
|
||||
integrity sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==
|
||||
dependencies:
|
||||
d3-path "1 - 2"
|
||||
|
||||
d3-shape@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556"
|
||||
@@ -9974,11 +10074,25 @@ d3-time-format@2:
|
||||
dependencies:
|
||||
d3-time "1"
|
||||
|
||||
"d3-time-format@2 - 3":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6"
|
||||
integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==
|
||||
dependencies:
|
||||
d3-time "1 - 2"
|
||||
|
||||
d3-time@1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
|
||||
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
|
||||
|
||||
"d3-time@1 - 2", d3-time@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682"
|
||||
integrity sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==
|
||||
dependencies:
|
||||
d3-array "2"
|
||||
|
||||
"d3-timer@1 - 2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6"
|
||||
@@ -11503,7 +11617,7 @@ eventemitter3@^3.1.0:
|
||||
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
|
||||
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
|
||||
|
||||
eventemitter3@^4.0.0, eventemitter3@^4.0.4:
|
||||
eventemitter3@^4.0.0, eventemitter3@^4.0.1, eventemitter3@^4.0.4:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
@@ -11885,6 +11999,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-equals@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
|
||||
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
|
||||
|
||||
fast-glob@^3.1.1:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.2.tgz#ade1a9d91148965d4bf7c51f72e1ca662d32e63d"
|
||||
@@ -13857,6 +13976,11 @@ internal-slot@^1.0.3:
|
||||
has "^1.0.3"
|
||||
side-channel "^1.0.4"
|
||||
|
||||
internmap@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
|
||||
integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==
|
||||
|
||||
interpret@^1.0.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
|
||||
@@ -19569,6 +19693,11 @@ postcss-unique-selectors@^5.0.2:
|
||||
alphanum-sort "^1.0.2"
|
||||
postcss-selector-parser "^6.0.5"
|
||||
|
||||
postcss-value-parser@^3.3.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
|
||||
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
|
||||
|
||||
postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
|
||||
@@ -20260,7 +20389,7 @@ react-inspector@^5.1.1:
|
||||
is-dom "^1.0.0"
|
||||
prop-types "^15.0.0"
|
||||
|
||||
react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.6, react-is@^16.9.0:
|
||||
react-is@^16.10.2, react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.6, react-is@^16.9.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
@@ -20317,6 +20446,15 @@ react-resize-detector@^2.3.0:
|
||||
prop-types "^15.6.0"
|
||||
resize-observer-polyfill "^1.5.0"
|
||||
|
||||
react-resize-detector@^6.6.3:
|
||||
version "6.7.8"
|
||||
resolved "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-6.7.8.tgz#318c85d1335e50f99d4fb8eb9ec34e066db597d0"
|
||||
integrity sha512-0FaEcUBAbn+pq3PT5a9hHRebUfuS1SRLGLpIw8LydU7zX429I6XJgKerKAMPsJH0qWAl6o5bVKNqFJqr6tGPYw==
|
||||
dependencies:
|
||||
"@types/resize-observer-browser" "^0.1.6"
|
||||
lodash "^4.17.21"
|
||||
resize-observer-polyfill "^1.5.1"
|
||||
|
||||
react-router-dom@6.0.0-beta.0:
|
||||
version "6.0.0-beta.0"
|
||||
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6"
|
||||
@@ -20354,6 +20492,15 @@ react-smooth@^1.0.5:
|
||||
raf "^3.4.0"
|
||||
react-transition-group "^2.5.0"
|
||||
|
||||
react-smooth@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.0.tgz#561647b33e498b2e25f449b3c6689b2e9111bf91"
|
||||
integrity sha512-wK4dBBR6P21otowgMT9toZk+GngMplGS1O5gk+2WSiHEXIrQgDvhR5IIlT74Vtu//qpTcipkgo21dD7a7AUNxw==
|
||||
dependencies:
|
||||
fast-equals "^2.0.0"
|
||||
raf "^3.4.0"
|
||||
react-transition-group "2.9.0"
|
||||
|
||||
react-sparklines@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60"
|
||||
@@ -20389,7 +20536,7 @@ react-text-truncate@^0.17.0:
|
||||
dependencies:
|
||||
prop-types "^15.5.7"
|
||||
|
||||
react-transition-group@^2.5.0:
|
||||
react-transition-group@2.9.0, react-transition-group@^2.5.0:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
|
||||
integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
|
||||
@@ -20434,6 +20581,26 @@ react-use@^17.2.4:
|
||||
ts-easing "^0.2.0"
|
||||
tslib "^2.1.0"
|
||||
|
||||
react-use@^17.3.1:
|
||||
version "17.3.2"
|
||||
resolved "https://registry.npmjs.org/react-use/-/react-use-17.3.2.tgz#448abf515f47c41c32455024db28167cb6e53be8"
|
||||
integrity sha512-bj7OD0/1wL03KyWmzFXAFe425zziuTf7q8olwCYBfOeFHY1qfO1FAMjROQLsLZYwG4Rx63xAfb7XAbBrJsZmEw==
|
||||
dependencies:
|
||||
"@types/js-cookie" "^2.2.6"
|
||||
"@xobotyi/scrollbar-width" "^1.9.5"
|
||||
copy-to-clipboard "^3.3.1"
|
||||
fast-deep-equal "^3.1.3"
|
||||
fast-shallow-equal "^1.0.0"
|
||||
js-cookie "^2.2.1"
|
||||
nano-css "^5.3.1"
|
||||
react-universal-interface "^0.6.2"
|
||||
resize-observer-polyfill "^1.5.1"
|
||||
screenfull "^5.1.0"
|
||||
set-harmonic-interval "^1.0.1"
|
||||
throttle-debounce "^3.0.1"
|
||||
ts-easing "^0.2.0"
|
||||
tslib "^2.1.0"
|
||||
|
||||
react-virtualized-auto-sizer@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
|
||||
@@ -20647,6 +20814,13 @@ recharts-scale@^0.4.2:
|
||||
dependencies:
|
||||
decimal.js-light "^2.4.1"
|
||||
|
||||
recharts-scale@^0.4.4:
|
||||
version "0.4.5"
|
||||
resolved "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9"
|
||||
integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==
|
||||
dependencies:
|
||||
decimal.js-light "^2.4.1"
|
||||
|
||||
recharts@^1.8.5:
|
||||
version "1.8.5"
|
||||
resolved "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz#ca94a3395550946334a802e35004ceb2583fdb12"
|
||||
@@ -20664,6 +20838,26 @@ recharts@^1.8.5:
|
||||
recharts-scale "^0.4.2"
|
||||
reduce-css-calc "^1.3.0"
|
||||
|
||||
recharts@^2.1.5:
|
||||
version "2.1.8"
|
||||
resolved "https://registry.npmjs.org/recharts/-/recharts-2.1.8.tgz#ca8774fcec5f5d7ec15dedd638db9ee12faf1c09"
|
||||
integrity sha512-Wi7ufdDGyvy/BPf1za1Ok7VeWB2KtEejaewO9ulmlUhvn5l5RPS4AOkrUfhtMRTTjgJ4K6AbWMDpwtDjczUHJA==
|
||||
dependencies:
|
||||
"@types/d3-interpolate" "^2.0.0"
|
||||
"@types/d3-scale" "^3.0.0"
|
||||
"@types/d3-shape" "^2.0.0"
|
||||
classnames "^2.2.5"
|
||||
d3-interpolate "^2.0.0"
|
||||
d3-scale "^3.0.0"
|
||||
d3-shape "^2.0.0"
|
||||
eventemitter3 "^4.0.1"
|
||||
lodash "^4.17.19"
|
||||
react-is "^16.10.2"
|
||||
react-resize-detector "^6.6.3"
|
||||
react-smooth "^2.0.0"
|
||||
recharts-scale "^0.4.4"
|
||||
reduce-css-calc "^2.1.8"
|
||||
|
||||
rechoir@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
|
||||
@@ -20710,6 +20904,14 @@ reduce-css-calc@^1.3.0:
|
||||
math-expression-evaluator "^1.2.14"
|
||||
reduce-function-call "^1.0.1"
|
||||
|
||||
reduce-css-calc@^2.1.8:
|
||||
version "2.1.8"
|
||||
resolved "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz#7ef8761a28d614980dc0c982f772c93f7a99de03"
|
||||
integrity sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==
|
||||
dependencies:
|
||||
css-unit-converter "^1.1.1"
|
||||
postcss-value-parser "^3.3.0"
|
||||
|
||||
reduce-function-call@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz#60350f7fb252c0a67eb10fd4694d16909971300f"
|
||||
|
||||
Reference in New Issue
Block a user