feat(cicd-statistics): Added CI/CD statistics plugin

Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
Gustaf Räntilä
2022-01-13 11:15:32 +01:00
committed by blam
parent c367ddb301
commit 770c195f34
25 changed files with 2471 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cicd-statistics': minor
---
Added new plugin "CI/CD Statistics" which charts pipeline build durations over time
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+7
View File
@@ -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.
+182
View File
@@ -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)
```
+53
View File
@@ -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',
});
+18
View File
@@ -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';
+225
View File
@@ -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}
/>
);
}
+175
View File
@@ -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;
}
+18
View File
@@ -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';
+40
View File
@@ -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, [])),
}));
}