feat(cicd-statistics): Added CI/CD statistics plugin
Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
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,7 @@
|
||||
# CI/CD Statistics Plugin
|
||||
|
||||
This plugin shows charts of CI/CD pipeline durations over time. It expects to be used on the Software Catalog entity page, as it uses `useEntity` to figure out what component to get the build information for.
|
||||
|
||||
To use this plugin, you need to implement an API `CicdStatisticsApi` and bind it to the `cicdStatisticsApiRef`. This API is defined in `src/apis/types.ts` and is an interface with two functions, `getConfiguration()` and `fetchBuilds(options)`. This plugin will call `getConfiguration` to allow the implementation to specify defaults an settings for the UI. First time the UI shows, and each time the user changes filters and clicks `Update` to refresh the data, `fetchBuilds` is invoked with the filter options. The API implementation is the expected to fetch build information from somewhere, format it into a generic and rather simpe type `Build` (also defined in `types.ts`). The API can optionally signal completion for a progress bar in the UI.
|
||||
|
||||
When this plugin has fetched the builds, it will transpose the list of builds (and build stages) into a tree of build stages. As build pipelines sometimes change, certain stages might end or begin within the timerange of the view.
|
||||
@@ -0,0 +1,182 @@
|
||||
## API Report File for "@backstage/plugin-cicd-statistics"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <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) "branchTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const branchTypes: Array<FilterBranchType>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "Build" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export interface Build {
|
||||
buildType: FilterBranchType;
|
||||
duration: number;
|
||||
id: string;
|
||||
// (undocumented)
|
||||
raw?: unknown;
|
||||
requestedAt: Date;
|
||||
stages: Array<Stage>;
|
||||
status: FilterStatusType;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "BuildWithRaw" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type BuildWithRaw<T = any> = Build & {
|
||||
raw: T;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "CicdConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export interface CicdConfiguration {
|
||||
availableStatuses: ReadonlyArray<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 {
|
||||
collapsedLimit: number;
|
||||
// (undocumented)
|
||||
filterStatus: Array<FilterStatusType>;
|
||||
// (undocumented)
|
||||
filterType: FilterBranchType<'all'>;
|
||||
lowercaseNames: boolean;
|
||||
normalizeTimeRange: boolean;
|
||||
// (undocumented)
|
||||
timeFrom: Date;
|
||||
// (undocumented)
|
||||
timeTo: Date;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "CicdState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export interface CicdState {
|
||||
// (undocumented)
|
||||
builds: Array<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(): 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-forgotten-export) The symbol "EntityPageCicdCharts" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "EntityCicdStatisticsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const EntityCicdStatisticsContent: EntityPageCicdCharts;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "FetchBuildsOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export interface FetchBuildsOptions {
|
||||
// (undocumented)
|
||||
abortSignal: AbortSignal;
|
||||
// (undocumented)
|
||||
entity: Entity;
|
||||
// (undocumented)
|
||||
filterStatus: Array<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<Extra extends string = never> =
|
||||
| Extra
|
||||
| 'master'
|
||||
| 'branch';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "FilterStatusType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type FilterStatusType<Extra extends string = never> =
|
||||
| Extra
|
||||
| 'unknown'
|
||||
| 'enqueued'
|
||||
| 'scheduled'
|
||||
| 'running'
|
||||
| 'aborted'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'stalled'
|
||||
| 'expired';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "rootCatalogCicdStatsRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const rootCatalogCicdStatsRouteRef: RouteRef<undefined>;
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
// 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) "UpdateProgress" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
export type UpdateProgress = (
|
||||
completed: number,
|
||||
total: number,
|
||||
started?: number,
|
||||
) => void;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@backstage/plugin-cicd-statistics",
|
||||
"description": "A frontend plugin visualizing CI/CD pipeline statistics (build time)",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/cicd-statistics"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.8",
|
||||
"@backstage/core-plugin-api": "^0.4.1",
|
||||
"@backstage/plugin-catalog-react": "^0.6.9",
|
||||
"@date-io/date-fns": "^1.3.13",
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.11.2",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@material-ui/pickers": "^3.3.10",
|
||||
"already": "^3.2.0",
|
||||
"date-fns": "^2.27.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react-use": "^17.3.1",
|
||||
"recharts": "^2.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -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,225 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* This is a generic enum of build statuses.
|
||||
*
|
||||
* If all of these aren't applicable to the underlying CI/CD, these can be
|
||||
* configured to be hidden, using the `availableStatuses` in `CicdConfiguration`.
|
||||
*/
|
||||
export type FilterStatusType<Extra extends string = never> =
|
||||
| Extra
|
||||
| '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<Extra extends string = never> =
|
||||
| Extra
|
||||
| 'master'
|
||||
| 'branch';
|
||||
export const branchTypes: Array<FilterBranchType> = ['master', 'branch'];
|
||||
|
||||
/**
|
||||
* A Stage is a part of either a Build or a parent Stage.
|
||||
*
|
||||
* This may be called things like Stage or Step or Task in CI/CD systems, but is
|
||||
* generic here. There's also no concept of parallelism which might exist within
|
||||
* some stages.
|
||||
*/
|
||||
export interface Stage {
|
||||
name: string;
|
||||
|
||||
/** Stage duration in milliseconds */
|
||||
duration: number;
|
||||
|
||||
/** Sub stages within this stage */
|
||||
stages?: Array<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 status of the build */
|
||||
status: FilterStatusType;
|
||||
|
||||
/** Branch type */
|
||||
buildType: FilterBranchType;
|
||||
|
||||
/** Time when the build started */
|
||||
requestedAt: Date;
|
||||
|
||||
/** The overall duration of the build */
|
||||
duration: number;
|
||||
|
||||
/** Top-level build stages */
|
||||
stages: Array<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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export type UpdateProgress = (
|
||||
completed: number,
|
||||
total: number,
|
||||
started?: number,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* When fetching, the Api should fetch build information about the `entity` and
|
||||
* respect the `timeFrom`, `timeTo`, `filterStatus` and `filterType`.
|
||||
*
|
||||
* Optionally implement support for `updateProgress` and `abortSignal` if
|
||||
* preferred.
|
||||
*
|
||||
* When the UI re-fetches, it will abort any previous fetching, so polling
|
||||
* `abortSignal.aborted`, and possibly throwing an `AbortError`, can be useful.
|
||||
*/
|
||||
export interface FetchBuildsOptions {
|
||||
entity: Entity;
|
||||
updateProgress: UpdateProgress;
|
||||
abortSignal: AbortSignal;
|
||||
timeFrom: Date;
|
||||
timeTo: Date;
|
||||
filterStatus: Array<FilterStatusType<'all'>>;
|
||||
filterType: FilterBranchType<'all'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface which is mapped to the `cicdStatisticsApiRef` which is used by
|
||||
* the UI.
|
||||
*/
|
||||
export interface CicdStatisticsApi {
|
||||
getConfiguration(): Promise<Partial<CicdConfiguration>>;
|
||||
fetchBuilds(options: FetchBuildsOptions): Promise<CicdState>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FilterStatusType } from '../apis/types';
|
||||
|
||||
export const statusColorMap: Record<FilterStatusType, string> = {
|
||||
unknown: '#3d01a4',
|
||||
enqueued: '#7ad1b9',
|
||||
scheduled: '#0391ce',
|
||||
running: '#f3f318',
|
||||
aborted: '#8600af',
|
||||
succeeded: '#66b032',
|
||||
failed: '#fe2712',
|
||||
stalled: '#fb9904',
|
||||
expired: '#a7194b',
|
||||
};
|
||||
|
||||
export const fireColors: Array<[percent: string, color: string]> = [
|
||||
['5%', '#e19678'],
|
||||
['30%', '#dfe178'],
|
||||
['50%', '#82ca9d'],
|
||||
['95%', '#82ca9d'],
|
||||
];
|
||||
|
||||
export const colorStroke = '#c0c0c0';
|
||||
export const colorStrokeAvg = '#788ee1';
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { map } from 'already';
|
||||
|
||||
import { Build, Stage, FilterStatusType, statusTypes } from '../apis/types';
|
||||
import {
|
||||
Averagify,
|
||||
ChartableStage,
|
||||
ChartableStageAnalysis,
|
||||
ChartableStageDatapoints,
|
||||
ChartableStagesAnalysis,
|
||||
} from './types';
|
||||
|
||||
function makeStage(name: string): ChartableStage {
|
||||
return {
|
||||
analysis: {
|
||||
unknown: { avg: 0, max: 0, min: 0 },
|
||||
enqueued: { avg: 0, max: 0, min: 0 },
|
||||
scheduled: { avg: 0, max: 0, min: 0 },
|
||||
running: { avg: 0, max: 0, min: 0 },
|
||||
aborted: { avg: 0, max: 0, min: 0 },
|
||||
succeeded: { avg: 0, max: 0, min: 0 },
|
||||
failed: { avg: 0, max: 0, min: 0 },
|
||||
stalled: { avg: 0, max: 0, min: 0 },
|
||||
expired: { avg: 0, max: 0, min: 0 },
|
||||
},
|
||||
combinedAnalysis: { avg: 0, max: 0, min: 0 },
|
||||
statusSet: new Set<FilterStatusType>(),
|
||||
name,
|
||||
values: [],
|
||||
stages: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartableStagesOptions {
|
||||
normalizeTimeRange: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of builds, each with a tree of stages (and durations) into a
|
||||
* merged tree of stages, and calculates {avg, min, max} of each stage.
|
||||
*/
|
||||
export async function buildsToChartableStages(
|
||||
builds: Array<Build>,
|
||||
options: ChartableStagesOptions,
|
||||
): Promise<ChartableStagesAnalysis> {
|
||||
const { normalizeTimeRange } = options;
|
||||
|
||||
const total: ChartableStage = makeStage('Total');
|
||||
|
||||
const recurseDown = (
|
||||
status: FilterStatusType,
|
||||
stageMap: Map<string, ChartableStage>,
|
||||
stage: Stage,
|
||||
__epoch: number,
|
||||
) => {
|
||||
const { name, duration } = stage;
|
||||
|
||||
const subChartableStage = getOrSetStage(stageMap, name);
|
||||
|
||||
subChartableStage.statusSet.add(status);
|
||||
subChartableStage.values.push({
|
||||
__epoch,
|
||||
[status]: duration,
|
||||
[`${status} avg`]: duration,
|
||||
});
|
||||
|
||||
stage.stages?.forEach(subStage => {
|
||||
recurseDown(status, subChartableStage.stages, subStage, __epoch);
|
||||
});
|
||||
};
|
||||
|
||||
const stages = new Map<string, ChartableStage>();
|
||||
|
||||
await map(builds, { chunk: 'idle' }, build => {
|
||||
const { duration, requestedAt, status } = build;
|
||||
const __epoch = requestedAt.getTime();
|
||||
|
||||
total.statusSet.add(status);
|
||||
total.values.push({
|
||||
__epoch,
|
||||
[status]: duration,
|
||||
[`${status} avg`]: duration,
|
||||
});
|
||||
|
||||
build.stages?.forEach(subStage => {
|
||||
recurseDown(status, stages, subStage, __epoch);
|
||||
});
|
||||
});
|
||||
|
||||
const allEpochs = normalizeTimeRange
|
||||
? builds.map(build => build.requestedAt.getTime())
|
||||
: [];
|
||||
|
||||
// Recurse down again and calculate averages
|
||||
await map([...stages.values()], { chunk: 'idle' }, stage =>
|
||||
finalizeStage(stage, { allEpochs, averageWidth: 10 }),
|
||||
);
|
||||
finalizeStage(total, { allEpochs, averageWidth: 10 });
|
||||
|
||||
return { total, stages };
|
||||
}
|
||||
|
||||
function getAnalysis(
|
||||
values: Array<ChartableStageDatapoints>,
|
||||
status: FilterStatusType,
|
||||
): ChartableStageAnalysis {
|
||||
const analysis: ChartableStageAnalysis = {
|
||||
max: 0,
|
||||
min: 0,
|
||||
avg: 0,
|
||||
};
|
||||
|
||||
const definedValues = values.filter(
|
||||
value => typeof value[status] !== 'undefined',
|
||||
);
|
||||
|
||||
analysis.max = definedValues.reduce(
|
||||
(prev, cur) => Math.max(prev, cur[status]!),
|
||||
0,
|
||||
);
|
||||
analysis.min = definedValues.reduce(
|
||||
(prev, cur) => Math.min(prev, cur[status]!),
|
||||
analysis.max,
|
||||
);
|
||||
analysis.avg =
|
||||
definedValues.length === 0
|
||||
? 0
|
||||
: definedValues.reduce((prev, cur) => prev + cur[status]!, 0) /
|
||||
values.length;
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
interface FinalizeStageOptions {
|
||||
averageWidth: number;
|
||||
allEpochs: Array<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate {avg, min, max} of a stage and its sub stages, recursively.
|
||||
* This is calculated per status (successful, failed, etc).
|
||||
*/
|
||||
function finalizeStage(stage: ChartableStage, options: FinalizeStageOptions) {
|
||||
const { averageWidth, allEpochs } = options;
|
||||
const { values, analysis, combinedAnalysis } = stage;
|
||||
|
||||
if (allEpochs.length > 0) {
|
||||
const valueEpochs = new Set(values.map(value => value.__epoch));
|
||||
|
||||
allEpochs.forEach(epoch => {
|
||||
if (!valueEpochs.has(epoch)) {
|
||||
values.push({ __epoch: epoch });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
values.sort((a, b) => a.__epoch - b.__epoch);
|
||||
|
||||
const avgDuration: [duration: number, count: number] = [0, 0];
|
||||
|
||||
statusTypes.forEach(status => {
|
||||
analysis[status] = getAnalysis(values, status);
|
||||
|
||||
const durationsIndexes = values
|
||||
.map(value => value[status])
|
||||
.map((duration, index) => ({ index, duration }))
|
||||
.filter(({ duration }) => typeof duration !== 'undefined')
|
||||
.map(({ index }) => index);
|
||||
const durationsDense = values
|
||||
.map(value => value[status])
|
||||
.filter(
|
||||
(duration): duration is number => typeof duration !== 'undefined',
|
||||
);
|
||||
|
||||
avgDuration[0] += durationsDense.reduce((prev, cur) => prev + cur, 0);
|
||||
avgDuration[1] += durationsDense.length;
|
||||
|
||||
const averages = durationsDense.map((_, i) =>
|
||||
average(
|
||||
durationsDense.slice(
|
||||
Math.max(i - averageWidth, 0),
|
||||
Math.min(i + averageWidth, durationsDense.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
averages.forEach((avg, index) => {
|
||||
const key: Averagify<FilterStatusType> = `${status} avg`;
|
||||
values[durationsIndexes[index]][key] = avg;
|
||||
});
|
||||
});
|
||||
|
||||
const analysisValues = Object.values(analysis);
|
||||
combinedAnalysis.max = analysisValues.reduce(
|
||||
(prev, cur) => Math.max(prev, cur.max),
|
||||
0,
|
||||
);
|
||||
combinedAnalysis.min = analysisValues.reduce(
|
||||
(prev, cur) => Math.min(prev, cur.min),
|
||||
combinedAnalysis.max,
|
||||
);
|
||||
combinedAnalysis.avg = !avgDuration[1] ? 0 : avgDuration[0] / avgDuration[1];
|
||||
|
||||
stage.stages.forEach(subStage => finalizeStage(subStage, options));
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
return !values.length
|
||||
? 0
|
||||
: Math.round(values.reduce((prev, cur) => prev + cur, 0) / values.length);
|
||||
}
|
||||
|
||||
function getOrSetStage(
|
||||
stages: Map<string, ChartableStage>,
|
||||
name: string,
|
||||
): ChartableStage {
|
||||
const stage = stages.get(name);
|
||||
if (stage) return stage;
|
||||
|
||||
const newStage: ChartableStage = makeStage(name);
|
||||
stages.set(name, newStage);
|
||||
return newStage;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Fragment, useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
ComposedChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
YAxisProps,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
LegendProps,
|
||||
Line,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
|
||||
import { statusTypes } from '../apis/types';
|
||||
import { ChartableStage } from './types';
|
||||
import {
|
||||
pickElements,
|
||||
labelFormatter,
|
||||
tickFormatterX,
|
||||
tickFormatterY,
|
||||
tooltipValueFormatter,
|
||||
formatDuration,
|
||||
} from './utils';
|
||||
import {
|
||||
statusColorMap,
|
||||
fireColors,
|
||||
colorStroke,
|
||||
colorStrokeAvg,
|
||||
} from './colors';
|
||||
|
||||
const fullWidth = {
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
const transitionProps = { unmountOnExit: true };
|
||||
|
||||
export interface StageChartProps {
|
||||
stage: ChartableStage;
|
||||
|
||||
defaultCollapsed?: number;
|
||||
zeroYAxis?: boolean;
|
||||
}
|
||||
|
||||
export function StageChart(props: StageChartProps) {
|
||||
const { stage, ...chartOptions } = props;
|
||||
const { defaultCollapsed = 0, zeroYAxis = false } = chartOptions;
|
||||
|
||||
const ticks = useMemo(
|
||||
() => pickElements(stage.values, 8).map(val => val.__epoch),
|
||||
[stage.values],
|
||||
);
|
||||
const domainY = useMemo(
|
||||
() => [zeroYAxis ? 0 : 'auto', 'auto'] as YAxisProps['domain'],
|
||||
[zeroYAxis],
|
||||
);
|
||||
const statuses = useMemo(
|
||||
() => statusTypes.filter(status => stage.statusSet.has(status)),
|
||||
[stage.statusSet],
|
||||
);
|
||||
const legendPayload = useMemo(
|
||||
(): LegendProps['payload'] =>
|
||||
statuses.map(status => ({
|
||||
value: status,
|
||||
type: 'line',
|
||||
id: status,
|
||||
color: statusColorMap[status],
|
||||
})),
|
||||
[statuses],
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
defaultExpanded={stage.combinedAnalysis.max > defaultCollapsed}
|
||||
TransitionProps={transitionProps}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>
|
||||
{stage.name} (avg {formatDuration(stage.combinedAnalysis.avg)})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{stage.values.length === 0 ? (
|
||||
<Alert severity="info">No data</Alert>
|
||||
) : (
|
||||
<Grid container direction="column">
|
||||
<Grid item>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart data={stage.values}>
|
||||
<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
|
||||
tickFormatter={tickFormatterY}
|
||||
type="number"
|
||||
tickCount={5}
|
||||
name="Duration"
|
||||
domain={domainY}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={tooltipValueFormatter}
|
||||
labelFormatter={labelFormatter}
|
||||
/>
|
||||
{statuses.map(status => (
|
||||
<Fragment key={status}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={status}
|
||||
stackId={status}
|
||||
stroke={
|
||||
statuses.length > 1
|
||||
? statusColorMap[status]
|
||||
: colorStroke
|
||||
}
|
||||
fillOpacity={statuses.length > 1 ? 0.5 : 1}
|
||||
fill={
|
||||
statuses.length > 1
|
||||
? statusColorMap[status]
|
||||
: 'url(#colorDur)'
|
||||
}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={`${status} avg`}
|
||||
stroke={
|
||||
statuses.length > 1
|
||||
? statusColorMap[status]
|
||||
: colorStrokeAvg
|
||||
}
|
||||
opacity={0.8}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</Grid>
|
||||
{stage.stages.size === 0 ? null : (
|
||||
<Grid item>
|
||||
<Accordion
|
||||
defaultExpanded={false}
|
||||
TransitionProps={transitionProps}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Sub stages ({stage.stages.size})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<div style={fullWidth}>
|
||||
{[...stage.stages.values()].map(subStage => (
|
||||
<StageChart
|
||||
key={subStage.name}
|
||||
{...chartOptions}
|
||||
stage={subStage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -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, { Fragment, useMemo } from 'react';
|
||||
import {
|
||||
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 { formatISO9075, parseISO } from 'date-fns';
|
||||
import { countBy } from 'lodash';
|
||||
|
||||
import { Build, FilterStatusType, statusTypes } from '../apis/types';
|
||||
import { labelFormatterWithoutTime, tickFormatterX } from './utils';
|
||||
import { statusColorMap } from './colors';
|
||||
|
||||
export interface StatusChartProps {
|
||||
builds: ReadonlyArray<Build>;
|
||||
}
|
||||
|
||||
export function StatusChart(props: StatusChartProps) {
|
||||
const { builds } = props;
|
||||
|
||||
const { statuses, values } = useMemo(() => {
|
||||
const buildsByDay = new Map<string, Array<Build>>();
|
||||
|
||||
const foundStatuses = new Set<string>();
|
||||
|
||||
builds.forEach(build => {
|
||||
foundStatuses.add(build.status);
|
||||
|
||||
const dayString = formatISO9075(build.requestedAt, {
|
||||
representation: 'date',
|
||||
});
|
||||
const dayList = buildsByDay.get(dayString);
|
||||
if (dayList) {
|
||||
dayList.push(build);
|
||||
} else {
|
||||
buildsByDay.set(dayString, [build]);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
statuses: [
|
||||
...statusTypes.filter(status => foundStatuses.has(status)),
|
||||
...[...foundStatuses].filter(
|
||||
status => !(statusTypes as Array<string>).includes(status),
|
||||
),
|
||||
],
|
||||
values: [...buildsByDay.entries()].map(([dayString, buildThisDay]) => ({
|
||||
__epoch: parseISO(dayString).getTime(),
|
||||
...countBy(buildThisDay, 'status'),
|
||||
})),
|
||||
};
|
||||
}, [builds]);
|
||||
|
||||
const legendPayload = useMemo(
|
||||
(): LegendProps['payload'] =>
|
||||
statuses.map(status => ({
|
||||
value: status,
|
||||
type: 'line',
|
||||
id: status,
|
||||
color: statusColorMap[status as FilterStatusType] ?? '',
|
||||
})),
|
||||
[statuses],
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion defaultExpanded={statuses.length > 1}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Build count per status</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{values.length === 0 ? (
|
||||
<Alert severity="info">No data</Alert>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart data={values}>
|
||||
<Legend payload={legendPayload} />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="__epoch"
|
||||
type="category"
|
||||
tickFormatter={tickFormatterX}
|
||||
/>
|
||||
<YAxis type="number" tickCount={5} name="Count" />
|
||||
<Tooltip labelFormatter={labelFormatterWithoutTime} />
|
||||
{statuses.map(status => (
|
||||
<Fragment key={status}>
|
||||
<Bar
|
||||
type="monotone"
|
||||
dataKey={status}
|
||||
stackId="1"
|
||||
stroke={statusColorMap[status as FilterStatusType] ?? ''}
|
||||
fillOpacity={0.8}
|
||||
fill={statusColorMap[status as FilterStatusType] ?? ''}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FilterStatusType } from '../apis/types';
|
||||
|
||||
export type Averagify<T extends string> = `${T} avg`;
|
||||
|
||||
export type ChartableStageDatapoints = {
|
||||
__epoch: number;
|
||||
} & {
|
||||
[status in FilterStatusType]?: number;
|
||||
} & {
|
||||
[status in Averagify<FilterStatusType>]?: number;
|
||||
};
|
||||
|
||||
export interface ChartableStageAnalysis {
|
||||
max: number;
|
||||
min: number;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
export interface ChartableStage {
|
||||
analysis: Record<FilterStatusType, ChartableStageAnalysis>;
|
||||
combinedAnalysis: ChartableStageAnalysis;
|
||||
name: string;
|
||||
values: Array<ChartableStageDatapoints>;
|
||||
statusSet: Set<FilterStatusType>;
|
||||
|
||||
stages: Map<string, ChartableStage>;
|
||||
}
|
||||
|
||||
export interface ChartableStagesAnalysis {
|
||||
total: ChartableStage;
|
||||
stages: Map<string, ChartableStage>;
|
||||
}
|
||||
@@ -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 React, { CSSProperties } from 'react';
|
||||
import { formatISO9075, formatDistanceStrict } from 'date-fns';
|
||||
|
||||
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],
|
||||
];
|
||||
}
|
||||
|
||||
export function labelFormatter(epoch: number) {
|
||||
return <span style={infoText}>{formatISO9075(new Date(epoch))}</span>;
|
||||
}
|
||||
|
||||
export function labelFormatterWithoutTime(epoch: number) {
|
||||
return (
|
||||
<span style={infoText}>
|
||||
{formatISO9075(new Date(epoch), { representation: 'date' })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function tickFormatterX(epoch: number) {
|
||||
return formatISO9075(new Date(epoch), { representation: 'date' });
|
||||
}
|
||||
|
||||
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(duration: number, name: string) {
|
||||
return [
|
||||
<span style={infoText}>
|
||||
{name}: {formatDuration(duration)}
|
||||
</span>,
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
const baseDate = new Date();
|
||||
const baseEpoch = baseDate.getTime();
|
||||
export function formatDuration(milliseconds: number) {
|
||||
return formatDistanceStrict(baseDate, new Date(baseEpoch + milliseconds));
|
||||
}
|
||||
@@ -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, MouseEvent } from 'react';
|
||||
import { ButtonGroup, Button, Tooltip, Zoom } from '@material-ui/core';
|
||||
|
||||
export interface SwitchValueDetails<T extends string> {
|
||||
value: T;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function ButtonSwitch<T extends string>(props: ButtonSwitchProps<T>) {
|
||||
const { values, vertical = false } = props;
|
||||
|
||||
const onClick = useCallback(
|
||||
(ev: MouseEvent<HTMLSpanElement>) => {
|
||||
const value = (ev.target as HTMLSpanElement).textContent!;
|
||||
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}
|
||||
>
|
||||
{switchValue(value)}
|
||||
</Button>,
|
||||
),
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* 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,
|
||||
Switch,
|
||||
Theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
MuiPickersUtilsProvider,
|
||||
KeyboardDatePicker,
|
||||
} from '@material-ui/pickers';
|
||||
import { subMonths, isSameDay } from 'date-fns';
|
||||
import DateFnsUtils from '@date-io/date-fns';
|
||||
|
||||
import {
|
||||
CicdConfiguration,
|
||||
FilterBranchType,
|
||||
FilterStatusType,
|
||||
} from '../apis/types';
|
||||
import { ButtonSwitch, SwitchValue } from './button-switch';
|
||||
import { Toggle } from './toggle';
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatisticsChartFilters',
|
||||
},
|
||||
);
|
||||
|
||||
export type BranchSelection = FilterBranchType<'all'>;
|
||||
export type StatusSelection = FilterStatusType;
|
||||
|
||||
export interface ChartFilter {
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
branch: BranchSelection;
|
||||
status: Array<StatusSelection>;
|
||||
}
|
||||
|
||||
export function getDefaultChartFilter(
|
||||
cicdConfiguration: CicdConfiguration,
|
||||
): ChartFilter {
|
||||
const toDate = cicdConfiguration.defaults?.timeTo ?? new Date();
|
||||
return {
|
||||
fromDate: cicdConfiguration.defaults?.timeFrom ?? subMonths(toDate, 1),
|
||||
toDate,
|
||||
branch: cicdConfiguration.defaults?.filterType ?? 'branch',
|
||||
status:
|
||||
cicdConfiguration.defaults?.filterStatus ??
|
||||
cicdConfiguration.availableStatuses.filter(
|
||||
status => status === 'succeeded',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isSameChartFilter(a: ChartFilter, b: ChartFilter): boolean {
|
||||
return (
|
||||
a.branch === b.branch &&
|
||||
[...a.status].sort().join(' ') === [...b.status].sort().join(' ') &&
|
||||
isSameDay(a.fromDate, b.fromDate) &&
|
||||
isSameDay(a.toDate, b.toDate)
|
||||
);
|
||||
}
|
||||
|
||||
export interface ViewOptions {
|
||||
lowercaseNames: boolean;
|
||||
normalizeTimeRange: boolean;
|
||||
}
|
||||
|
||||
export function getDefaultViewOptions(
|
||||
cicdConfiguration: CicdConfiguration,
|
||||
): ViewOptions {
|
||||
return {
|
||||
lowercaseNames: cicdConfiguration.defaults?.lowercaseNames ?? false,
|
||||
normalizeTimeRange: cicdConfiguration.defaults?.normalizeTimeRange ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartFiltersProps {
|
||||
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 {
|
||||
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 branchValues: Array<SwitchValue<BranchSelection>> = [
|
||||
'master',
|
||||
'branch',
|
||||
{
|
||||
value: 'all',
|
||||
tooltip:
|
||||
'NOTE; If the build pipelines are very different between master and branch ' +
|
||||
'builds, viewing them combined might not result in a very useful chart',
|
||||
},
|
||||
];
|
||||
const [branch, setBranch] = useState(initialFetchFilter.branch);
|
||||
|
||||
const statusValues: ReadonlyArray<StatusSelection> =
|
||||
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],
|
||||
);
|
||||
|
||||
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 (!isSameDay(toDate, new Date())) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<MuiPickersUtilsProvider utils={DateFnsUtils}>
|
||||
<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 as any as Date)}
|
||||
/>
|
||||
<br />
|
||||
<FormControl component="fieldset">
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={useNowAsToDate}
|
||||
onChange={toggleUseNowAsDate}
|
||||
/>
|
||||
}
|
||||
label="To today"
|
||||
/>
|
||||
{useNowAsToDate ? null : (
|
||||
<KeyboardDatePicker
|
||||
autoOk
|
||||
variant="inline"
|
||||
inputVariant="outlined"
|
||||
label="To date"
|
||||
format="yyyy-MM-dd"
|
||||
value={toDate}
|
||||
InputAdornmentProps={{ position: 'start' }}
|
||||
onChange={date => setToDate(date as any as Date)}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
>
|
||||
Branch
|
||||
</Typography>
|
||||
<ButtonSwitch<BranchSelection>
|
||||
values={branchValues}
|
||||
selection={branch}
|
||||
onChange={setBranch}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className={`${classes.title} ${classes.title}`}
|
||||
>
|
||||
Status
|
||||
</Typography>
|
||||
<ButtonSwitch<StatusSelection>
|
||||
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'
|
||||
}
|
||||
>
|
||||
<span>Lowercase names</span>
|
||||
</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.'
|
||||
}
|
||||
>
|
||||
<span>Normalize time range</span>
|
||||
</Tooltip>
|
||||
</Toggle>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</MuiPickersUtilsProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 React, { DependencyList } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Box, LinearProgress } from '@material-ui/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useApp } from '@backstage/core-plugin-api';
|
||||
|
||||
// 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 type ProgressAsLoading = {
|
||||
loading: true;
|
||||
progress?: number;
|
||||
progressBuffer?: number;
|
||||
error?: undefined;
|
||||
value?: undefined;
|
||||
};
|
||||
export type ProgressAsError = {
|
||||
loading?: false | undefined;
|
||||
progress?: undefined;
|
||||
progressBuffer?: undefined;
|
||||
error: Error;
|
||||
value?: undefined;
|
||||
};
|
||||
export type ProgressAsValue<T> = {
|
||||
loading?: false | undefined;
|
||||
progress?: undefined;
|
||||
progressBuffer?: 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 Progress<T> =
|
||||
| ProgressAsLoading
|
||||
| ProgressAsError
|
||||
| ProgressAsValue<T>;
|
||||
|
||||
const sentry = Symbol();
|
||||
|
||||
/**
|
||||
* Casts an AsyncState or Progress into its non-succeeded sub types
|
||||
*/
|
||||
type Unsuccessful<S extends Progress<any> | AsyncState<any>> =
|
||||
S extends Progress<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 Progress<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: Progress<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 stateAsProgress = state as ProgressAsLoading;
|
||||
|
||||
if (!stateAsProgress.progress && !stateAsProgress.progressBuffer) {
|
||||
return <Progress />;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress
|
||||
variant="buffer"
|
||||
value={(stateAsProgress.progress ?? 0) * 100}
|
||||
valueBuffer={(stateAsProgress.progressBuffer ?? 0) * 100}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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,175 @@
|
||||
/*
|
||||
* 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 { startOfDay, endOfDay } from 'date-fns';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
import {
|
||||
useCicdStatistics,
|
||||
UseCicdStatisticsOptions,
|
||||
} from './hooks/use-cicd-statistics';
|
||||
import { useCicdConfiguration } from './hooks/use-cicd-configuration';
|
||||
import { buildsToChartableStages } from './charts/conversions';
|
||||
import { StageChart } from './charts/stage-chart';
|
||||
import { StatusChart } from './charts/status-chart';
|
||||
import {
|
||||
ChartFilter,
|
||||
ChartFilters,
|
||||
getDefaultChartFilter,
|
||||
getDefaultViewOptions,
|
||||
ViewOptions,
|
||||
} from './components/chart-filters';
|
||||
import { AbortError, CicdConfiguration } from './apis';
|
||||
import { cleanupBuildTree } from './utils/stage-names';
|
||||
import { renderFallbacks, useAsyncChain } from './components/progress';
|
||||
|
||||
export function EntityPageCicdCharts() {
|
||||
const state = useCicdConfiguration();
|
||||
|
||||
return renderFallbacks(state, value => (
|
||||
<CicdCharts cicdConfiguration={value} />
|
||||
));
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<Theme>(
|
||||
theme => ({
|
||||
pane: {
|
||||
padding: theme.spacing(1, 1, 1, 1),
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'CicdStatisticsView',
|
||||
},
|
||||
);
|
||||
|
||||
interface CicdChartsProps {
|
||||
cicdConfiguration: CicdConfiguration;
|
||||
}
|
||||
|
||||
function CicdCharts(props: CicdChartsProps) {
|
||||
const { cicdConfiguration } = props;
|
||||
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const { entity } = useEntity();
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
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,
|
||||
filterType: fetchedChartData.chartFilter.branch,
|
||||
};
|
||||
}, [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],
|
||||
);
|
||||
|
||||
const onFilterChange = useCallback((filter: ChartFilter) => {
|
||||
setChartFilter(filter);
|
||||
}, []);
|
||||
|
||||
const onViewOptionsChange = useCallback(
|
||||
(options: ViewOptions) => {
|
||||
setViewOptions(options);
|
||||
},
|
||||
[setViewOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!chartableStagesState.error ||
|
||||
chartableStagesState.error instanceof AbortError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
errorApi.post(chartableStagesState.error);
|
||||
}, [errorApi, chartableStagesState.error]);
|
||||
|
||||
const collapsedLimit = cicdConfiguration.defaults.collapsedLimit ?? 60 * 1000; // 1m
|
||||
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid item lg={2} className={classes.pane}>
|
||||
<ChartFilters
|
||||
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 => (
|
||||
<>
|
||||
{!statisticsState.value?.builds.length ? null : (
|
||||
<StatusChart builds={statisticsState.value?.builds} />
|
||||
)}
|
||||
<StageChart stage={chartableStages.total} defaultCollapsed={0} />
|
||||
{[...chartableStages.stages.entries()].map(([name, stage]) => (
|
||||
<StageChart
|
||||
key={name}
|
||||
stage={stage}
|
||||
defaultCollapsed={collapsedLimit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { CicdConfiguration, statusTypes } from '../apis';
|
||||
import { Progress } from '../components/progress';
|
||||
import { defaultFormatStageName } from '../utils/stage-names';
|
||||
import { useCicdStatisticsApi } from './use-cicd-statistics-api';
|
||||
|
||||
export function useCicdConfiguration(): Progress<CicdConfiguration> {
|
||||
const cicdStatisticsApi = useCicdStatisticsApi();
|
||||
|
||||
const [state, setState] = useState<Progress<CicdConfiguration>>({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!cicdStatisticsApi) {
|
||||
setState({ error: new Error('No CI/CD Statistics API installed') });
|
||||
return;
|
||||
}
|
||||
|
||||
cicdStatisticsApi
|
||||
.getConfiguration()
|
||||
.then(configuration => {
|
||||
const {
|
||||
availableStatuses = statusTypes,
|
||||
formatStageName = defaultFormatStageName,
|
||||
defaults = {},
|
||||
} = configuration;
|
||||
setState({
|
||||
value: {
|
||||
availableStatuses,
|
||||
formatStageName,
|
||||
defaults,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
setState({ error });
|
||||
});
|
||||
}, [cicdStatisticsApi]);
|
||||
|
||||
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,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 { useState, useEffect } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
import {
|
||||
CicdState,
|
||||
FetchBuildsOptions,
|
||||
AbortError,
|
||||
FilterStatusType,
|
||||
FilterBranchType,
|
||||
} from '../apis';
|
||||
import { Progress } 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,
|
||||
): Progress<CicdState> {
|
||||
const {
|
||||
entity,
|
||||
abortController,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
filterStatus,
|
||||
filterType,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<Progress<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 updateProgress = debounce((count, total, started = 0) => {
|
||||
if (mounted && !completed) {
|
||||
setState({
|
||||
loading: true,
|
||||
progress: !total ? 0 : count / total,
|
||||
progressBuffer: !total ? 0 : started / total,
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
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,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 {
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
createRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export 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: 'cicd-statistics-page',
|
||||
}),
|
||||
);
|
||||
@@ -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, [])),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user