feat: improve circleci builds table

This commit is contained in:
Andrew Thauer
2020-11-23 11:44:42 -05:00
parent eeda19c7ce
commit a8de7f554c
10 changed files with 263 additions and 28 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'example-app': patch
'@backstage/plugin-circleci': patch
---
Improved CircleCI builds table to show more information and relevant links
@@ -79,10 +79,10 @@ export const CICDSwitcher = ({ entity }: { entity: Entity }) => {
return <JenkinsRouter entity={entity} />;
case isBuildkiteAvailable(entity):
return <BuildkiteRouter entity={entity} />;
case isGitHubActionsAvailable(entity):
return <GitHubActionsRouter entity={entity} />;
case isCircleCIAvailable(entity):
return <CircleCIRouter entity={entity} />;
case isGitHubActionsAvailable(entity):
return <GitHubActionsRouter entity={entity} />;
case isCloudbuildAvailable(entity):
return <CloudbuildRouter entity={entity} />;
case isTravisCIAvailable(entity):
+2
View File
@@ -29,6 +29,8 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"circleci-api": "^4.0.0",
"dayjs": "^1.9.4",
"lodash": "^4.17.15",
"moment": "^2.25.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { InfoCard, Progress, Link } from '@backstage/core';
import { BuildWithSteps, BuildStepAction } from '../../api';
@@ -31,7 +32,8 @@ import LaunchIcon from '@material-ui/icons/Launch';
import { useBuildWithSteps } from '../../state/useBuildWithSteps';
const IconLink = (IconButton as any) as typeof MaterialLink;
const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
const BuildName = ({ build }: { build?: BuildWithSteps }) => (
<Box display="flex" alignItems="center">
#{build?.build_num} - {build?.subject}
<IconLink href={build?.build_url} target="_blank">
@@ -39,6 +41,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
</IconLink>
</Box>
);
const useStyles = makeStyles(theme => ({
neutral: {},
failed: {
@@ -96,7 +99,7 @@ const pickClassName = (
return classes.neutral;
};
const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => (
const BuildsList = ({ build }: { build?: BuildWithSteps }) => (
<Box>
{build &&
build.steps &&
@@ -108,8 +111,11 @@ const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => (
</Box>
);
const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({
const ActionsList = ({
actions,
}: {
actions: BuildStepAction[];
name: string;
}) => {
const classes = useStyles();
return (
@@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { CITable } from '../CITable';
import { useBuilds } from '../../../../state';
export const Builds: FC<{}> = () => {
export const Builds = () => {
const [
{ total, loading, value, projectName, page, pageSize },
{ setPage, retry, setPageSize },
@@ -13,10 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Link, Typography, Box, IconButton } from '@material-ui/core';
import React from 'react';
import {
Avatar,
Link,
Typography,
Box,
IconButton,
makeStyles,
} from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import GitHubIcon from '@material-ui/icons/GitHub';
import LaunchIcon from '@material-ui/icons/Launch';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import {
StatusError,
@@ -27,17 +36,22 @@ import {
Table,
TableColumn,
} from '@backstage/core';
import { durationHumanized, relativeTimeTo } from '../../../../util';
import { circleCIBuildRouteRef } from '../../../../route-refs';
export type CITableBuildInfo = {
id: string;
buildName: string;
buildUrl?: string;
startTime?: string;
stopTime?: string;
source: {
branchName: string;
commit: {
hash: string;
shortHash: string;
url: string;
committerName?: string;
};
};
status: string;
@@ -48,6 +62,18 @@ export type CITableBuildInfo = {
failed: number;
testUrl: string; // fixme better name
};
workflow: {
id: string;
url: string;
name?: string;
jobName?: string;
};
user: {
isUser: boolean;
login: string;
name?: string;
avatarUrl?: string;
};
onRestartClick: () => void;
};
@@ -69,6 +95,43 @@ const getStatusComponent = (status: string | undefined = '') => {
}
};
const useStyles = makeStyles(theme => ({
root: {
display: 'flex',
'& > *': {
margin: theme.spacing(1),
verticalAlign: 'center',
},
},
small: {
width: theme.spacing(3),
height: theme.spacing(3),
},
}));
const SourceInfo = ({ build }: { build: CITableBuildInfo }) => {
const classes = useStyles();
const { user, source } = build;
return (
<Box display="flex" alignItems="center" className={classes.root}>
<Avatar alt={user.name} src={user.avatarUrl} className={classes.small} />
<Box>
<Typography variant="button">{source?.branchName}</Typography>
<Typography variant="body1">
{source?.commit?.url !== undefined ? (
<Link href={source?.commit?.url} target="_blank">
{source?.commit.shortHash}
</Link>
) : (
source?.commit.shortHash
)}
</Typography>
</Box>
</Box>
);
};
const generatedColumns: TableColumn[] = [
{
title: 'ID',
@@ -80,26 +143,43 @@ const generatedColumns: TableColumn[] = [
title: 'Build',
field: 'buildName',
highlight: true,
width: '20%',
render: (row: Partial<CITableBuildInfo>) => (
<Link
component={RouterLink}
to={`${generatePath(circleCIBuildRouteRef.path, { buildId: row.id! })}`}
to={`${generatePath(circleCIBuildRouteRef.path, {
buildId: row.id!,
})}`}
>
{row.buildName}
{row.buildName ? row.buildName : row?.workflow?.name}
</Link>
),
},
{
title: 'Job',
field: 'buildName',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => (
<Link href={row?.buildUrl} target="_blank">
<Box display="flex" alignItems="center">
<LaunchIcon fontSize="small" color="disabled" />
<Box mr={1} />
{row?.workflow?.jobName}
</Box>
</Link>
),
},
{
title: 'Source',
field: 'source.commit.hash',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => (
<>
<p>{row.source?.branchName}</p>
<p>{row.source?.commit.hash}</p>
</>
<SourceInfo build={row as any} />
),
},
{
title: 'Status',
field: 'status',
render: (row: Partial<CITableBuildInfo>) => (
<Box display="flex" alignItems="center">
{getStatusComponent(row.status)}
@@ -108,14 +188,32 @@ const generatedColumns: TableColumn[] = [
</Box>
),
},
{
title: 'Time',
field: 'startTime',
render: (row: Partial<CITableBuildInfo>) => (
<>
<Typography variant="body2">
run {relativeTimeTo(row?.startTime)}
</Typography>
<Typography variant="body2">
took {durationHumanized(row?.startTime, row?.stopTime)}
</Typography>
</>
),
},
{
title: 'Workflow',
field: 'workflow.name',
},
{
title: 'Actions',
width: '10%',
render: (row: Partial<CITableBuildInfo>) => (
<IconButton onClick={row.onRestartClick}>
<RetryIcon />
</IconButton>
),
width: '10%',
},
];
@@ -130,7 +228,8 @@ type Props = {
pageSize: number;
onChangePageSize: (pageSize: number) => void;
};
export const CITable: FC<Props> = ({
export const CITable = ({
projectName,
loading,
pageSize,
@@ -140,11 +239,16 @@ export const CITable: FC<Props> = ({
onChangePage,
onChangePageSize,
total,
}) => {
}: Props) => {
return (
<Table
isLoading={loading}
options={{ paging: true, pageSize }}
options={{
paging: true,
pageSize,
padding: 'dense',
pageSizeOptions: [10, 20, 50],
}}
totalCount={total}
page={page}
actions={[
+42 -8
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { errorApiRef, useApi } from '@backstage/core';
import { BuildSummary, GitType } from 'circleci-api';
import { useCallback, useEffect, useState } from 'react';
@@ -21,6 +22,7 @@ import { circleCIApiRef } from '../api/index';
import type { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
import { useEntity } from '@backstage/plugin-catalog';
import { CIRCLECI_ANNOTATION } from '../constants';
import { getOr } from 'lodash/fp';
const makeReadableStatus = (status: string | undefined) => {
if (!status) return '';
@@ -41,6 +43,39 @@ const makeReadableStatus = (status: string | undefined) => {
} as Record<string, string>)[status];
};
const mapWorkflowDetails = (buildData: BuildSummary) => {
// Workflows should be an object: fixed in https://github.com/worldturtlemedia/circleci-api/pull/787
const { workflows } = (buildData as any) ?? {};
return {
id: workflows?.workflow_id,
url: `${buildData.build_url}/workflows/${workflows?.workflow_id}`,
jobName: workflows?.job_name,
name: workflows?.workflow_name,
};
};
const mapSourceDetails = (buildData: BuildSummary) => {
const commitDetails = getOr({}, 'all_commit_details[0]', buildData);
return {
branchName: String(buildData.branch),
commit: {
hash: String(buildData.vcs_revision),
shortHash: String(buildData.vcs_revision).substr(0, 7),
committerName: buildData.committer_name,
url: commitDetails.commit_url,
},
};
};
const mapUser = (buildData: BuildSummary) => ({
isUser: buildData?.user?.is_user || false,
login: buildData?.user?.login || 'none',
name: (buildData?.user as any)?.name,
avatarUrl: (buildData?.user as any)?.avatar_url,
});
export const transform = (
buildsData: BuildSummary[],
restartBuild: { (buildId: number): Promise<void> },
@@ -52,16 +87,14 @@ export const transform = (
? buildData.subject +
(buildData.retry_of ? ` (retry of #${buildData.retry_of})` : '')
: '',
startTime: buildData.start_time,
stopTime: buildData.stop_time,
onRestartClick: () =>
typeof buildData.build_num !== 'undefined' &&
restartBuild(buildData.build_num),
source: {
branchName: String(buildData.branch),
commit: {
hash: String(buildData.vcs_revision),
url: 'todo',
},
},
source: mapSourceDetails(buildData),
workflow: mapWorkflowDetails(buildData),
user: mapUser(buildData),
status: makeReadableStatus(buildData.status),
buildUrl: buildData.build_url,
};
@@ -79,6 +112,7 @@ export const useProjectSlugFromEntity = () => {
export function mapVcsType(vcs: string): GitType {
switch (vcs) {
case 'gh':
case 'github':
return GitType.GITHUB;
default:
@@ -93,7 +127,7 @@ export function useBuilds() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [pageSize, setPageSize] = useState(10);
const getBuilds = useCallback(
async ({ limit, offset }: { limit: number; offset: number }) => {
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 './time';
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { durationHumanized, relativeTimeTo } from './time';
describe('times utils', () => {
describe('toRelativeTime', () => {
it('should give a relative time of x from today', () => {
expect(relativeTimeTo('2020-01-01')).toEqual(expect.any(String));
});
});
describe('durationHumanized', () => {
it('should give a humanized duration', () => {
expect(durationHumanized('2020-11-01', '2020-11-03')).toBe('2 days');
});
});
});
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* 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 dayjs from 'dayjs';
import durationPlugin from 'dayjs/plugin/duration';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
dayjs.extend(durationPlugin);
dayjs.extend(relativeTimePlugin);
type DateTimeObject = Date | string | number | undefined;
export function relativeTimeTo(time: DateTimeObject, withoutSuffix = false) {
return dayjs().to(dayjs(time), withoutSuffix);
}
export function durationHumanized(
startTime: DateTimeObject,
endTime: DateTimeObject,
) {
return dayjs.duration(dayjs(startTime).diff(dayjs(endTime))).humanize();
}