Merge pull request #8912 from backstage/grantila/cicd-statistics

Added CI/CD statistics plugin
This commit is contained in:
Ben Lambert
2022-02-09 21:11:01 +01:00
committed by GitHub
36 changed files with 4043 additions and 7 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')],
};
+13
View File
@@ -0,0 +1,13 @@
# CI/CD Statistics Plugin
This plugin shows charts of CI/CD pipeline durations over time. It expects to be used on the Software Catalog entity page, as it uses `useEntity` to figure out what component to get the build information for.
## Usage
> This plugin cannot be used as-is; it requires a custom implementation to fetch build information
To use this plugin, you need to implement an API `CicdStatisticsApi` and bind it to the `cicdStatisticsApiRef`. This API is defined in `src/apis/types.ts` and is an interface with two functions, `getConfiguration(options)` and `fetchBuilds(options)`. This plugin will call `getConfiguration` to allow the implementation to specify defaults and settings for the UI.
First time the UI shows, and each time the user changes filters and clicks `Update` to refresh the data, `fetchBuilds` is invoked with the filter options. The API implementation is the expected to fetch build information from somewhere, format it into a generic and rather simple type `Build` (also defined in `types.ts`). The API can optionally signal completion for a progress bar in the UI.
When this plugin has fetched the builds, it will transpose the list of builds (and build stages) into a tree of build stages. As build pipelines sometimes change, certain stages might end or begin within the date range of the view (when _Normalize time range_ is enabled, which is the default).
+222
View File
@@ -0,0 +1,222 @@
## API Report File for "@backstage/plugin-cicd-statistics"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "AbortError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class AbortError extends Error {}
// Warning: (ae-missing-release-tag) "Build" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface Build {
branchType: FilterBranchType;
duration: number;
id: string;
// (undocumented)
raw?: unknown;
requestedAt: Date;
stages: Array<Stage>;
status: FilterStatusType;
triggeredBy?: TriggerReason;
}
// Warning: (ae-missing-release-tag) "BuildWithRaw" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type BuildWithRaw<T = any> = Build & {
raw: T;
};
// Warning: (ae-missing-release-tag) "ChartType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type ChartType = 'duration' | 'count';
// Warning: (ae-missing-release-tag) "ChartTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type ChartTypes = Array<ChartType>;
// Warning: (ae-missing-release-tag) "CicdConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface CicdConfiguration {
availableStatuses: ReadonlyArray<FilterStatusType>;
defaults: Partial<CicdDefaults>;
formatStageName: (parentNames: Array<string>, stageName: string) => string;
}
// Warning: (ae-missing-release-tag) "CicdDefaults" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface CicdDefaults {
chartTypes: Record<FilterStatusType, ChartTypes>;
collapsedLimit: number;
// (undocumented)
filterStatus: Array<FilterStatusType>;
// (undocumented)
filterType: FilterBranchType | 'all';
hideLimit: number;
lowercaseNames: boolean;
normalizeTimeRange: boolean;
// (undocumented)
timeFrom: Date;
// (undocumented)
timeTo: Date;
}
// Warning: (ae-missing-release-tag) "CicdState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface CicdState {
// (undocumented)
builds: Array<Build>;
}
// Warning: (ae-missing-release-tag) "CicdStatisticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface CicdStatisticsApi {
// (undocumented)
fetchBuilds(options: FetchBuildsOptions): Promise<CicdState>;
// (undocumented)
getConfiguration(
options: GetConfigurationOptions,
): Promise<Partial<CicdConfiguration>>;
}
// Warning: (ae-missing-release-tag) "cicdStatisticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const cicdStatisticsApiRef: ApiRef<CicdStatisticsApi>;
// Warning: (ae-missing-release-tag) "cicdStatisticsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const cicdStatisticsPlugin: BackstagePlugin<
{
entityContent: RouteRef<undefined>;
},
{}
>;
// Warning: (ae-missing-release-tag) "EntityCicdStatisticsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityCicdStatisticsContent: EntityPageCicdCharts;
// Warning: (ae-missing-release-tag) "EntityPageCicdCharts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function EntityPageCicdCharts(): JSX.Element;
// Warning: (ae-missing-release-tag) "FetchBuildsOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface FetchBuildsOptions {
// (undocumented)
abortSignal: AbortSignal;
// (undocumented)
entity: Entity;
// (undocumented)
filterStatus: Array<FilterStatusType | 'all'>;
// (undocumented)
filterType: FilterBranchType | 'all';
// (undocumented)
timeFrom: Date;
// (undocumented)
timeTo: Date;
// (undocumented)
updateProgress: UpdateProgress;
}
// Warning: (ae-missing-release-tag) "FilterBranchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type FilterBranchType = 'master' | 'branch';
// Warning: (ae-missing-release-tag) "FilterStatusType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type FilterStatusType =
| 'unknown'
| 'enqueued'
| 'scheduled'
| 'running'
| 'aborted'
| 'succeeded'
| 'failed'
| 'stalled'
| 'expired';
// Warning: (ae-missing-release-tag) "GetConfigurationOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface GetConfigurationOptions {
// (undocumented)
entity: Entity;
}
// Warning: (ae-missing-release-tag) "Stage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface Stage {
duration: number;
// (undocumented)
name: string;
stages?: Array<Stage>;
status: FilterStatusType;
}
// Warning: (ae-missing-release-tag) "statusTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const statusTypes: Array<FilterStatusType>;
// Warning: (ae-missing-release-tag) "TriggerReason" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type TriggerReason =
/** Triggered by source code management, e.g. a Github hook */
| 'scm'
/** Triggered manually */
| 'manual'
/** Triggered internally (non-scm, or perhaps after being delayed/enqueued) */
| 'internal'
/** Triggered for some other reason */
| 'other';
// Warning: (ae-missing-release-tag) "triggerReasons" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const triggerReasons: Array<TriggerReason>;
// Warning: (ae-missing-release-tag) "UpdateProgress" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface UpdateProgress {
// (undocumented)
(completed: number, total: number, started?: number): void;
// (undocumented)
(
steps: Array<{
title: string;
completed: number;
total: number;
started?: number;
}>,
): void;
}
// (No @packageDocumentation comment for this package)
```
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-cicd-statistics",
"description": "A frontend plugin visualizing CI/CD pipeline statistics (build time)",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/cicd-statistics"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"devDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"@types/luxon": "^2.0.5"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.8",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/plugin-catalog-react": "^0.6.9",
"@date-io/luxon": "^1.3.13",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/pickers": "^3.3.10",
"humanize-duration": "^3.27.0",
"already": "^3.2.0",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
"react-use": "^17.3.1",
"recharts": "^2.1.5"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,23 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core-plugin-api';
import { CicdStatisticsApi } from './types';
export const cicdStatisticsApiRef = createApiRef<CicdStatisticsApi>({
id: 'cicd-statistics-api',
});
+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';
+275
View File
@@ -0,0 +1,275 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
/**
* This is a generic enum of build statuses.
*
* If all of these aren't applicable to the underlying CI/CD, these can be
* configured to be hidden, using the `availableStatuses` in `CicdConfiguration`.
*/
export type FilterStatusType =
| 'unknown'
| 'enqueued'
| 'scheduled'
| 'running'
| 'aborted'
| 'succeeded'
| 'failed'
| 'stalled'
| 'expired';
export const statusTypes: Array<FilterStatusType> = [
'succeeded',
'failed',
'enqueued',
'scheduled',
'running',
'aborted',
'stalled',
'expired',
'unknown',
];
/**
* The branch enum of either 'master' or 'branch' (or possibly the meta 'all').
*
* The concept of what constitues a master branch is generic. It might be called
* something like 'release' or 'main' or 'trunk' in the underlying CI/CD system,
* which is then up to the Api to map accordingly.
*/
export type FilterBranchType = 'master' | 'branch';
export type TriggerReason =
/** Triggered by source code management, e.g. a Github hook */
| 'scm'
/** Triggered manually */
| 'manual'
/** Triggered internally (non-scm, or perhaps after being delayed/enqueued) */
| 'internal'
/** Triggered for some other reason */
| 'other';
export const triggerReasons: Array<TriggerReason> = [
'scm',
'manual',
'internal',
'other',
];
/**
* A Stage is a part of either a Build or a parent Stage.
*
* This may be called things like Stage or Step or Task in CI/CD systems, but is
* generic here. There's also no concept of parallelism which might exist within
* some stages.
*/
export interface Stage {
name: string;
/** The status of the stage */
status: FilterStatusType;
/** Stage duration in milliseconds */
duration: number;
/** Sub stages within this stage */
stages?: Array<Stage>;
}
/**
* Generic Build type.
*
* A build has e.g. a build type (master/branch), a status and (possibly) sub stages.
*/
export interface Build {
raw?: unknown;
/** Build id */
id: string;
/** The reason this build was started */
triggeredBy?: TriggerReason;
/** The status of the build */
status: FilterStatusType;
/** Branch type */
branchType: FilterBranchType;
/** Time when the build started */
requestedAt: Date;
/** The overall duration of the build */
duration: number;
/** Top-level build stages */
stages: Array<Stage>;
}
/**
* Helper type which is a Build with a certain typed 'raw' field.
*
* This can be useful in an Api to use while mapping internal data structures
* (raw) into generic builds.
*/
export type BuildWithRaw<T = any> = Build & {
raw: T;
};
/**
* Chart type.
*
* Values are:
* * `duration`: shows an area chart of the duration over time
* * `count`: shows a bar chart of the number of build per day
*/
export type ChartType = 'duration' | 'count';
export type ChartTypes = Array<ChartType>;
/**
* Default settings for the fetching options and view options.
*
* These are all optional, but can be overridden from the Api to whatever makes
* most sense for that implementation.
*/
export interface CicdDefaults {
timeFrom: Date;
timeTo: Date;
filterStatus: Array<FilterStatusType>;
filterType: FilterBranchType | 'all';
/** Lower-case all stage names (to potentially merge stages with different cases) */
lowercaseNames: boolean;
/** Normalize the from-to date range in all charts */
normalizeTimeRange: boolean;
/** Default collapse the stages with a max-duration below this value */
collapsedLimit: number;
/** Default hide stages with a max-duration below this value */
hideLimit: number;
/** Chart types per status */
chartTypes: Record<FilterStatusType, ChartTypes>;
}
/**
* A configuration interface which the Api must implement.
*
* When the UI for the CI/CD Statistics is loaded, it begins with fetching the
* configuration before anything else.
*
* All of these fields are optional though, and will fallback to hard-coded defaults.
*/
export interface CicdConfiguration {
/**
* This field can be used to override what statuses are available
*/
availableStatuses: ReadonlyArray<FilterStatusType>;
/**
* When transposing the list of builds into a tree of stages, the stage names
* will be transformed through this function.
*
* Override this for a custom implementation. The default will try to remove
* parent names off of child names, if they are prepended by them.
*
* For example; if a stage has the name 'Install' and a child stage has the
* name 'Install - Fetch dependencies', the child name will be replaced with
* 'Fetch dependencies'.
*/
formatStageName: (parentNames: Array<string>, stageName: string) => string;
/**
* Default options for the UI
*/
defaults: Partial<CicdDefaults>;
}
/**
* If the Api implements support for aborting the fetching of builds, throw an
* AbortError of this type (or any other error with name === 'AbortError').
*/
export class AbortError extends Error {}
/**
* The result type for `fetchBuilds`.
*/
export interface CicdState {
builds: Array<Build>;
}
/**
* When fetching, if applicable, the Api can feedback progress back to the UI.
*
* Use the `updateProgress(completed, total, started?)` to signal that
* `completed` builds out of a `total` has finished. Optionally use the
* `started` to signal how many builds have been started in total (i.e. at least
* the amount of `completed`).
*
* This can be called at any rate. Rate limiting (debouncing) is implemented in
* the UI.
*
* Optionally this can signal multiple progresses in several steps
*/
export interface UpdateProgress {
(completed: number, total: number, started?: number): void;
(
steps: Array<{
title: string;
completed: number;
total: number;
started?: number;
}>,
): void;
}
/**
* When reading configuration, the Api can return a custom settings depending on
* the entity being viewed.
*/
export interface GetConfigurationOptions {
entity: Entity;
}
/**
* When fetching, the Api should fetch build information about the `entity` and
* respect the `timeFrom`, `timeTo`, `filterStatus` and `filterType`.
*
* Optionally implement support for `updateProgress` and `abortSignal` if
* preferred.
*
* When the UI re-fetches, it will abort any previous fetching, so polling
* `abortSignal.aborted`, and possibly throwing an `AbortError`, can be useful.
*/
export interface FetchBuildsOptions {
entity: Entity;
updateProgress: UpdateProgress;
abortSignal: AbortSignal;
timeFrom: Date;
timeTo: Date;
filterStatus: Array<FilterStatusType | 'all'>;
filterType: FilterBranchType | 'all';
}
/**
* The interface which is mapped to the `cicdStatisticsApiRef` which is used by
* the UI.
*/
export interface CicdStatisticsApi {
getConfiguration(
options: GetConfigurationOptions,
): Promise<Partial<CicdConfiguration>>;
fetchBuilds(options: FetchBuildsOptions): Promise<CicdState>;
}
@@ -0,0 +1,46 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FilterStatusType, TriggerReason } from '../apis/types';
export const statusColorMap: Record<FilterStatusType, string> = {
unknown: '#3d01a4',
enqueued: '#7ad1b9',
scheduled: '#0391ce',
running: '#f3f318',
aborted: '#8600af',
succeeded: '#66b032',
failed: '#fe2712',
stalled: '#fb9904',
expired: '#a7194b',
};
export const triggerColorMap: Record<TriggerReason, string> = {
scm: '#0391ce',
manual: '#a7194b',
internal: '#82ca9d',
other: '#f3f318',
};
export const fireColors: Array<[percent: string, color: string]> = [
['5%', '#e19678'],
['30%', '#dfe178'],
['50%', '#82ca9d'],
['95%', '#82ca9d'],
];
export const colorStroke = '#c0c0c0';
export const colorStrokeAvg = '#788ee1';
@@ -0,0 +1,79 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FilterStatusType } from '../../apis/types';
import { ChartableStageAnalysis, ChartableStageDatapoints } from '../types';
export function getAnalysis(
values: Array<ChartableStageDatapoints>,
status: FilterStatusType,
): ChartableStageAnalysis {
const analysis: ChartableStageAnalysis = {
max: 0,
min: 0,
avg: 0,
med: 0,
};
const definedValues = values
.filter(value => typeof value[status] !== 'undefined')
.map(value => value[status]!)
.sort((a, b) => a - b);
analysis.max = definedValues[definedValues.length - 1] ?? 0;
analysis.min = definedValues[0] ?? 0;
analysis.avg =
definedValues.length === 0
? 0
: definedValues.reduce((prev, cur) => prev + cur, 0) / values.length;
analysis.med = definedValues[Math.ceil(definedValues.length / 2)] ?? 0;
return analysis;
}
export function makeCombinedAnalysis(
analysis: Record<FilterStatusType, ChartableStageAnalysis>,
allDurations: Array<number>,
): ChartableStageAnalysis {
if (analysis.succeeded) {
// If succeeded is a viewed status, it's probably what's expected to see
// overall. Otherwise combine all other.
return analysis.succeeded;
}
const analysisValues = Object.values(analysis);
const max = analysisValues.reduce((prev, cur) => Math.max(prev, cur.max), 0);
const min = analysisValues.reduce(
(prev, cur) => Math.min(prev, cur.min),
max,
);
const avg = !allDurations.length
? 0
: allDurations.reduce((prev, cur) => prev + cur, 0) / allDurations.length;
allDurations.sort((a, b) => a - b);
const med = allDurations[Math.ceil(allDurations.length / 2)] ?? 0;
return {
max,
min,
avg,
med,
};
}
@@ -0,0 +1,120 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { map } from 'already';
import { Build, Stage, FilterStatusType } from '../../apis/types';
import { ChartableStage, ChartableStagesAnalysis } from '../types';
import { getOrSetStage, makeStage, sortStatuses } from './utils';
import { finalizeStage } from './finalize-stage';
import { dailySummary } from './daily-summary';
export interface ChartableStagesOptions {
normalizeTimeRange: boolean;
}
/**
* Converts a list of builds, each with a tree of stages (and durations) into a
* merged tree of stages, and calculates {avg, min, max} of each stage.
*/
export async function buildsToChartableStages(
builds: Array<Build>,
options: ChartableStagesOptions,
): Promise<ChartableStagesAnalysis> {
const { normalizeTimeRange } = options;
const total: ChartableStage = makeStage('Total');
const recurseDown = (
stageMap: Map<string, ChartableStage>,
stage: Stage,
__epoch: number,
) => {
const { name, status, duration } = stage;
const subChartableStage = getOrSetStage(stageMap, name);
subChartableStage.statusSet.add(status);
subChartableStage.values.push({
__epoch,
[status]: duration,
[`${status} avg`]: duration,
});
stage.stages?.forEach(subStage => {
recurseDown(subChartableStage.stages, subStage, __epoch);
});
};
const stages = new Map<string, ChartableStage>();
await map(builds, { chunk: 'idle' }, build => {
const { duration, requestedAt, status } = build;
const __epoch = requestedAt.getTime();
total.statusSet.add(status);
total.values.push({
__epoch,
[status]: duration,
[`${status} avg`]: duration,
});
build.stages?.forEach(subStage => {
recurseDown(stages, subStage, __epoch);
});
});
const allEpochs = normalizeTimeRange
? builds.map(build => build.requestedAt.getTime())
: [];
// Recurse down again and calculate averages
await map([...stages.values()], { chunk: 'idle' }, stage =>
finalizeStage(stage, { allEpochs, averageWidth: 10 }),
);
finalizeStage(total, { allEpochs, averageWidth: 10 });
const daily = dailySummary(builds);
const statuses = findStatuses(total, [...stages.values()]);
return { daily, total, stages, statuses };
}
function findStatuses(
total: ChartableStage,
stages: Array<ChartableStage>,
): Array<string> {
const statuses = new Set<string>();
const addStatuses = (set: Set<FilterStatusType>) => {
set.forEach(status => {
statuses.add(status);
});
};
addStatuses(total.statusSet);
const recurse = (subStages: Array<ChartableStage>) => {
subStages.forEach(stage => {
addStatuses(stage.statusSet);
recurse([...stage.stages.values()]);
});
};
recurse(stages);
return sortStatuses([...statuses]);
}
@@ -0,0 +1,47 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { groupBy } from 'lodash';
import { FilterStatusType, statusTypes } from '../../apis/types';
import { Countify, ChartableStageDatapoints } from '../types';
import { startOfDay } from './utils';
export function countBuildsPerDay(
values: ReadonlyArray<ChartableStageDatapoints>,
) {
const days = groupBy(values, value => startOfDay(value.__epoch));
Object.entries(days).forEach(([_startOfDay, valuesThisDay]) => {
const counts = Object.fromEntries(
statusTypes
.map(
type =>
[
type,
valuesThisDay.filter(value => value[type] !== undefined).length,
] as const,
)
.filter(([_type, count]) => count > 0)
.map(([type, count]): [Countify<FilterStatusType>, number] => [
`${type} count`,
count,
]),
);
// Assign the count for this day to the first value this day
Object.assign(valuesThisDay[0], counts);
});
}
@@ -0,0 +1,115 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { groupBy, countBy } from 'lodash';
import { Build } from '../../apis/types';
import {
Epoch,
TriggerReasonsDatapoint,
StatusesDatapoint,
ChartableDaily,
} from '../types';
import { sortStatuses, sortTriggerReasons, startOfDay } from './utils';
export function dailySummary(builds: ReadonlyArray<Build>): ChartableDaily {
const triggersDaily = countTriggersPerDay(builds);
const statusesDaily = countStatusesPerDay(builds);
const { triggerReasons } = triggersDaily;
const { statuses } = statusesDaily;
const reasonMap = new Map(
triggersDaily.values.map(value => [value.__epoch, value]),
);
const statusMap = new Map(
statusesDaily.values.map(value => [value.__epoch, value]),
);
const days = Object.keys(
groupBy(builds, value => startOfDay(value.requestedAt)),
)
.map(epoch => parseInt(epoch, 10))
.sort();
return {
values: days.map(epoch => ({
__epoch: epoch,
...reasonMap.get(epoch),
...statusMap.get(epoch),
})),
triggerReasons,
statuses,
};
}
function countTriggersPerDay(builds: ReadonlyArray<Build>) {
const days = groupBy(builds, value => startOfDay(value.requestedAt));
const triggerReasons = sortTriggerReasons([
...new Set(
builds
.map(({ triggeredBy }) => triggeredBy)
.filter((v): v is NonNullable<typeof v> => !!v),
),
]);
const values = Object.entries(days).map(([epoch, buildsThisDay]) => {
const datapoint = Object.fromEntries(
triggerReasons
.map(reason => [
reason,
buildsThisDay.filter(build => build.triggeredBy === reason).length,
])
.filter(([_type, count]) => count > 0),
) as Omit<TriggerReasonsDatapoint, '__epoch'>;
// Assign the count for this day to the first value this day
const value: Epoch & TriggerReasonsDatapoint = Object.assign(datapoint, {
__epoch: parseInt(epoch, 10),
});
return value;
});
return { triggerReasons, values };
}
function countStatusesPerDay(builds: ReadonlyArray<Build>) {
const days = groupBy(builds, value => startOfDay(value.requestedAt));
const foundStatuses = new Set<string>();
const values = Object.entries(days).map(([epoch, buildsThisDay]) => {
const byStatus = countBy(buildsThisDay, 'status');
const value: Epoch & StatusesDatapoint = {
__epoch: parseInt(epoch, 10),
...byStatus,
};
Object.keys(byStatus).forEach(status => {
foundStatuses.add(status);
});
return value;
});
return {
statuses: sortStatuses([...foundStatuses]),
values,
};
}
@@ -0,0 +1,93 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FilterStatusType, statusTypes } from '../../apis/types';
import { Averagify, ChartableStage } from '../types';
import { countBuildsPerDay } from './count-builds-per-day';
import { getAnalysis, makeCombinedAnalysis } from './analysis';
import { average } from './utils';
interface FinalizeStageOptions {
averageWidth: number;
allEpochs: Array<number>;
}
/**
* Calculate:
* * {avg, min, max}
* * count per day
* of a stage and its sub stages, recursively.
*
* This is calculated per status (successful, failed, etc).
*/
export function finalizeStage(
stage: ChartableStage,
options: FinalizeStageOptions,
) {
const { averageWidth, allEpochs } = options;
const { values, analysis, combinedAnalysis } = stage;
if (allEpochs.length > 0) {
const valueEpochs = new Set(values.map(value => value.__epoch));
allEpochs.forEach(epoch => {
if (!valueEpochs.has(epoch)) {
values.push({ __epoch: epoch });
}
});
}
values.sort((a, b) => a.__epoch - b.__epoch);
countBuildsPerDay(values);
const allDurations: Array<number> = [];
statusTypes.forEach(status => {
analysis[status] = getAnalysis(values, status);
const durationsIndexes = values
.map(value => value[status])
.map((duration, index) => ({ index, duration }))
.filter(({ duration }) => typeof duration !== 'undefined')
.map(({ index }) => index);
const durationsDense = values
.map(value => value[status])
.filter(
(duration): duration is number => typeof duration !== 'undefined',
);
durationsDense.forEach(dur => allDurations.push(dur));
const averages = durationsDense.map((_, i) =>
average(
durationsDense.slice(
Math.max(i - averageWidth, 0),
Math.min(i + averageWidth, durationsDense.length),
),
),
);
averages.forEach((avg, index) => {
const key: Averagify<FilterStatusType> = `${status} avg`;
values[durationsIndexes[index]][key] = avg;
});
});
Object.assign(combinedAnalysis, makeCombinedAnalysis(analysis, allDurations));
stage.stages.forEach(subStage => finalizeStage(subStage, options));
}
@@ -0,0 +1,27 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { sortTriggerReasons } from './utils';
describe('daily-summary', () => {
it('sortTriggerReasons', () => {
const values = ['a', 'manual', 'b', 'other', 'c', 'scm', 'd'];
const expected = ['manual', 'scm', 'other', 'a', 'b', 'c', 'd'];
expect(sortTriggerReasons(values)).toStrictEqual(expected);
expect(sortTriggerReasons(values.reverse())).toStrictEqual(expected);
});
});
@@ -0,0 +1,87 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateTime } from 'luxon';
import { FilterStatusType, statusTypes } from '../../apis/types';
import { ChartableStage } from '../types';
export function average(values: number[]): number {
return !values.length
? 0
: Math.round(values.reduce((prev, cur) => prev + cur, 0) / values.length);
}
export function getOrSetStage(
stages: Map<string, ChartableStage>,
name: string,
): ChartableStage {
const stage = stages.get(name);
if (stage) return stage;
const newStage: ChartableStage = makeStage(name);
stages.set(name, newStage);
return newStage;
}
export function makeStage(name: string): ChartableStage {
return {
analysis: {
unknown: { avg: 0, med: 0, max: 0, min: 0 },
enqueued: { avg: 0, med: 0, max: 0, min: 0 },
scheduled: { avg: 0, med: 0, max: 0, min: 0 },
running: { avg: 0, med: 0, max: 0, min: 0 },
aborted: { avg: 0, med: 0, max: 0, min: 0 },
succeeded: { avg: 0, med: 0, max: 0, min: 0 },
failed: { avg: 0, med: 0, max: 0, min: 0 },
stalled: { avg: 0, med: 0, max: 0, min: 0 },
expired: { avg: 0, med: 0, max: 0, min: 0 },
},
combinedAnalysis: { avg: 0, med: 0, max: 0, min: 0 },
statusSet: new Set<FilterStatusType>(),
name,
values: [],
stages: new Map(),
};
}
export function startOfDay(date: number | Date) {
if (typeof date === 'number') {
return DateTime.fromMillis(date).startOf('day').toMillis();
}
return DateTime.fromJSDate(date).startOf('day').toMillis();
}
export function sortTriggerReasons(reasons: Array<string>): Array<string> {
return reasons.sort((a, b) => {
if (a === 'manual') return -1;
else if (b === 'manual') return 1;
else if (a === 'scm') return -1;
else if (b === 'scm') return 1;
else if (a === 'other') return -1;
else if (b === 'other') return 1;
return a.localeCompare(b);
});
}
export function sortStatuses(statuses: Array<string>): Array<string> {
return [
...statusTypes.filter(status => statuses.includes(status)),
...statuses
.filter(status => !(statusTypes as Array<string>).includes(status))
.sort((a, b) => a.localeCompare(b)),
];
}
@@ -0,0 +1,269 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { CSSProperties, Fragment, useMemo } from 'react';
import {
Area,
Bar,
ComposedChart,
XAxis,
YAxis,
YAxisProps,
CartesianGrid,
Legend,
LegendProps,
Line,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import Alert from '@material-ui/lab/Alert';
import {
Accordion,
AccordionSummary,
AccordionDetails,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { capitalize } from 'lodash';
import { useZoom, useZoomArea } from './zoom';
import { CicdDefaults, statusTypes } from '../apis/types';
import { ChartableStage } from './types';
import {
pickElements,
labelFormatter,
tickFormatterX,
tickFormatterY,
tooltipValueFormatter,
formatDuration,
} from '../components/utils';
import {
statusColorMap,
fireColors,
colorStroke,
colorStrokeAvg,
} from './colors';
const fullWidth: CSSProperties = { width: '100%' };
const noUserSelect: CSSProperties = { userSelect: 'none' };
const transitionProps = { unmountOnExit: true };
export interface StageChartProps {
stage: ChartableStage;
chartTypes: CicdDefaults['chartTypes'];
defaultCollapsed?: number;
defaultHidden?: number;
zeroYAxis?: boolean;
}
export function StageChart(props: StageChartProps) {
const { stage, ...chartOptions } = props;
const {
chartTypes,
defaultCollapsed = 0,
defaultHidden = 0,
zeroYAxis = false,
} = chartOptions;
const { zoomFilterValues } = useZoom();
const { zoomProps, getZoomArea } = useZoomArea();
const ticks = useMemo(
() => pickElements(stage.values, 8).map(val => val.__epoch),
[stage.values],
);
const domainY = useMemo(
() => [zeroYAxis ? 0 : 'auto', 'auto'] as YAxisProps['domain'],
[zeroYAxis],
);
const statuses = useMemo(
() => statusTypes.filter(status => stage.statusSet.has(status)),
[stage.statusSet],
);
const legendPayload = useMemo(
(): LegendProps['payload'] =>
statuses.map(status => ({
value: capitalize(status),
type: 'line',
id: status,
color: statusColorMap[status],
})),
[statuses],
);
const subStages = useMemo(
() =>
new Map<string, ChartableStage>(
[...stage.stages.entries()].filter(
([_name, subStage]) => subStage.combinedAnalysis.max > defaultHidden,
),
),
[stage.stages, defaultHidden],
);
const zoomFilteredValues = useMemo(
() => zoomFilterValues(stage.values),
[stage.values, zoomFilterValues],
);
return stage.combinedAnalysis.max < defaultHidden ? null : (
<Accordion
defaultExpanded={stage.combinedAnalysis.max > defaultCollapsed}
TransitionProps={transitionProps}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>
{stage.name} (med {formatDuration(stage.combinedAnalysis.med)}, avg{' '}
{formatDuration(stage.combinedAnalysis.avg)})
</Typography>
</AccordionSummary>
<AccordionDetails>
{stage.values.length === 0 ? (
<Alert severity="info">No data</Alert>
) : (
<Grid container direction="column">
<Grid item style={noUserSelect}>
<ResponsiveContainer width="100%" height={140}>
<ComposedChart data={zoomFilteredValues} {...zoomProps}>
<defs>
<linearGradient id="colorDur" x1="0" y1="0" x2="0" y2="1">
{fireColors.map(([percent, color]) => (
<stop
key={percent}
offset={percent}
stopColor={color}
stopOpacity={0.8}
/>
))}
</linearGradient>
</defs>
{statuses.length > 1 && <Legend payload={legendPayload} />}
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="__epoch"
type="category"
ticks={ticks}
tickFormatter={tickFormatterX}
/>
<YAxis
yAxisId={1}
tickFormatter={tickFormatterY}
type="number"
tickCount={5}
name="Duration"
domain={domainY}
/>
<YAxis
yAxisId={2}
orientation="right"
type="number"
tickCount={5}
name="Count"
/>
<Tooltip
formatter={tooltipValueFormatter}
labelFormatter={labelFormatter}
/>
{statuses.reverse().map(status => (
<Fragment key={status}>
{!chartTypes[status].includes('duration') ? null : (
<>
<Area
isAnimationActive={false}
yAxisId={1}
type="monotone"
dataKey={status}
stackId={status}
stroke={
statuses.length > 1
? statusColorMap[status]
: colorStroke
}
fillOpacity={statuses.length > 1 ? 0.5 : 1}
fill={
statuses.length > 1
? statusColorMap[status]
: 'url(#colorDur)'
}
connectNulls
/>
<Line
isAnimationActive={false}
yAxisId={1}
type="monotone"
dataKey={`${status} avg`}
stroke={
statuses.length > 1
? statusColorMap[status]
: colorStrokeAvg
}
opacity={0.8}
strokeWidth={2}
dot={false}
connectNulls
/>
</>
)}
{!chartTypes[status].includes('count') ? null : (
<Bar
isAnimationActive={false}
yAxisId={2}
type="monotone"
dataKey={`${status} count`}
stackId="1"
stroke={statusColorMap[status] ?? ''}
fillOpacity={0.5}
fill={statusColorMap[status] ?? ''}
/>
)}
</Fragment>
))}
{getZoomArea({ yAxisId: 1 })}
</ComposedChart>
</ResponsiveContainer>
</Grid>
{subStages.size === 0 ? null : (
<Grid item>
<Accordion
defaultExpanded={false}
TransitionProps={transitionProps}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>Sub stages ({subStages.size})</Typography>
</AccordionSummary>
<AccordionDetails>
<div style={fullWidth}>
{[...subStages.values()].map(subStage => (
<StageChart
key={subStage.name}
{...chartOptions}
stage={subStage}
/>
))}
</div>
</AccordionDetails>
</Accordion>
</Grid>
)}
</Grid>
)}
</AccordionDetails>
</Accordion>
);
}
@@ -0,0 +1,220 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Fragment, useMemo } from 'react';
import {
Area,
Bar,
ComposedChart,
XAxis,
YAxis,
CartesianGrid,
Legend,
LegendProps,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import Alert from '@material-ui/lab/Alert';
import {
Accordion,
AccordionSummary,
AccordionDetails,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { capitalize } from 'lodash';
import { useZoom, useZoomArea } from './zoom';
import { FilterStatusType, TriggerReason } from '../apis/types';
import { labelFormatterWithoutTime, tickFormatterX } from '../components/utils';
import { statusColorMap, triggerColorMap } from './colors';
import { ChartableStagesAnalysis } from './types';
export interface StatusChartProps {
analysis: ChartableStagesAnalysis;
}
export function StatusChart(props: StatusChartProps) {
const { analysis } = props;
const { zoomFilterValues } = useZoom();
const { zoomProps, getZoomArea } = useZoomArea();
const values = useMemo(() => {
return analysis.daily.values.map(value => {
const totTriggers = analysis.daily.triggerReasons.reduce(
(prev, cur) => prev + (value[cur as TriggerReason] ?? 0),
0,
);
if (!totTriggers) {
return value;
}
return {
...value,
...Object.fromEntries(
analysis.daily.triggerReasons.map(reason => [
reason,
(value[reason as TriggerReason] ?? 0) / totTriggers,
]),
),
};
});
}, [analysis.daily]);
const triggerReasonLegendPayload = useMemo(
(): NonNullable<LegendProps['payload']> =>
analysis.daily.triggerReasons.map(reason => ({
value: humanTriggerReason(reason),
type: 'line',
id: reason,
color: triggerColorMap[reason as TriggerReason] ?? '',
})),
[analysis.daily.triggerReasons],
);
const statusesLegendPayload = useMemo(
(): NonNullable<LegendProps['payload']> =>
analysis.daily.statuses.map(status => ({
value: capitalize(status),
type: 'line',
id: status,
color: statusColorMap[status as FilterStatusType] ?? '',
})),
[analysis.daily.statuses],
);
const legendPayload = useMemo(
(): NonNullable<LegendProps['payload']> => [
...triggerReasonLegendPayload,
...statusesLegendPayload,
],
[statusesLegendPayload, triggerReasonLegendPayload],
);
const tooltipFormatter = useMemo(() => {
const reasonSet = new Set(analysis.daily.triggerReasons);
return (percentOrCount: number, name: string) => {
const label = reasonSet.has(name)
? humanTriggerReason(name)
: capitalize(name);
const valueText = reasonSet.has(name)
? `${(percentOrCount * 100).toFixed(0)}%`
: percentOrCount;
return [
<span>
{label}: {valueText}
</span>,
null,
];
};
}, [analysis.daily.triggerReasons]);
const zoomFilteredValues = useMemo(
() => zoomFilterValues(values),
[values, zoomFilterValues],
);
const barSize = getBarSize(analysis.daily.values.length);
return (
<Accordion defaultExpanded={analysis.daily.statuses.length > 1}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>
Build count per status over build trigger reason
</Typography>
</AccordionSummary>
<AccordionDetails>
{values.length === 0 ? (
<Alert severity="info">No data</Alert>
) : (
<ResponsiveContainer width="100%" height={140}>
<ComposedChart data={zoomFilteredValues} {...zoomProps}>
<Legend payload={legendPayload} />
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="__epoch"
type="category"
tickFormatter={tickFormatterX}
/>
<YAxis yAxisId={1} type="number" tickCount={5} name="Count" />
<YAxis yAxisId={2} type="number" name="Triggers" hide />
<Tooltip
labelFormatter={labelFormatterWithoutTime}
formatter={tooltipFormatter}
/>
{triggerReasonLegendPayload.map(reason => (
<Fragment key={reason.id}>
<Area
isAnimationActive={false}
type="monotone"
dataKey={reason.id!}
stackId="triggers"
yAxisId={2}
stroke={triggerColorMap[reason.id as TriggerReason] ?? ''}
fillOpacity={0.5}
fill={triggerColorMap[reason.id as TriggerReason] ?? ''}
/>
</Fragment>
))}
{[...analysis.daily.statuses].reverse().map(status => (
<Fragment key={status}>
<Bar
isAnimationActive={false}
type="monotone"
barSize={barSize}
dataKey={status}
stackId="statuses"
yAxisId={1}
stroke={statusColorMap[status as FilterStatusType] ?? ''}
fillOpacity={0.8}
fill={statusColorMap[status as FilterStatusType] ?? ''}
/>
</Fragment>
))}
{getZoomArea({ yAxisId: 1 })}
</ComposedChart>
</ResponsiveContainer>
)}
</AccordionDetails>
</Accordion>
);
}
function humanTriggerReason(reason: string): string {
if ((reason as TriggerReason) === 'manual') {
return 'Triggered manually';
} else if ((reason as TriggerReason) === 'scm') {
return 'Triggered by SCM';
} else if ((reason as TriggerReason) === 'internal') {
return 'Triggered internally';
} else if ((reason as TriggerReason) === 'other') {
return 'Triggered by another reason';
}
return `Triggered by ${reason}`;
}
function getBarSize(count: number): number {
if (count < 20) {
return 10;
} else if (count < 40) {
return 8;
}
return 5;
}
@@ -0,0 +1,97 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FilterStatusType, TriggerReason } from '../apis/types';
export type Averagify<T extends string> = `${T} avg`;
export type Countify<T extends string> = `${T} count`;
export type Epoch = { __epoch: number };
export type ChartableStageDatapoints = Epoch & {
[status in FilterStatusType]?: number;
} & {
[status in Averagify<FilterStatusType>]?: number;
} & {
[status in Countify<FilterStatusType>]?: number;
};
export interface ChartableStageAnalysis {
/** Maximum duration */
max: number;
/** Minimum duration */
min: number;
/** Average duration */
avg: number;
/** Median duration */
med: number;
}
export interface ChartableStage {
analysis: Record<FilterStatusType, ChartableStageAnalysis>;
combinedAnalysis: ChartableStageAnalysis;
name: string;
values: Array<ChartableStageDatapoints>;
statusSet: Set<FilterStatusType>;
stages: Map<string, ChartableStage>;
}
export type TriggerReasonsDatapoint = {
[K in TriggerReason]?: number;
};
export type StatusesDatapoint = { [status in FilterStatusType]?: number };
export type ChartableDailyDatapoint = Epoch &
TriggerReasonsDatapoint &
StatusesDatapoint;
export interface ChartableDaily {
values: Array<ChartableDailyDatapoint>;
/**
* The build trigger reasons
*/
triggerReasons: Array<string>;
/**
* The top-level (build) statuses
*/
statuses: Array<string>;
}
export interface ChartableStagesAnalysis {
/**
* Summary of statuses and trigger reasons per day
*/
daily: ChartableDaily;
/**
* Total aggregates of sub stages
*/
total: ChartableStage;
/**
* Top-level stages {name -> stage}
*/
stages: Map<string, ChartableStage>;
/**
* All statuses found deeper in the stage tree. A stage might have been
* _aborted_ although the build actually _failed_, e.g.
*/
statuses: Array<string>;
}
+221
View File
@@ -0,0 +1,221 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { throttle } from 'lodash';
import React, {
PropsWithChildren,
Dispatch,
SetStateAction,
Fragment,
useContext,
useState,
useCallback,
useMemo,
useEffect,
} from 'react';
import { ReferenceArea } from 'recharts';
import type { Epoch } from './types';
interface ZoomState {
left?: number;
right?: number;
}
interface ZoomContext {
registerSelection(setter: Dispatch<ZoomState>): void;
setSelectState: Dispatch<SetStateAction<ZoomState>>;
zoomState: ZoomState;
setZoomState: Dispatch<SetStateAction<ZoomState>>;
resetZoom: () => void;
}
const context = React.createContext<ZoomContext>(undefined as any);
export function ZoomProvider({ children }: PropsWithChildren<{}>) {
const [registeredSelectors, setRegisteredSelectors] = useState<
Array<Dispatch<ZoomState>>
>([]);
const [selectState, setSelectState] = useState<ZoomState>({});
const [zoomState, setZoomState] = useState<ZoomState>({});
const registerSelection = useCallback(
(selector: Dispatch<ZoomState>) => {
setRegisteredSelectors(old => [...old, selector]);
return () => {
setRegisteredSelectors(old => old.filter(sel => sel === selector));
};
},
[setRegisteredSelectors],
);
const callSelectors = useCallback(
(state: ZoomState) => {
registeredSelectors.forEach(selector => {
selector(state);
});
},
[registeredSelectors],
);
const throttledCallSelectors = useMemo(
() => throttle(callSelectors, 200),
[callSelectors],
);
useEffect(() => {
throttledCallSelectors({
left: selectState.left,
right: selectState.right,
});
}, [selectState.left, selectState.right, throttledCallSelectors]);
const resetZoom = useCallback(() => {
setSelectState({});
setZoomState({});
}, [setSelectState, setZoomState]);
const value = useMemo(
(): ZoomContext => ({
registerSelection,
setSelectState,
zoomState,
setZoomState,
resetZoom,
}),
[registerSelection, setSelectState, zoomState, setZoomState, resetZoom],
);
return <context.Provider value={value} children={children} />;
}
export function useZoom() {
const { zoomState, resetZoom } = useContext(context);
const zoomFilterValues = useCallback(
<T extends Epoch>(values: Array<T>): Array<T> => {
const { left, right } = zoomState;
return left === undefined || right === undefined
? values
: values.filter(({ __epoch }) => __epoch > left && __epoch < right);
},
[zoomState],
);
return useMemo(
() => ({
resetZoom,
zoomState,
zoomFilterValues,
}),
[resetZoom, zoomState, zoomFilterValues],
);
}
export interface ZoomAreaProps {
yAxisId?: number | string | undefined;
}
export function useZoomArea() {
const [showSelection, setShowSelection] = useState(false);
const [state, setState] = useState<ZoomState>({});
const { setSelectState, setZoomState, registerSelection } =
useContext(context);
const onMouseDown = useCallback(
(e: any) => {
if (!e?.activeLabel) return;
setSelectState({ left: e.activeLabel });
setShowSelection(true);
},
[setSelectState, setShowSelection],
);
const onMouseMove = useCallback(
(e: any) => {
if (!e?.activeLabel) return;
setSelectState(area => {
if (!area.left) {
return area;
}
return { ...area, right: e.activeLabel };
});
},
[setSelectState],
);
const doZoom = useCallback(() => {
setSelectState(old => {
const { left, right } = old;
if (left === undefined || right === undefined || left === right) {
// Either is undefined or both are same - zoom out
setZoomState({});
} else if (left < right) {
setZoomState({ left, right });
} else if (left > right) {
setZoomState({ left: right, right: left });
}
return {};
});
setShowSelection(false);
}, [setSelectState, setZoomState, setShowSelection]);
const zoomProps = useMemo(
() => ({
onMouseDown,
onMouseMove,
onMouseUp: doZoom,
}),
[onMouseDown, onMouseMove, doZoom],
);
useEffect(() => {
if (!showSelection) {
return undefined;
}
return registerSelection(setState);
}, [registerSelection, setState, showSelection]);
const getZoomArea = useCallback(
(props?: ZoomAreaProps) => (
<Fragment key="zoom-area">
{showSelection && state.left && state.right ? (
<ReferenceArea
x1={state.left}
x2={state.right}
strokeOpacity={0.5}
{...props}
/>
) : null}
</Fragment>
),
[showSelection, state.left, state.right],
);
return {
zoomProps,
getZoomArea,
};
}
@@ -0,0 +1,140 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, MouseEvent } from 'react';
import { ButtonGroup, Button, Tooltip, Zoom } from '@material-ui/core';
export interface SwitchValueDetails<T extends string> {
value: T;
tooltip?: string;
text?: string | JSX.Element;
}
export type SwitchValue<T extends string> = T | SwitchValueDetails<T>;
export interface ButtonSwitchPropsBase<T extends string> {
values: ReadonlyArray<SwitchValue<T>>;
vertical?: boolean;
}
export interface ButtonSwitchPropsSingle<T extends string>
extends ButtonSwitchPropsBase<T> {
multi?: false;
selection: T;
onChange: (selected: T) => void;
}
export interface ButtonSwitchPropsMulti<T extends string>
extends ButtonSwitchPropsBase<T> {
multi: true;
selection: ReadonlyArray<T>;
onChange: (selected: Array<T>) => void;
}
export type ButtonSwitchProps<T extends string> =
| ButtonSwitchPropsSingle<T>
| ButtonSwitchPropsMulti<T>;
function switchValue<T extends string>(value: SwitchValue<T>): T {
return typeof value === 'object' ? value.value : value;
}
function switchText<T extends string>(
value: SwitchValue<T>,
): string | JSX.Element {
return typeof value === 'object' ? value.text ?? value.value : value;
}
function findParent(tagName: string, elem: HTMLElement): HTMLElement {
let node: HTMLElement | null = elem;
while (node.tagName !== tagName) {
node = node.parentElement;
if (!node) {
throw new Error(`Couldn't find ${tagName} parent`);
}
}
return node;
}
export function ButtonSwitch<T extends string>(props: ButtonSwitchProps<T>) {
const { values, vertical = false } = props;
const onClick = useCallback(
(ev: MouseEvent<HTMLSpanElement>) => {
const btn = findParent('BUTTON', ev.target as HTMLElement);
const index = [...btn.parentElement!.children].findIndex(
child => child === btn,
);
const value = switchValue(values[index]);
if (props.multi) {
props.onChange(
props.selection.includes(value as T)
? props.selection.filter(val => val !== value)
: [...props.selection, value as T],
);
} else {
props.onChange(value as T);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[values, props.selection, props.multi, props.onChange],
);
const hasSelection = (value: T) => {
if (props.multi) {
return props.selection.includes(value);
}
return props.selection === value;
};
const tooltipify = (value: SwitchValue<T>, elem: JSX.Element) =>
typeof value === 'object' && value.tooltip ? (
<Tooltip
key={value.value}
TransitionComponent={Zoom}
title={value.tooltip}
arrow
>
{elem}
</Tooltip>
) : (
elem
);
return (
<ButtonGroup
disableElevation
orientation={vertical ? 'vertical' : 'horizontal'}
variant="outlined"
size="small"
>
{values.map(value =>
tooltipify(
value,
<Button
key={switchValue(value)}
color={hasSelection(switchValue(value)) ? 'primary' : 'default'}
variant={
hasSelection(switchValue(value)) ? 'contained' : 'outlined'
}
onClick={onClick}
>
{switchText(value)}
</Button>,
),
)}
</ButtonGroup>
);
}
@@ -0,0 +1,497 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import {
Button,
Card,
CardHeader,
CardContent,
FormControl,
FormGroup,
FormControlLabel,
Grid,
Switch,
Tooltip,
Typography,
Theme,
makeStyles,
} from '@material-ui/core';
import ShowChartIcon from '@material-ui/icons/ShowChart';
import BarChartIcon from '@material-ui/icons/BarChart';
import {
MuiPickersUtilsProvider,
KeyboardDatePicker,
} from '@material-ui/pickers';
import { DateTime } from 'luxon';
import LuxonUtils from '@date-io/luxon';
import {
ChartType,
ChartTypes,
CicdConfiguration,
CicdDefaults,
FilterBranchType,
FilterStatusType,
statusTypes,
} from '../apis/types';
import { ChartableStagesAnalysis } from '../charts/types';
import { ButtonSwitch, SwitchValue } from './button-switch';
import { Toggle } from './toggle';
import { DurationSlider } from './duration-slider';
import { Label } from './label';
export const useStyles = makeStyles<Theme>(
theme => ({
rootCard: {
padding: theme.spacing(0, 0, 0, 0),
margin: theme.spacing(0, 0, 2, 0),
},
updateButton: {
margin: theme.spacing(1, 0, 0, 0),
},
header: {
margin: theme.spacing(0, 0, 0, 0),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
title: {
margin: theme.spacing(3, 0, 1, 0),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
'&:first-child': {
margin: theme.spacing(1, 0, 1, 0),
},
},
buttonDescription: {
textTransform: 'uppercase',
margin: theme.spacing(1, 0, 0, 1),
},
}),
{
name: 'CicdStatistics',
},
);
export type BranchSelection = FilterBranchType | 'all';
export type StatusSelection = FilterStatusType;
export interface ChartFilter {
fromDate: Date;
toDate: Date;
branch: string;
status: Array<string>;
}
export function getDefaultChartFilter(
cicdConfiguration: CicdConfiguration,
): ChartFilter {
const toDate = cicdConfiguration.defaults?.timeTo ?? new Date();
return {
fromDate:
cicdConfiguration.defaults?.timeFrom ??
DateTime.fromJSDate(toDate).minus({ months: 1 }).toJSDate(),
toDate,
branch: cicdConfiguration.defaults?.filterType ?? 'branch',
status:
cicdConfiguration.defaults?.filterStatus ??
cicdConfiguration.availableStatuses.filter(
status => status === 'succeeded' || status === 'failed',
),
};
}
function isSameChartFilter(a: ChartFilter, b: ChartFilter): boolean {
return (
a.branch === b.branch &&
[...a.status].sort().join(' ') === [...b.status].sort().join(' ') &&
DateTime.fromJSDate(a.fromDate).hasSame(
DateTime.fromJSDate(b.fromDate),
'day',
) &&
DateTime.fromJSDate(a.toDate).hasSame(DateTime.fromJSDate(b.toDate), 'day')
);
}
export type ViewOptions = Pick<
CicdDefaults,
| 'lowercaseNames'
| 'normalizeTimeRange'
| 'collapsedLimit'
| 'hideLimit'
| 'chartTypes'
>;
export function getDefaultViewOptions(
cicdConfiguration: CicdConfiguration,
): ViewOptions {
return {
lowercaseNames: cicdConfiguration.defaults?.lowercaseNames ?? false,
normalizeTimeRange: cicdConfiguration.defaults?.normalizeTimeRange ?? true,
collapsedLimit: 60 * 1000, // 1m
hideLimit: 20 * 1000, // 20s
chartTypes: {
succeeded: ['duration'],
failed: ['count'],
enqueued: ['count'],
scheduled: ['count'],
running: ['count'],
aborted: ['count'],
stalled: ['count'],
expired: ['count'],
unknown: ['count'],
},
};
}
const branchValues: Array<SwitchValue<BranchSelection>> = [
'master',
'branch',
{
value: 'all',
tooltip:
'NOTE; If the build pipelines are very different between master and branch ' +
'builds, viewing them combined might not result in a very useful chart',
},
];
const chartTypeValues: Array<SwitchValue<ChartType>> = [
{ value: 'duration', text: <ShowChartIcon />, tooltip: 'Duration' },
{ value: 'count', text: <BarChartIcon />, tooltip: 'Count per day' },
];
export interface ChartFiltersProps {
analysis?: ChartableStagesAnalysis;
cicdConfiguration: CicdConfiguration;
initialFetchFilter: ChartFilter;
currentFetchFilter?: ChartFilter;
onChangeFetchFilter(filter: ChartFilter): void;
updateFetchFilter(filter: ChartFilter): void;
initialViewOptions: ViewOptions;
onChangeViewOptions(filter: ViewOptions): void;
}
interface InternalRef {
first: boolean;
}
export function ChartFilters(props: ChartFiltersProps) {
const {
analysis,
cicdConfiguration,
initialFetchFilter,
currentFetchFilter,
onChangeFetchFilter,
updateFetchFilter,
initialViewOptions,
onChangeViewOptions,
} = props;
const classes = useStyles();
const [internalRef] = useState<InternalRef>({ first: true });
const [useNowAsToDate, setUseNowAsToDate] = useState(true);
const [toDate, setToDate] = useState(initialFetchFilter.toDate);
const [fromDate, setFromDate] = useState(initialFetchFilter.fromDate);
const [branch, setBranch] = useState(initialFetchFilter.branch);
const statusValues: ReadonlyArray<StatusSelection> =
cicdConfiguration.availableStatuses;
const [selectedStatus, setSelectedStatus] = useState(
initialFetchFilter.status,
);
const [viewOptions, setViewOptions] = useState(initialViewOptions);
const setLowercaseNames = useCallback(
(lowercaseNames: boolean) => {
setViewOptions(old => ({ ...old, lowercaseNames }));
},
[setViewOptions],
);
const setNormalizeTimeRange = useCallback(
(normalizeTimeRange: boolean) => {
setViewOptions(old => ({ ...old, normalizeTimeRange }));
},
[setViewOptions],
);
const setHideLimit = useCallback(
(value: number) => {
setViewOptions(old => ({ ...old, hideLimit: value }));
},
[setViewOptions],
);
const setCollapseLimit = useCallback(
(value: number) => {
setViewOptions(old => ({ ...old, collapsedLimit: value }));
},
[setViewOptions],
);
const setChartType = useCallback(
(statusType: FilterStatusType, chartTypes: ChartTypes) => {
setViewOptions(old => ({
...old,
chartTypes: { ...old.chartTypes, [statusType]: chartTypes },
}));
},
[setViewOptions],
);
const setChartTypeSpecific = useMemo(
() =>
Object.fromEntries(
statusTypes.map(
status =>
[
status,
(chartTypes: ChartTypes) => setChartType(status, chartTypes),
] as const,
),
),
[setChartType],
);
useEffect(() => {
onChangeViewOptions(viewOptions);
}, [onChangeViewOptions, viewOptions]);
useEffect(() => {
if (internalRef.first) {
// Skip calling onChangeFetchFilter first time
internalRef.first = false;
return;
}
onChangeFetchFilter({
toDate,
fromDate,
branch,
status: selectedStatus,
});
}, [
internalRef,
toDate,
fromDate,
branch,
selectedStatus,
onChangeFetchFilter,
]);
const toggleUseNowAsDate = useCallback(() => {
setUseNowAsToDate(!useNowAsToDate);
if (!DateTime.fromJSDate(toDate).hasSame(DateTime.now(), 'day')) {
setToDate(new Date());
}
}, [useNowAsToDate, toDate]);
const hasFetchFilterChanges = useMemo(
() =>
!currentFetchFilter ||
!isSameChartFilter(
{
toDate,
fromDate,
branch,
status: selectedStatus,
},
currentFetchFilter,
),
[toDate, fromDate, branch, selectedStatus, currentFetchFilter],
);
const updateFilter = useCallback(() => {
updateFetchFilter({
toDate,
fromDate,
branch,
status: selectedStatus,
});
}, [toDate, fromDate, branch, selectedStatus, updateFetchFilter]);
const inrefferedStatuses = analysis?.statuses ?? selectedStatus;
return (
<MuiPickersUtilsProvider utils={LuxonUtils}>
<Card className={classes.rootCard}>
<CardHeader
action={
<Button
size="small"
color="secondary"
variant="contained"
onClick={updateFilter}
disabled={!hasFetchFilterChanges}
>
Update
</Button>
}
title={
<Typography variant="subtitle2" className={classes.header}>
Fetching options
</Typography>
}
/>
<CardContent>
<Typography
variant="subtitle2"
className={`${classes.title} ${classes.title}`}
>
Date range
</Typography>
<KeyboardDatePicker
autoOk
variant="inline"
inputVariant="outlined"
label="From date"
format="yyyy-MM-dd"
value={fromDate}
InputAdornmentProps={{ position: 'start' }}
onChange={date => setFromDate(date?.toJSDate() ?? new Date())}
/>
<br />
<FormControl component="fieldset">
<FormGroup>
<FormControlLabel
control={
<Switch
checked={useNowAsToDate}
onChange={toggleUseNowAsDate}
/>
}
label={<Label>To today</Label>}
/>
{useNowAsToDate ? null : (
<KeyboardDatePicker
autoOk
variant="inline"
inputVariant="outlined"
label="To date"
format="yyyy-MM-dd"
value={toDate}
InputAdornmentProps={{ position: 'start' }}
onChange={date => setToDate(date?.toJSDate() ?? new Date())}
/>
)}
</FormGroup>
</FormControl>
<Typography
variant="subtitle2"
className={`${classes.title} ${classes.title}`}
>
Branch
</Typography>
<ButtonSwitch<string>
values={branchValues}
selection={branch}
onChange={setBranch}
/>
<Typography
variant="subtitle2"
className={`${classes.title} ${classes.title}`}
>
Status
</Typography>
<ButtonSwitch<string>
values={statusValues}
multi
vertical
selection={selectedStatus}
onChange={setSelectedStatus}
/>
</CardContent>
</Card>
<Card className={classes.rootCard}>
<CardHeader
title={
<Typography variant="subtitle2" className={classes.header}>
View options
</Typography>
}
/>
<CardContent>
<Toggle
checked={viewOptions.lowercaseNames}
setChecked={setLowercaseNames}
>
<Tooltip
arrow
title={
'Lowercasing names can reduce duplications ' +
'when stage names have changed casing'
}
>
<Label>Lowercase names</Label>
</Tooltip>
</Toggle>
<Toggle
checked={viewOptions.normalizeTimeRange}
setChecked={setNormalizeTimeRange}
>
<Tooltip
arrow
title={
'All charts will use the same x-axis. ' +
'This reduces confusion when stages have been altered over time ' +
'and only appear in a part of the time range.'
}
>
<Label>Normalize time range</Label>
</Tooltip>
</Toggle>
<DurationSlider
header="Hide under peak"
value={viewOptions.hideLimit}
setValue={setHideLimit}
/>
<DurationSlider
header="Collapse under peak"
value={viewOptions.collapsedLimit}
setValue={setCollapseLimit}
/>
<Typography
variant="subtitle2"
className={`${classes.title} ${classes.title}`}
>
Chart styles
</Typography>
{inrefferedStatuses.map(status => (
<Grid key={status} container spacing={0}>
<Grid item>
<ButtonSwitch<ChartType>
values={chartTypeValues}
selection={viewOptions.chartTypes[status as FilterStatusType]}
onChange={setChartTypeSpecific[status]}
multi
/>
</Grid>
<Grid item className={classes.buttonDescription}>
<div>{status}</div>
</Grid>
</Grid>
))}
</CardContent>
</Card>
</MuiPickersUtilsProvider>
);
}
@@ -0,0 +1,117 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { Slider } from '@material-ui/core';
import { debounce } from 'lodash';
import { formatDuration, formatDurationFromSeconds } from './utils';
import { Label } from './label';
const marks = [
0,
1,
2,
3,
4,
5,
10,
20,
30,
60,
2 * 60,
3 * 60,
4 * 60,
5 * 60,
10 * 60,
15 * 60,
30 * 60,
45 * 60,
60 * 60,
].map((value, index) => ({
value: index,
label: formatDurationFromSeconds(value),
seconds: value,
}));
function findMarkIndex(seconds: number): number {
if (marks[0].seconds > seconds) {
return 0;
} else if (marks[marks.length - 1].seconds < seconds) {
return marks.length - 1;
}
for (let i = 0; i < marks.length - 1; ++i) {
const a = marks[i];
const b = marks[i + 1];
if (seconds === a.seconds) {
return i;
} else if (seconds === b.seconds) {
return i + 1;
} else if (a.seconds < seconds && b.seconds > seconds) {
return seconds - a.seconds < b.seconds - seconds ? i : i - 1;
}
}
return 0; // Won't happen
}
function formatDurationFromIndex(index: number) {
return formatDurationFromSeconds(marks[index].seconds);
}
export interface DurationSliderProps {
header: string;
value: number;
setValue: (value: number) => void;
}
export function DurationSlider(props: DurationSliderProps) {
const { header, value, setValue } = props;
const [curValue, setCurValue] = useState(value);
const debouncedSetValue = useMemo(() => debounce(setValue, 1000), [setValue]);
const onChange = useCallback(
(_: any, index: number | number[]) => {
const millis = marks[index as number].seconds * 1000;
setCurValue(millis);
debouncedSetValue(millis);
},
[debouncedSetValue],
);
const indexValue = useMemo(() => findMarkIndex(curValue / 1000), [curValue]);
return (
<>
<Label>
{header} {formatDuration(curValue)}
</Label>
<Slider
value={indexValue}
min={0}
step={1}
max={marks.length - 1}
marks
getAriaValueText={formatDurationFromIndex}
valueLabelFormat={formatDurationFromIndex}
onChange={onChange}
valueLabelDisplay="auto"
aria-labelledby="slider-hide-limit"
/>
</>
);
}
@@ -0,0 +1,40 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import { Typography, Theme, makeStyles } from '@material-ui/core';
export const useStyles = makeStyles<Theme>(
theme => ({
label: {
fontWeight: 'normal',
margin: theme.spacing(0),
},
}),
{
name: 'CicdStatisticsLabel',
},
);
export function Label({ children }: PropsWithChildren<{}>) {
const classes = useStyles();
return (
<Typography variant="subtitle2" className={classes.label}>
{children}
</Typography>
);
}
@@ -0,0 +1,216 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { CSSProperties, DependencyList } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { Box, LinearProgress } from '@material-ui/core';
import Timeline from '@material-ui/lab/Timeline';
import TimelineItem from '@material-ui/lab/TimelineItem';
import TimelineSeparator from '@material-ui/lab/TimelineSeparator';
import TimelineConnector from '@material-ui/lab/TimelineConnector';
import TimelineContent from '@material-ui/lab/TimelineContent';
import TimelineOppositeContent from '@material-ui/lab/TimelineOppositeContent';
import TimelineDot, { TimelineDotProps } from '@material-ui/lab/TimelineDot';
import Alert from '@material-ui/lab/Alert';
import { useApp } from '@backstage/core-plugin-api';
const stepProgressStyle: CSSProperties = {
marginTop: 6,
};
// Matching react-use, only has loading/error/value
type AsyncState<T> =
| {
loading: boolean;
error?: undefined;
value?: undefined;
}
| {
loading: true;
error?: Error | undefined;
value?: T;
}
| {
loading: false;
error: Error;
value?: undefined;
}
| {
loading: false;
error?: undefined;
value: T;
};
export interface ProgressStep extends ProgessAsSingle {
title: string;
}
export interface ProgessAsSteps {
steps: Array<ProgressStep>;
}
export interface ProgessAsSingle<T = number> {
progress?: T;
progressBuffer?: T;
}
export type ProgessState<T = number> =
| ProgessAsSingle<T>
| (T extends number ? ProgessAsSteps : { steps?: undefined });
export type ProgressAsLoading = ProgessState & {
loading: true;
error?: undefined;
value?: undefined;
};
export type ProgressAsError = ProgessState<undefined> & {
loading?: false | undefined;
error: Error;
value?: undefined;
};
export type ProgressAsValue<T> = ProgessState<undefined> & {
loading?: false | undefined;
error?: undefined;
value: T;
};
/**
* An AsyncState but with the addition of progress (decimal 0-1) to allow
* rendering a progress bar while waiting.
*/
export type ProgressType<T> =
| ProgressAsLoading
| ProgressAsError
| ProgressAsValue<T>;
const sentry = Symbol();
/**
* Casts an AsyncState or Progress into its non-succeeded sub types
*/
type Unsuccessful<S extends ProgressType<any> | AsyncState<any>> =
S extends ProgressType<any>
? ProgressAsLoading | ProgressAsError
: Omit<AsyncState<any>, 'value'>;
/**
* Similar to useAsync except it "waits" for a dependent (upstream) async state
* to finish first, otherwise it forwards the dependent pending state.
*
* When/if the dependent state has settled successfully, the callback will be
* invoked for a new layer of async state with the dependent (upstream) success
* result as argument.
*/
export function useAsyncChain<S extends ProgressType<any> | AsyncState<any>, R>(
parentState: S,
fn: (value: NonNullable<S['value']>) => Promise<R>,
deps: DependencyList,
): Unsuccessful<S> | AsyncState<R> {
const childState = useAsync(
async () => (!parentState.value ? sentry : fn(parentState.value)),
[!parentState.error, !parentState.loading, parentState.value, ...deps],
);
if (!parentState.value) {
return parentState as Unsuccessful<S>;
} else if (childState.value === sentry) {
return { loading: true };
}
return childState as AsyncState<R>;
}
export function renderFallbacks<T>(
state: ProgressType<T> | AsyncState<T>,
success: (value: T) => JSX.Element,
): JSX.Element {
if (state.loading) {
return <ViewProgress state={state} />;
} else if (state.error) {
return <Alert severity="error">{state.error.stack}</Alert>;
}
return success(state.value!);
}
export function ViewProgress({
state,
}: {
state: ProgressAsLoading | { loading: boolean };
}) {
const { Progress } = useApp().getComponents();
const stateAsSingleProgress = state as ProgessAsSingle;
const stateAsStepProgress = state as ProgessAsSteps;
if (
!stateAsSingleProgress.progress &&
!stateAsSingleProgress.progressBuffer &&
!stateAsStepProgress.steps
) {
// Simple spinner
return <Progress />;
} else if (stateAsSingleProgress.progress !== undefined) {
// Simple _single_ progress
return (
<Box sx={{ width: '100%' }}>
<LinearProgress
variant="buffer"
value={(stateAsSingleProgress.progress ?? 0) * 100}
valueBuffer={(stateAsSingleProgress.progressBuffer ?? 0) * 100}
/>
</Box>
);
}
// Multi-step progresses
return (
<Box sx={{ width: '100%' }}>
<Timeline>
{stateAsStepProgress.steps.map((step, index) => (
<TimelineItem key={index}>
<TimelineOppositeContent>{step.title}</TimelineOppositeContent>
<TimelineSeparator>
<TimelineDot color={getDotColor(step)} />
{index < stateAsStepProgress.steps.length - 1 ? (
<TimelineConnector />
) : null}
</TimelineSeparator>
<TimelineContent>
{!step.progress && !step.progressBuffer ? null : (
<LinearProgress
style={stepProgressStyle}
variant="buffer"
value={(step.progress ?? 0) * 100}
valueBuffer={(step.progressBuffer ?? 0) * 100}
/>
)}
</TimelineContent>
</TimelineItem>
))}
</Timeline>
</Box>
);
}
function getDotColor(step: ProgressStep): TimelineDotProps['color'] {
const progress = step.progress ?? 0;
if (progress >= 1) {
return 'primary';
} else if (progress > 0) {
return 'secondary';
}
return 'grey';
}
@@ -0,0 +1,40 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, PropsWithChildren } from 'react';
import { FormControlLabel, Switch } from '@material-ui/core';
export interface ToggleProps {
checked: boolean;
setChecked: (checked: boolean) => void;
}
export function Toggle({
checked,
setChecked,
children,
}: PropsWithChildren<ToggleProps>) {
const toggler = useCallback(() => {
setChecked(!checked);
}, [checked, setChecked]);
return (
<FormControlLabel
control={<Switch checked={checked} onChange={toggler} />}
label={children}
/>
);
}
@@ -0,0 +1,133 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { CSSProperties } from 'react';
import { DateTime, Duration } from 'luxon';
import humanizeDuration from 'humanize-duration';
import { capitalize } from 'lodash';
const infoText: CSSProperties = { color: 'InfoText' };
/**
* Picks {num} elements from {arr} evenly, from the first to the last
*/
export function pickElements<T>(arr: ReadonlyArray<T>, num: number): Array<T> {
if (arr.length <= num) {
return [...arr];
}
if (num < 2) {
return [arr[arr.length / 2]];
}
const step = arr.length / (num - 1);
return [
...Array.from(Array(num - 1)).map(
(_, index) => arr[Math.round(index * step)],
),
arr[arr.length - 1],
];
}
function formatDateShort(milliseconds: number) {
if ((milliseconds as any) === 'auto') {
// When recharts gets confused (empty data)
return '';
}
return DateTime.fromMillis(milliseconds).toLocaleString(DateTime.DATE_SHORT);
}
function formatDateTimeShort(milliseconds: number) {
if ((milliseconds as any) === 'auto') {
// When recharts gets confused (empty data)
return '';
}
return DateTime.fromMillis(milliseconds).toLocaleString(
DateTime.DATETIME_SHORT,
);
}
export function labelFormatter(epoch: number) {
return <span style={infoText}>{formatDateTimeShort(epoch)}</span>;
}
export function labelFormatterWithoutTime(epoch: number) {
return <span style={infoText}>{formatDateShort(epoch)}</span>;
}
export function tickFormatterX(epoch: number) {
return formatDateShort(epoch);
}
export function tickFormatterY(duration: number) {
if (duration === 0) {
return '0';
} else if (duration < 500) {
return `${duration} ms`;
}
return formatDuration(duration)
.replace(/second.*/, 'sec')
.replace(/minute.*/, 'min')
.replace(/hour.*/, 'h')
.replace(/day.*/, 'd')
.replace(/month.*/, 'm')
.replace(/year.*/, 'y');
}
export function tooltipValueFormatter(durationOrCount: number, name: string) {
return [
<span style={infoText}>
{capitalize(name)}:{' '}
{name.endsWith(' count')
? durationOrCount
: formatDuration(durationOrCount)}
</span>,
null,
];
}
export function formatDuration(millis: number) {
let rest = Math.round(millis);
const days = Math.floor(rest / (1000 * 60 * 60 * 24));
rest -= days * (1000 * 60 * 60 * 24);
const hours = Math.floor(rest / (1000 * 60 * 60));
rest -= hours * (1000 * 60 * 60);
const minutes = Math.floor(rest / (1000 * 60));
rest -= minutes * (1000 * 60);
const seconds = Math.floor(rest / 1000);
rest -= seconds * 1000;
const milliseconds = rest;
if (!days && !hours && !minutes) {
if (seconds < 1) {
return `${milliseconds}ms`;
} else if (seconds < 2) {
return `${((milliseconds + seconds * 1000) / 1000).toFixed(1)}s`;
}
}
const dur = Duration.fromObject({
...(days && { days }),
...(hours && { hours }),
...(minutes && !days && { minutes }),
...(seconds && !days && !hours && { seconds }),
});
return humanizeDuration(dur.toMillis(), { round: true });
}
export function formatDurationFromSeconds(seconds: number) {
return formatDuration(seconds * 1000);
}
+213
View File
@@ -0,0 +1,213 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { Grid, makeStyles, Theme } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
import { DateTime } from 'luxon';
import {
useCicdStatistics,
UseCicdStatisticsOptions,
} from './hooks/use-cicd-statistics';
import { useCicdConfiguration } from './hooks/use-cicd-configuration';
import { buildsToChartableStages } from './charts/logic/conversions';
import { ZoomProvider, useZoom } from './charts/zoom';
import { StageChart } from './charts/stage-chart';
import { StatusChart } from './charts/status-chart';
import {
ChartFilter,
ChartFilters,
getDefaultChartFilter,
getDefaultViewOptions,
ViewOptions,
} from './components/chart-filters';
import {
CicdConfiguration,
FilterStatusType,
FilterBranchType,
} from './apis/types';
import { cleanupBuildTree } from './utils/stage-names';
import { renderFallbacks, useAsyncChain } from './components/progress';
import { sortFilterStatusType } from './utils/api';
export function EntityPageCicdCharts() {
const state = useCicdConfiguration();
return renderFallbacks(state, value => (
<ZoomProvider>
<CicdCharts cicdConfiguration={value} />
</ZoomProvider>
));
}
const useStyles = makeStyles<Theme>(
theme => ({
pane: {
padding: theme.spacing(1, 1, 1, 1),
},
}),
{
name: 'CicdStatisticsView',
},
);
function startOfDay(date: Date) {
return DateTime.fromJSDate(date).startOf('day').toJSDate();
}
function endOfDay(date: Date) {
return DateTime.fromJSDate(date).endOf('day').toJSDate();
}
function cleanChartFilter(filter: ChartFilter): ChartFilter {
return {
...filter,
status: sortFilterStatusType(filter.status as FilterStatusType[]),
};
}
interface CicdChartsProps {
cicdConfiguration: CicdConfiguration;
}
function CicdCharts(props: CicdChartsProps) {
const { cicdConfiguration } = props;
const errorApi = useApi(errorApiRef);
const { entity } = useEntity();
const classes = useStyles();
const { resetZoom } = useZoom();
const [chartFilter, setChartFilter] = useState(
getDefaultChartFilter(cicdConfiguration),
);
const [fetchedChartData, setFetchedChartData] = useState({
abortController: null as null | AbortController,
chartFilter,
});
const [viewOptions, setViewOptions] = useState(
getDefaultViewOptions(cicdConfiguration),
);
const fetchStatisticsOptions = useMemo((): UseCicdStatisticsOptions => {
const abortController = new AbortController();
fetchedChartData.abortController = abortController;
return {
abortController,
entity,
timeFrom: startOfDay(fetchedChartData.chartFilter.fromDate),
timeTo: endOfDay(fetchedChartData.chartFilter.toDate),
filterStatus: fetchedChartData.chartFilter.status as FilterStatusType[],
filterType: fetchedChartData.chartFilter.branch as FilterBranchType,
};
}, [entity, fetchedChartData]);
const statisticsState = useCicdStatistics(fetchStatisticsOptions);
const updateFilter = useCallback(() => {
// Abort previous fetch
fetchedChartData.abortController?.abort();
setFetchedChartData({ abortController: null, chartFilter });
}, [fetchedChartData, setFetchedChartData, chartFilter]);
const chartableStagesState = useAsyncChain(
statisticsState,
async value =>
buildsToChartableStages(
await cleanupBuildTree(value.builds, {
formatStageName: cicdConfiguration.formatStageName,
lowerCase: viewOptions.lowercaseNames,
}),
{ normalizeTimeRange: viewOptions.normalizeTimeRange },
),
[statisticsState, cicdConfiguration, viewOptions],
);
useEffect(() => {
resetZoom();
}, [resetZoom, statisticsState.value]);
const onFilterChange = useCallback((filter: ChartFilter) => {
setChartFilter(cleanChartFilter(filter));
}, []);
const onViewOptionsChange = useCallback(
(options: ViewOptions) => {
setViewOptions(options);
},
[setViewOptions],
);
useEffect(() => {
if (
!chartableStagesState.error ||
chartableStagesState.error?.name === 'AbortError'
) {
return;
}
errorApi.post(chartableStagesState.error);
}, [errorApi, chartableStagesState.error]);
return (
<Grid container>
<Grid item lg={2} className={classes.pane}>
<ChartFilters
analysis={chartableStagesState.value}
cicdConfiguration={cicdConfiguration}
initialFetchFilter={chartFilter}
currentFetchFilter={fetchedChartData.chartFilter}
onChangeFetchFilter={onFilterChange}
updateFetchFilter={updateFilter}
initialViewOptions={viewOptions}
onChangeViewOptions={onViewOptionsChange}
/>
</Grid>
<Grid item xs={12} lg={10} className={classes.pane}>
{renderFallbacks(chartableStagesState, chartableStages => (
<>
{chartableStages.stages.size > 0 ? null : (
<Alert severity="info">No data</Alert>
)}
{!statisticsState.value?.builds?.length ||
!chartableStagesState.value?.daily?.values?.length ? null : (
<StatusChart analysis={chartableStagesState.value} />
)}
<StageChart
stage={chartableStages.total}
defaultCollapsed={0}
defaultHidden={viewOptions.hideLimit}
chartTypes={viewOptions.chartTypes}
/>
{[...chartableStages.stages.entries()].map(([name, stage]) => (
<StageChart
key={name}
stage={stage}
defaultCollapsed={viewOptions.collapsedLimit}
defaultHidden={viewOptions.hideLimit}
chartTypes={viewOptions.chartTypes}
/>
))}
</>
))}
</Grid>
</Grid>
);
}
@@ -0,0 +1,61 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { CicdConfiguration, statusTypes } from '../apis';
import { ProgressType } from '../components/progress';
import { defaultFormatStageName } from '../utils/stage-names';
import { useCicdStatisticsApi } from './use-cicd-statistics-api';
export function useCicdConfiguration(): ProgressType<CicdConfiguration> {
const cicdStatisticsApi = useCicdStatisticsApi();
const { entity } = useEntity();
const [state, setState] = useState<ProgressType<CicdConfiguration>>({
loading: true,
});
useEffect(() => {
if (!cicdStatisticsApi) {
setState({ error: new Error('No CI/CD Statistics API installed') });
return;
}
cicdStatisticsApi
.getConfiguration({ entity })
.then(configuration => {
const {
availableStatuses = statusTypes,
formatStageName = defaultFormatStageName,
defaults = {},
} = configuration;
setState({
value: {
availableStatuses,
formatStageName,
defaults,
},
});
})
.catch(error => {
setState({ error });
});
}, [cicdStatisticsApi, entity]);
return state;
}
@@ -0,0 +1,27 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import { cicdStatisticsApiRef } from '../apis';
export function useCicdStatisticsApi() {
try {
return useApi(cicdStatisticsApiRef);
} catch (err) {
return undefined;
}
}
@@ -0,0 +1,147 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { throttle } from 'lodash';
import { Entity } from '@backstage/catalog-model';
import {
CicdState,
FetchBuildsOptions,
AbortError,
FilterStatusType,
FilterBranchType,
UpdateProgress,
} from '../apis';
import { ProgressType } from '../components/progress';
import { useCicdStatisticsApi } from './use-cicd-statistics-api';
export interface UseCicdStatisticsOptions {
entity: Entity;
abortController: AbortController;
timeFrom: Date;
timeTo: Date;
filterStatus: Array<FilterStatusType | 'all'>;
filterType: FilterBranchType | 'all';
}
export function useCicdStatistics(
options: UseCicdStatisticsOptions,
): ProgressType<CicdState> {
const {
entity,
abortController,
timeFrom,
timeTo,
filterStatus,
filterType,
} = options;
const [state, setState] = useState<ProgressType<CicdState>>({
loading: true,
});
const cicdStatisticsApi = useCicdStatisticsApi();
useEffect(() => {
if (!cicdStatisticsApi) {
setState({ error: new Error('No CI/CD Statistics API installed') });
return () => {};
}
let mounted = true;
let completed = false; // successfully or failed
const updateProgressImpl: UpdateProgress = (_count, _total?, _started?) => {
if (!mounted || completed) {
return;
}
if (Array.isArray(_count)) {
// Multi-progress
setState({
loading: true,
steps: _count.map(step => ({
title: step.title,
progress: !step.total ? 0 : step.completed / step.total,
progressBuffer: !step.total ? 0 : (step.started ?? 0) / step.total,
})),
});
} else {
// Single-progress
const count = _count;
const total = _total as number;
const started = (_started as number) ?? 0;
setState({
loading: true,
progress: !total ? 0 : count / total,
progressBuffer: !total ? 0 : started / total,
});
}
};
const updateProgress = throttle(
updateProgressImpl,
200,
// throttle doesn't handle types of multi-signature functions
) as any as UpdateProgress;
const fetchOptions: FetchBuildsOptions = {
entity,
updateProgress,
abortSignal: abortController.signal,
timeFrom,
timeTo,
filterStatus,
filterType,
};
(async () => {
return cicdStatisticsApi.fetchBuilds(fetchOptions);
})()
.then(builds => {
completed = true;
if (mounted) {
setState({
value: builds,
});
}
})
.catch(err => {
completed = true;
if (mounted) {
setState({
error: abortController.signal.aborted ? new AbortError() : err,
});
}
});
return () => {
mounted = false;
abortController.abort();
};
}, [
abortController,
entity,
timeFrom,
timeTo,
filterStatus,
filterType,
cicdStatisticsApi,
]);
return state;
}
+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';
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createPlugin,
createRoutableExtension,
createRouteRef,
} from '@backstage/core-plugin-api';
export type { EntityPageCicdCharts } from './entity-page';
const rootCatalogCicdStatsRouteRef = createRouteRef({
id: 'cicd-statistics',
});
export const cicdStatisticsPlugin = createPlugin({
id: 'cicd-statistics',
routes: {
entityContent: rootCatalogCicdStatsRouteRef,
},
});
export const EntityCicdStatisticsContent = cicdStatisticsPlugin.provide(
createRoutableExtension({
component: () => import('./entity-page').then(m => m.EntityPageCicdCharts),
mountPoint: rootCatalogCicdStatsRouteRef,
name: 'EntityCicdStatisticsContent',
}),
);
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FilterStatusType, statusTypes } from '../apis/types';
export function sortFilterStatusType<T extends FilterStatusType>(
statuses: ReadonlyArray<T>,
): Array<T> {
const statusSet = new Set<T>(statuses);
const sorted = (['all', ...statusTypes] as Array<T>).filter((status: T) => {
if (statusSet.has(status)) {
statusSet.delete(status);
return true;
}
return false;
});
return [...sorted, ...statusSet];
}
@@ -0,0 +1,73 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { map } from 'already';
import { Build, Stage } from '../apis/types';
export function defaultFormatStageName(
parentNames: Array<string>,
stageName: string,
): string {
let name = stageName;
// Cut off parent names (if they are prefixed to the stage name)
parentNames.forEach(parentName => {
if (name.startsWith(parentName)) {
const newName = name
.slice(parentName.length)
// Remove things like ' - '
.replace(/^[^\w\d]+/g, '');
if (newName) {
name = newName;
}
}
});
// Cut off anything after colon in what looks like pulling docker images
return name.replace(/((pulling|(running)) [^:/]*\/.*?):.*/g, '$1');
}
export interface CleanupBuildTreeOptions {
formatStageName: typeof defaultFormatStageName;
lowerCase: boolean;
}
export async function cleanupBuildTree(
builds: Build[],
opts: CleanupBuildTreeOptions,
): Promise<Build[]> {
const { formatStageName, lowerCase } = opts;
const recurseStage = (stage: Stage, parentNames: Array<string>): Stage => {
const name = formatStageName(
parentNames,
lowerCase ? stage.name.toLocaleLowerCase('en-US') : stage.name,
);
const ancestry = [...parentNames, name];
return {
...stage,
name,
stages: stage.stages?.map(subStage => recurseStage(subStage, ancestry)),
};
};
return map(builds, { chunk: 'idle' }, build => ({
...build,
stages: build.stages.map(stage => recurseStage(stage, [])),
}));
}
+209 -7
View File
@@ -1409,7 +1409,7 @@
zen-observable "^0.8.15"
zod "^3.11.6"
"@backstage/core-plugin-api@^0.4.0":
"@backstage/core-plugin-api@^0.4.0", "@backstage/core-plugin-api@^0.4.1":
version "0.4.1"
resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-0.4.1.tgz#c0a13504bdfa61ae3d0db96934cd6c32a7574446"
integrity sha512-IIb7XTcquTPaSIlamMKUgeTs5uLqkKN0Nw32QdTZhKgFkFFVzWC0AwN+henkaMNBZFdGb0ttPzrvNXGj5E6dGg==
@@ -1440,7 +1440,7 @@
"@material-ui/lab" "4.0.0-alpha.57"
react-use "^17.2.4"
"@backstage/plugin-catalog-react@^0.6.13", "@backstage/plugin-catalog-react@^0.6.5":
"@backstage/plugin-catalog-react@^0.6.13", "@backstage/plugin-catalog-react@^0.6.5", "@backstage/plugin-catalog-react@^0.6.9":
version "0.6.13"
resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.6.13.tgz#b325eae501d3edeb8b7caef5d9615f2e632f5430"
integrity sha512-XBwop7PwAZqfongx3KP6jAJar+MEscLSp8nLuHYX5XxA+suQNiBgi96uO3SEQmvtae+hvsRM7c0WHSxbYiXsDA==
@@ -1842,6 +1842,13 @@
dependencies:
"@date-io/core" "^1.3.13"
"@date-io/luxon@^1.3.13":
version "1.3.13"
resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a"
integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw==
dependencies:
"@date-io/core" "^1.3.13"
"@elastic/elasticsearch-mock@^0.3.0":
version "0.3.0"
resolved "https://registry.npmjs.org/@elastic/elasticsearch-mock/-/elasticsearch-mock-0.3.0.tgz#6b1d8448aad3ca20f760fa01c0206b733c9c1e54"
@@ -3622,7 +3629,7 @@
react-beautiful-dnd "^13.0.0"
react-double-scrollbar "0.0.15"
"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2":
"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2", "@material-ui/core@^4.9.13":
version "4.12.3"
resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz#80d665caf0f1f034e52355c5450c0e38b099d3ca"
integrity sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==
@@ -5309,6 +5316,11 @@
resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.0.2.tgz#53f2d6325f66ee79afd707c05ac849e8ae0edbb0"
integrity sha512-WVx6zBiz4sWlboCy7TCgjeyHpNjMsoF36yaagny1uXfbadc9f+5BeBf7U+lRmQqY3EHbGQpP8UdW8AC+cywSwQ==
"@types/d3-color@^2":
version "2.0.3"
resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.3.tgz#8bc4589073c80e33d126345542f588056511fe82"
integrity sha512-+0EtEjBfKEDtH9Rk3u3kLOUXM5F+iZK+WvASPb0MhIZl8J8NUvGeZRwKCXl+P3HkYx5TdU4YtcibpqHkSR9n7w==
"@types/d3-force@^2.1.1":
version "2.1.1"
resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-2.1.1.tgz#a18b6f029d056eb0f8f84a09471e6228e4469b14"
@@ -5321,6 +5333,13 @@
dependencies:
"@types/d3-color" "*"
"@types/d3-interpolate@^2.0.0":
version "2.0.2"
resolved "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.2.tgz#78eddf7278b19e48e8652603045528d46897aba0"
integrity sha512-lElyqlUfIPyWG/cD475vl6msPL4aMU7eJvx1//Q177L8mdXoVPFl1djIESF2FKnc0NyaHvQlJpWwKJYwAhUoCw==
dependencies:
"@types/d3-color" "^2"
"@types/d3-path@*":
version "3.0.0"
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b"
@@ -5331,6 +5350,18 @@
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c"
integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==
"@types/d3-path@^2":
version "2.0.1"
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.1.tgz#ca03dfa8b94d8add97ad0cd97e96e2006b4763cb"
integrity sha512-6K8LaFlztlhZO7mwsZg7ClRsdLg3FJRzIIi6SZXDWmmSJc2x8dd2VkESbLXdk3p8cuvz71f36S0y8Zv2AxqvQw==
"@types/d3-scale@^3.0.0":
version "3.3.2"
resolved "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.2.tgz#18c94e90f4f1c6b1ee14a70f14bfca2bd1c61d06"
integrity sha512-gGqr7x1ost9px3FvIfUMi5XA/F/yAf4UkUDtdQhpH92XCT0Oa7zkkRzY61gPVJq+DxpHn/btouw5ohWkbBsCzQ==
dependencies:
"@types/d3-time" "^2"
"@types/d3-selection@*", "@types/d3-selection@^3.0.1":
version "3.0.2"
resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.2.tgz#23e48a285b24063630bbe312cc0cfe2276de4a59"
@@ -5343,6 +5374,13 @@
dependencies:
"@types/d3-path" "^1"
"@types/d3-shape@^2.0.0":
version "2.1.3"
resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz#35d397b9e687abaa0de82343b250b9897b8cacf3"
integrity sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==
dependencies:
"@types/d3-path" "^2"
"@types/d3-shape@^3.0.1":
version "3.0.2"
resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.0.2.tgz#4b1ca4ddaac294e76b712429726d40365cd1e8ca"
@@ -5350,6 +5388,11 @@
dependencies:
"@types/d3-path" "*"
"@types/d3-time@^2":
version "2.1.1"
resolved "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.1.tgz#743fdc821c81f86537cbfece07093ac39b4bc342"
integrity sha512-9MVYlmIgmRR31C5b4FVSWtuMmBHh2mOWQYfl7XAYOa8dsnb7iEmUmRSWSFgXFtkjxO65d7hTUHQC+RhR/9IWFg==
"@types/d3-zoom@^3.0.1":
version "3.0.1"
resolved "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz#4bfc7e29625c4f79df38e2c36de52ec3e9faf826"
@@ -6095,6 +6138,11 @@
"@types/tough-cookie" "*"
form-data "^2.5.0"
"@types/resize-observer-browser@^0.1.6":
version "0.1.7"
resolved "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz#294aaadf24ac6580b8fbd1fe3ab7b59fe85f9ef3"
integrity sha512-G9eN0Sn0ii9PWQ3Vl72jDPgeJwRWhv2Qk/nQkJuWmRmOB4HX3/BhD5SE1dZs/hzPZL/WKnvF0RHdTSG54QJFyg==
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@@ -6897,6 +6945,11 @@ alphanum-sort@^1.0.2:
resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
already@^3.2.0:
version "3.3.0"
resolved "https://registry.npmjs.org/already/-/already-3.3.0.tgz#a5e5becd167cf537b45f8f1c23d331488ed77003"
integrity sha512-ADGyKddqEp8t/Wu4ITc0y9GGsgZDgyMeMk38AM5qrPK7VEjNAYD87QGTGGgNhSQahmjw76V3mi+3fJRwPJXcTw==
anafanafo@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/anafanafo/-/anafanafo-2.0.0.tgz#43f56274680bc553dd67a9625a920f88d0057b5c"
@@ -9635,7 +9688,7 @@ css-tree@^1.1.3:
mdn-data "2.0.14"
source-map "^0.6.1"
css-unit-converter@^1.1.2:
css-unit-converter@^1.1.1, css-unit-converter@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21"
integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==
@@ -9850,6 +9903,13 @@ cypress@^7.3.0:
url "^0.11.0"
yauzl "^2.10.0"
d3-array@2, d3-array@^2.3.0:
version "2.12.1"
resolved "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81"
integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==
dependencies:
internmap "^1.0.0"
d3-array@^1.2.0:
version "1.2.4"
resolved "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
@@ -9865,6 +9925,11 @@ d3-color@1:
resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
"d3-color@1 - 2":
version "2.0.0"
resolved "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e"
integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==
"d3-color@1 - 3":
version "3.0.1"
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.0.1.tgz#03316e595955d1fcd39d9f3610ad41bb90194d0a"
@@ -9907,6 +9972,11 @@ d3-format@1:
resolved "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
"d3-format@1 - 2":
version "2.0.0"
resolved "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767"
integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==
d3-interpolate@1, d3-interpolate@^1.3.0:
version "1.4.0"
resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
@@ -9921,11 +9991,23 @@ d3-interpolate@1, d3-interpolate@^1.3.0:
dependencies:
d3-color "1 - 3"
"d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163"
integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==
dependencies:
d3-color "1 - 2"
d3-path@1:
version "1.0.9"
resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
"d3-path@1 - 2":
version "2.0.0"
resolved "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8"
integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==
"d3-path@1 - 3":
version "3.0.1"
resolved "https://registry.npmjs.org/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e"
@@ -9948,6 +10030,17 @@ d3-scale@^2.1.0:
d3-time "1"
d3-time-format "2"
d3-scale@^3.0.0:
version "3.3.0"
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3"
integrity sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==
dependencies:
d3-array "^2.3.0"
d3-format "1 - 2"
d3-interpolate "1.2.0 - 2"
d3-time "^2.1.1"
d3-time-format "2 - 3"
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
@@ -9960,6 +10053,13 @@ d3-shape@^1.2.0:
dependencies:
d3-path "1"
d3-shape@^2.0.0:
version "2.1.0"
resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz#3b6a82ccafbc45de55b57fcf956c584ded3b666f"
integrity sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==
dependencies:
d3-path "1 - 2"
d3-shape@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556"
@@ -9974,11 +10074,25 @@ d3-time-format@2:
dependencies:
d3-time "1"
"d3-time-format@2 - 3":
version "3.0.0"
resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6"
integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==
dependencies:
d3-time "1 - 2"
d3-time@1:
version "1.1.0"
resolved "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
"d3-time@1 - 2", d3-time@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682"
integrity sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==
dependencies:
d3-array "2"
"d3-timer@1 - 2":
version "2.0.0"
resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6"
@@ -11503,7 +11617,7 @@ eventemitter3@^3.1.0:
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
eventemitter3@^4.0.0, eventemitter3@^4.0.4:
eventemitter3@^4.0.0, eventemitter3@^4.0.1, eventemitter3@^4.0.4:
version "4.0.7"
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
@@ -11885,6 +11999,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-equals@^2.0.0:
version "2.0.4"
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
fast-glob@^3.1.1:
version "3.2.2"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.2.tgz#ade1a9d91148965d4bf7c51f72e1ca662d32e63d"
@@ -13857,6 +13976,11 @@ internal-slot@^1.0.3:
has "^1.0.3"
side-channel "^1.0.4"
internmap@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==
interpret@^1.0.0:
version "1.4.0"
resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
@@ -19569,6 +19693,11 @@ postcss-unique-selectors@^5.0.2:
alphanum-sort "^1.0.2"
postcss-selector-parser "^6.0.5"
postcss-value-parser@^3.3.0:
version "3.3.1"
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
@@ -20260,7 +20389,7 @@ react-inspector@^5.1.1:
is-dom "^1.0.0"
prop-types "^15.0.0"
react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.6, react-is@^16.9.0:
react-is@^16.10.2, react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.6, react-is@^16.9.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -20317,6 +20446,15 @@ react-resize-detector@^2.3.0:
prop-types "^15.6.0"
resize-observer-polyfill "^1.5.0"
react-resize-detector@^6.6.3:
version "6.7.8"
resolved "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-6.7.8.tgz#318c85d1335e50f99d4fb8eb9ec34e066db597d0"
integrity sha512-0FaEcUBAbn+pq3PT5a9hHRebUfuS1SRLGLpIw8LydU7zX429I6XJgKerKAMPsJH0qWAl6o5bVKNqFJqr6tGPYw==
dependencies:
"@types/resize-observer-browser" "^0.1.6"
lodash "^4.17.21"
resize-observer-polyfill "^1.5.1"
react-router-dom@6.0.0-beta.0:
version "6.0.0-beta.0"
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6"
@@ -20354,6 +20492,15 @@ react-smooth@^1.0.5:
raf "^3.4.0"
react-transition-group "^2.5.0"
react-smooth@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.0.tgz#561647b33e498b2e25f449b3c6689b2e9111bf91"
integrity sha512-wK4dBBR6P21otowgMT9toZk+GngMplGS1O5gk+2WSiHEXIrQgDvhR5IIlT74Vtu//qpTcipkgo21dD7a7AUNxw==
dependencies:
fast-equals "^2.0.0"
raf "^3.4.0"
react-transition-group "2.9.0"
react-sparklines@^1.7.0:
version "1.7.0"
resolved "https://registry.npmjs.org/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60"
@@ -20389,7 +20536,7 @@ react-text-truncate@^0.17.0:
dependencies:
prop-types "^15.5.7"
react-transition-group@^2.5.0:
react-transition-group@2.9.0, react-transition-group@^2.5.0:
version "2.9.0"
resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
@@ -20434,6 +20581,26 @@ react-use@^17.2.4:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-use@^17.3.1:
version "17.3.2"
resolved "https://registry.npmjs.org/react-use/-/react-use-17.3.2.tgz#448abf515f47c41c32455024db28167cb6e53be8"
integrity sha512-bj7OD0/1wL03KyWmzFXAFe425zziuTf7q8olwCYBfOeFHY1qfO1FAMjROQLsLZYwG4Rx63xAfb7XAbBrJsZmEw==
dependencies:
"@types/js-cookie" "^2.2.6"
"@xobotyi/scrollbar-width" "^1.9.5"
copy-to-clipboard "^3.3.1"
fast-deep-equal "^3.1.3"
fast-shallow-equal "^1.0.0"
js-cookie "^2.2.1"
nano-css "^5.3.1"
react-universal-interface "^0.6.2"
resize-observer-polyfill "^1.5.1"
screenfull "^5.1.0"
set-harmonic-interval "^1.0.1"
throttle-debounce "^3.0.1"
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtualized-auto-sizer@^1.0.6:
version "1.0.6"
resolved "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
@@ -20647,6 +20814,13 @@ recharts-scale@^0.4.2:
dependencies:
decimal.js-light "^2.4.1"
recharts-scale@^0.4.4:
version "0.4.5"
resolved "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9"
integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==
dependencies:
decimal.js-light "^2.4.1"
recharts@^1.8.5:
version "1.8.5"
resolved "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz#ca94a3395550946334a802e35004ceb2583fdb12"
@@ -20664,6 +20838,26 @@ recharts@^1.8.5:
recharts-scale "^0.4.2"
reduce-css-calc "^1.3.0"
recharts@^2.1.5:
version "2.1.8"
resolved "https://registry.npmjs.org/recharts/-/recharts-2.1.8.tgz#ca8774fcec5f5d7ec15dedd638db9ee12faf1c09"
integrity sha512-Wi7ufdDGyvy/BPf1za1Ok7VeWB2KtEejaewO9ulmlUhvn5l5RPS4AOkrUfhtMRTTjgJ4K6AbWMDpwtDjczUHJA==
dependencies:
"@types/d3-interpolate" "^2.0.0"
"@types/d3-scale" "^3.0.0"
"@types/d3-shape" "^2.0.0"
classnames "^2.2.5"
d3-interpolate "^2.0.0"
d3-scale "^3.0.0"
d3-shape "^2.0.0"
eventemitter3 "^4.0.1"
lodash "^4.17.19"
react-is "^16.10.2"
react-resize-detector "^6.6.3"
react-smooth "^2.0.0"
recharts-scale "^0.4.4"
reduce-css-calc "^2.1.8"
rechoir@^0.6.2:
version "0.6.2"
resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
@@ -20710,6 +20904,14 @@ reduce-css-calc@^1.3.0:
math-expression-evaluator "^1.2.14"
reduce-function-call "^1.0.1"
reduce-css-calc@^2.1.8:
version "2.1.8"
resolved "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz#7ef8761a28d614980dc0c982f772c93f7a99de03"
integrity sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==
dependencies:
css-unit-converter "^1.1.1"
postcss-value-parser "^3.3.0"
reduce-function-call@^1.0.1:
version "1.0.3"
resolved "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz#60350f7fb252c0a67eb10fd4694d16909971300f"