Allow user to select the number of days for trend data
Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
@@ -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
-4
@@ -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();
|
||||
|
||||
+41
-78
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user