diff --git a/.changeset/mighty-cycles-impress.md b/.changeset/mighty-cycles-impress.md new file mode 100644 index 0000000000..33b2aa008c --- /dev/null +++ b/.changeset/mighty-cycles-impress.md @@ -0,0 +1,6 @@ +--- +'example-app': patch +'@backstage/plugin-circleci': patch +--- + +Improved CircleCI builds table to show more information and relevant links diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index d826bfa25e..c201fe88ad 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -79,10 +79,10 @@ export const CICDSwitcher = ({ entity }: { entity: Entity }) => { return ; case isBuildkiteAvailable(entity): return ; - case isGitHubActionsAvailable(entity): - return ; case isCircleCIAvailable(entity): return ; + case isGitHubActionsAvailable(entity): + return ; case isCloudbuildAvailable(entity): return ; case isTravisCIAvailable(entity): diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index c9ad54659f..eaa8c319a0 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -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", diff --git a/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 30994f5ce3..18ae5a2d04 100644 --- a/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -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 }) => ( #{build?.build_num} - {build?.subject} @@ -39,6 +41,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); + 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 }) => ( {build && build.steps && @@ -108,8 +111,11 @@ const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); -const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({ +const ActionsList = ({ actions, +}: { + actions: BuildStepAction[]; + name: string; }) => { const classes = useStyles(); return ( diff --git a/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx index bed96bb24c..685e61a5af 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx @@ -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 }, diff --git a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index 8e693429c2..e5e8b951be 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -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 ( + + + + {source?.branchName} + + {source?.commit?.url !== undefined ? ( + + {source?.commit.shortHash} + + ) : ( + source?.commit.shortHash + )} + + + + ); +}; + const generatedColumns: TableColumn[] = [ { title: 'ID', @@ -80,26 +143,43 @@ const generatedColumns: TableColumn[] = [ title: 'Build', field: 'buildName', highlight: true, + width: '20%', render: (row: Partial) => ( - {row.buildName} + {row.buildName ? row.buildName : row?.workflow?.name} + + ), + }, + { + title: 'Job', + field: 'buildName', + highlight: true, + render: (row: Partial) => ( + + + + + {row?.workflow?.jobName} + ), }, { title: 'Source', + field: 'source.commit.hash', + highlight: true, render: (row: Partial) => ( - <> -

{row.source?.branchName}

-

{row.source?.commit.hash}

- + ), }, { title: 'Status', + field: 'status', render: (row: Partial) => ( {getStatusComponent(row.status)} @@ -108,14 +188,32 @@ const generatedColumns: TableColumn[] = [ ), }, + { + title: 'Time', + field: 'startTime', + render: (row: Partial) => ( + <> + + run {relativeTimeTo(row?.startTime)} + + + took {durationHumanized(row?.startTime, row?.stopTime)} + + + ), + }, + { + title: 'Workflow', + field: 'workflow.name', + }, { title: 'Actions', + width: '10%', render: (row: Partial) => ( ), - width: '10%', }, ]; @@ -130,7 +228,8 @@ type Props = { pageSize: number; onChangePageSize: (pageSize: number) => void; }; -export const CITable: FC = ({ + +export const CITable = ({ projectName, loading, pageSize, @@ -140,11 +239,16 @@ export const CITable: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return ( { if (!status) return ''; @@ -41,6 +43,39 @@ const makeReadableStatus = (status: string | undefined) => { } as Record)[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 }, @@ -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 }) => { diff --git a/plugins/circleci/src/util/index.ts b/plugins/circleci/src/util/index.ts new file mode 100644 index 0000000000..55bb001ae4 --- /dev/null +++ b/plugins/circleci/src/util/index.ts @@ -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'; diff --git a/plugins/circleci/src/util/time.test.ts b/plugins/circleci/src/util/time.test.ts new file mode 100644 index 0000000000..dae029c8d2 --- /dev/null +++ b/plugins/circleci/src/util/time.test.ts @@ -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'); + }); + }); +}); diff --git a/plugins/circleci/src/util/time.ts b/plugins/circleci/src/util/time.ts new file mode 100644 index 0000000000..8756333a87 --- /dev/null +++ b/plugins/circleci/src/util/time.ts @@ -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(); +}