Add duration data to overview

Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
Niklas Granander
2021-07-27 17:25:03 +02:00
parent 2627f19b7b
commit 63efbf0386
14 changed files with 230 additions and 174 deletions
@@ -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<BuildTime[]> {
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<BuildStatusResult[]> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(
+8
View File
@@ -52,6 +52,13 @@ export type BuildCount = {
builds: number;
};
export type BuildTime = {
day: string;
durationP50: number;
durationP95: number;
totalDuration: number;
};
export type PaginationResult<T> = {
items: T[];
metadata: {
@@ -65,6 +72,7 @@ export interface XcmetricsApi {
getBuild(id: string): Promise<Build>;
getBuilds(): Promise<Build[]>;
getBuildCounts(days: number): Promise<BuildCount[]>;
getBuildTimes(days: number): Promise<BuildTime[]>;
getBuildStatuses(limit: number): Promise<BuildStatusResult[]>;
}
@@ -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';
@@ -34,7 +34,7 @@ describe('DataValueComponent', () => {
<DataValueComponent field={field} />,
);
expect(rendered.getByText(field)).toBeInTheDocument();
expect(rendered.getByText('Unknown')).toBeInTheDocument();
expect(rendered.getByText('--')).toBeInTheDocument();
});
it('grid item should render', async () => {
@@ -25,7 +25,7 @@ export const DataValueComponent = ({ field, value }: DataValueProps) => {
return (
<div>
<Typography variant="caption">{field}</Typography>
<Typography variant="subtitle1">{value ?? 'Unknown'}</Typography>
<Typography variant="subtitle1">{value ?? '--'}</Typography>
</div>
);
};
@@ -33,10 +33,11 @@ export const DataValueComponent = ({ field, value }: DataValueProps) => {
interface GridProps {
xs?: GridSize;
md?: GridSize;
lg?: GridSize;
}
export const DataValueGridItem = (props: DataValueProps & GridProps) => (
<Grid item xs={props.xs ?? 6} md={props.md ?? 4}>
<Grid item xs={props.xs ?? 6} md={props.md ?? 6} lg={props.lg ?? 4}>
<DataValueComponent {...props} />
</Grid>
);
@@ -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(
<ErrorTrendComponent buildCounts={buildCounts} />,
);
expect(rendered.findAllByText('Error Rate')).toBeTruthy();
});
});
@@ -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<BackstageTheme>();
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 (
<>
<Typography variant="overline">{TRENDLINE_TITLE}</Typography>
<TrendLine
data={averageErrors}
title={TRENDLINE_TITLE}
max={max}
color={theme.palette.status.warning}
/>
</>
);
};
@@ -30,6 +30,8 @@ describe('OverviewTrendsComponent', () => {
</ApiProvider>,
);
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', () => {
<OverviewTrendsComponent days={14} />
</ApiProvider>,
);
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(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewTrendsComponent days={14} />
</ApiProvider>,
);
expect(rendered.getByText(errorMessage)).toBeInTheDocument();
expect(rendered.getByText(buildCountError)).toBeInTheDocument();
expect(rendered.getByText(buildTimesError)).toBeInTheDocument();
});
});
@@ -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<BackstageTheme>();
const classes = useStyles();
const client = useApi(xcmetricsApiRef);
const { value: buildCounts, loading, error } = useAsync(
async (): Promise<BuildCount[]> => 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 <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
} else if (!buildCounts || buildCounts.length === 0) {
return (
<>
<Typography variant="h6">No Trends Available</Typography>
<TrendLine data={[0, 0]} title="No data" color="#CECECE" />
</>
);
}
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 (
<>
<Typography variant="h6">Last {days} Days</Typography>
<ErrorTrendComponent buildCounts={buildCounts} />
<BuildTrendComponent buildCounts={buildCounts} />
<Grid
container
spacing={3}
direction="row"
className={classes.spacingTop}
>
<DataValueGridItem field="Build Count" value={sumCount} />
<DataValueGridItem field="Error Count" value={sumErrors} />
<DataValueGridItem
field="Error Rate"
value={formatPercentage(errorRate)}
/>
</Grid>
{buildCountsResult.error && (
<Alert severity="error" className={classes.spacingVertical}>
<AlertTitle>Failed to fetch build counts</AlertTitle>
{buildCountsResult?.error?.message}
</Alert>
)}
{buildTimesResult.error && (
<Alert severity="error" className={classes.spacingVertical}>
<AlertTitle>Failed to fetch build times</AlertTitle>
{buildTimesResult?.error?.message}
</Alert>
)}
{(!buildCountsResult.error || !buildTimesResult.error) && (
<>
<TrendComponent
title="Build Time"
color={theme.palette.secondary.main}
data={getBuildDurationsP50(buildTimesResult.value)}
/>
<TrendComponent
title="Error Rate"
color={theme.palette.status.warning}
data={getErrorRatios(buildCountsResult.value)}
/>
<TrendComponent
title="Build Count"
color={theme.palette.primary.main}
data={getBuildCounts(buildCountsResult.value)}
/>
<Grid
container
spacing={3}
direction="row"
className={classes.spacingTop}
>
<DataValueGridItem field="Build Count" value={sumBuilds} />
<DataValueGridItem field="Error Count" value={sumErrors} />
<DataValueGridItem
field="Error Rate"
value={errorRate && formatPercentage(errorRate)}
/>
<DataValueGridItem
field="Avg. Build Time (P50)"
value={averageBuildDurationP50}
/>
<DataValueGridItem
field="Avg. Build Time (P95)"
value={averageBuildDurationP95}
/>
<DataValueGridItem
field="Total Build Time"
value={totalBuildTime}
/>
</Grid>
</>
)}
</>
);
};
@@ -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(
<BuildTrendComponent buildCounts={buildCounts} />,
<TrendComponent data={data} title={title} color="#000" />,
);
expect(rendered.findAllByText('Build Count')).toBeTruthy();
expect(rendered.findAllByText('testTitle')).toBeTruthy();
});
it('should render empty state', async () => {
const title = 'testTitle';
const rendered = await renderInTestApp(
<TrendComponent title={title} color="#000" />,
);
expect(rendered.findAllByText('testTitle')).toBeTruthy();
});
});
@@ -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<BackstageTheme>();
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 (
<>
<Typography variant="overline">{TRENDLINE_TITLE}</Typography>
<Typography variant="overline">{title}</Typography>
<TrendLine
data={builds}
title={TRENDLINE_TITLE}
data={data ?? emptyData}
title={title}
max={max}
color={theme.palette.primary.main}
color={data && color}
/>
</>
);
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './ErrorTrendComponent';
export * from './TrendComponent';
@@ -44,4 +44,18 @@ export const createMockXcmetricsApi = (): jest.Mocked<XcmetricsApi> => ({
{ 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,
},
]),
});
+12 -4
View File
@@ -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(