Allow user to select the number of days for trend data

Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
Niklas Granander
2021-07-27 19:48:15 +02:00
parent 63efbf0386
commit 70b18d97d6
7 changed files with 198 additions and 87 deletions
@@ -31,7 +31,7 @@ import { Build, BuildStatus, xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
import { StatusMatrixComponent } from '../StatusMatrixComponent';
import { formatTime } from '../../utils';
import { formatDuration, formatTime } from '../../utils';
import { Chip, Grid } from '@material-ui/core';
import { OverviewTrendsComponent } from '../OverviewTrendsComponent';
@@ -60,6 +60,11 @@ const columns: TableColumn<Build>[] = [
searchable: false,
render: data => formatTime(data.startTimestamp),
},
{
title: 'Duration',
field: 'duration',
render: data => formatDuration(data.duration),
},
{
title: 'User',
field: 'userid',
@@ -101,7 +106,7 @@ export const OverviewComponent = () => {
<SupportButton>Dashboard for XCMetrics</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="row">
<Grid item xs={12} md={8} lg={7} xl={9}>
<Grid item xs={12} md={8} lg={8} xl={9}>
<Table
options={{ paging: false, search: false }}
data={builds}
@@ -114,9 +119,9 @@ export const OverviewComponent = () => {
}
/>
</Grid>
<Grid item xs={12} md={4} lg={5} xl={3}>
<Grid item xs={12} md={4} lg={4} xl={3}>
<InfoCard>
<OverviewTrendsComponent days={14} />
<OverviewTrendsComponent />
</InfoCard>
</Grid>
</Grid>
@@ -19,6 +19,7 @@ import { renderInTestApp } from '@backstage/test-utils';
import { xcmetricsApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { createMockXcmetricsApi } from '../../test-utils';
import userEvent from '@testing-library/user-event';
describe('OverviewTrendsComponent', () => {
it('should render', async () => {
@@ -26,10 +27,10 @@ describe('OverviewTrendsComponent', () => {
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, createMockXcmetricsApi())}
>
<OverviewTrendsComponent days={14} />
<OverviewTrendsComponent />
</ApiProvider>,
);
expect(rendered.getByText('Last 14 Days')).toBeInTheDocument();
expect(rendered.getByText('Trends for')).toBeInTheDocument();
expect(rendered.getAllByText('Build Count').length).toEqual(3);
expect(rendered.getByText('Avg. Build Time (P50)')).toBeInTheDocument();
});
@@ -40,12 +41,26 @@ describe('OverviewTrendsComponent', () => {
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewTrendsComponent days={14} />
<OverviewTrendsComponent />
</ApiProvider>,
);
expect(rendered.getByText('--')).toBeInTheDocument();
});
it('should change number of days when select is changed', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, createMockXcmetricsApi())}
>
<OverviewTrendsComponent />
</ApiProvider>,
);
userEvent.click(rendered.getByText('14 days'));
userEvent.click(await rendered.findByText('30 days'));
expect(await rendered.findByText('30 days')).toBeInTheDocument();
});
it('should show errors when API not responding', async () => {
const api = createMockXcmetricsApi();
const buildCountError = 'MockBuildCountErrorMessage';
@@ -60,7 +75,7 @@ describe('OverviewTrendsComponent', () => {
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewTrendsComponent days={14} />
<OverviewTrendsComponent />
</ApiProvider>,
);
expect(rendered.getByText(buildCountError)).toBeInTheDocument();
@@ -13,68 +13,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Grid, makeStyles, Typography, useTheme } from '@material-ui/core';
import React from 'react';
import { Progress } from '@backstage/core-components';
import { Grid, makeStyles, useTheme } from '@material-ui/core';
import React, { useState } from 'react';
import { Progress, Select } from '@backstage/core-components';
import { TrendComponent } from '../TrendComponent';
import { Alert, AlertTitle } from '@material-ui/lab';
import { BuildCount, BuildTime, xcmetricsApiRef } from '../../api';
import { xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { DataValueGridItem } from '../DataValueComponent';
import { formatDuration, formatPercentage } from '../../utils';
import {
formatDuration,
formatPercentage,
getAverageDuration,
getErrorRatios,
getValues,
sumField,
} 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,
@@ -85,30 +42,31 @@ const useStyles = makeStyles({
},
});
export const OverviewTrendsComponent = ({ days }: { days: number }) => {
const DAYS_SELECT_ITEMS = [
{ label: '7 days', value: 7 },
{ label: '14 days', value: 14 },
{ label: '30 days', value: 30 },
{ label: '60 days', value: 60 },
];
export const OverviewTrendsComponent = () => {
const [days, setDays] = useState(14);
const theme = useTheme<BackstageTheme>();
const classes = useStyles();
const client = useApi(xcmetricsApiRef);
const buildCountsResult = useAsync(
async () => client.getBuildCounts(days),
[],
);
const buildTimesResult = useAsync(async () => client.getBuildTimes(days), []);
const buildCountsResult = useAsync(async () => client.getBuildCounts(days), [
days,
]);
const buildTimesResult = useAsync(async () => client.getBuildTimes(days), [
days,
]);
if (buildCountsResult.loading && buildTimesResult.loading) {
return <Progress />;
}
const sumBuilds = buildCountsResult.value?.reduce(
(sum, current) => sum + current.builds,
0,
);
const sumErrors = buildCountsResult.value?.reduce(
(sum, current) => sum + current.errors,
0,
);
const sumBuilds = sumField(b => b.builds, buildCountsResult.value);
const sumErrors = sumField(b => b.errors, buildCountsResult.value);
const errorRate = sumBuilds && sumErrors ? sumErrors / sumBuilds : undefined;
const averageBuildDurationP50 = getAverageDuration(
@@ -119,11 +77,16 @@ export const OverviewTrendsComponent = ({ days }: { days: number }) => {
buildTimesResult.value,
b => b.durationP95,
);
const totalBuildTime = getTotalBuildDuration(buildTimesResult.value);
const totalBuildTime = sumField(t => t.totalDuration, buildTimesResult.value);
return (
<>
<Typography variant="h6">Last {days} Days</Typography>
<Select
selected={days}
items={DAYS_SELECT_ITEMS}
label="Trends for"
onChange={selection => setDays(selection as number)}
/>
{buildCountsResult.error && (
<Alert severity="error" className={classes.spacingVertical}>
<AlertTitle>Failed to fetch build counts</AlertTitle>
@@ -137,11 +100,11 @@ export const OverviewTrendsComponent = ({ days }: { days: number }) => {
</Alert>
)}
{(!buildCountsResult.error || !buildTimesResult.error) && (
<>
<div className={classes.spacingVertical}>
<TrendComponent
title="Build Time"
color={theme.palette.secondary.main}
data={getBuildDurationsP50(buildTimesResult.value)}
data={getValues(e => e.durationP50, buildTimesResult.value)}
/>
<TrendComponent
title="Error Rate"
@@ -151,7 +114,7 @@ export const OverviewTrendsComponent = ({ days }: { days: number }) => {
<TrendComponent
title="Build Count"
color={theme.palette.primary.main}
data={getBuildCounts(buildCountsResult.value)}
data={getValues(e => e.builds, buildCountsResult.value)}
/>
<Grid
container
@@ -175,10 +138,10 @@ export const OverviewTrendsComponent = ({ days }: { days: number }) => {
/>
<DataValueGridItem
field="Total Build Time"
value={totalBuildTime}
value={totalBuildTime && formatDuration(totalBuildTime)}
/>
</Grid>
</>
</div>
)}
</>
);
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 const sumField = <T extends {}>(
field: (element: T) => number,
arr?: T[],
) => {
return arr?.reduce((sum, current) => sum + field(current), 0);
};
export const getValues = <T extends {}>(
field: (element: T) => number,
arr?: T[],
) => {
if (!arr?.length) {
return undefined;
}
return arr.map(element => field(element));
};
+42
View File
@@ -0,0 +1,42 @@
/*
* 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 { formatDuration } from '.';
import { BuildCount, BuildTime } from '../api';
export const getErrorRatios = (buildCounts?: BuildCount[]) => {
if (!buildCounts?.length) {
return undefined;
}
return buildCounts.map(counts =>
counts.builds === 0 ? 0 : counts.errors / counts.builds,
);
};
export const getAverageDuration = (
buildTimes: BuildTime[] | undefined,
field: (b: BuildTime) => number,
) => {
if (!buildTimes?.length) {
return undefined;
}
return formatDuration(
buildTimes.reduce((sum, current) => sum + field(current), 0) /
buildTimes.length,
);
};
+2
View File
@@ -13,5 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './array';
export * from './buildData';
export * from './format';
export * from './classnames';
+52 -1
View File
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { classNames } from './';
import { classNames, getValues, sumField } from './';
import { getAverageDuration, getErrorRatios } from './buildData';
describe('utils', () => {
describe('classNames', () => {
@@ -34,4 +35,54 @@ describe('utils', () => {
);
});
});
describe('sumFields', () => {
it('should sum the given field', () => {
expect(sumField(e => e.a, [{ a: 1 }, { a: 1 }, { a: 1, b: 10 }])).toEqual(
3,
);
expect(sumField(e => (e as any).field)).toBeUndefined();
});
});
describe('getValues', () => {
it('should return the values of the specified field', () => {
expect(getValues(e => e.a, [{ a: 1 }, { a: 2, b: 10 }])).toEqual([1, 2]);
expect(getValues(e => (e as any).field)).toBeUndefined();
expect(getValues(e => e.field, [] as { field: any }[])).toBeUndefined();
});
});
describe('getErrorRatios', () => {
it('should return the ratio between errors and builds', () => {
expect(
getErrorRatios([{ day: '2021-01-01', errors: 10, builds: 1 }]),
).toEqual([10]);
expect(
getErrorRatios([{ day: '2021-01-01', errors: 0, builds: 0 }]),
).toEqual([0]);
expect(getErrorRatios()).toBeUndefined();
});
});
describe('getAverageDuration', () => {
it('should return the average duration', () => {
const data = [
{
day: '2021-01-01',
durationP50: 3.0,
durationP95: 0,
totalDuration: 0,
},
{
day: '2021-01-01',
durationP50: 1.0,
durationP95: 0,
totalDuration: 0,
},
];
expect(getAverageDuration(data, e => e.durationP50)).toMatch('2 s');
expect(getAverageDuration([], e => e.durationP50)).toBeUndefined();
});
});
});