diff --git a/.changeset/strong-ties-exist.md b/.changeset/strong-ties-exist.md new file mode 100644 index 0000000000..dae77c2805 --- /dev/null +++ b/.changeset/strong-ties-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cicd-statistics': minor +--- + +Added new plugin "CI/CD Statistics" which charts pipeline build durations over time diff --git a/plugins/cicd-statistics/.eslintrc.js b/plugins/cicd-statistics/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/cicd-statistics/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/cicd-statistics/README.md b/plugins/cicd-statistics/README.md new file mode 100644 index 0000000000..8d91d74794 --- /dev/null +++ b/plugins/cicd-statistics/README.md @@ -0,0 +1,13 @@ +# CI/CD Statistics Plugin + +This plugin shows charts of CI/CD pipeline durations over time. It expects to be used on the Software Catalog entity page, as it uses `useEntity` to figure out what component to get the build information for. + +## Usage + +> This plugin cannot be used as-is; it requires a custom implementation to fetch build information + +To use this plugin, you need to implement an API `CicdStatisticsApi` and bind it to the `cicdStatisticsApiRef`. This API is defined in `src/apis/types.ts` and is an interface with two functions, `getConfiguration(options)` and `fetchBuilds(options)`. This plugin will call `getConfiguration` to allow the implementation to specify defaults and settings for the UI. + +First time the UI shows, and each time the user changes filters and clicks `Update` to refresh the data, `fetchBuilds` is invoked with the filter options. The API implementation is the expected to fetch build information from somewhere, format it into a generic and rather simple type `Build` (also defined in `types.ts`). The API can optionally signal completion for a progress bar in the UI. + +When this plugin has fetched the builds, it will transpose the list of builds (and build stages) into a tree of build stages. As build pipelines sometimes change, certain stages might end or begin within the date range of the view (when _Normalize time range_ is enabled, which is the default). diff --git a/plugins/cicd-statistics/api-report.md b/plugins/cicd-statistics/api-report.md new file mode 100644 index 0000000000..3c7a52cdad --- /dev/null +++ b/plugins/cicd-statistics/api-report.md @@ -0,0 +1,222 @@ +## API Report File for "@backstage/plugin-cicd-statistics" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// Warning: (ae-missing-release-tag) "AbortError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class AbortError extends Error {} + +// Warning: (ae-missing-release-tag) "Build" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface Build { + branchType: FilterBranchType; + duration: number; + id: string; + // (undocumented) + raw?: unknown; + requestedAt: Date; + stages: Array; + status: FilterStatusType; + triggeredBy?: TriggerReason; +} + +// Warning: (ae-missing-release-tag) "BuildWithRaw" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type BuildWithRaw = Build & { + raw: T; +}; + +// Warning: (ae-missing-release-tag) "ChartType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ChartType = 'duration' | 'count'; + +// Warning: (ae-missing-release-tag) "ChartTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ChartTypes = Array; + +// Warning: (ae-missing-release-tag) "CicdConfiguration" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface CicdConfiguration { + availableStatuses: ReadonlyArray; + defaults: Partial; + formatStageName: (parentNames: Array, stageName: string) => string; +} + +// Warning: (ae-missing-release-tag) "CicdDefaults" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface CicdDefaults { + chartTypes: Record; + collapsedLimit: number; + // (undocumented) + filterStatus: Array; + // (undocumented) + filterType: FilterBranchType | 'all'; + hideLimit: number; + lowercaseNames: boolean; + normalizeTimeRange: boolean; + // (undocumented) + timeFrom: Date; + // (undocumented) + timeTo: Date; +} + +// Warning: (ae-missing-release-tag) "CicdState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface CicdState { + // (undocumented) + builds: Array; +} + +// Warning: (ae-missing-release-tag) "CicdStatisticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface CicdStatisticsApi { + // (undocumented) + fetchBuilds(options: FetchBuildsOptions): Promise; + // (undocumented) + getConfiguration( + options: GetConfigurationOptions, + ): Promise>; +} + +// Warning: (ae-missing-release-tag) "cicdStatisticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const cicdStatisticsApiRef: ApiRef; + +// Warning: (ae-missing-release-tag) "cicdStatisticsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const cicdStatisticsPlugin: BackstagePlugin< + { + entityContent: RouteRef; + }, + {} +>; + +// Warning: (ae-missing-release-tag) "EntityCicdStatisticsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityCicdStatisticsContent: EntityPageCicdCharts; + +// Warning: (ae-missing-release-tag) "EntityPageCicdCharts" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function EntityPageCicdCharts(): JSX.Element; + +// Warning: (ae-missing-release-tag) "FetchBuildsOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface FetchBuildsOptions { + // (undocumented) + abortSignal: AbortSignal; + // (undocumented) + entity: Entity; + // (undocumented) + filterStatus: Array; + // (undocumented) + filterType: FilterBranchType | 'all'; + // (undocumented) + timeFrom: Date; + // (undocumented) + timeTo: Date; + // (undocumented) + updateProgress: UpdateProgress; +} + +// Warning: (ae-missing-release-tag) "FilterBranchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type FilterBranchType = 'master' | 'branch'; + +// Warning: (ae-missing-release-tag) "FilterStatusType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type FilterStatusType = + | 'unknown' + | 'enqueued' + | 'scheduled' + | 'running' + | 'aborted' + | 'succeeded' + | 'failed' + | 'stalled' + | 'expired'; + +// Warning: (ae-missing-release-tag) "GetConfigurationOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface GetConfigurationOptions { + // (undocumented) + entity: Entity; +} + +// Warning: (ae-missing-release-tag) "Stage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface Stage { + duration: number; + // (undocumented) + name: string; + stages?: Array; + status: FilterStatusType; +} + +// Warning: (ae-missing-release-tag) "statusTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const statusTypes: Array; + +// Warning: (ae-missing-release-tag) "TriggerReason" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TriggerReason = + /** Triggered by source code management, e.g. a Github hook */ + | 'scm' + /** Triggered manually */ + | 'manual' + /** Triggered internally (non-scm, or perhaps after being delayed/enqueued) */ + | 'internal' + /** Triggered for some other reason */ + | 'other'; + +// Warning: (ae-missing-release-tag) "triggerReasons" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const triggerReasons: Array; + +// Warning: (ae-missing-release-tag) "UpdateProgress" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface UpdateProgress { + // (undocumented) + (completed: number, total: number, started?: number): void; + // (undocumented) + ( + steps: Array<{ + title: string; + completed: number; + total: number; + started?: number; + }>, + ): void; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/cicd-statistics/package.json b/plugins/cicd-statistics/package.json new file mode 100644 index 0000000000..1c4d8a455e --- /dev/null +++ b/plugins/cicd-statistics/package.json @@ -0,0 +1,57 @@ +{ + "name": "@backstage/plugin-cicd-statistics", + "description": "A frontend plugin visualizing CI/CD pipeline statistics (build time)", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/cicd-statistics" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "@types/luxon": "^2.0.5" + }, + "dependencies": { + "@backstage/catalog-model": "^0.9.8", + "@backstage/core-plugin-api": "^0.4.1", + "@backstage/plugin-catalog-react": "^0.6.9", + "@date-io/luxon": "^1.3.13", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "4.0.0-alpha.57", + "@material-ui/pickers": "^3.3.10", + "humanize-duration": "^3.27.0", + "already": "^3.2.0", + "lodash": "^4.17.21", + "luxon": "^2.0.2", + "react-use": "^17.3.1", + "recharts": "^2.1.5" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cicd-statistics/src/apis/cicd-statistics.ts b/plugins/cicd-statistics/src/apis/cicd-statistics.ts new file mode 100644 index 0000000000..714900a1bd --- /dev/null +++ b/plugins/cicd-statistics/src/apis/cicd-statistics.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core-plugin-api'; + +import { CicdStatisticsApi } from './types'; + +export const cicdStatisticsApiRef = createApiRef({ + id: 'cicd-statistics-api', +}); diff --git a/plugins/cicd-statistics/src/apis/index.ts b/plugins/cicd-statistics/src/apis/index.ts new file mode 100644 index 0000000000..cb2fff3700 --- /dev/null +++ b/plugins/cicd-statistics/src/apis/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './types'; +export * from './cicd-statistics'; diff --git a/plugins/cicd-statistics/src/apis/types.ts b/plugins/cicd-statistics/src/apis/types.ts new file mode 100644 index 0000000000..0a87b40fc2 --- /dev/null +++ b/plugins/cicd-statistics/src/apis/types.ts @@ -0,0 +1,275 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; + +/** + * This is a generic enum of build statuses. + * + * If all of these aren't applicable to the underlying CI/CD, these can be + * configured to be hidden, using the `availableStatuses` in `CicdConfiguration`. + */ +export type FilterStatusType = + | 'unknown' + | 'enqueued' + | 'scheduled' + | 'running' + | 'aborted' + | 'succeeded' + | 'failed' + | 'stalled' + | 'expired'; +export const statusTypes: Array = [ + 'succeeded', + 'failed', + 'enqueued', + 'scheduled', + 'running', + 'aborted', + 'stalled', + 'expired', + 'unknown', +]; + +/** + * The branch enum of either 'master' or 'branch' (or possibly the meta 'all'). + * + * The concept of what constitues a master branch is generic. It might be called + * something like 'release' or 'main' or 'trunk' in the underlying CI/CD system, + * which is then up to the Api to map accordingly. + */ +export type FilterBranchType = 'master' | 'branch'; + +export type TriggerReason = + /** Triggered by source code management, e.g. a Github hook */ + | 'scm' + /** Triggered manually */ + | 'manual' + /** Triggered internally (non-scm, or perhaps after being delayed/enqueued) */ + | 'internal' + /** Triggered for some other reason */ + | 'other'; + +export const triggerReasons: Array = [ + 'scm', + 'manual', + 'internal', + 'other', +]; + +/** + * A Stage is a part of either a Build or a parent Stage. + * + * This may be called things like Stage or Step or Task in CI/CD systems, but is + * generic here. There's also no concept of parallelism which might exist within + * some stages. + */ +export interface Stage { + name: string; + + /** The status of the stage */ + status: FilterStatusType; + + /** Stage duration in milliseconds */ + duration: number; + + /** Sub stages within this stage */ + stages?: Array; +} + +/** + * Generic Build type. + * + * A build has e.g. a build type (master/branch), a status and (possibly) sub stages. + */ +export interface Build { + raw?: unknown; + + /** Build id */ + id: string; + + /** The reason this build was started */ + triggeredBy?: TriggerReason; + + /** The status of the build */ + status: FilterStatusType; + + /** Branch type */ + branchType: FilterBranchType; + + /** Time when the build started */ + requestedAt: Date; + + /** The overall duration of the build */ + duration: number; + + /** Top-level build stages */ + stages: Array; +} + +/** + * Helper type which is a Build with a certain typed 'raw' field. + * + * This can be useful in an Api to use while mapping internal data structures + * (raw) into generic builds. + */ +export type BuildWithRaw = Build & { + raw: T; +}; + +/** + * Chart type. + * + * Values are: + * * `duration`: shows an area chart of the duration over time + * * `count`: shows a bar chart of the number of build per day + */ +export type ChartType = 'duration' | 'count'; +export type ChartTypes = Array; + +/** + * Default settings for the fetching options and view options. + * + * These are all optional, but can be overridden from the Api to whatever makes + * most sense for that implementation. + */ +export interface CicdDefaults { + timeFrom: Date; + timeTo: Date; + filterStatus: Array; + filterType: FilterBranchType | 'all'; + + /** Lower-case all stage names (to potentially merge stages with different cases) */ + lowercaseNames: boolean; + /** Normalize the from-to date range in all charts */ + normalizeTimeRange: boolean; + /** Default collapse the stages with a max-duration below this value */ + collapsedLimit: number; + /** Default hide stages with a max-duration below this value */ + hideLimit: number; + /** Chart types per status */ + chartTypes: Record; +} + +/** + * A configuration interface which the Api must implement. + * + * When the UI for the CI/CD Statistics is loaded, it begins with fetching the + * configuration before anything else. + * + * All of these fields are optional though, and will fallback to hard-coded defaults. + */ +export interface CicdConfiguration { + /** + * This field can be used to override what statuses are available + */ + availableStatuses: ReadonlyArray; + + /** + * When transposing the list of builds into a tree of stages, the stage names + * will be transformed through this function. + * + * Override this for a custom implementation. The default will try to remove + * parent names off of child names, if they are prepended by them. + * + * For example; if a stage has the name 'Install' and a child stage has the + * name 'Install - Fetch dependencies', the child name will be replaced with + * 'Fetch dependencies'. + */ + formatStageName: (parentNames: Array, stageName: string) => string; + + /** + * Default options for the UI + */ + defaults: Partial; +} + +/** + * If the Api implements support for aborting the fetching of builds, throw an + * AbortError of this type (or any other error with name === 'AbortError'). + */ +export class AbortError extends Error {} + +/** + * The result type for `fetchBuilds`. + */ +export interface CicdState { + builds: Array; +} + +/** + * When fetching, if applicable, the Api can feedback progress back to the UI. + * + * Use the `updateProgress(completed, total, started?)` to signal that + * `completed` builds out of a `total` has finished. Optionally use the + * `started` to signal how many builds have been started in total (i.e. at least + * the amount of `completed`). + * + * This can be called at any rate. Rate limiting (debouncing) is implemented in + * the UI. + * + * Optionally this can signal multiple progresses in several steps + */ +export interface UpdateProgress { + (completed: number, total: number, started?: number): void; + ( + steps: Array<{ + title: string; + completed: number; + total: number; + started?: number; + }>, + ): void; +} + +/** + * When reading configuration, the Api can return a custom settings depending on + * the entity being viewed. + */ +export interface GetConfigurationOptions { + entity: Entity; +} + +/** + * When fetching, the Api should fetch build information about the `entity` and + * respect the `timeFrom`, `timeTo`, `filterStatus` and `filterType`. + * + * Optionally implement support for `updateProgress` and `abortSignal` if + * preferred. + * + * When the UI re-fetches, it will abort any previous fetching, so polling + * `abortSignal.aborted`, and possibly throwing an `AbortError`, can be useful. + */ +export interface FetchBuildsOptions { + entity: Entity; + updateProgress: UpdateProgress; + abortSignal: AbortSignal; + timeFrom: Date; + timeTo: Date; + filterStatus: Array; + filterType: FilterBranchType | 'all'; +} + +/** + * The interface which is mapped to the `cicdStatisticsApiRef` which is used by + * the UI. + */ +export interface CicdStatisticsApi { + getConfiguration( + options: GetConfigurationOptions, + ): Promise>; + fetchBuilds(options: FetchBuildsOptions): Promise; +} diff --git a/plugins/cicd-statistics/src/charts/colors.ts b/plugins/cicd-statistics/src/charts/colors.ts new file mode 100644 index 0000000000..670b214665 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/colors.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FilterStatusType, TriggerReason } from '../apis/types'; + +export const statusColorMap: Record = { + unknown: '#3d01a4', + enqueued: '#7ad1b9', + scheduled: '#0391ce', + running: '#f3f318', + aborted: '#8600af', + succeeded: '#66b032', + failed: '#fe2712', + stalled: '#fb9904', + expired: '#a7194b', +}; + +export const triggerColorMap: Record = { + scm: '#0391ce', + manual: '#a7194b', + internal: '#82ca9d', + other: '#f3f318', +}; + +export const fireColors: Array<[percent: string, color: string]> = [ + ['5%', '#e19678'], + ['30%', '#dfe178'], + ['50%', '#82ca9d'], + ['95%', '#82ca9d'], +]; + +export const colorStroke = '#c0c0c0'; +export const colorStrokeAvg = '#788ee1'; diff --git a/plugins/cicd-statistics/src/charts/logic/analysis.ts b/plugins/cicd-statistics/src/charts/logic/analysis.ts new file mode 100644 index 0000000000..4333ae3a6f --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/analysis.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FilterStatusType } from '../../apis/types'; +import { ChartableStageAnalysis, ChartableStageDatapoints } from '../types'; + +export function getAnalysis( + values: Array, + status: FilterStatusType, +): ChartableStageAnalysis { + const analysis: ChartableStageAnalysis = { + max: 0, + min: 0, + avg: 0, + med: 0, + }; + + const definedValues = values + .filter(value => typeof value[status] !== 'undefined') + .map(value => value[status]!) + .sort((a, b) => a - b); + + analysis.max = definedValues[definedValues.length - 1] ?? 0; + + analysis.min = definedValues[0] ?? 0; + + analysis.avg = + definedValues.length === 0 + ? 0 + : definedValues.reduce((prev, cur) => prev + cur, 0) / values.length; + + analysis.med = definedValues[Math.ceil(definedValues.length / 2)] ?? 0; + + return analysis; +} + +export function makeCombinedAnalysis( + analysis: Record, + allDurations: Array, +): ChartableStageAnalysis { + if (analysis.succeeded) { + // If succeeded is a viewed status, it's probably what's expected to see + // overall. Otherwise combine all other. + return analysis.succeeded; + } + + const analysisValues = Object.values(analysis); + + const max = analysisValues.reduce((prev, cur) => Math.max(prev, cur.max), 0); + const min = analysisValues.reduce( + (prev, cur) => Math.min(prev, cur.min), + max, + ); + const avg = !allDurations.length + ? 0 + : allDurations.reduce((prev, cur) => prev + cur, 0) / allDurations.length; + allDurations.sort((a, b) => a - b); + const med = allDurations[Math.ceil(allDurations.length / 2)] ?? 0; + + return { + max, + min, + avg, + med, + }; +} diff --git a/plugins/cicd-statistics/src/charts/logic/conversions.ts b/plugins/cicd-statistics/src/charts/logic/conversions.ts new file mode 100644 index 0000000000..c548b0e2f5 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/conversions.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { map } from 'already'; + +import { Build, Stage, FilterStatusType } from '../../apis/types'; +import { ChartableStage, ChartableStagesAnalysis } from '../types'; +import { getOrSetStage, makeStage, sortStatuses } from './utils'; +import { finalizeStage } from './finalize-stage'; +import { dailySummary } from './daily-summary'; + +export interface ChartableStagesOptions { + normalizeTimeRange: boolean; +} + +/** + * Converts a list of builds, each with a tree of stages (and durations) into a + * merged tree of stages, and calculates {avg, min, max} of each stage. + */ +export async function buildsToChartableStages( + builds: Array, + options: ChartableStagesOptions, +): Promise { + const { normalizeTimeRange } = options; + + const total: ChartableStage = makeStage('Total'); + + const recurseDown = ( + stageMap: Map, + stage: Stage, + __epoch: number, + ) => { + const { name, status, duration } = stage; + + const subChartableStage = getOrSetStage(stageMap, name); + + subChartableStage.statusSet.add(status); + subChartableStage.values.push({ + __epoch, + [status]: duration, + [`${status} avg`]: duration, + }); + + stage.stages?.forEach(subStage => { + recurseDown(subChartableStage.stages, subStage, __epoch); + }); + }; + + const stages = new Map(); + + await map(builds, { chunk: 'idle' }, build => { + const { duration, requestedAt, status } = build; + const __epoch = requestedAt.getTime(); + + total.statusSet.add(status); + total.values.push({ + __epoch, + [status]: duration, + [`${status} avg`]: duration, + }); + + build.stages?.forEach(subStage => { + recurseDown(stages, subStage, __epoch); + }); + }); + + const allEpochs = normalizeTimeRange + ? builds.map(build => build.requestedAt.getTime()) + : []; + + // Recurse down again and calculate averages + await map([...stages.values()], { chunk: 'idle' }, stage => + finalizeStage(stage, { allEpochs, averageWidth: 10 }), + ); + finalizeStage(total, { allEpochs, averageWidth: 10 }); + + const daily = dailySummary(builds); + + const statuses = findStatuses(total, [...stages.values()]); + + return { daily, total, stages, statuses }; +} + +function findStatuses( + total: ChartableStage, + stages: Array, +): Array { + const statuses = new Set(); + + const addStatuses = (set: Set) => { + set.forEach(status => { + statuses.add(status); + }); + }; + + addStatuses(total.statusSet); + + const recurse = (subStages: Array) => { + subStages.forEach(stage => { + addStatuses(stage.statusSet); + recurse([...stage.stages.values()]); + }); + }; + recurse(stages); + + return sortStatuses([...statuses]); +} diff --git a/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts b/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts new file mode 100644 index 0000000000..54e47c3f5b --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/count-builds-per-day.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { groupBy } from 'lodash'; + +import { FilterStatusType, statusTypes } from '../../apis/types'; +import { Countify, ChartableStageDatapoints } from '../types'; +import { startOfDay } from './utils'; + +export function countBuildsPerDay( + values: ReadonlyArray, +) { + const days = groupBy(values, value => startOfDay(value.__epoch)); + Object.entries(days).forEach(([_startOfDay, valuesThisDay]) => { + const counts = Object.fromEntries( + statusTypes + .map( + type => + [ + type, + valuesThisDay.filter(value => value[type] !== undefined).length, + ] as const, + ) + .filter(([_type, count]) => count > 0) + .map(([type, count]): [Countify, number] => [ + `${type} count`, + count, + ]), + ); + + // Assign the count for this day to the first value this day + Object.assign(valuesThisDay[0], counts); + }); +} diff --git a/plugins/cicd-statistics/src/charts/logic/daily-summary.ts b/plugins/cicd-statistics/src/charts/logic/daily-summary.ts new file mode 100644 index 0000000000..93c7b07c34 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/daily-summary.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { groupBy, countBy } from 'lodash'; + +import { Build } from '../../apis/types'; +import { + Epoch, + TriggerReasonsDatapoint, + StatusesDatapoint, + ChartableDaily, +} from '../types'; +import { sortStatuses, sortTriggerReasons, startOfDay } from './utils'; + +export function dailySummary(builds: ReadonlyArray): ChartableDaily { + const triggersDaily = countTriggersPerDay(builds); + const statusesDaily = countStatusesPerDay(builds); + + const { triggerReasons } = triggersDaily; + const { statuses } = statusesDaily; + + const reasonMap = new Map( + triggersDaily.values.map(value => [value.__epoch, value]), + ); + const statusMap = new Map( + statusesDaily.values.map(value => [value.__epoch, value]), + ); + + const days = Object.keys( + groupBy(builds, value => startOfDay(value.requestedAt)), + ) + .map(epoch => parseInt(epoch, 10)) + .sort(); + + return { + values: days.map(epoch => ({ + __epoch: epoch, + ...reasonMap.get(epoch), + ...statusMap.get(epoch), + })), + triggerReasons, + statuses, + }; +} + +function countTriggersPerDay(builds: ReadonlyArray) { + const days = groupBy(builds, value => startOfDay(value.requestedAt)); + + const triggerReasons = sortTriggerReasons([ + ...new Set( + builds + .map(({ triggeredBy }) => triggeredBy) + .filter((v): v is NonNullable => !!v), + ), + ]); + + const values = Object.entries(days).map(([epoch, buildsThisDay]) => { + const datapoint = Object.fromEntries( + triggerReasons + .map(reason => [ + reason, + buildsThisDay.filter(build => build.triggeredBy === reason).length, + ]) + .filter(([_type, count]) => count > 0), + ) as Omit; + + // Assign the count for this day to the first value this day + const value: Epoch & TriggerReasonsDatapoint = Object.assign(datapoint, { + __epoch: parseInt(epoch, 10), + }); + + return value; + }); + + return { triggerReasons, values }; +} + +function countStatusesPerDay(builds: ReadonlyArray) { + const days = groupBy(builds, value => startOfDay(value.requestedAt)); + + const foundStatuses = new Set(); + + const values = Object.entries(days).map(([epoch, buildsThisDay]) => { + const byStatus = countBy(buildsThisDay, 'status'); + + const value: Epoch & StatusesDatapoint = { + __epoch: parseInt(epoch, 10), + ...byStatus, + }; + + Object.keys(byStatus).forEach(status => { + foundStatuses.add(status); + }); + + return value; + }); + + return { + statuses: sortStatuses([...foundStatuses]), + values, + }; +} diff --git a/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts b/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts new file mode 100644 index 0000000000..bb54fbf41e --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/finalize-stage.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FilterStatusType, statusTypes } from '../../apis/types'; +import { Averagify, ChartableStage } from '../types'; +import { countBuildsPerDay } from './count-builds-per-day'; +import { getAnalysis, makeCombinedAnalysis } from './analysis'; +import { average } from './utils'; + +interface FinalizeStageOptions { + averageWidth: number; + allEpochs: Array; +} + +/** + * Calculate: + * * {avg, min, max} + * * count per day + * of a stage and its sub stages, recursively. + * + * This is calculated per status (successful, failed, etc). + */ +export function finalizeStage( + stage: ChartableStage, + options: FinalizeStageOptions, +) { + const { averageWidth, allEpochs } = options; + const { values, analysis, combinedAnalysis } = stage; + + if (allEpochs.length > 0) { + const valueEpochs = new Set(values.map(value => value.__epoch)); + + allEpochs.forEach(epoch => { + if (!valueEpochs.has(epoch)) { + values.push({ __epoch: epoch }); + } + }); + } + + values.sort((a, b) => a.__epoch - b.__epoch); + + countBuildsPerDay(values); + + const allDurations: Array = []; + + statusTypes.forEach(status => { + analysis[status] = getAnalysis(values, status); + + const durationsIndexes = values + .map(value => value[status]) + .map((duration, index) => ({ index, duration })) + .filter(({ duration }) => typeof duration !== 'undefined') + .map(({ index }) => index); + const durationsDense = values + .map(value => value[status]) + .filter( + (duration): duration is number => typeof duration !== 'undefined', + ); + + durationsDense.forEach(dur => allDurations.push(dur)); + + const averages = durationsDense.map((_, i) => + average( + durationsDense.slice( + Math.max(i - averageWidth, 0), + Math.min(i + averageWidth, durationsDense.length), + ), + ), + ); + + averages.forEach((avg, index) => { + const key: Averagify = `${status} avg`; + values[durationsIndexes[index]][key] = avg; + }); + }); + + Object.assign(combinedAnalysis, makeCombinedAnalysis(analysis, allDurations)); + + stage.stages.forEach(subStage => finalizeStage(subStage, options)); +} diff --git a/plugins/cicd-statistics/src/charts/logic/utils.test.ts b/plugins/cicd-statistics/src/charts/logic/utils.test.ts new file mode 100644 index 0000000000..72e0a22101 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/utils.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { sortTriggerReasons } from './utils'; + +describe('daily-summary', () => { + it('sortTriggerReasons', () => { + const values = ['a', 'manual', 'b', 'other', 'c', 'scm', 'd']; + const expected = ['manual', 'scm', 'other', 'a', 'b', 'c', 'd']; + + expect(sortTriggerReasons(values)).toStrictEqual(expected); + expect(sortTriggerReasons(values.reverse())).toStrictEqual(expected); + }); +}); diff --git a/plugins/cicd-statistics/src/charts/logic/utils.ts b/plugins/cicd-statistics/src/charts/logic/utils.ts new file mode 100644 index 0000000000..f3a0087a3d --- /dev/null +++ b/plugins/cicd-statistics/src/charts/logic/utils.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime } from 'luxon'; + +import { FilterStatusType, statusTypes } from '../../apis/types'; +import { ChartableStage } from '../types'; + +export function average(values: number[]): number { + return !values.length + ? 0 + : Math.round(values.reduce((prev, cur) => prev + cur, 0) / values.length); +} + +export function getOrSetStage( + stages: Map, + name: string, +): ChartableStage { + const stage = stages.get(name); + if (stage) return stage; + + const newStage: ChartableStage = makeStage(name); + stages.set(name, newStage); + return newStage; +} + +export function makeStage(name: string): ChartableStage { + return { + analysis: { + unknown: { avg: 0, med: 0, max: 0, min: 0 }, + enqueued: { avg: 0, med: 0, max: 0, min: 0 }, + scheduled: { avg: 0, med: 0, max: 0, min: 0 }, + running: { avg: 0, med: 0, max: 0, min: 0 }, + aborted: { avg: 0, med: 0, max: 0, min: 0 }, + succeeded: { avg: 0, med: 0, max: 0, min: 0 }, + failed: { avg: 0, med: 0, max: 0, min: 0 }, + stalled: { avg: 0, med: 0, max: 0, min: 0 }, + expired: { avg: 0, med: 0, max: 0, min: 0 }, + }, + combinedAnalysis: { avg: 0, med: 0, max: 0, min: 0 }, + statusSet: new Set(), + name, + values: [], + stages: new Map(), + }; +} + +export function startOfDay(date: number | Date) { + if (typeof date === 'number') { + return DateTime.fromMillis(date).startOf('day').toMillis(); + } + return DateTime.fromJSDate(date).startOf('day').toMillis(); +} + +export function sortTriggerReasons(reasons: Array): Array { + return reasons.sort((a, b) => { + if (a === 'manual') return -1; + else if (b === 'manual') return 1; + else if (a === 'scm') return -1; + else if (b === 'scm') return 1; + else if (a === 'other') return -1; + else if (b === 'other') return 1; + return a.localeCompare(b); + }); +} + +export function sortStatuses(statuses: Array): Array { + return [ + ...statusTypes.filter(status => statuses.includes(status)), + ...statuses + .filter(status => !(statusTypes as Array).includes(status)) + .sort((a, b) => a.localeCompare(b)), + ]; +} diff --git a/plugins/cicd-statistics/src/charts/stage-chart.tsx b/plugins/cicd-statistics/src/charts/stage-chart.tsx new file mode 100644 index 0000000000..ba3474153c --- /dev/null +++ b/plugins/cicd-statistics/src/charts/stage-chart.tsx @@ -0,0 +1,269 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { CSSProperties, Fragment, useMemo } from 'react'; +import { + Area, + Bar, + ComposedChart, + XAxis, + YAxis, + YAxisProps, + CartesianGrid, + Legend, + LegendProps, + Line, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import Alert from '@material-ui/lab/Alert'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { capitalize } from 'lodash'; + +import { useZoom, useZoomArea } from './zoom'; +import { CicdDefaults, statusTypes } from '../apis/types'; +import { ChartableStage } from './types'; +import { + pickElements, + labelFormatter, + tickFormatterX, + tickFormatterY, + tooltipValueFormatter, + formatDuration, +} from '../components/utils'; +import { + statusColorMap, + fireColors, + colorStroke, + colorStrokeAvg, +} from './colors'; + +const fullWidth: CSSProperties = { width: '100%' }; +const noUserSelect: CSSProperties = { userSelect: 'none' }; + +const transitionProps = { unmountOnExit: true }; + +export interface StageChartProps { + stage: ChartableStage; + + chartTypes: CicdDefaults['chartTypes']; + defaultCollapsed?: number; + defaultHidden?: number; + zeroYAxis?: boolean; +} + +export function StageChart(props: StageChartProps) { + const { stage, ...chartOptions } = props; + const { + chartTypes, + defaultCollapsed = 0, + defaultHidden = 0, + zeroYAxis = false, + } = chartOptions; + + const { zoomFilterValues } = useZoom(); + const { zoomProps, getZoomArea } = useZoomArea(); + + const ticks = useMemo( + () => pickElements(stage.values, 8).map(val => val.__epoch), + [stage.values], + ); + const domainY = useMemo( + () => [zeroYAxis ? 0 : 'auto', 'auto'] as YAxisProps['domain'], + [zeroYAxis], + ); + const statuses = useMemo( + () => statusTypes.filter(status => stage.statusSet.has(status)), + [stage.statusSet], + ); + const legendPayload = useMemo( + (): LegendProps['payload'] => + statuses.map(status => ({ + value: capitalize(status), + type: 'line', + id: status, + color: statusColorMap[status], + })), + [statuses], + ); + + const subStages = useMemo( + () => + new Map( + [...stage.stages.entries()].filter( + ([_name, subStage]) => subStage.combinedAnalysis.max > defaultHidden, + ), + ), + [stage.stages, defaultHidden], + ); + + const zoomFilteredValues = useMemo( + () => zoomFilterValues(stage.values), + [stage.values, zoomFilterValues], + ); + + return stage.combinedAnalysis.max < defaultHidden ? null : ( + defaultCollapsed} + TransitionProps={transitionProps} + > + }> + + {stage.name} (med {formatDuration(stage.combinedAnalysis.med)}, avg{' '} + {formatDuration(stage.combinedAnalysis.avg)}) + + + + {stage.values.length === 0 ? ( + No data + ) : ( + + + + + + + {fireColors.map(([percent, color]) => ( + + ))} + + + {statuses.length > 1 && } + + + + + + {statuses.reverse().map(status => ( + + {!chartTypes[status].includes('duration') ? null : ( + <> + 1 + ? statusColorMap[status] + : colorStroke + } + fillOpacity={statuses.length > 1 ? 0.5 : 1} + fill={ + statuses.length > 1 + ? statusColorMap[status] + : 'url(#colorDur)' + } + connectNulls + /> + 1 + ? statusColorMap[status] + : colorStrokeAvg + } + opacity={0.8} + strokeWidth={2} + dot={false} + connectNulls + /> + + )} + {!chartTypes[status].includes('count') ? null : ( + + )} + + ))} + {getZoomArea({ yAxisId: 1 })} + + + + {subStages.size === 0 ? null : ( + + + }> + Sub stages ({subStages.size}) + + +
+ {[...subStages.values()].map(subStage => ( + + ))} +
+
+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/plugins/cicd-statistics/src/charts/status-chart.tsx b/plugins/cicd-statistics/src/charts/status-chart.tsx new file mode 100644 index 0000000000..7b07885ca5 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/status-chart.tsx @@ -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 => + analysis.daily.triggerReasons.map(reason => ({ + value: humanTriggerReason(reason), + type: 'line', + id: reason, + color: triggerColorMap[reason as TriggerReason] ?? '', + })), + [analysis.daily.triggerReasons], + ); + + const statusesLegendPayload = useMemo( + (): NonNullable => + analysis.daily.statuses.map(status => ({ + value: capitalize(status), + type: 'line', + id: status, + color: statusColorMap[status as FilterStatusType] ?? '', + })), + [analysis.daily.statuses], + ); + + const legendPayload = useMemo( + (): NonNullable => [ + ...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 [ + + {label}: {valueText} + , + null, + ]; + }; + }, [analysis.daily.triggerReasons]); + + const zoomFilteredValues = useMemo( + () => zoomFilterValues(values), + [values, zoomFilterValues], + ); + + const barSize = getBarSize(analysis.daily.values.length); + + return ( + 1}> + }> + + Build count per status over build trigger reason + + + + {values.length === 0 ? ( + No data + ) : ( + + + + + + + + + {triggerReasonLegendPayload.map(reason => ( + + + + ))} + {[...analysis.daily.statuses].reverse().map(status => ( + + + + ))} + {getZoomArea({ yAxisId: 1 })} + + + )} + + + ); +} + +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; +} diff --git a/plugins/cicd-statistics/src/charts/types.ts b/plugins/cicd-statistics/src/charts/types.ts new file mode 100644 index 0000000000..88c0085f77 --- /dev/null +++ b/plugins/cicd-statistics/src/charts/types.ts @@ -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} avg`; +export type Countify = `${T} count`; + +export type Epoch = { __epoch: number }; + +export type ChartableStageDatapoints = Epoch & { + [status in FilterStatusType]?: number; +} & { + [status in Averagify]?: number; +} & { + [status in Countify]?: 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; + combinedAnalysis: ChartableStageAnalysis; + name: string; + values: Array; + statusSet: Set; + + stages: Map; +} + +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; + + /** + * The build trigger reasons + */ + triggerReasons: Array; + + /** + * The top-level (build) statuses + */ + statuses: Array; +} + +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; + + /** + * All statuses found deeper in the stage tree. A stage might have been + * _aborted_ although the build actually _failed_, e.g. + */ + statuses: Array; +} diff --git a/plugins/cicd-statistics/src/charts/zoom.tsx b/plugins/cicd-statistics/src/charts/zoom.tsx new file mode 100644 index 0000000000..57e2b8678f --- /dev/null +++ b/plugins/cicd-statistics/src/charts/zoom.tsx @@ -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): void; + setSelectState: Dispatch>; + + zoomState: ZoomState; + setZoomState: Dispatch>; + + resetZoom: () => void; +} + +const context = React.createContext(undefined as any); + +export function ZoomProvider({ children }: PropsWithChildren<{}>) { + const [registeredSelectors, setRegisteredSelectors] = useState< + Array> + >([]); + const [selectState, setSelectState] = useState({}); + const [zoomState, setZoomState] = useState({}); + + const registerSelection = useCallback( + (selector: Dispatch) => { + 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 ; +} + +export function useZoom() { + const { zoomState, resetZoom } = useContext(context); + + const zoomFilterValues = useCallback( + (values: Array): Array => { + 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({}); + 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) => ( + + {showSelection && state.left && state.right ? ( + + ) : null} + + ), + [showSelection, state.left, state.right], + ); + + return { + zoomProps, + getZoomArea, + }; +} diff --git a/plugins/cicd-statistics/src/components/button-switch.tsx b/plugins/cicd-statistics/src/components/button-switch.tsx new file mode 100644 index 0000000000..65f55eee41 --- /dev/null +++ b/plugins/cicd-statistics/src/components/button-switch.tsx @@ -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 { + value: T; + tooltip?: string; + text?: string | JSX.Element; +} + +export type SwitchValue = T | SwitchValueDetails; + +export interface ButtonSwitchPropsBase { + values: ReadonlyArray>; + vertical?: boolean; +} +export interface ButtonSwitchPropsSingle + extends ButtonSwitchPropsBase { + multi?: false; + selection: T; + onChange: (selected: T) => void; +} +export interface ButtonSwitchPropsMulti + extends ButtonSwitchPropsBase { + multi: true; + selection: ReadonlyArray; + onChange: (selected: Array) => void; +} + +export type ButtonSwitchProps = + | ButtonSwitchPropsSingle + | ButtonSwitchPropsMulti; + +function switchValue(value: SwitchValue): T { + return typeof value === 'object' ? value.value : value; +} + +function switchText( + value: SwitchValue, +): 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(props: ButtonSwitchProps) { + const { values, vertical = false } = props; + + const onClick = useCallback( + (ev: MouseEvent) => { + 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, elem: JSX.Element) => + typeof value === 'object' && value.tooltip ? ( + + {elem} + + ) : ( + elem + ); + + return ( + + {values.map(value => + tooltipify( + value, + , + ), + )} + + ); +} diff --git a/plugins/cicd-statistics/src/components/chart-filters.tsx b/plugins/cicd-statistics/src/components/chart-filters.tsx new file mode 100644 index 0000000000..03cbea5dd0 --- /dev/null +++ b/plugins/cicd-statistics/src/components/chart-filters.tsx @@ -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 => ({ + 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; +} + +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> = [ + '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> = [ + { value: 'duration', text: , tooltip: 'Duration' }, + { value: 'count', text: , 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({ 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 = + 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 ( + + + + Update + + } + title={ + + Fetching options + + } + /> + + + Date range + + setFromDate(date?.toJSDate() ?? new Date())} + /> +
+ + + + } + label={} + /> + {useNowAsToDate ? null : ( + setToDate(date?.toJSDate() ?? new Date())} + /> + )} + + + + Branch + + + values={branchValues} + selection={branch} + onChange={setBranch} + /> + + Status + + + values={statusValues} + multi + vertical + selection={selectedStatus} + onChange={setSelectedStatus} + /> +
+
+ + + View options + + } + /> + + + + + + + + + + + + + + + Chart styles + + {inrefferedStatuses.map(status => ( + + + + values={chartTypeValues} + selection={viewOptions.chartTypes[status as FilterStatusType]} + onChange={setChartTypeSpecific[status]} + multi + /> + + +
{status}
+
+
+ ))} +
+
+
+ ); +} diff --git a/plugins/cicd-statistics/src/components/duration-slider.tsx b/plugins/cicd-statistics/src/components/duration-slider.tsx new file mode 100644 index 0000000000..6ba4fa88ce --- /dev/null +++ b/plugins/cicd-statistics/src/components/duration-slider.tsx @@ -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 ( + <> + + + + ); +} diff --git a/plugins/cicd-statistics/src/components/label.tsx b/plugins/cicd-statistics/src/components/label.tsx new file mode 100644 index 0000000000..fe064d30a6 --- /dev/null +++ b/plugins/cicd-statistics/src/components/label.tsx @@ -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 => ({ + label: { + fontWeight: 'normal', + margin: theme.spacing(0), + }, + }), + { + name: 'CicdStatisticsLabel', + }, +); + +export function Label({ children }: PropsWithChildren<{}>) { + const classes = useStyles(); + + return ( + + {children} + + ); +} diff --git a/plugins/cicd-statistics/src/components/progress.tsx b/plugins/cicd-statistics/src/components/progress.tsx new file mode 100644 index 0000000000..6ea8e6497d --- /dev/null +++ b/plugins/cicd-statistics/src/components/progress.tsx @@ -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 = + | { + 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; +} +export interface ProgessAsSingle { + progress?: T; + progressBuffer?: T; +} + +export type ProgessState = + | ProgessAsSingle + | (T extends number ? ProgessAsSteps : { steps?: undefined }); + +export type ProgressAsLoading = ProgessState & { + loading: true; + error?: undefined; + value?: undefined; +}; +export type ProgressAsError = ProgessState & { + loading?: false | undefined; + error: Error; + value?: undefined; +}; +export type ProgressAsValue = ProgessState & { + 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 = + | ProgressAsLoading + | ProgressAsError + | ProgressAsValue; + +const sentry = Symbol(); + +/** + * Casts an AsyncState or Progress into its non-succeeded sub types + */ +type Unsuccessful | AsyncState> = + S extends ProgressType + ? ProgressAsLoading | ProgressAsError + : Omit, '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 | AsyncState, R>( + parentState: S, + fn: (value: NonNullable) => Promise, + deps: DependencyList, +): Unsuccessful | AsyncState { + const childState = useAsync( + async () => (!parentState.value ? sentry : fn(parentState.value)), + [!parentState.error, !parentState.loading, parentState.value, ...deps], + ); + + if (!parentState.value) { + return parentState as Unsuccessful; + } else if (childState.value === sentry) { + return { loading: true }; + } + return childState as AsyncState; +} + +export function renderFallbacks( + state: ProgressType | AsyncState, + success: (value: T) => JSX.Element, +): JSX.Element { + if (state.loading) { + return ; + } else if (state.error) { + return {state.error.stack}; + } + + 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 ; + } else if (stateAsSingleProgress.progress !== undefined) { + // Simple _single_ progress + return ( + + + + ); + } + + // Multi-step progresses + + return ( + + + {stateAsStepProgress.steps.map((step, index) => ( + + {step.title} + + + {index < stateAsStepProgress.steps.length - 1 ? ( + + ) : null} + + + {!step.progress && !step.progressBuffer ? null : ( + + )} + + + ))} + + + ); +} + +function getDotColor(step: ProgressStep): TimelineDotProps['color'] { + const progress = step.progress ?? 0; + + if (progress >= 1) { + return 'primary'; + } else if (progress > 0) { + return 'secondary'; + } + return 'grey'; +} diff --git a/plugins/cicd-statistics/src/components/toggle.tsx b/plugins/cicd-statistics/src/components/toggle.tsx new file mode 100644 index 0000000000..02f1941d85 --- /dev/null +++ b/plugins/cicd-statistics/src/components/toggle.tsx @@ -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) { + const toggler = useCallback(() => { + setChecked(!checked); + }, [checked, setChecked]); + + return ( + } + label={children} + /> + ); +} diff --git a/plugins/cicd-statistics/src/components/utils.tsx b/plugins/cicd-statistics/src/components/utils.tsx new file mode 100644 index 0000000000..0133cf7ba8 --- /dev/null +++ b/plugins/cicd-statistics/src/components/utils.tsx @@ -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(arr: ReadonlyArray, num: number): Array { + 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 {formatDateTimeShort(epoch)}; +} + +export function labelFormatterWithoutTime(epoch: number) { + return {formatDateShort(epoch)}; +} + +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 [ + + {capitalize(name)}:{' '} + {name.endsWith(' count') + ? durationOrCount + : formatDuration(durationOrCount)} + , + 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); +} diff --git a/plugins/cicd-statistics/src/entity-page.tsx b/plugins/cicd-statistics/src/entity-page.tsx new file mode 100644 index 0000000000..3c51dabf2a --- /dev/null +++ b/plugins/cicd-statistics/src/entity-page.tsx @@ -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 => ( + + + + )); +} + +const useStyles = makeStyles( + 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 ( + + + + + + {renderFallbacks(chartableStagesState, chartableStages => ( + <> + {chartableStages.stages.size > 0 ? null : ( + No data + )} + {!statisticsState.value?.builds?.length || + !chartableStagesState.value?.daily?.values?.length ? null : ( + + )} + + {[...chartableStages.stages.entries()].map(([name, stage]) => ( + + ))} + + ))} + + + ); +} diff --git a/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts b/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts new file mode 100644 index 0000000000..d21b978a92 --- /dev/null +++ b/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts @@ -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 { + const cicdStatisticsApi = useCicdStatisticsApi(); + const { entity } = useEntity(); + + const [state, setState] = useState>({ + 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; +} diff --git a/plugins/cicd-statistics/src/hooks/use-cicd-statistics-api.ts b/plugins/cicd-statistics/src/hooks/use-cicd-statistics-api.ts new file mode 100644 index 0000000000..02773b20ef --- /dev/null +++ b/plugins/cicd-statistics/src/hooks/use-cicd-statistics-api.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; + +import { cicdStatisticsApiRef } from '../apis'; + +export function useCicdStatisticsApi() { + try { + return useApi(cicdStatisticsApiRef); + } catch (err) { + return undefined; + } +} diff --git a/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts b/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts new file mode 100644 index 0000000000..e52673a576 --- /dev/null +++ b/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts @@ -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; + filterType: FilterBranchType | 'all'; +} + +export function useCicdStatistics( + options: UseCicdStatisticsOptions, +): ProgressType { + const { + entity, + abortController, + timeFrom, + timeTo, + filterStatus, + filterType, + } = options; + + const [state, setState] = useState>({ + 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; +} diff --git a/plugins/cicd-statistics/src/index.ts b/plugins/cicd-statistics/src/index.ts new file mode 100644 index 0000000000..0190f37f20 --- /dev/null +++ b/plugins/cicd-statistics/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './plugin'; +export * from './apis'; diff --git a/plugins/cicd-statistics/src/plugin.ts b/plugins/cicd-statistics/src/plugin.ts new file mode 100644 index 0000000000..ca9506475a --- /dev/null +++ b/plugins/cicd-statistics/src/plugin.ts @@ -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', + }), +); diff --git a/plugins/cicd-statistics/src/utils/api.ts b/plugins/cicd-statistics/src/utils/api.ts new file mode 100644 index 0000000000..8af21b3507 --- /dev/null +++ b/plugins/cicd-statistics/src/utils/api.ts @@ -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( + statuses: ReadonlyArray, +): Array { + const statusSet = new Set(statuses); + + const sorted = (['all', ...statusTypes] as Array).filter((status: T) => { + if (statusSet.has(status)) { + statusSet.delete(status); + return true; + } + return false; + }); + + return [...sorted, ...statusSet]; +} diff --git a/plugins/cicd-statistics/src/utils/stage-names.ts b/plugins/cicd-statistics/src/utils/stage-names.ts new file mode 100644 index 0000000000..69c8cdcf25 --- /dev/null +++ b/plugins/cicd-statistics/src/utils/stage-names.ts @@ -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, + 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 { + const { formatStageName, lowerCase } = opts; + + const recurseStage = (stage: Stage, parentNames: Array): 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, [])), + })); +} diff --git a/yarn.lock b/yarn.lock index ffc877abc6..6988edda3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"