Merge pull request #6715 from ngranander/xcmetrics-build-details
Xcmetrics build details
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-xcmetrics': patch
|
||||
---
|
||||
|
||||
Enable browsing detailed build information such as host configuration, errors, warnings, metadata and a timeline for all targets
|
||||
@@ -30,7 +30,8 @@
|
||||
"luxon": "^2.0.2",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^17.2.4"
|
||||
"react-use": "^17.2.4",
|
||||
"recharts": "^1.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.8",
|
||||
|
||||
@@ -20,9 +20,14 @@ import { DateTime } from 'luxon';
|
||||
import {
|
||||
Build,
|
||||
BuildCount,
|
||||
BuildError,
|
||||
BuildFilters,
|
||||
BuildHost,
|
||||
BuildMetadata,
|
||||
BuildResponse,
|
||||
BuildStatusResult,
|
||||
BuildTime,
|
||||
BuildWarning,
|
||||
PaginationResult,
|
||||
XcmetricsApi,
|
||||
} from './types';
|
||||
@@ -38,25 +43,13 @@ export class XcmetricsClient implements XcmetricsApi {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return ((await response.json()) as Record<'build', Build>).build;
|
||||
async getBuild(id: string): Promise<BuildResponse> {
|
||||
const response = await this.get(`/build/${id}`);
|
||||
return (await response.json()) as BuildResponse;
|
||||
}
|
||||
|
||||
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 +58,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,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Build, BuildFilters, XcmetricsApi } from '../types';
|
||||
import {
|
||||
Build,
|
||||
BuildCount,
|
||||
BuildError,
|
||||
BuildFilters,
|
||||
BuildHost,
|
||||
BuildMetadata,
|
||||
BuildStatusResult,
|
||||
BuildTime,
|
||||
BuildWarning,
|
||||
Target,
|
||||
XcmetricsApi,
|
||||
Xcode,
|
||||
} from '../types';
|
||||
|
||||
export const mockBuild = {
|
||||
const MOCK_BUILD_ID = 'buildId';
|
||||
|
||||
export const mockBuild: Build = {
|
||||
userid: 'userid1',
|
||||
warningCount: 1,
|
||||
duration: 1,
|
||||
@@ -31,29 +46,140 @@ export const mockBuild = {
|
||||
projectName: 'ProjectName',
|
||||
compilationEndTimestampMicroseconds: 1,
|
||||
errorCount: 1,
|
||||
id: 'buildId',
|
||||
id: MOCK_BUILD_ID,
|
||||
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: MOCK_BUILD_ID,
|
||||
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: MOCK_BUILD_ID,
|
||||
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: MOCK_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: MOCK_BUILD_ID,
|
||||
characterRangeStart: 0,
|
||||
};
|
||||
|
||||
export const mockTarget: Target = {
|
||||
id: 'MyMac_34580469-5792-40F3-BEFB-7C5925996F23_1992',
|
||||
category: 'noop',
|
||||
startTimestamp: '2020-11-02T10:59:09Z',
|
||||
compilationEndTimestampMicroseconds: 1604314749.2909288,
|
||||
endTimestampMicroseconds: 1604314982.298002,
|
||||
endTimestamp: '2020-11-02T11:03:02Z',
|
||||
fetchedFromCache: false,
|
||||
errorCount: 0,
|
||||
day: '2020-11-02T00:00:00Z',
|
||||
warningCount: 0,
|
||||
compilationEndTimestamp: '2020-11-02T10:59:09Z',
|
||||
compilationDuration: 0,
|
||||
compiledCount: 0,
|
||||
duration: 0.000233007,
|
||||
buildIdentifier: MOCK_BUILD_ID,
|
||||
name: 'Model',
|
||||
startTimestampMicroseconds: 1604314749.2909288,
|
||||
};
|
||||
|
||||
export const mockXcode: Xcode = {
|
||||
buildNumber: '12A7209',
|
||||
id: '6354C87F-0ADC-4354-929C-02EBE545E099',
|
||||
buildIdentifier: MOCK_BUILD_ID,
|
||||
day: '2020-11-02T00:00:00Z',
|
||||
version: '1200',
|
||||
};
|
||||
|
||||
export const mockBuildResponse = {
|
||||
build: mockBuild,
|
||||
targets: [mockTarget],
|
||||
xcode: mockXcode,
|
||||
};
|
||||
|
||||
export const XcmetricsClient: XcmetricsApi = {
|
||||
getBuild: (id: string) => {
|
||||
return Promise.resolve({ ...mockBuild, id });
|
||||
getBuild: (_id: string) => {
|
||||
return Promise.resolve(mockBuildResponse);
|
||||
},
|
||||
getBuilds: () => {
|
||||
return Promise.resolve([
|
||||
@@ -78,15 +204,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]);
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
@@ -68,6 +132,40 @@ export type PaginationResult<T> = {
|
||||
};
|
||||
};
|
||||
|
||||
export type Target = {
|
||||
id: string;
|
||||
category: string;
|
||||
startTimestamp: string;
|
||||
compilationEndTimestampMicroseconds: number;
|
||||
endTimestampMicroseconds: number;
|
||||
endTimestamp: string;
|
||||
fetchedFromCache: boolean;
|
||||
errorCount: number;
|
||||
day: string;
|
||||
warningCount: number;
|
||||
compilationEndTimestamp: string;
|
||||
compilationDuration: number;
|
||||
compiledCount: number;
|
||||
duration: number;
|
||||
buildIdentifier: string;
|
||||
name: string;
|
||||
startTimestampMicroseconds: number;
|
||||
};
|
||||
|
||||
export type Xcode = {
|
||||
buildNumber: string;
|
||||
id: string;
|
||||
buildIdentifier: string;
|
||||
day: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type BuildResponse = {
|
||||
build: Build;
|
||||
targets: Target[];
|
||||
xcode: Xcode;
|
||||
};
|
||||
|
||||
export type BuildFilters = {
|
||||
from: string; // ISO Date (e.g. "2021-01-01")
|
||||
to: string; // ISO Date (e.g. "2021-01-02")
|
||||
@@ -76,16 +174,20 @@ export type BuildFilters = {
|
||||
};
|
||||
|
||||
export interface XcmetricsApi {
|
||||
getBuild(id: string): Promise<Build>;
|
||||
getBuild(id: string): Promise<BuildResponse>;
|
||||
getBuilds(limit?: number): Promise<Build[]>;
|
||||
getFilteredBuilds(
|
||||
filters: BuildFilters,
|
||||
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,71 @@
|
||||
/*
|
||||
* 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;
|
||||
unmountOnExit?: boolean;
|
||||
}
|
||||
|
||||
export const AccordionComponent = (
|
||||
props: PropsWithChildren<AccordionProps>,
|
||||
) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
disabled={props.disabled}
|
||||
TransitionProps={{ unmountOnExit: props.unmountOnExit ?? false }}
|
||||
>
|
||||
<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 { AccordionComponent } from './AccordionComponent';
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../BuildTimelineComponent', () => ({
|
||||
BuildTimelineComponent: () => 'BuildTimelineComponent',
|
||||
}));
|
||||
|
||||
describe('BuildDetailsComponent', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
|
||||
>
|
||||
<BuildDetailsComponent buildData={client.mockBuildResponse} />
|
||||
</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('accordion-Timeline')).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,202 @@
|
||||
/*
|
||||
* 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 { BuildResponse, xcmetricsApiRef } from '../../api';
|
||||
import { 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';
|
||||
import { BuildTimelineComponent } from '../BuildTimelineComponent';
|
||||
import { PreformattedTextComponent } from '../PreformattedTextComponent';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) =>
|
||||
createStyles({
|
||||
divider: {
|
||||
marginTop: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface BuildDetailsProps {
|
||||
buildData: BuildResponse;
|
||||
showId?: boolean;
|
||||
}
|
||||
|
||||
export const BuildDetailsComponent = ({
|
||||
buildData: { build, targets, xcode },
|
||||
showId,
|
||||
}: BuildDetailsProps) => {
|
||||
const classes = useStyles();
|
||||
const client = useApi(xcmetricsApiRef);
|
||||
const hostResult = useAsync(
|
||||
async () => client.getBuildHost(build.id),
|
||||
[build.id],
|
||||
);
|
||||
const errorsResult = useAsync(
|
||||
async () => client.getBuildErrors(build.id),
|
||||
[build.id],
|
||||
);
|
||||
const warningsResult = useAsync(
|
||||
async () => client.getBuildWarnings(build.id),
|
||||
[build.id],
|
||||
);
|
||||
const metadataResult = useAsync(
|
||||
async () => client.getBuildMetadata(build.id),
|
||||
[build.id],
|
||||
);
|
||||
|
||||
const buildDetails = {
|
||||
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)}
|
||||
</>
|
||||
),
|
||||
xcode: `${xcode.version} (${xcode.buildNumber})`,
|
||||
CI: build.isCi,
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container item direction="row">
|
||||
<Grid item xs={4}>
|
||||
<StructuredMetadataTable
|
||||
metadata={
|
||||
showId === false ? buildDetails : { id: build.id, ...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?.map((error, idx) => (
|
||||
<div key={error.id}>
|
||||
<PreformattedTextComponent
|
||||
title="Error Details"
|
||||
text={error.detail}
|
||||
maxChars={190}
|
||||
expandable
|
||||
/>
|
||||
{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?.map((warning, idx) => (
|
||||
<div key={warning.id}>
|
||||
<PreformattedTextComponent
|
||||
title="Warning Details"
|
||||
text={warning.detail ?? warning.title}
|
||||
maxChars={190}
|
||||
expandable
|
||||
/>
|
||||
{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>
|
||||
|
||||
<AccordionComponent id="buildTimeline" heading="Timeline" unmountOnExit>
|
||||
<BuildTimelineComponent targets={targets} />
|
||||
</AccordionComponent>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type WithRequestProps = Omit<BuildDetailsProps, 'buildData'> & {
|
||||
buildId: string;
|
||||
};
|
||||
|
||||
export const withRequest =
|
||||
(Component: typeof BuildDetailsComponent) =>
|
||||
({ buildId, ...props }: WithRequestProps) => {
|
||||
const client = useApi(xcmetricsApiRef);
|
||||
const {
|
||||
value: buildResponse,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => client.getBuild(buildId), []);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
if (!buildResponse) {
|
||||
return <Alert severity="error">Could not load build {buildId}</Alert>;
|
||||
}
|
||||
|
||||
return <Component {...props} buildData={buildResponse} />;
|
||||
};
|
||||
@@ -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 { BuildDetailsComponent, withRequest } 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,11 @@ jest.mock('../BuildListFilterComponent', () => ({
|
||||
BuildListFilterComponent: () => 'BuildListFilterComponent',
|
||||
}));
|
||||
|
||||
jest.mock('../BuildDetailsComponent', () => ({
|
||||
withRequest: (component: any) => component,
|
||||
BuildDetailsComponent: () => 'BuildDetailsComponent',
|
||||
}));
|
||||
|
||||
describe('BuildListComponent', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
@@ -42,6 +48,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, withRequest } 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,14 @@ export const BuildListComponent = () => {
|
||||
.catch(reason => reject(reason));
|
||||
});
|
||||
}}
|
||||
detailPanel={rowData => {
|
||||
const BuildDetails = withRequest(BuildDetailsComponent);
|
||||
return (
|
||||
<div className={classes.detailPanel}>
|
||||
<BuildDetails buildId={(rowData as any).rowData.id} />
|
||||
</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',
|
||||
|
||||
+43
@@ -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 { BuildTimelineComponent } from './BuildTimelineComponent';
|
||||
|
||||
jest.mock('../../api/XcmetricsClient');
|
||||
const client = require('../../api/XcmetricsClient');
|
||||
|
||||
describe('BuildTimelineComponent', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<BuildTimelineComponent
|
||||
targets={[client.mockTarget]}
|
||||
height={100}
|
||||
width={100}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText(client.mockTarget.name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a message if no targets are provided', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<BuildTimelineComponent targets={[]} />,
|
||||
);
|
||||
expect(rendered.getByText('No Targets')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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, makeStyles, useTheme } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Target } from '../../api';
|
||||
import { formatSecondsInterval, formatPercentage } from '../../utils';
|
||||
|
||||
const EMPTY_HEIGHT = 100;
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) =>
|
||||
createStyles({
|
||||
toolTip: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
opacity: 0.8,
|
||||
padding: 8,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const TargetToolTip = ({ active, payload, label }: any) => {
|
||||
const classes = useStyles();
|
||||
|
||||
if (active && payload && payload.length === 2) {
|
||||
const buildTime = payload[0].value[1] - payload[0].value[0];
|
||||
const compileTime = payload[1].value[1] - payload[1].value[0];
|
||||
return (
|
||||
<div className={classes.toolTip}>
|
||||
{`${label}: ${formatSecondsInterval(payload[0].value)}`}
|
||||
<br />
|
||||
{buildTime > 0 &&
|
||||
`Compile time: ${formatPercentage(compileTime / buildTime)}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTimelineData = (targets: Target[]) => {
|
||||
const min = targets[0].startTimestampMicroseconds;
|
||||
|
||||
return targets
|
||||
.filter(target => target.fetchedFromCache === false)
|
||||
.map(target => ({
|
||||
name: target.name,
|
||||
buildTime: [
|
||||
target.startTimestampMicroseconds - min,
|
||||
target.endTimestampMicroseconds - min,
|
||||
],
|
||||
compileTime: [
|
||||
target.startTimestampMicroseconds - min,
|
||||
target.compilationEndTimestampMicroseconds - min,
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
export interface BuildTimelineProps {
|
||||
targets: Target[];
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const BuildTimelineComponent = ({
|
||||
targets,
|
||||
height,
|
||||
width,
|
||||
}: BuildTimelineProps) => {
|
||||
const theme = useTheme();
|
||||
if (!targets.length) return <p>No Targets</p>;
|
||||
|
||||
const data = getTimelineData(targets);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
height={height}
|
||||
width={width}
|
||||
minHeight={EMPTY_HEIGHT + targets.length * 5}
|
||||
>
|
||||
<BarChart layout="vertical" data={data} maxBarSize={10} barGap={0}>
|
||||
<CartesianGrid strokeDasharray="2 2" />
|
||||
<XAxis type="number" domain={[0, 'dataMax']} />
|
||||
<YAxis type="category" dataKey="name" padding={{ top: 0, bottom: 0 }} />
|
||||
<Tooltip content={<TargetToolTip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="buildTime"
|
||||
fill={theme.palette.grey[400]}
|
||||
minPointSize={1}
|
||||
/>
|
||||
<Bar dataKey="compileTime" fill={theme.palette.primary.main} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
@@ -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 { BuildTimelineComponent } from './BuildTimelineComponent';
|
||||
@@ -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,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 { useRouteRefParams } from '@backstage/core-plugin-api';
|
||||
import { buildsRouteRef } from '../../routes';
|
||||
import { BuildListComponent } from '../BuildListComponent';
|
||||
import { BuildDetailsComponent, withRequest } from '../BuildDetailsComponent';
|
||||
import { 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}>
|
||||
<BuildDetails buildId={buildId} showId={false} />
|
||||
</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 { BuildsPage } from './BuildsPage';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createStyles,
|
||||
InputBase,
|
||||
InputProps,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Typography,
|
||||
withStyles,
|
||||
@@ -50,6 +51,13 @@ const BootstrapInput = withStyles((theme: Theme) =>
|
||||
}),
|
||||
)(InputBase);
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
});
|
||||
|
||||
interface DatePickerProps {
|
||||
label: string;
|
||||
onDateChange?: (date: string) => void;
|
||||
@@ -59,15 +67,19 @@ export const DatePickerComponent = ({
|
||||
label,
|
||||
onDateChange,
|
||||
...inputProps
|
||||
}: InputProps & DatePickerProps) => (
|
||||
<>
|
||||
<Typography variant="button">{label}</Typography>
|
||||
<BootstrapInput
|
||||
inputProps={{ 'aria-label': label }}
|
||||
type="date"
|
||||
fullWidth
|
||||
onChange={event => onDateChange?.(event.target.value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}: InputProps & DatePickerProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Typography variant="button">{label}</Typography>
|
||||
<BootstrapInput
|
||||
inputProps={{ 'aria-label': label }}
|
||||
type="date"
|
||||
fullWidth
|
||||
onChange={event => onDateChange?.(event.target.value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 {
|
||||
Button,
|
||||
createStyles,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import React, { useState } from 'react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) =>
|
||||
createStyles({
|
||||
pre: {
|
||||
whiteSpace: 'pre-line',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
expandable: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
fullPre: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: theme.spacing(1),
|
||||
top: theme.spacing(1),
|
||||
color: theme.palette.grey[500],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface PreformattedTextProps {
|
||||
text: string;
|
||||
maxChars: number;
|
||||
}
|
||||
|
||||
interface ExpandableProps extends PreformattedTextProps {
|
||||
expandable: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface NonExpandableProps extends PreformattedTextProps {
|
||||
expandable?: never;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const PreformattedTextComponent = ({
|
||||
text,
|
||||
maxChars,
|
||||
expandable,
|
||||
title,
|
||||
}: NonExpandableProps | ExpandableProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const classes = useStyles();
|
||||
|
||||
const handleKeyUp = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (expandable && event.key === 'Enter') {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role={expandable ? 'button' : undefined}
|
||||
onClick={() => expandable && setOpen(true)}
|
||||
onKeyUp={handleKeyUp}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<pre className={cn(classes.pre, expandable && classes.expandable)}>
|
||||
{text.slice(0, maxChars - 1).trim()}
|
||||
{text.length > maxChars - 1 && '…'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{expandable && (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
aria-labelledby="dialog-title"
|
||||
aria-describedby="dialog-description"
|
||||
maxWidth="xl"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle id="dialog-title">
|
||||
{title}
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
className={classes.closeButton}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<pre className={classes.fullPre}>{text}</pre>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="primary" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 { PreformattedTextComponent } from './PreformattedTextComponent';
|
||||
@@ -28,15 +28,16 @@ interface TooltipContentProps {
|
||||
|
||||
const TooltipContent = ({ buildId }: TooltipContentProps) => {
|
||||
const client = useApi(xcmetricsApiRef);
|
||||
const {
|
||||
value: build,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => client.getBuild(buildId), []);
|
||||
const { value, loading, error } = useAsync(
|
||||
async () => client.getBuild(buildId),
|
||||
[],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div>{error.message}</div>;
|
||||
} else if (loading || !build) {
|
||||
}
|
||||
|
||||
if (loading || !value?.build) {
|
||||
return <Progress style={{ width: 100 }} />;
|
||||
}
|
||||
|
||||
@@ -45,15 +46,15 @@ const TooltipContent = ({ buildId }: TooltipContentProps) => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Started</td>
|
||||
<td>{new Date(build.startTimestamp).toLocaleString()}</td>
|
||||
<td>{new Date(value.build.startTimestamp).toLocaleString()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duration</td>
|
||||
<td>{formatDuration(build.duration)}</td>
|
||||
<td>{formatDuration(value.build.duration)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>{formatStatus(build.buildStatus)}</td>
|
||||
<td>{formatStatus(value.build.buildStatus)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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 { StatusIconComponent } 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 />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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` : '';
|
||||
@@ -28,6 +32,12 @@ export const formatDuration = (seconds: number) => {
|
||||
return `${h} ${m} ${s}`;
|
||||
};
|
||||
|
||||
export const formatSecondsInterval = ([start, end]: [number, number]) => {
|
||||
return `${Math.round(start * 100) / 100} s - ${
|
||||
Math.round(end * 100) / 100
|
||||
} s`;
|
||||
};
|
||||
|
||||
export const formatTime = (timestamp: string) => {
|
||||
return DateTime.fromISO(timestamp).toLocaleString(
|
||||
DateTime.DATETIME_SHORT_WITH_SECONDS,
|
||||
|
||||
Reference in New Issue
Block a user