Add details page for builds

Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
Niklas Granander
2021-08-03 15:30:56 +02:00
parent 0e9fef8e3f
commit 5a4d10c04e
21 changed files with 942 additions and 107 deletions
+80 -80
View File
@@ -20,9 +20,13 @@ import { DateTime } from 'luxon';
import {
Build,
BuildCount,
BuildError,
BuildFilters,
BuildHost,
BuildMetadata,
BuildStatusResult,
BuildTime,
BuildWarning,
PaginationResult,
XcmetricsApi,
} from './types';
@@ -39,24 +43,12 @@ export class XcmetricsClient implements XcmetricsApi {
}
async getBuild(id: string): Promise<Build> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(`${baseUrl}/build/${id}`);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const response = await this.get(`/build/${id}`);
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);
}
const response = await this.get(`/build?per=${limit}`);
return ((await response.json()) as PaginationResult<Build>).items;
}
@@ -65,80 +57,88 @@ export class XcmetricsClient implements XcmetricsApi {
page?: number,
perPage?: number,
): Promise<PaginationResult<Build>> {
const response = await this.post('/build/filter', {
from: DateTime.fromISO(filters.from)
.startOf('day')
.toISO({ suppressMilliseconds: true }),
to: DateTime.fromISO(filters.to)
.endOf('day')
.startOf('second')
.toISO({ suppressMilliseconds: true }),
status: filters.buildStatus,
projectName: filters.project,
page,
per: perPage,
});
return (await response.json()) as PaginationResult<Build>;
}
async getBuildCounts(days: number): Promise<BuildCount[]> {
const response = await this.get(`/statistics/build/count?days=${days}`);
return (await response.json()) as BuildCount[];
}
async getBuildErrors(buildId: string): Promise<BuildError[]> {
const response = await this.get(`/build/error/${buildId}`);
return (await response.json()) as BuildError[];
}
async getBuildHost(buildId: string): Promise<BuildHost> {
const response = await this.get(`/build/host/${buildId}`);
return (await response.json()) as BuildHost;
}
async getBuildMetadata(buildId: string): Promise<BuildMetadata> {
const response = await this.get(`/build/metadata/${buildId}`);
return ((await response.json()) as Record<'metadata', BuildMetadata>)
.metadata;
}
async getBuildTimes(days: number): Promise<BuildTime[]> {
const response = await this.get(`/statistics/build/time?days=${days}`);
return (await response.json()) as BuildTime[];
}
async getBuildStatuses(limit: number): Promise<BuildStatusResult[]> {
const response = await this.get(`/statistics/build/status?per=${limit}`);
return ((await response.json()) as PaginationResult<BuildStatusResult>)
.items;
}
async getBuildWarnings(buildId: string): Promise<BuildWarning[]> {
const response = await this.get(`/build/warning/${buildId}`);
return (await response.json()) as BuildWarning[];
}
async getProjects(): Promise<string[]> {
const response = await this.get('/build/project');
return (await response.json()) as string[];
}
private async get(path: string): Promise<Response> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(`${baseUrl}/build/filter`, {
const response = await fetch(`${baseUrl}${path}`);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return response;
}
private async post(path: string, body: Object): Promise<Response> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: DateTime.fromISO(filters.from)
.startOf('day')
.toISO({ suppressMilliseconds: true }),
to: DateTime.fromISO(filters.to)
.endOf('day')
.startOf('second')
.toISO({ suppressMilliseconds: true }),
status: filters.buildStatus,
projectName: filters.project,
page,
per: perPage,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return (await response.json()) as PaginationResult<Build>;
}
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;
}
async getProjects(): Promise<string[]> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`;
const response = await fetch(`${baseUrl}/build/project`);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return (await response.json()) as string[];
return response;
}
}
@@ -14,9 +14,20 @@
* limitations under the License.
*/
import { Build, BuildFilters, XcmetricsApi } from '../types';
import {
Build,
BuildCount,
BuildError,
BuildFilters,
BuildHost,
BuildMetadata,
BuildStatusResult,
BuildTime,
BuildWarning,
XcmetricsApi,
} from '../types';
export const mockBuild = {
export const mockBuild: Build = {
userid: 'userid1',
warningCount: 1,
duration: 1,
@@ -34,22 +45,99 @@ export const mockBuild = {
id: 'buildId',
buildStatus: 'succeeded',
compilationDuration: 1,
schema: 'Schema',
schema: 'SchemaName',
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 = {
export const mockBuildCount: BuildCount = {
day: '2021-07-10',
builds: 10,
errors: 1,
};
export const mockBuildError: BuildError = {
detail: `\/Users\/<redacted>\/myproject\/Sources\/MyClass.m:241:97:// / error: instance method 'fetch' not found ; did you mean 'fetchIt'?\r
myclass:[self.myService fetch]\r ^~~~~~~~~~~~~~\r fetch\r
1 error generated.\r"`,
characterRangeEnd: 13815,
id: '3E6EF185-6AC1-4E95-87E8-E305F41916E9',
endingColumn: 97,
parentIdentifier: 'MyMac_34580469-5792-40F3-BEFB-7C5925996F23_8860',
day: '2020-11-02T00:00:00Z',
type: 'clangError',
title: 'Instance method "fetch" not found ; did you mean "fetchIt"?',
endingLine: 241,
severity: 2,
startingLine: 241,
parentType: 'step',
buildIdentifier: 'buildId',
startingColumn: 97,
characterRangeStart: 0,
documentURL: 'file:///Users/<redacted>/myproject/Sources/MyClass.m',
};
export const mockBuildHost: BuildHost = {
id: '9DD5508D-4AD9-4C1C-AB7C-45BC2183EC51',
swapFreeMb: 1615.25,
hostOsFamily: 'Darwin',
isVirtual: false,
uptimeSeconds: 1602055187,
hostModel: 'MacBookPro14,2',
hostOsVersion: '10.15.7',
day: '2020-10-26T00:00:00Z',
cpuCount: 4,
swapTotalMb: 7168,
hostOs: 'Mac OS X',
hostArchitecture: 'x86_64',
memoryTotalMb: 16384,
timezone: 'CET',
cpuModel: 'Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz',
buildIdentifier: 'buildId',
memoryFreeMb: 24.5234375,
cpuSpeedGhz: 3.5,
};
export const mockBuildMetadata: BuildMetadata = {
anotherKey: '42',
thirdKey: 'Third value',
aKey: 'value1',
};
export const mockBuildTime: BuildTime = {
day: '2021-07-10',
durationP50: 1.1,
durationP95: 2.1,
totalDuration: 3.1,
};
export const mockBuildStatus = { id: 'build_id', status: 'succeeded' };
export const mockBuildStatus: BuildStatusResult = {
id: 'build_id',
buildStatus: 'succeeded',
};
export const mockBuildWarning: BuildWarning = {
detail: null,
characterRangeEnd: 9817,
documentURL: 'file:///Users/<redacted>/myproject/Sources/MyViewController.m',
endingColumn: 22,
id: '5F2011AC-F87F-4EDC-BBC6-2BBA3D789EB3',
parentIdentifier: 'MyMac_34580469-5792-40F3-BEFB-7C5925996F23_1845',
day: '2020-11-02T00:00:00Z',
type: 'deprecatedWarning',
title:
"'dimsBackgroundDuringPresentation' is deprecated: first deprecated in iOS 12.0",
endingLine: 235,
severity: 1,
startingLine: 235,
parentType: 'step',
clangFlag: '[-Wdeprecated-declarations]',
startingColumn: 22,
buildIdentifier: 'MyMac_34580469-5792-40F3-BEFB-7C5925996F23_1',
characterRangeStart: 0,
};
export const XcmetricsClient: XcmetricsApi = {
getBuild: (id: string) => {
@@ -78,15 +166,27 @@ export const XcmetricsClient: XcmetricsApi = {
},
});
},
getBuildErrors: (buildId: string) => {
return Promise.resolve([{ ...mockBuildError, buildIdentifier: buildId }]);
},
getBuildCounts: () => {
return Promise.resolve([mockBuildCount, mockBuildCount]);
},
getBuildHost: (buildId: string) => {
return Promise.resolve({ ...mockBuildHost, buildIdentifier: buildId });
},
getBuildMetadata: (_buildId: string) => {
return Promise.resolve(mockBuildMetadata);
},
getBuildStatuses: (limit: number) => {
return Promise.resolve([mockBuild].slice(0, limit));
},
getBuildTimes: (days: number) => {
return Promise.resolve([mockBuildTime, mockBuildTime].slice(0, days));
},
getBuildWarnings: (buildId: string) => {
return Promise.resolve([{ ...mockBuildWarning, buildIdentifier: buildId }]);
},
getProjects: () => {
return Promise.resolve([mockBuild.projectName]);
},
+68
View File
@@ -52,6 +52,50 @@ export type BuildCount = {
builds: number;
};
export type BuildError = {
detail: string;
characterRangeEnd: number;
id: string;
endingColumn: number;
parentIdentifier: string;
day: string;
type: string;
title: string;
endingLine: number;
severity: number;
startingLine: number;
parentType: string;
buildIdentifier: string;
startingColumn: number;
characterRangeStart: number;
documentURL: string;
};
export type BuildHost = {
id: string;
swapFreeMb: number;
hostOsFamily: string;
isVirtual: boolean;
uptimeSeconds: number;
hostModel: string;
hostOsVersion: string;
day: string;
cpuCount: number;
swapTotalMb: number;
hostOs: string;
hostArchitecture: string;
memoryTotalMb: number;
timezone: string;
cpuModel: string;
buildIdentifier: string;
memoryFreeMb: number;
cpuSpeedGhz: number;
};
export type BuildMetadata = {
[key: string]: string;
};
export type BuildTime = {
day: string;
durationP50: number;
@@ -59,6 +103,26 @@ export type BuildTime = {
totalDuration: number;
};
export type BuildWarning = {
detail: string | null;
characterRangeEnd: number;
documentURL: string;
endingColumn: number;
id: string;
parentIdentifier: string;
day: string;
type: string;
title: string;
endingLine: number;
severity: number;
startingLine: number;
parentType: string;
clangFlag: string;
startingColumn: number;
buildIdentifier: string;
characterRangeStart: number;
};
export type PaginationResult<T> = {
items: T[];
metadata: {
@@ -83,9 +147,13 @@ export interface XcmetricsApi {
page?: number,
perPage?: number,
): Promise<PaginationResult<Build>>;
getBuildErrors(buildId: string): Promise<BuildError[]>;
getBuildCounts(days: number): Promise<BuildCount[]>;
getBuildHost(buildId: string): Promise<BuildHost>;
getBuildMetadata(buildId: string): Promise<BuildMetadata>;
getBuildTimes(days: number): Promise<BuildTime[]>;
getBuildStatuses(limit: number): Promise<BuildStatusResult[]>;
getBuildWarnings(buildId: string): Promise<BuildWarning[]>;
getProjects(): Promise<string[]>;
}
@@ -0,0 +1,51 @@
/*
* 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 { AccordionComponent } from './AccordionComponent';
import userEvent from '@testing-library/user-event';
describe('AccordionComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<AccordionComponent
id="accordionId"
heading="heading"
secondaryHeading="secondaryHeading"
/>,
);
expect(rendered.getByText('heading')).toBeInTheDocument();
expect(rendered.getByText('secondaryHeading')).toBeInTheDocument();
});
it('should show content when clicked', async () => {
const rendered = await renderInTestApp(
<AccordionComponent
id="accordionId"
heading="heading"
secondaryHeading="secondaryHeading"
>
Content
</AccordionComponent>,
);
expect(rendered.getByText('Content')).not.toBeVisible();
userEvent.click(rendered.getByRole('button'));
expect(await rendered.findByText('Content')).toBeVisible();
});
});
@@ -0,0 +1,67 @@
/*
* 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 {
Accordion,
AccordionSummary,
Typography,
AccordionDetails,
makeStyles,
createStyles,
} from '@material-ui/core';
import React, { PropsWithChildren } from 'react';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles((theme: BackstageTheme) =>
createStyles({
heading: {
flexBasis: '33.33%',
flexShrink: 0,
},
secondaryHeading: {
color: theme.palette.text.secondary,
},
}),
);
interface AccordionProps {
id: string;
heading: string;
secondaryHeading?: string | number;
disabled?: boolean;
}
export const AccordionComponent = (
props: PropsWithChildren<AccordionProps>,
) => {
const classes = useStyles();
return (
<Accordion disabled={props.disabled}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${props.id}-content`}
id={`${props.id}-header`}
>
<Typography className={classes.heading}>{props.heading}</Typography>
<Typography className={classes.secondaryHeading}>
{props.secondaryHeading}
</Typography>
</AccordionSummary>
<AccordionDetails>{props.children}</AccordionDetails>
</Accordion>
);
};
@@ -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 './AccordionComponent';
@@ -0,0 +1,101 @@
/*
* 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 { BuildDetailsComponent, withRequest } from './BuildDetailsComponent';
import { xcmetricsApiRef } from '../../api';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
jest.mock('../AccordionComponent', () => ({
AccordionComponent: ({ heading }: { heading: string }) => (
<div>accordion-{heading}</div>
),
}));
describe('BuildDetailsComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildDetailsComponent build={client.mockBuild} />
</ApiProvider>,
);
expect(rendered.getByText('accordion-Host')).toBeInTheDocument();
expect(rendered.getByText('accordion-Errors')).toBeInTheDocument();
expect(rendered.getByText('accordion-Warnings')).toBeInTheDocument();
expect(rendered.getByText('accordion-Metadata')).toBeInTheDocument();
expect(rendered.getByText(client.mockBuild.id)).toBeInTheDocument();
expect(
rendered.getByText(client.mockBuild.projectName),
).toBeInTheDocument();
expect(rendered.getByText(client.mockBuild.schema)).toBeInTheDocument();
});
});
describe('BuildDetailsComponent with request', () => {
const BuildDetails = withRequest(BuildDetailsComponent);
it('should fetch the build and render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildDetails buildId={client.mockBuild.id} />
</ApiProvider>,
);
expect(rendered.getByText(client.mockBuild.id)).toBeInTheDocument();
});
it('should show an error when API not responding', async () => {
const errorMessage = 'MockErrorMessage';
client.XcmetricsClient.getBuild = jest
.fn()
.mockRejectedValue({ message: errorMessage });
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildDetails buildId={client.mockBuild.id} />
</ApiProvider>,
);
expect(rendered.getByText(errorMessage)).toBeInTheDocument();
});
it('should show a message when no build is returned from the API', async () => {
client.XcmetricsClient.getBuild = jest.fn().mockReturnValue(undefined);
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildDetails buildId={client.mockBuild.id} />
</ApiProvider>,
);
expect(
rendered.getByText(`Could not load build ${client.mockBuild.id}`),
).toBeInTheDocument();
});
});
@@ -0,0 +1,184 @@
/*
* 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 { createStyles, Divider, Grid, makeStyles } from '@material-ui/core';
import React from 'react';
import { Build, xcmetricsApiRef } from '../../api';
import {
OverflowTooltip,
Progress,
StructuredMetadataTable,
} from '@backstage/core-components';
import { Alert } from '@material-ui/lab';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { formatDuration, formatStatus, formatTime } from '../../utils';
import { StatusIconComponent as StatusIcon } from '../StatusIconComponent';
import { BackstageTheme } from '@backstage/theme';
import { AccordionComponent } from '../AccordionComponent';
const useStyles = makeStyles((theme: BackstageTheme) =>
createStyles({
divider: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}),
);
interface BuildDetailsProps {
build: Build;
}
export const BuildDetailsComponent = ({ build }: BuildDetailsProps) => {
const classes = useStyles();
const client = useApi(xcmetricsApiRef);
const hostResult = useAsync(async () => client.getBuildHost(build.id), []);
const errorsResult = useAsync(
async () => client.getBuildErrors(build.id),
[],
);
const warningsResult = useAsync(
async () => client.getBuildWarnings(build.id),
[],
);
const metadataResult = useAsync(
async () => client.getBuildMetadata(build.id),
[],
);
const buildDetails = {
id: build.id,
project: build.projectName,
schema: build.schema,
category: build.category,
userId: build.userid,
'started at': formatTime(build.startTimestamp),
'ended at': formatTime(build.endTimestamp),
duration: formatDuration(build.duration),
status: (
<>
<StatusIcon buildStatus={build.buildStatus} />
{formatStatus(build.buildStatus)}
</>
),
CI: build.isCi,
};
return (
<Grid container direction="column" spacing={3}>
<Grid container item direction="row">
<Grid item xs={4}>
<StructuredMetadataTable metadata={buildDetails} />
</Grid>
<Grid item xs={8}>
<AccordionComponent
id="buildHost"
heading="Host"
secondaryHeading={build.machineName}
>
{hostResult.loading && <Progress />}
{!hostResult.loading && hostResult.value && (
<StructuredMetadataTable metadata={hostResult.value} />
)}
</AccordionComponent>
<AccordionComponent
id="buildErrors"
heading="Errors"
secondaryHeading={build.errorCount}
disabled={build.errorCount === 0}
>
<div>
{errorsResult.loading && <Progress />}
{!errorsResult.loading &&
errorsResult.value &&
errorsResult.value.map((error, idx) => (
<div key={error.id}>
<OverflowTooltip text={error.detail} line={5} />
{idx !== errorsResult.value.length - 1 && (
<Divider className={classes.divider} />
)}
</div>
))}
</div>
</AccordionComponent>
<AccordionComponent
id="buildWarnings"
heading="Warnings"
secondaryHeading={build.warningCount}
disabled={build.warningCount === 0}
>
<div>
{warningsResult.loading && <Progress />}
{!warningsResult.loading &&
warningsResult.value &&
warningsResult.value.map((warning, idx) => (
<div key={warning.id}>
<OverflowTooltip
text={warning.detail ?? warning.title}
line={5}
/>
{idx !== warningsResult.value.length - 1 && (
<Divider className={classes.divider} />
)}
</div>
))}
</div>
</AccordionComponent>
<AccordionComponent
id="buildMetadata"
heading="Metadata"
disabled={!metadataResult.loading && !metadataResult.value}
>
{metadataResult.loading && <Progress />}
{!metadataResult.loading && metadataResult.value && (
<StructuredMetadataTable metadata={metadataResult.value} />
)}
</AccordionComponent>
</Grid>
</Grid>
<Grid item>{/* TODO: Sequnce chart */}</Grid>
</Grid>
);
};
export const withRequest = (Component: typeof BuildDetailsComponent) => ({
buildId,
}: {
buildId: string;
}) => {
const client = useApi(xcmetricsApiRef);
const { value: build, loading, error } = useAsync(
async () => client.getBuild(buildId),
[],
);
if (loading) {
return <Progress />;
}
if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
if (!build) {
return <Alert severity="error">Could not load build {buildId}</Alert>;
}
return <Component build={build} />;
};
@@ -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 './BuildDetailsComponent';
@@ -18,6 +18,7 @@ import { renderInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { BuildListComponent } from './BuildListComponent';
import { xcmetricsApiRef } from '../../api';
import userEvent from '@testing-library/user-event';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
@@ -26,6 +27,10 @@ jest.mock('../BuildListFilterComponent', () => ({
BuildListFilterComponent: () => 'BuildListFilterComponent',
}));
jest.mock('../BuildDetailsComponent', () => ({
BuildDetailsComponent: () => 'BuildDetailsComponent',
}));
describe('BuildListComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
@@ -42,6 +47,23 @@ describe('BuildListComponent', () => {
).toBeInTheDocument();
});
it('should show build details', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildListComponent />
</ApiProvider>,
);
userEvent.click(
(await rendered.findAllByLabelText('Detail panel visiblity toggle'))[0],
);
expect(
await rendered.findByText('BuildDetailsComponent'),
).toBeInTheDocument();
});
it('should show errors', async () => {
const message = 'error';
client.XcmetricsClient.getFilteredBuilds = jest
@@ -17,12 +17,24 @@ import React, { useRef, useState } from 'react';
import { Table } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { BuildFilters, xcmetricsApiRef } from '../../api';
import { Grid } from '@material-ui/core';
import { createStyles, Grid, makeStyles } from '@material-ui/core';
import { BuildListFilterComponent as Filters } from '../BuildListFilterComponent';
import { DateTime } from 'luxon';
import { buildPageColumns } from '../BuildTableColumns';
import { BuildDetailsComponent } from '../BuildDetailsComponent';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles((theme: BackstageTheme) =>
createStyles({
detailPanel: {
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
},
}),
);
export const BuildListComponent = () => {
const classes = useStyles();
const client = useApi(xcmetricsApiRef);
const tableRef = useRef<any>();
@@ -68,6 +80,11 @@ export const BuildListComponent = () => {
.catch(reason => reject(reason));
});
}}
detailPanel={rowData => (
<div className={classes.detailPanel}>
<BuildDetailsComponent build={(rowData as any).rowData} />
</div>
)}
/>
</Grid>
);
@@ -15,26 +15,16 @@
*/
import { Chip } from '@material-ui/core';
import React, { ReactChild } from 'react';
import {
StatusOK,
StatusError,
StatusWarning,
TableColumn,
} from '@backstage/core-components';
import { Build, BuildStatus } from '../api';
import React from 'react';
import { TableColumn } from '@backstage/core-components';
import { Build } from '../api';
import { formatTime, formatDuration } from '../utils';
const STATUS_ICONS: { [key in BuildStatus]: ReactChild } = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
import { StatusIconComponent as StatusIcon } from './StatusIconComponent';
const baseColumns: TableColumn<Build>[] = [
{
field: 'buildStatus',
render: data => STATUS_ICONS[data.buildStatus],
render: data => <StatusIcon buildStatus={data.buildStatus} />,
},
{
title: 'Project',
@@ -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 React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { BuildsPage } from './BuildsPage';
jest.mock('../BuildDetailsComponent', () => ({
withRequest: (component: any) => component,
BuildDetailsComponent: () => 'BuildDetailsComponent',
}));
jest.mock('../BuildListComponent', () => ({
BuildListComponent: () => 'BuildListComponent',
}));
describe('BuildPageComponent', () => {
it('should render BuildDetailsWithDataComponent if build id is provided in path', async () => {
const rendered = await renderInTestApp(<BuildsPage />, {
routeEntries: [`/buildId`],
});
expect(rendered.getByText('BuildDetailsComponent')).toBeInTheDocument();
});
it('should render BuildListComponent if no build id is provided in path', async () => {
const rendered = await renderInTestApp(<BuildsPage />);
expect(rendered.getByText('BuildListComponent')).toBeInTheDocument();
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useRouteRefParams } from '@backstage/core-plugin-api';
import { buildsRouteRef } from '../../routes';
import { BuildListComponent } from '../BuildListComponent';
import { BuildDetailsComponent, withRequest } from '../BuildDetailsComponent';
import { CopyTextButton, InfoCard } from '@backstage/core-components';
export const BuildsPage = () => {
const { '*': buildId } = useRouteRefParams(buildsRouteRef) ?? { '*': '' };
if (buildId) {
const BuildDetails = withRequest(BuildDetailsComponent);
return (
<InfoCard
title="Build Details"
subheader={
<>
{buildId}{' '}
<CopyTextButton
text={buildId}
tooltipText="Build Id copied to clipboard"
/>
</>
}
>
<BuildDetails buildId={buildId} />
</InfoCard>
);
}
return <BuildListComponent />;
};
@@ -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 './BuildsPage';
@@ -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 { StatusIconComponent } from './StatusIconComponent';
describe('StatusIconComponent', () => {
it('should render', async () => {
let rendered = await renderInTestApp(
<StatusIconComponent buildStatus="succeeded" />,
);
expect(rendered.getByLabelText('Status ok')).toBeInTheDocument();
rendered = await renderInTestApp(
<StatusIconComponent buildStatus="failed" />,
);
expect(rendered.getByLabelText('Status error')).toBeInTheDocument();
rendered = await renderInTestApp(
<StatusIconComponent buildStatus="stopped" />,
);
expect(rendered.getByLabelText('Status warning')).toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
/*
* 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 {
StatusError,
StatusOK,
StatusWarning,
} from '@backstage/core-components';
import { BuildStatus } from '../../api';
const STATUS_ICONS: { [key in BuildStatus]: JSX.Element } = {
succeeded: <StatusOK />,
failed: <StatusError />,
stopped: <StatusWarning />,
};
interface StatusIconProps {
buildStatus: BuildStatus;
}
export const StatusIconComponent = ({ buildStatus }: StatusIconProps) =>
STATUS_ICONS[buildStatus];
@@ -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 './StatusIconComponent';
@@ -18,6 +18,7 @@ import { renderInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { XcmetricsLayout } from './XcmetricsLayout';
import { xcmetricsApiRef } from '../../api';
import userEvent from '@testing-library/user-event';
jest.mock('../../api/XcmetricsClient');
const client = require('../../api/XcmetricsClient');
@@ -44,5 +45,7 @@ describe('XcmetricsLayout', () => {
expect(rendered.getByText('Builds')).toBeInTheDocument();
expect(rendered.getByText('OverviewComponent')).toBeInTheDocument();
userEvent.click(rendered.getByText('Builds'));
expect(await rendered.findByText('BuildListComponent')).toBeInTheDocument();
});
});
@@ -24,7 +24,7 @@ import {
import { OverviewComponent } from '../OverviewComponent';
import { buildsRouteRef, rootRouteRef } from '../../routes';
import { RouteRef, SubRouteRef } from '@backstage/core-plugin-api';
import { BuildListComponent } from '../BuildListComponent';
import { BuildsPage } from '../BuildsPage';
export interface TabConfig {
routeRef: RouteRef | SubRouteRef;
@@ -41,7 +41,7 @@ const TABS: TabConfig[] = [
{
routeRef: buildsRouteRef,
title: 'Builds',
component: <BuildListComponent />,
component: <BuildsPage />,
},
];
+6 -2
View File
@@ -18,8 +18,12 @@ import { BuildStatus } from '../api';
export const formatDuration = (seconds: number) => {
const duration = Duration.fromObject({
seconds: Math.round(seconds),
}).shiftTo('hours', 'minutes', 'seconds');
seconds: seconds,
}).shiftTo('hours', 'minutes', 'seconds', 'milliseconds');
if (duration.hours + duration.minutes + duration.seconds === 0) {
return `${Math.round(duration.milliseconds)} ms`;
}
const h = duration.hours ? `${duration.hours} h` : '';
const m = duration.minutes ? `${duration.minutes} m` : '';