From 63efbf03868a1d62a6b7485d3c1b7b40ea1d353f Mon Sep 17 00:00:00 2001 From: Niklas Granander Date: Tue, 27 Jul 2021 17:25:03 +0200 Subject: [PATCH] Add duration data to overview Signed-off-by: Niklas Granander --- plugins/xcmetrics/src/api/XcmetricsClient.ts | 14 ++ plugins/xcmetrics/src/api/types.ts | 8 + .../components/BuildTrendComponent/index.ts | 16 -- .../DataValueComponent.test.tsx | 2 +- .../DataValueComponent/DataValueComponent.tsx | 5 +- .../ErrorTrendComponent.test.tsx | 31 ---- .../ErrorTrendComponent.tsx | 50 ----- .../OverviewTrendsComponent.test.tsx | 19 +- .../OverviewTrendsComponent.tsx | 174 ++++++++++++++---- .../TrendComponent.test.tsx} | 22 ++- .../TrendComponent.tsx} | 31 ++-- .../index.ts | 2 +- .../src/test-utils/mockXcmetricsApi.ts | 14 ++ plugins/xcmetrics/src/utils/format.ts | 16 +- 14 files changed, 230 insertions(+), 174 deletions(-) delete mode 100644 plugins/xcmetrics/src/components/BuildTrendComponent/index.ts delete mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx delete mode 100644 plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx rename plugins/xcmetrics/src/components/{BuildTrendComponent/BuildTrendComponent.test.tsx => TrendComponent/TrendComponent.test.tsx} (59%) rename plugins/xcmetrics/src/components/{BuildTrendComponent/BuildTrendComponent.tsx => TrendComponent/TrendComponent.tsx} (53%) rename plugins/xcmetrics/src/components/{ErrorTrendComponent => TrendComponent}/index.ts (93%) diff --git a/plugins/xcmetrics/src/api/XcmetricsClient.ts b/plugins/xcmetrics/src/api/XcmetricsClient.ts index 998244f967..0d4ed0dee1 100644 --- a/plugins/xcmetrics/src/api/XcmetricsClient.ts +++ b/plugins/xcmetrics/src/api/XcmetricsClient.ts @@ -20,6 +20,7 @@ import { Build, BuildCount, BuildStatusResult, + BuildTime, PaginationResult, XcmetricsApi, } from './types'; @@ -70,6 +71,19 @@ export class XcmetricsClient implements XcmetricsApi { return (await response.json()) as BuildCount[]; } + async getBuildTimes(days: number): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch( + `${baseUrl}/statistics/build/time?days=${days}`, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return (await response.json()) as BuildTime[]; + } + async getBuildStatuses(limit: number): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; const response = await fetch( diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 487da5065d..48bee5fd38 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -52,6 +52,13 @@ export type BuildCount = { builds: number; }; +export type BuildTime = { + day: string; + durationP50: number; + durationP95: number; + totalDuration: number; +}; + export type PaginationResult = { items: T[]; metadata: { @@ -65,6 +72,7 @@ export interface XcmetricsApi { getBuild(id: string): Promise; getBuilds(): Promise; getBuildCounts(days: number): Promise; + getBuildTimes(days: number): Promise; getBuildStatuses(limit: number): Promise; } diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts b/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts deleted file mode 100644 index 4f4ef09aca..0000000000 --- a/plugins/xcmetrics/src/components/BuildTrendComponent/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2021 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 './BuildTrendComponent'; diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx index 68c32a1a3d..df52d020ee 100644 --- a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.test.tsx @@ -34,7 +34,7 @@ describe('DataValueComponent', () => { , ); expect(rendered.getByText(field)).toBeInTheDocument(); - expect(rendered.getByText('Unknown')).toBeInTheDocument(); + expect(rendered.getByText('--')).toBeInTheDocument(); }); it('grid item should render', async () => { diff --git a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx index d29dfe3213..35b0331001 100644 --- a/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx +++ b/plugins/xcmetrics/src/components/DataValueComponent/DataValueComponent.tsx @@ -25,7 +25,7 @@ export const DataValueComponent = ({ field, value }: DataValueProps) => { return (
{field} - {value ?? 'Unknown'} + {value ?? '--'}
); }; @@ -33,10 +33,11 @@ export const DataValueComponent = ({ field, value }: DataValueProps) => { interface GridProps { xs?: GridSize; md?: GridSize; + lg?: GridSize; } export const DataValueGridItem = (props: DataValueProps & GridProps) => ( - + ); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx deleted file mode 100644 index 67b2b0ce90..0000000000 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2021 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 from 'react'; -import { ErrorTrendComponent } from './ErrorTrendComponent'; -import { renderInTestApp } from '@backstage/test-utils'; -import { BuildCount } from '../../api'; - -describe('ErrorTrendComponent', () => { - it('should render', async () => { - const buildCounts: BuildCount[] = [ - { day: '2021-01-01', errors: 10, builds: 100 }, - ]; - const rendered = await renderInTestApp( - , - ); - expect(rendered.findAllByText('Error Rate')).toBeTruthy(); - }); -}); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx b/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx deleted file mode 100644 index d6bf789f5b..0000000000 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/ErrorTrendComponent.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2021 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 from 'react'; -import { TrendLine } from '@backstage/core-components'; -import { BuildCount } from '../../api'; -import { Typography, useTheme } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; - -const TRENDLINE_TITLE = 'Error Rate'; - -interface ErrorTrendProps { - buildCounts: BuildCount[]; -} - -export const ErrorTrendComponent = ({ buildCounts }: ErrorTrendProps) => { - const theme = useTheme(); - - let max = 0; - const averageErrors = buildCounts.map(counts => { - if (counts.builds === 0) return 0; - const dayAverage = counts.errors / counts.builds; - max = Math.max(max, dayAverage); - return dayAverage; - }); - - return ( - <> - {TRENDLINE_TITLE} - - - ); -}; diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx index 957602384d..a25f64c685 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.test.tsx @@ -30,6 +30,8 @@ describe('OverviewTrendsComponent', () => { , ); expect(rendered.getByText('Last 14 Days')).toBeInTheDocument(); + expect(rendered.getAllByText('Build Count').length).toEqual(3); + expect(rendered.getByText('Avg. Build Time (P50)')).toBeInTheDocument(); }); it('should render empty state', async () => { @@ -41,20 +43,27 @@ describe('OverviewTrendsComponent', () => { , ); - expect(rendered.getByText('No Trends Available')).toBeInTheDocument(); + expect(rendered.getByText('--')).toBeInTheDocument(); }); - it('should show an error when API not responding', async () => { + it('should show errors when API not responding', async () => { const api = createMockXcmetricsApi(); - const errorMessage = 'MockErrorMessage'; + const buildCountError = 'MockBuildCountErrorMessage'; + const buildTimesError = 'MockBuildTimesErrorMessage'; - api.getBuildCounts = jest.fn().mockRejectedValue({ message: errorMessage }); + api.getBuildCounts = jest + .fn() + .mockRejectedValue({ message: buildCountError }); + api.getBuildTimes = jest + .fn() + .mockRejectedValue({ message: buildTimesError }); const rendered = await renderInTestApp( , ); - expect(rendered.getByText(errorMessage)).toBeInTheDocument(); + expect(rendered.getByText(buildCountError)).toBeInTheDocument(); + expect(rendered.getByText(buildTimesError)).toBeInTheDocument(); }); }); diff --git a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx index 5dff52c2db..a7f46d1851 100644 --- a/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx +++ b/plugins/xcmetrics/src/components/OverviewTrendsComponent/OverviewTrendsComponent.tsx @@ -13,73 +13,173 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Grid, makeStyles, Typography } from '@material-ui/core'; +import { Grid, makeStyles, Typography, useTheme } from '@material-ui/core'; import React from 'react'; -import { Progress, TrendLine } from '@backstage/core-components'; -import { ErrorTrendComponent } from '../ErrorTrendComponent'; -import { Alert } from '@material-ui/lab'; -import { BuildCount, xcmetricsApiRef } from '../../api'; +import { Progress } from '@backstage/core-components'; +import { TrendComponent } from '../TrendComponent'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { BuildCount, BuildTime, xcmetricsApiRef } from '../../api'; import { useAsync } from 'react-use'; import { useApi } from '@backstage/core-plugin-api'; -import { BuildTrendComponent } from '../BuildTrendComponent'; import { DataValueGridItem } from '../DataValueComponent'; -import { formatPercentage } from '../../utils'; +import { formatDuration, formatPercentage } from '../../utils'; +import { BackstageTheme } from '@backstage/theme'; + +const getErrorRatios = (buildCounts?: BuildCount[]) => { + if (!buildCounts?.length) { + return undefined; + } + + return buildCounts.map(counts => + counts.builds === 0 ? 0 : counts.errors / counts.builds, + ); +}; + +const getBuildCounts = (buildCounts?: BuildCount[]) => { + if (!buildCounts?.length) { + return undefined; + } + + return buildCounts.map(counts => counts.builds); +}; + +const getBuildDurationsP50 = (buildTimes?: BuildTime[]) => { + if (!buildTimes?.length) { + return undefined; + } + + return buildTimes.map(times => times.durationP50); +}; + +const getAverageDuration = ( + buildTimes: BuildTime[] | undefined, + accessor: (b: BuildTime) => number, +) => { + if (!buildTimes?.length) { + return undefined; + } + + return formatDuration( + buildTimes.reduce((sum, current) => sum + accessor(current), 0) / + buildTimes.length, + ); +}; + +const getTotalBuildDuration = (buildTimes?: BuildTime[]) => { + if (!buildTimes?.length) { + return undefined; + } + + return formatDuration( + buildTimes.reduce((sum, current) => sum + current.totalDuration, 0), + ); +}; const useStyles = makeStyles({ spacingTop: { marginTop: 8, }, + spacingVertical: { + marginTop: 8, + marginBottom: 8, + }, }); export const OverviewTrendsComponent = ({ days }: { days: number }) => { + const theme = useTheme(); const classes = useStyles(); const client = useApi(xcmetricsApiRef); - const { value: buildCounts, loading, error } = useAsync( - async (): Promise => client.getBuildCounts(days), + const buildCountsResult = useAsync( + async () => client.getBuildCounts(days), [], ); + const buildTimesResult = useAsync(async () => client.getBuildTimes(days), []); - if (loading) { + if (buildCountsResult.loading && buildTimesResult.loading) { return ; - } else if (error) { - return {error.message}; - } else if (!buildCounts || buildCounts.length === 0) { - return ( - <> - No Trends Available - - - ); } - const sumCount = buildCounts.reduce( + const sumBuilds = buildCountsResult.value?.reduce( (sum, current) => sum + current.builds, 0, ); - const sumErrors = buildCounts.reduce( + + const sumErrors = buildCountsResult.value?.reduce( (sum, current) => sum + current.errors, 0, ); - const errorRate = sumCount > 0 ? sumErrors / sumCount : 0; + + const errorRate = sumBuilds && sumErrors ? sumErrors / sumBuilds : undefined; + + const averageBuildDurationP50 = getAverageDuration( + buildTimesResult.value, + b => b.durationP50, + ); + const averageBuildDurationP95 = getAverageDuration( + buildTimesResult.value, + b => b.durationP95, + ); + const totalBuildTime = getTotalBuildDuration(buildTimesResult.value); return ( <> Last {days} Days - - - - - - - + {buildCountsResult.error && ( + + Failed to fetch build counts + {buildCountsResult?.error?.message} + + )} + {buildTimesResult.error && ( + + Failed to fetch build times + {buildTimesResult?.error?.message} + + )} + {(!buildCountsResult.error || !buildTimesResult.error) && ( + <> + + + + + + + + + + + + + )} ); }; diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx similarity index 59% rename from plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx rename to plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx index 2e96a0fb4e..0a1b0e0b32 100644 --- a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.test.tsx +++ b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.test.tsx @@ -14,18 +14,24 @@ * limitations under the License. */ import React from 'react'; -import { BuildTrendComponent } from './BuildTrendComponent'; +import { TrendComponent } from './TrendComponent'; import { renderInTestApp } from '@backstage/test-utils'; -import { BuildCount } from '../../api'; -describe('BuildTrendComponent', () => { +describe('TrendComponent', () => { it('should render', async () => { - const buildCounts: BuildCount[] = [ - { day: '2021-01-01', errors: 10, builds: 100 }, - ]; + const data = [1, 2, 3, 4]; + const title = 'testTitle'; const rendered = await renderInTestApp( - , + , ); - expect(rendered.findAllByText('Build Count')).toBeTruthy(); + expect(rendered.findAllByText('testTitle')).toBeTruthy(); + }); + + it('should render empty state', async () => { + const title = 'testTitle'; + const rendered = await renderInTestApp( + , + ); + expect(rendered.findAllByText('testTitle')).toBeTruthy(); }); }); diff --git a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx similarity index 53% rename from plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx rename to plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx index 7403ec1c43..ece6acfd6c 100644 --- a/plugins/xcmetrics/src/components/BuildTrendComponent/BuildTrendComponent.tsx +++ b/plugins/xcmetrics/src/components/TrendComponent/TrendComponent.tsx @@ -15,33 +15,26 @@ */ import React from 'react'; import { TrendLine } from '@backstage/core-components'; -import { BuildCount } from '../../api'; -import { Typography, useTheme } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import { Typography } from '@material-ui/core'; -const TRENDLINE_TITLE = 'Build Count'; - -interface BuildTrendProps { - buildCounts: BuildCount[]; +interface TrendProps { + data?: number[]; + title: string; + color: string; } -export const BuildTrendComponent = ({ buildCounts }: BuildTrendProps) => { - const theme = useTheme(); - - let max = 0; - const builds = buildCounts.map(counts => { - max = Math.max(max, counts.builds); - return counts.builds; - }); +export const TrendComponent = ({ data, title, color }: TrendProps) => { + const emptyData = [0, 0]; + const max = Math.max(...(data ?? emptyData)); return ( <> - {TRENDLINE_TITLE} + {title} ); diff --git a/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts b/plugins/xcmetrics/src/components/TrendComponent/index.ts similarity index 93% rename from plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts rename to plugins/xcmetrics/src/components/TrendComponent/index.ts index 974f40a8ae..69dce3ff7f 100644 --- a/plugins/xcmetrics/src/components/ErrorTrendComponent/index.ts +++ b/plugins/xcmetrics/src/components/TrendComponent/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './ErrorTrendComponent'; +export * from './TrendComponent'; diff --git a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts index b73b361926..08e75f2653 100644 --- a/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts +++ b/plugins/xcmetrics/src/test-utils/mockXcmetricsApi.ts @@ -44,4 +44,18 @@ export const createMockXcmetricsApi = (): jest.Mocked => ({ { day: '2021-07-10', builds: 10, errors: 1 }, { day: '2021-07-09', builds: 11, errors: 2 }, ]), + getBuildTimes: jest.fn().mockResolvedValue([ + { + day: '2021-07-10', + durationP50: 1.1, + durationP95: 2.1, + totalDuration: 3.1, + }, + { + day: '2021-07-09', + durationP50: 1.2, + durationP95: 2.2, + totalDuration: 3.2, + }, + ]), }); diff --git a/plugins/xcmetrics/src/utils/format.ts b/plugins/xcmetrics/src/utils/format.ts index 20d8cc68db..085441bb84 100644 --- a/plugins/xcmetrics/src/utils/format.ts +++ b/plugins/xcmetrics/src/utils/format.ts @@ -16,10 +16,18 @@ import { DateTime, Duration } from 'luxon'; import { BuildStatus } from '../api'; -export const formatDuration = (seconds: number) => - Duration.fromObject({ seconds: Math.round(seconds) }).toISOTime({ - suppressMilliseconds: true, - }); +export const formatDuration = (seconds: number) => { + const duration = Duration.fromObject({ + seconds: Math.round(seconds), + }).shiftTo('hours', 'minutes', 'seconds'); + + const h = duration.hours ? `${duration.hours} h` : ''; + const m = duration.minutes ? `${duration.minutes} m` : ''; + const s = + duration.hours < 12 && duration.seconds ? `${duration.seconds} s` : ''; + + return `${h} ${m} ${s}`; +}; export const formatTime = (timestamp: string) => { return DateTime.fromISO(timestamp).toLocaleString(