Add timeline and better handling of warning/error message

Signed-off-by: Niklas Granander <ngranander@spotify.com>
This commit is contained in:
Niklas Granander
2021-08-04 18:11:06 +02:00
parent 2daf2635a4
commit 416c75c477
17 changed files with 551 additions and 131 deletions
+2 -1
View File
@@ -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.7",
+3 -2
View File
@@ -24,6 +24,7 @@ import {
BuildFilters,
BuildHost,
BuildMetadata,
BuildResponse,
BuildStatusResult,
BuildTime,
BuildWarning,
@@ -42,9 +43,9 @@ export class XcmetricsClient implements XcmetricsApi {
this.discoveryApi = options.discoveryApi;
}
async getBuild(id: string): Promise<Build> {
async getBuild(id: string): Promise<BuildResponse> {
const response = await this.get(`/build/${id}`);
return ((await response.json()) as Record<'build', Build>).build;
return (await response.json()) as BuildResponse;
}
async getBuilds(limit: number = 10): Promise<Build[]> {
@@ -24,9 +24,13 @@ import {
BuildStatusResult,
BuildTime,
BuildWarning,
Target,
XcmetricsApi,
Xcode,
} from '../types';
const MOCK_BUILD_ID = 'buildId';
export const mockBuild: Build = {
userid: 'userid1',
warningCount: 1,
@@ -42,7 +46,7 @@ export const mockBuild: Build = {
projectName: 'ProjectName',
compilationEndTimestampMicroseconds: 1,
errorCount: 1,
id: 'buildId',
id: MOCK_BUILD_ID,
buildStatus: 'succeeded',
compilationDuration: 1,
schema: 'SchemaName',
@@ -74,7 +78,7 @@ export const mockBuildError: BuildError = {
severity: 2,
startingLine: 241,
parentType: 'step',
buildIdentifier: 'buildId',
buildIdentifier: MOCK_BUILD_ID,
startingColumn: 97,
characterRangeStart: 0,
documentURL: 'file:///Users/<redacted>/myproject/Sources/MyClass.m',
@@ -96,7 +100,7 @@ export const mockBuildHost: BuildHost = {
memoryTotalMb: 16384,
timezone: 'CET',
cpuModel: 'Intel(R) Core(TM) i7-7567U CPU @ 3.50GHz',
buildIdentifier: 'buildId',
buildIdentifier: MOCK_BUILD_ID,
memoryFreeMb: 24.5234375,
cpuSpeedGhz: 3.5,
};
@@ -114,7 +118,7 @@ export const mockBuildTime: BuildTime = {
totalDuration: 3.1,
};
export const mockBuildStatus: BuildStatusResult = {
id: 'build_id',
id: MOCK_BUILD_ID,
buildStatus: 'succeeded',
};
@@ -135,13 +139,47 @@ export const mockBuildWarning: BuildWarning = {
parentType: 'step',
clangFlag: '[-Wdeprecated-declarations]',
startingColumn: 22,
buildIdentifier: 'MyMac_34580469-5792-40F3-BEFB-7C5925996F23_1',
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([
+35 -1
View File
@@ -132,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")
@@ -140,7 +174,7 @@ export type BuildFilters = {
};
export interface XcmetricsApi {
getBuild(id: string): Promise<Build>;
getBuild(id: string): Promise<BuildResponse>;
getBuilds(limit?: number): Promise<Build[]>;
getFilteredBuilds(
filters: BuildFilters,
@@ -42,6 +42,7 @@ interface AccordionProps {
heading: string;
secondaryHeading?: string | number;
disabled?: boolean;
unmountOnExit?: boolean;
}
export const AccordionComponent = (
@@ -50,7 +51,10 @@ export const AccordionComponent = (
const classes = useStyles();
return (
<Accordion disabled={props.disabled}>
<Accordion
disabled={props.disabled}
TransitionProps={{ unmountOnExit: props.unmountOnExit ?? false }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${props.id}-content`}
@@ -28,13 +28,17 @@ jest.mock('../AccordionComponent', () => ({
),
}));
jest.mock('../BuildTimelineComponent', () => ({
BuildTimelineComponent: () => 'BuildTimelineComponent',
}));
describe('BuildDetailsComponent', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.with(xcmetricsApiRef, client.XcmetricsClient)}
>
<BuildDetailsComponent build={client.mockBuild} />
<BuildDetailsComponent buildData={client.mockBuildResponse} />
</ApiProvider>,
);
@@ -42,6 +46,7 @@ describe('BuildDetailsComponent', () => {
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(
@@ -15,12 +15,8 @@
*/
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 { 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';
@@ -28,6 +24,8 @@ 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({
@@ -39,10 +37,14 @@ const useStyles = makeStyles((theme: BackstageTheme) =>
);
interface BuildDetailsProps {
build: Build;
buildData: BuildResponse;
showId?: boolean;
}
export const BuildDetailsComponent = ({ build }: BuildDetailsProps) => {
export const BuildDetailsComponent = ({
buildData: { build, targets, xcode },
showId,
}: BuildDetailsProps) => {
const classes = useStyles();
const client = useApi(xcmetricsApiRef);
const hostResult = useAsync(async () => client.getBuildHost(build.id), []);
@@ -60,7 +62,6 @@ export const BuildDetailsComponent = ({ build }: BuildDetailsProps) => {
);
const buildDetails = {
id: build.id,
project: build.projectName,
schema: build.schema,
category: build.category,
@@ -74,96 +75,110 @@ export const BuildDetailsComponent = ({ build }: BuildDetailsProps) => {
{formatStatus(build.buildStatus)}
</>
),
xcode: `${xcode.version} (${xcode.buildNumber})`,
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 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 item>{/* TODO: Sequnce chart */}</Grid>
</Grid>
);
};
type WithRequestProps = Omit<BuildDetailsProps, 'buildData'> & {
buildId: string;
};
export const withRequest = (Component: typeof BuildDetailsComponent) => ({
buildId,
}: {
buildId: string;
}) => {
...props
}: WithRequestProps) => {
const client = useApi(xcmetricsApiRef);
const { value: build, loading, error } = useAsync(
const { value: buildResponse, loading, error } = useAsync(
async () => client.getBuild(buildId),
[],
);
@@ -176,9 +191,9 @@ export const withRequest = (Component: typeof BuildDetailsComponent) => ({
return <Alert severity="error">{error.message}</Alert>;
}
if (!build) {
if (!buildResponse) {
return <Alert severity="error">Could not load build {buildId}</Alert>;
}
return <Component build={build} />;
return <Component {...props} buildData={buildResponse} />;
};
@@ -28,6 +28,7 @@ jest.mock('../BuildListFilterComponent', () => ({
}));
jest.mock('../BuildDetailsComponent', () => ({
withRequest: (component: any) => component,
BuildDetailsComponent: () => 'BuildDetailsComponent',
}));
@@ -21,7 +21,7 @@ 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 { BuildDetailsComponent, withRequest } from '../BuildDetailsComponent';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles((theme: BackstageTheme) =>
@@ -80,11 +80,14 @@ export const BuildListComponent = () => {
.catch(reason => reject(reason));
});
}}
detailPanel={rowData => (
<div className={classes.detailPanel}>
<BuildDetailsComponent build={(rowData as any).rowData} />
</div>
)}
detailPanel={rowData => {
const BuildDetails = withRequest(BuildDetailsComponent);
return (
<div className={classes.detailPanel}>
<BuildDetails buildId={(rowData as any).rowData.id} />
</div>
);
}}
/>
</Grid>
);
@@ -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 * from './BuildTimelineComponent';
@@ -18,7 +18,7 @@ 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';
import { InfoCard } from '@backstage/core-components';
export const BuildsPage = () => {
const { '*': buildId } = useRouteRefParams(buildsRouteRef) ?? { '*': '' };
@@ -27,19 +27,8 @@ export const BuildsPage = () => {
const BuildDetails = withRequest(BuildDetailsComponent);
return (
<InfoCard
title="Build Details"
subheader={
<>
{buildId}{' '}
<CopyTextButton
text={buildId}
tooltipText="Build Id copied to clipboard"
/>
</>
}
>
<BuildDetails buildId={buildId} />
<InfoCard title="Build Details" subheader={buildId}>
<BuildDetails buildId={buildId} showId={false} />
</InfoCard>
);
}
@@ -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 - 3).trim()}
{text.length > maxChars && '...'}
</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 * 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>
+6
View File
@@ -32,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,