From 365e3f87ad0d593205d56bcdeb839f396f8bfc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Mon, 7 Feb 2022 12:56:21 +0100 Subject: [PATCH] feat(cicd-statistics): Allow multi-step progress when loading statistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading can take a pretty long time, and often in several steps, such as "loading builds" followed by "loading pipelines per build". Signed-off-by: Gustaf Räntilä --- plugins/cicd-statistics/src/apis/types.ts | 18 ++- .../src/components/progress.tsx | 113 ++++++++++++++---- plugins/cicd-statistics/src/entity-page.tsx | 4 + .../src/hooks/use-cicd-configuration.ts | 6 +- .../src/hooks/use-cicd-statistics.ts | 39 +++++- 5 files changed, 144 insertions(+), 36 deletions(-) diff --git a/plugins/cicd-statistics/src/apis/types.ts b/plugins/cicd-statistics/src/apis/types.ts index 0726c566c7..0a87b40fc2 100644 --- a/plugins/cicd-statistics/src/apis/types.ts +++ b/plugins/cicd-statistics/src/apis/types.ts @@ -220,12 +220,20 @@ export interface CicdState { * * 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 type UpdateProgress = ( - completed: number, - total: number, - started?: number, -) => void; +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 diff --git a/plugins/cicd-statistics/src/components/progress.tsx b/plugins/cicd-statistics/src/components/progress.tsx index 1b707573c0..9973ba170d 100644 --- a/plugins/cicd-statistics/src/components/progress.tsx +++ b/plugins/cicd-statistics/src/components/progress.tsx @@ -14,12 +14,23 @@ * limitations under the License. */ -import React, { DependencyList } from 'react'; +import React, { CSSProperties, DependencyList } from 'react'; import { useAsync } from 'react-use'; 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 = | { @@ -43,24 +54,33 @@ type AsyncState = value: T; }; -export type ProgressAsLoading = { +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; - progress?: number; - progressBuffer?: number; error?: undefined; value?: undefined; }; -export type ProgressAsError = { +export type ProgressAsError = ProgessState & { loading?: false | undefined; - progress?: undefined; - progressBuffer?: undefined; error: Error; value?: undefined; }; -export type ProgressAsValue = { +export type ProgressAsValue = ProgessState & { loading?: false | undefined; - progress?: undefined; - progressBuffer?: undefined; error?: undefined; value: T; }; @@ -69,7 +89,7 @@ export type ProgressAsValue = { * An AsyncState but with the addition of progress (decimal 0-1) to allow * rendering a progress bar while waiting. */ -export type Progress = +export type ProgressType = | ProgressAsLoading | ProgressAsError | ProgressAsValue; @@ -79,8 +99,8 @@ const sentry = Symbol(); /** * Casts an AsyncState or Progress into its non-succeeded sub types */ -type Unsuccessful | AsyncState> = - S extends Progress +type Unsuccessful | AsyncState> = + S extends ProgressType ? ProgressAsLoading | ProgressAsError : Omit, 'value'>; @@ -92,7 +112,7 @@ type Unsuccessful | AsyncState> = * invoked for a new layer of async state with the dependent (upstream) success * result as argument. */ -export function useAsyncChain | AsyncState, R>( +export function useAsyncChain | AsyncState, R>( parentState: S, fn: (value: NonNullable) => Promise, deps: DependencyList, @@ -111,7 +131,7 @@ export function useAsyncChain | AsyncState, R>( } export function renderFallbacks( - state: Progress | AsyncState, + state: ProgressType | AsyncState, success: (value: T) => JSX.Element, ): JSX.Element { if (state.loading) { @@ -130,18 +150,67 @@ export function ViewProgress({ }) { const { Progress } = useApp().getComponents(); - const stateAsProgress = state as ProgressAsLoading; + const stateAsSingleProgress = state as ProgessAsSingle; + const stateAsStepProgress = state as ProgessAsSteps; - if (!stateAsProgress.progress && !stateAsProgress.progressBuffer) { + 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/entity-page.tsx b/plugins/cicd-statistics/src/entity-page.tsx index 74a0add0b3..3c51dabf2a 100644 --- a/plugins/cicd-statistics/src/entity-page.tsx +++ b/plugins/cicd-statistics/src/entity-page.tsx @@ -16,6 +16,7 @@ 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'; @@ -182,6 +183,9 @@ function CicdCharts(props: CicdChartsProps) { {renderFallbacks(chartableStagesState, chartableStages => ( <> + {chartableStages.stages.size > 0 ? null : ( + No data + )} {!statisticsState.value?.builds?.length || !chartableStagesState.value?.daily?.values?.length ? null : ( diff --git a/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts b/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts index d435cb21aa..d21b978a92 100644 --- a/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts +++ b/plugins/cicd-statistics/src/hooks/use-cicd-configuration.ts @@ -18,15 +18,15 @@ import { useState, useEffect } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { CicdConfiguration, statusTypes } from '../apis'; -import { Progress } from '../components/progress'; +import { ProgressType } from '../components/progress'; import { defaultFormatStageName } from '../utils/stage-names'; import { useCicdStatisticsApi } from './use-cicd-statistics-api'; -export function useCicdConfiguration(): Progress { +export function useCicdConfiguration(): ProgressType { const cicdStatisticsApi = useCicdStatisticsApi(); const { entity } = useEntity(); - const [state, setState] = useState>({ + const [state, setState] = useState>({ loading: true, }); diff --git a/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts b/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts index 5c1fe1bfe2..e52673a576 100644 --- a/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts +++ b/plugins/cicd-statistics/src/hooks/use-cicd-statistics.ts @@ -24,8 +24,9 @@ import { AbortError, FilterStatusType, FilterBranchType, + UpdateProgress, } from '../apis'; -import { Progress } from '../components/progress'; +import { ProgressType } from '../components/progress'; import { useCicdStatisticsApi } from './use-cicd-statistics-api'; export interface UseCicdStatisticsOptions { @@ -39,7 +40,7 @@ export interface UseCicdStatisticsOptions { export function useCicdStatistics( options: UseCicdStatisticsOptions, -): Progress { +): ProgressType { const { entity, abortController, @@ -49,7 +50,9 @@ export function useCicdStatistics( filterType, } = options; - const [state, setState] = useState>({ loading: true }); + const [state, setState] = useState>({ + loading: true, + }); const cicdStatisticsApi = useCicdStatisticsApi(); @@ -62,15 +65,39 @@ export function useCicdStatistics( let mounted = true; let completed = false; // successfully or failed - const updateProgress = throttle((count, total, started = 0) => { - if (mounted && !completed) { + 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, }); } - }, 200); + }; + + const updateProgress = throttle( + updateProgressImpl, + 200, + // throttle doesn't handle types of multi-signature functions + ) as any as UpdateProgress; const fetchOptions: FetchBuildsOptions = { entity,