Add duration data to overview
Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+14
-5
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+137
-37
@@ -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
-8
@@ -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();
|
||||
});
|
||||
});
|
||||
+12
-19
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user