Merge pull request #6633 from ngranander/xcmetrics-overview-features

Show more metrics of XCMetrics
This commit is contained in:
Ben Lambert
2021-07-28 13:28:03 +02:00
committed by GitHub
28 changed files with 1271 additions and 70 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-xcmetrics': minor
---
New data in form of trend lines, status timeline and other is added to the dashboard of XCMetrics to give a better understanding of how the build system is behaving.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 860 KiB

+62 -4
View File
@@ -16,7 +16,14 @@
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { BuildItem, BuildsResult, XcmetricsApi } from './types';
import {
Build,
BuildCount,
BuildStatusResult,
BuildTime,
PaginationResult,
XcmetricsApi,
} from './types';
interface Options {
discoveryApi: DiscoveryApi;
@@ -29,14 +36,65 @@ export class XcmetricsClient implements XcmetricsApi {
this.discoveryApi = options.discoveryApi;
}
async getBuilds(): Promise<BuildItem[]> {
async getBuild(id: string): Promise<Build> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(`${baseUrl}/build`);
const response = await fetch(`${baseUrl}/build/${id}`);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return ((await response.json()) as BuildsResult).items;
return ((await response.json()) as Record<'build', Build>).build;
}
async getBuilds(limit: number = 10): Promise<Build[]> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(`${baseUrl}/build?per=${limit}`);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return ((await response.json()) as PaginationResult<Build>).items;
}
async getBuildCounts(days: number): Promise<BuildCount[]> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(
`${baseUrl}/statistics/build/count?days=${days}`,
);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
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(
`${baseUrl}/statistics/build/status?per=${limit}`,
);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return ((await response.json()) as PaginationResult<BuildStatusResult>)
.items;
}
}
@@ -0,0 +1,73 @@
/*
* 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 { Build, XcmetricsApi } from '../types';
export const mockBuild = {
userid: 'userid1',
warningCount: 1,
duration: 1,
startTimestamp: '2021-01-01T00:00:00Z',
isCi: true,
startTimestampMicroseconds: 0,
category: '',
endTimestampMicroseconds: 10000,
day: '2021-01-01',
compilationEndTimestamp: '2021-01-01T00:00:01Z',
tag: '',
projectName: 'Project',
compilationEndTimestampMicroseconds: 1,
errorCount: 1,
id: 'buildId',
buildStatus: 'succeeded',
compilationDuration: 1,
schema: 'Schema',
compiledCount: 1,
endTimestamp: '2021-01-01T00:00:01Z',
userid256: 'userId256',
machineName: 'Example_Machine',
wasSuspended: true,
} as Build;
export const mockBuildCount = { day: '2021-07-10', builds: 10, errors: 1 };
export const mockBuildTime = {
day: '2021-07-10',
durationP50: 1.1,
durationP95: 2.1,
totalDuration: 3.1,
};
export const mockBuildStatus = { id: 'build_id', status: 'succeeded' };
export const XcmetricsClient: XcmetricsApi = {
getBuild: (id: string) => {
return Promise.resolve({ ...mockBuild, id });
},
getBuilds: () => {
return Promise.resolve([
{ ...mockBuild, id: '1' },
{ ...mockBuild, id: '2', userid: 'userid2' },
]);
},
getBuildCounts: () => {
return Promise.resolve([mockBuildCount, mockBuildCount]);
},
getBuildStatuses: (limit: number) => {
return Promise.resolve([mockBuild].slice(0, limit));
},
getBuildTimes: (days: number) => {
return Promise.resolve([mockBuildTime, mockBuildTime].slice(0, days));
},
};
+23 -4
View File
@@ -18,7 +18,7 @@ import { createApiRef } from '@backstage/core-plugin-api';
export type BuildStatus = 'succeeded' | 'failed' | 'stopped';
export type BuildItem = {
export type Build = {
userid: string;
warningCount: number;
duration: number;
@@ -44,8 +44,23 @@ export type BuildItem = {
wasSuspended: boolean;
};
export type BuildsResult = {
items: BuildItem[];
export type BuildStatusResult = Pick<Build, 'id' | 'buildStatus'>;
export type BuildCount = {
day: string;
errors: number;
builds: number;
};
export type BuildTime = {
day: string;
durationP50: number;
durationP95: number;
totalDuration: number;
};
export type PaginationResult<T> = {
items: T[];
metadata: {
per: number;
total: number;
@@ -54,7 +69,11 @@ export type BuildsResult = {
};
export interface XcmetricsApi {
getBuilds(): Promise<BuildItem[]>;
getBuild(id: string): Promise<Build>;
getBuilds(): Promise<Build[]>;
getBuildCounts(days: number): Promise<BuildCount[]>;
getBuildTimes(days: number): Promise<BuildTime[]>;
getBuildStatuses(limit: number): Promise<BuildStatusResult[]>;
}
export const xcmetricsApiRef = createApiRef<XcmetricsApi>({
@@ -0,0 +1,49 @@
/*
* 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('should render placeholder text when no value is present', async () => {
const field = 'Field';
const rendered = await renderInTestApp(
<DataValueComponent field={field} />,
);
expect(rendered.getByText(field)).toBeInTheDocument();
expect(rendered.getByText('--')).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,43 @@
/*
* 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 | null | 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;
lg?: GridSize;
}
export const DataValueGridItem = (props: DataValueProps & GridProps) => (
<Grid item xs={props.xs ?? 6} md={props.md ?? 6} lg={props.lg ?? 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';
@@ -14,62 +14,61 @@
* limitations under the License.
*/
import React from 'react';
import { OverviewComponent } from './OverviewComponent';
import { renderInTestApp } from '@backstage/test-utils';
import { XcmetricsApi, xcmetricsApiRef } from '../../api';
import { xcmetricsApiRef } from '../../api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { OverviewComponent } from './OverviewComponent';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
jest.mock('../OverviewTrendsComponent', () => ({
OverviewTrendsComponent: () => 'OverviewTrendsComponent',
}));
jest.mock('../StatusMatrixComponent', () => ({
StatusMatrixComponent: () => 'StatusMatrixComponent',
}));
describe('OverviewComponent', () => {
it('should render', async () => {
const mockUserId = 'mockUser';
const mockApi: jest.Mocked<XcmetricsApi> = {
getBuilds: jest.fn().mockResolvedValue([
{
userid: mockUserId,
warningCount: 1,
duration: 123.45,
isCi: false,
projectName: 'App',
buildStatus: 'succeeded',
schema: 'AppSchema',
},
]),
};
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockApi)}>
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<OverviewComponent />
</ApiProvider>,
);
expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument();
expect(rendered.getByText(mockUserId)).toBeInTheDocument();
expect(rendered.queryByText('CI')).toBeNull();
expect(rendered.getByText(client.mockBuild.userid)).toBeInTheDocument();
});
it('should render an empty state when no builds exist', async () => {
const mockApi: jest.Mocked<XcmetricsApi> = {
getBuilds: jest.fn().mockResolvedValue([]),
};
const api = client.XcmetricsClient;
api.getBuilds = jest.fn().mockResolvedValue([]);
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockApi)}>
<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 = client.XcmetricsClient;
const errorMessage = 'MockErrorMessage';
const mockApi: jest.Mocked<XcmetricsApi> = {
getBuilds: jest.fn().mockRejectedValue({ message: errorMessage }),
};
api.getBuilds = jest.fn().mockRejectedValue({ message: errorMessage });
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, mockApi)}>
<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,
@@ -24,31 +24,28 @@ import {
Table,
TableColumn,
EmptyState,
InfoCard,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { BuildItem, BuildStatus, xcmetricsApiRef } from '../../api';
import { Build, BuildStatus, xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { Alert } from '@material-ui/lab';
import { Duration } from 'luxon';
import { Chip } from '@material-ui/core';
import { StatusMatrixComponent } from '../StatusMatrixComponent';
import { formatDuration, formatTime } from '../../utils';
import { Chip, Grid } from '@material-ui/core';
import { OverviewTrendsComponent } from '../OverviewTrendsComponent';
const formatStatus = (status: BuildStatus, warningCount: number) => {
const statusIcons = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
return (
<>
{statusIcons[status]} {status[0].toUpperCase() + status.slice(1)}
{warningCount > 0 && ` with ${warningCount} warning`}
{warningCount > 1 && 's'}
</>
);
const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
const columns: TableColumn<BuildItem>[] = [
const columns: TableColumn<Build>[] = [
{
field: 'buildStatus',
render: data => STATUS_ICONS[data.buildStatus],
},
{
title: 'Project',
field: 'projectName',
@@ -57,22 +54,21 @@ const columns: TableColumn<BuildItem>[] = [
title: 'Schema',
field: 'schema',
},
{
title: 'Started',
field: 'startedAt',
searchable: false,
render: data => formatTime(data.startTimestamp),
},
{
title: 'Duration',
field: 'duration',
type: 'time',
searchable: false,
render: data => Duration.fromObject({ seconds: data.duration }).toISOTime(),
render: data => formatDuration(data.duration),
},
{
title: 'User',
field: 'userid',
},
{
title: 'Status',
field: 'buildStatus',
render: data => formatStatus(data.buildStatus, data.warningCount),
},
{
field: 'isCI',
render: data => data.isCi && <Chip label="CI" size="small" />,
@@ -84,7 +80,7 @@ const columns: TableColumn<BuildItem>[] = [
export const OverviewComponent = () => {
const client = useApi(xcmetricsApiRef);
const { value: builds, loading, error } = useAsync(
async (): Promise<BuildItem[]> => client.getBuilds(),
async () => client.getBuilds(),
[],
);
@@ -109,12 +105,26 @@ export const OverviewComponent = () => {
<ContentHeader title="XCMetrics Dashboard">
<SupportButton>Dashboard for XCMetrics</SupportButton>
</ContentHeader>
<Table
options={{ paging: false, search: false }}
data={builds}
columns={columns}
title="Latest Builds"
/>
<Grid container spacing={3} direction="row">
<Grid item xs={12} md={8} lg={8} xl={9}>
<Table
options={{ paging: false, search: false }}
data={builds}
columns={columns}
title={
<>
Latest Builds
<StatusMatrixComponent />
</>
}
/>
</Grid>
<Grid item xs={12} md={4} lg={4} xl={3}>
<InfoCard>
<OverviewTrendsComponent />
</InfoCard>
</Grid>
</Grid>
</>
);
};
@@ -0,0 +1,86 @@
/*
* 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 userEvent from '@testing-library/user-event';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
describe('OverviewTrendsComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<OverviewTrendsComponent />
</ApiProvider>,
);
expect(rendered.getByText('Trends for')).toBeInTheDocument();
expect(rendered.getAllByText('Build Count').length).toEqual(3);
expect(rendered.getByText('Avg. Build Time (P50)')).toBeInTheDocument();
});
it('should render empty state', async () => {
const api = client.XcmetricsClient;
api.getBuildCounts = jest.fn().mockResolvedValue([]);
const rendered = await renderInTestApp(
<ApiProvider apis={ApiRegistry.with(xcmetricsApiRef, api)}>
<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, client.XcmetricsClient)}
>
<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 = client.XcmetricsClient;
const buildCountError = 'MockBuildCountErrorMessage';
const buildTimesError = 'MockBuildTimesErrorMessage';
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 />
</ApiProvider>,
);
expect(rendered.getByText(buildCountError)).toBeInTheDocument();
expect(rendered.getByText(buildTimesError)).toBeInTheDocument();
});
});
@@ -0,0 +1,148 @@
/*
* 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, 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 { xcmetricsApiRef } from '../../api';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { DataValueGridItem } from '../DataValueComponent';
import {
formatDuration,
formatPercentage,
getAverageDuration,
getErrorRatios,
getValues,
sumField,
} from '../../utils';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles({
spacingTop: {
marginTop: 8,
},
spacingVertical: {
marginTop: 8,
marginBottom: 8,
},
});
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), [
days,
]);
const buildTimesResult = useAsync(async () => client.getBuildTimes(days), [
days,
]);
if (buildCountsResult.loading && buildTimesResult.loading) {
return <Progress />;
}
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(
buildTimesResult.value,
b => b.durationP50,
);
const averageBuildDurationP95 = getAverageDuration(
buildTimesResult.value,
b => b.durationP95,
);
const totalBuildTime = sumField(t => t.totalDuration, buildTimesResult.value);
return (
<>
<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>
{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) && (
<div className={classes.spacingVertical}>
<TrendComponent
title="Build Time"
color={theme.palette.secondary.main}
data={getValues(e => e.durationP50, 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={getValues(e => e.builds, 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 && formatDuration(totalBuildTime)}
/>
</Grid>
</div>
)}
</>
);
};
@@ -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';
@@ -0,0 +1,54 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import userEvent from '@testing-library/user-event';
import { StatusCellComponent } from './StatusCellComponent';
import { xcmetricsApiRef } from '../../api';
import { formatDuration, formatStatus } from '../../utils';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
describe('StatusCellComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<StatusCellComponent
buildStatus={{
id: client.mockBuild.id,
buildStatus: client.mockBuild.buildStatus,
}}
size={10}
spacing={10}
/>
</ApiProvider>,
);
userEvent.hover(rendered.getByTestId(client.mockBuild.id));
expect(
await rendered.findByText(formatStatus(client.mockBuild.buildStatus)),
).toBeInTheDocument();
expect(
await rendered.findByText(
formatDuration(client.mockBuild.duration).trim(),
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,123 @@
/*
* 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 { makeStyles, Tooltip } from '@material-ui/core';
import React from 'react';
import { BackstageTheme } from '@backstage/theme';
import { BuildStatus, BuildStatusResult, xcmetricsApiRef } from '../../api';
import { cn, formatDuration, formatStatus } from '../../utils';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { Progress } from '@backstage/core-components';
interface TooltipContentProps {
buildId: string;
}
const TooltipContent = ({ buildId }: TooltipContentProps) => {
const client = useApi(xcmetricsApiRef);
const { value: build, loading, error } = useAsync(
async () => client.getBuild(buildId),
[],
);
if (error) {
return <div>{error.message}</div>;
} else if (loading || !build) {
return <Progress style={{ width: 100 }} />;
}
return (
<table>
<tbody>
<tr>
<td>Started</td>
<td>{new Date(build.startTimestamp).toLocaleString()}</td>
</tr>
<tr>
<td>Duration</td>
<td>{formatDuration(build.duration)}</td>
</tr>
<tr>
<td>Status</td>
<td>{formatStatus(build.buildStatus)}</td>
</tr>
</tbody>
</table>
);
};
interface StatusCellProps {
buildStatus?: BuildStatusResult;
size: number;
spacing: number;
}
type StatusStyle = {
[key in BuildStatus]: any;
};
const useStyles = makeStyles<BackstageTheme, StatusCellProps>(theme => ({
root: {
width: ({ size }) => size,
height: ({ size }) => size,
marginRight: ({ spacing }) => spacing,
marginBottom: ({ spacing }) => spacing,
backgroundColor: theme.palette.grey[600],
'&:hover': {
transform: 'scale(1.2)',
},
},
...({
succeeded: {
backgroundColor:
theme.palette.type === 'light'
? theme.palette.success.light
: theme.palette.success.main,
},
} as StatusStyle), // Make sure that key matches a status
...({
failed: {
backgroundColor: theme.palette.error[theme.palette.type],
},
} as StatusStyle),
...({
stopped: {
backgroundColor: theme.palette.warning[theme.palette.type],
},
} as StatusStyle),
}));
export const StatusCellComponent = (props: StatusCellProps) => {
const classes = useStyles(props);
const { buildStatus } = props;
if (!buildStatus) {
return <div className={classes.root} />;
}
return (
<Tooltip
title={<TooltipContent buildId={buildStatus.id} />}
enterNextDelay={500}
arrow
>
<div
data-testid={buildStatus.id}
className={cn(classes.root, classes[buildStatus.buildStatus])}
/>
</Tooltip>
);
};
@@ -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 './StatusCellComponent';
@@ -0,0 +1,38 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { StatusMatrixComponent } from './StatusMatrixComponent';
import { xcmetricsApiRef } from '../../api';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
describe('StatusMatrixComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<StatusMatrixComponent />
</ApiProvider>,
);
const cell = rendered.getByTestId(client.mockBuild.id);
expect(cell).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
/*
* 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 { makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import { xcmetricsApiRef } from '../../api';
import { useAsync, useMeasure } from 'react-use';
import { cn } from '../../utils';
import { useApi } from '@backstage/core-plugin-api';
import { Alert } from '@material-ui/lab';
import { StatusCellComponent } from '../StatusCellComponent';
const CELL_SIZE = 12;
const CELL_MARGIN = 4;
const MAX_ROWS = 4;
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
marginTop: 8,
display: 'flex',
flexWrap: 'wrap',
width: '100%',
},
loading: {
animation: `$loadingOpacity 900ms ${theme.transitions.easing.easeInOut}`,
animationIterationCount: 'infinite',
},
'@keyframes loadingOpacity': {
'0%': { opacity: 0.3 },
'100%': { opacity: 0.8 },
},
}));
export const StatusMatrixComponent = () => {
const classes = useStyles();
const [measureRef, { width: rootWidth }] = useMeasure<HTMLDivElement>();
const client = useApi(xcmetricsApiRef);
const { value: builds, loading, error } = useAsync(
async () => client.getBuildStatuses(300),
[],
);
if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
const cols = Math.trunc(rootWidth / (CELL_SIZE + CELL_MARGIN)) || 1;
return (
<div
className={cn(classes.root, loading && classes.loading)}
ref={measureRef}
>
{loading &&
[...new Array(cols * MAX_ROWS)].map((_, index) => {
return (
<StatusCellComponent
key={index}
size={CELL_SIZE}
spacing={CELL_MARGIN}
/>
);
})}
{builds &&
builds
.slice(0, cols * MAX_ROWS)
.map((buildStatus, index) => (
<StatusCellComponent
key={index}
buildStatus={buildStatus}
size={CELL_SIZE}
spacing={CELL_MARGIN}
/>
))}
</div>
);
};
@@ -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 './StatusMatrixComponent';
@@ -0,0 +1,37 @@
/*
* 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 { TrendComponent } from './TrendComponent';
import { renderInTestApp } from '@backstage/test-utils';
describe('TrendComponent', () => {
it('should render', async () => {
const data = [1, 2, 3, 4];
const title = 'testTitle';
const rendered = await renderInTestApp(
<TrendComponent data={data} title={title} color="#000" />,
);
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();
});
});
@@ -0,0 +1,41 @@
/*
* 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 { Typography } from '@material-ui/core';
interface TrendProps {
data?: number[];
title: string;
color: string;
}
export const TrendComponent = ({ data, title, color }: TrendProps) => {
const emptyData = [0, 0];
const max = Math.max(...(data ?? emptyData));
return (
<>
<Typography variant="overline">{title}</Typography>
<TrendLine
data={data ?? emptyData}
title={title}
max={max}
color={data && color}
/>
</>
);
};
@@ -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 './TrendComponent';
+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,
);
};
+22
View File
@@ -0,0 +1,22 @@
/*
* 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.
*/
type ClassName = string | false | undefined | null;
export const classNames = (...args: ClassName[]) =>
args.filter(c => !!c).join(' ');
export const cn = classNames;
+43
View File
@@ -0,0 +1,43 @@
/*
* 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 { DateTime, Duration } from 'luxon';
import { BuildStatus } from '../api';
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(
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);
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 './array';
export * from './buildData';
export * from './format';
export * from './classnames';
+88
View File
@@ -0,0 +1,88 @@
/*
* 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 { classNames, getValues, sumField } from './';
import { getAverageDuration, getErrorRatios } from './buildData';
describe('utils', () => {
describe('classNames', () => {
it('should concatinate strings', () => {
expect(classNames('class1', 'class2', 'class3')).toEqual(
'class1 class2 class3',
);
});
it('should not include values null, undefined or empty strings', () => {
expect(classNames('class1', undefined, null, '')).toEqual('class1');
});
it('should handle strings with boolean expressions', () => {
expect(classNames(true && 'class1', false && 'class2', false)).toEqual(
'class1',
);
});
});
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();
});
});
});