Merge pull request #6633 from ngranander/xcmetrics-overview-features
Show more metrics of XCMetrics
This commit is contained in:
@@ -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 |
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+86
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user