Add trendline for build count and a few data fields

Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
Niklas Granander
2021-07-19 15:40:05 +02:00
parent 7a7680d950
commit d54717b139
17 changed files with 472 additions and 78 deletions
@@ -0,0 +1,31 @@
/*
* 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 { BuildTrendComponent } from './BuildTrendComponent';
import { renderInTestApp } from '@backstage/test-utils';
import { BuildCount } from '../../api';
describe('BuildTrendComponent', () => {
it('should render', async () => {
const buildCounts: BuildCount[] = [
{ day: '2021-01-01', errors: 10, builds: 100 },
];
const rendered = await renderInTestApp(
<BuildTrendComponent buildCounts={buildCounts} />,
);
expect(rendered.findAllByText('Build Count')).toBeTruthy();
});
});
@@ -0,0 +1,48 @@
/*
* 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 = 'Build Count';
interface BuildTrendProps {
buildCounts: BuildCount[];
}
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;
});
return (
<>
<Typography variant="overline">{TRENDLINE_TITLE}</Typography>
<TrendLine
data={builds}
title={TRENDLINE_TITLE}
max={max}
color={theme.palette.primary.main}
/>
</>
);
};
@@ -0,0 +1,16 @@
/*
* 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';
@@ -0,0 +1,40 @@
/*
* 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 { DataValueComponent, DataValueGridItem } from './DataValueComponent';
import { renderInTestApp } from '@backstage/test-utils';
describe('DataValueComponent', () => {
it('should render', async () => {
const field = 'Field';
const value = 'Value';
const rendered = await renderInTestApp(
<DataValueComponent field={field} value={value} />,
);
expect(rendered.getByText(field)).toBeInTheDocument();
expect(rendered.getByText(value)).toBeInTheDocument();
});
it('grid item should render', async () => {
const field = 'Field';
const value = 'Value';
const rendered = await renderInTestApp(
<DataValueGridItem field={field} value={value} />,
);
expect(rendered.getByText(field)).toBeInTheDocument();
expect(rendered.getByText(value)).toBeInTheDocument();
});
});
@@ -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 { Grid, GridSize, Typography } from '@material-ui/core';
import React from 'react';
interface DataValueProps {
field: string;
value: string | number | undefined;
}
export const DataValueComponent = ({ field, value }: DataValueProps) => {
return (
<div>
<Typography variant="caption">{field}</Typography>
<Typography variant="subtitle1">{value}</Typography>
</div>
);
};
interface GridProps {
xs?: GridSize;
md?: GridSize;
}
export const DataValueGridItem = (props: DataValueProps & GridProps) => (
<Grid item xs={props.xs ?? 6} md={props.md ?? 4}>
<DataValueComponent {...props} />
</Grid>
);
@@ -0,0 +1,16 @@
/*
* 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 './DataValueComponent';
@@ -0,0 +1,31 @@
/*
* 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();
});
});
@@ -14,32 +14,19 @@
* limitations under the License.
*/
import React from 'react';
import { Progress, TrendLine } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { BuildCount, xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
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 {
days: number;
buildCounts: BuildCount[];
}
export const ErrorTrendComponent = ({ days }: ErrorTrendProps) => {
const client = useApi(xcmetricsApiRef);
const { value: buildCounts, loading, error } = useAsync(
async (): Promise<BuildCount[]> => client.getBuildCounts(days),
[],
);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
} else if (!buildCounts) {
return <TrendLine data={Array(days).fill(0)} title={TRENDLINE_TITLE} />;
}
export const ErrorTrendComponent = ({ buildCounts }: ErrorTrendProps) => {
const theme = useTheme<BackstageTheme>();
let max = 0;
const averageErrors = buildCounts.map(counts => {
@@ -49,5 +36,15 @@ export const ErrorTrendComponent = ({ days }: ErrorTrendProps) => {
return dayAverage;
});
return <TrendLine data={averageErrors} title={TRENDLINE_TITLE} max={max} />;
return (
<>
<Typography variant="overline">{TRENDLINE_TITLE}</Typography>
<TrendLine
data={averageErrors}
title={TRENDLINE_TITLE}
max={max}
color={theme.palette.status.warning}
/>
</>
);
};
@@ -14,47 +14,59 @@
* limitations under the License.
*/
import React from 'react';
import { OverviewComponent } from './OverviewComponent';
import { renderInTestApp } from '@backstage/test-utils';
import { xcmetricsApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { mockUserId, mockXcmetricsApi } from '../../test-utils';
import { mockUserId, createMockXcmetricsApi } from '../../test-utils';
import { OverviewComponent } from './OverviewComponent';
jest.mock('../OverviewTrendsComponent', () => ({
OverviewTrendsComponent: () => 'OverviewTrendsComponent',
}));
jest.mock('../StatusMatrixComponent', () => ({
StatusMatrixComponent: () => 'StatusMatrixComponent',
}));
describe('OverviewComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockXcmetricsApi)}>
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, createMockXcmetricsApi())}
>
<OverviewComponent />
</ApiProvider>,
);
expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument();
expect(rendered.getByText(mockUserId)).toBeInTheDocument();
expect(rendered.queryByText('CI')).toBeNull();
});
it('should render an empty state when no builds exist', async () => {
mockXcmetricsApi.getBuilds = jest.fn().mockResolvedValue([]);
const api = createMockXcmetricsApi();
api.getBuilds = jest.fn().mockResolvedValue([]);
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockXcmetricsApi)}>
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewComponent />
</ApiProvider>,
);
expect(rendered.getByText('No builds to show')).toBeInTheDocument();
});
it('should show an error when API not responding', async () => {
const api = createMockXcmetricsApi();
const errorMessage = 'MockErrorMessage';
mockXcmetricsApi.getBuilds = jest
.fn()
.mockRejectedValue({ message: errorMessage });
api.getBuilds = jest.fn().mockRejectedValue({ message: errorMessage });
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockXcmetricsApi)}>
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewComponent />
</ApiProvider>,
);
expect(rendered.getByText(errorMessage)).toBeInTheDocument();
});
});
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import React, { ReactChild } from 'react';
import {
ContentHeader,
SupportButton,
@@ -31,33 +31,21 @@ import { Build, BuildStatus, xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
import { StatusMatrixComponent } from '../StatusMatrixComponent';
import { formatDuration, formatStatus } from '../../utils';
import { Chip, Grid, Typography } from '@material-ui/core';
import { ErrorTrendComponent } from '../ErrorTrendComponent';
import { formatTime } from '../../utils';
import { Chip, Grid } from '@material-ui/core';
import { OverviewTrendsComponent } from '../OverviewTrendsComponent';
const Status = ({
status,
warningCount,
}: {
status: BuildStatus;
warningCount: number;
}) => {
const statusIcons = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
return (
<>
{statusIcons[status]} {formatStatus(status)}
{warningCount > 0 && ` with ${warningCount} warning`}
{warningCount > 1 && 's'}
</>
);
const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
const columns: TableColumn<Build>[] = [
{
field: 'buildStatus',
render: data => STATUS_ICONS[data.buildStatus],
},
{
title: 'Project',
field: 'projectName',
@@ -67,23 +55,15 @@ const columns: TableColumn<Build>[] = [
field: 'schema',
},
{
title: 'Duration',
field: 'duration',
type: 'time',
title: 'Started',
field: 'startedAt',
searchable: false,
render: data => formatDuration(data.duration),
render: data => formatTime(data.startTimestamp),
},
{
title: 'User',
field: 'userid',
},
{
title: 'Status',
field: 'buildStatus',
render: data => (
<Status status={data.buildStatus} warningCount={data.warningCount} />
),
},
{
field: 'isCI',
render: data => data.isCi && <Chip label="CI" size="small" />,
@@ -121,7 +101,7 @@ export const OverviewComponent = () => {
<SupportButton>Dashboard for XCMetrics</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="row">
<Grid item xs={7}>
<Grid item xs={12} md={8} lg={7} xl={9}>
<Table
options={{ paging: false, search: false }}
data={builds}
@@ -134,10 +114,9 @@ export const OverviewComponent = () => {
}
/>
</Grid>
<Grid item xs={5}>
<Grid item xs={12} md={4} lg={5} xl={3}>
<InfoCard>
<Typography variant="overline">Error Rate</Typography>
<ErrorTrendComponent days={14} />
<OverviewTrendsComponent days={14} />
</InfoCard>
</Grid>
</Grid>
@@ -0,0 +1,60 @@
/*
* 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 { OverviewTrendsComponent } from './OverviewTrendsComponent';
import { renderInTestApp } from '@backstage/test-utils';
import { xcmetricsApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { createMockXcmetricsApi } from '../../test-utils';
describe('OverviewTrendsComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, createMockXcmetricsApi())}
>
<OverviewTrendsComponent days={14} />
</ApiProvider>,
);
expect(rendered.getByText('Last 14 Days')).toBeInTheDocument();
});
it('should render empty state', async () => {
const api = createMockXcmetricsApi();
api.getBuildCounts = jest.fn().mockResolvedValue([]);
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewTrendsComponent days={14} />
</ApiProvider>,
);
expect(rendered.getByText('No Trends Available')).toBeInTheDocument();
});
it('should show an error when API not responding', async () => {
const api = createMockXcmetricsApi();
const errorMessage = 'MockErrorMessage';
api.getBuildCounts = jest.fn().mockRejectedValue({ message: errorMessage });
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<OverviewTrendsComponent days={14} />
</ApiProvider>,
);
expect(rendered.getByText(errorMessage)).toBeInTheDocument();
});
});
@@ -0,0 +1,85 @@
/*
* 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 { Grid, makeStyles, Typography } 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 { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { BuildTrendComponent } from '../BuildTrendComponent';
import { DataValueGridItem } from '../DataValueComponent';
import { formatPercentage } from '../../utils';
const useStyles = makeStyles({
spacingTop: {
marginTop: 8,
},
});
export const OverviewTrendsComponent = ({ days }: { days: number }) => {
const classes = useStyles();
const client = useApi(xcmetricsApiRef);
const { value: buildCounts, loading, error } = useAsync(
async (): Promise<BuildCount[]> => client.getBuildCounts(days),
[],
);
if (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(
(sum, current) => sum + current.builds,
0,
);
const sumErrors = buildCounts.reduce(
(sum, current) => sum + current.errors,
0,
);
const errorRate = sumCount > 0 ? sumErrors / sumCount : 0;
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="Total Build Count" value={sumCount} />
<DataValueGridItem field="Total Error Count" value={sumErrors} />
<DataValueGridItem
field="Error Rate"
value={formatPercentage(errorRate)}
/>
</Grid>
</>
);
};
@@ -0,0 +1,16 @@
/*
* 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 './OverviewTrendsComponent';
@@ -19,13 +19,19 @@ import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import userEvent from '@testing-library/user-event';
import { StatusCellComponent } from './StatusCellComponent';
import { xcmetricsApiRef } from '../../api';
import { mockBuildId, mockStatus, mockXcmetricsApi } from '../../test-utils';
import {
mockBuildId,
mockStatus,
createMockXcmetricsApi,
} from '../../test-utils';
import { formatStatus } from '../../utils';
describe('StatusCellComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockXcmetricsApi)}>
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, createMockXcmetricsApi())}
>
<StatusCellComponent
buildStatus={{ id: mockBuildId, buildStatus: mockStatus }}
size={10}
@@ -18,12 +18,14 @@ import { renderInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { StatusMatrixComponent } from './StatusMatrixComponent';
import { xcmetricsApiRef } from '../../api';
import { mockBuildId, mockXcmetricsApi } from '../../test-utils';
import { mockBuildId, createMockXcmetricsApi } from '../../test-utils';
describe('StatusMatrixComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockXcmetricsApi)}>
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, createMockXcmetricsApi())}
>
<StatusMatrixComponent />
</ApiProvider>,
);
@@ -19,7 +19,7 @@ export const mockUserId = 'user_id';
export const mockBuildId = 'build_id';
export const mockStatus = 'succeeded';
export const mockXcmetricsApi: jest.Mocked<XcmetricsApi> = {
export const createMockXcmetricsApi = (): jest.Mocked<XcmetricsApi> => ({
getBuildStatuses: jest
.fn()
.mockResolvedValue([{ id: mockBuildId, status: mockStatus }]),
@@ -40,5 +40,8 @@ export const mockXcmetricsApi: jest.Mocked<XcmetricsApi> = {
schema: 'AppSchema',
},
]),
getBuildCounts: jest.fn(),
};
getBuildCounts: jest.fn().mockResolvedValue([
{ day: '2021-07-10', builds: 10, errors: 1 },
{ day: '2021-07-09', builds: 11, errors: 2 },
]),
});
+11 -1
View File
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Duration } from 'luxon';
import { DateTime, Duration } from 'luxon';
import { BuildStatus } from '../api';
export const formatDuration = (seconds: number) =>
@@ -21,5 +21,15 @@ export const formatDuration = (seconds: number) =>
suppressMilliseconds: true,
});
export const formatTime = (timestamp: string) => {
return DateTime.fromISO(timestamp).toLocaleString(
DateTime.DATETIME_SHORT_WITH_SECONDS,
);
};
export const formatPercentage = (number: number) => {
return `${Math.round(number * 100)} %`;
};
export const formatStatus = (status: BuildStatus) =>
status[0].toUpperCase() + status.slice(1);