feat: improve circleci builds table
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user