feat(github-actions): widget inside catalog

This commit is contained in:
Ivan Shmidt
2020-07-28 11:29:27 +02:00
parent c94dc3dca0
commit 7248e9a581
21 changed files with 511 additions and 258 deletions
@@ -33,12 +33,14 @@ export type GithubActionsApi = {
repo,
pageSize,
page,
branch,
}: {
token: string;
owner: string;
repo: string;
pageSize?: number;
page?: number;
branch?: string;
}) => Promise<ActionsListWorkflowRunsForRepoResponseData>;
getWorkflow: ({
token,
@@ -46,12 +46,14 @@ export class GithubActionsClient implements GithubActionsApi {
repo,
pageSize = 100,
page = 0,
branch,
}: {
token: string;
owner: string;
repo: string;
pageSize?: number;
page?: number;
branch?: string;
}): Promise<ActionsListWorkflowRunsForRepoResponseData> {
const workflowRuns = await new Octokit({
auth: token,
@@ -60,6 +62,7 @@ export class GithubActionsClient implements GithubActionsApi {
repo,
per_page: pageSize,
page,
...(branch ? { branch } : {}),
});
return workflowRuns.data;
}
@@ -0,0 +1,110 @@
/*
* 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 React, { useEffect } from 'react';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRun } from '../WorkflowRunsTable';
import { Entity } from '@backstage/catalog-model';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import {
Link,
Theme,
makeStyles,
LinearProgress,
Typography,
} from '@material-ui/core';
import {
InfoCard,
StructuredMetadataTable,
errorApiRef,
useApi,
} from '@backstage/core';
import ExternalLinkIcon from '@material-ui/icons/Launch';
const useStyles = makeStyles<Theme>({
externalLinkIcon: {
fontSize: 'inherit',
verticalAlign: 'bottom',
},
});
const WidgetContent = ({
error,
loading,
lastRun,
branch,
}: {
error?: Error;
loading?: boolean;
lastRun: WorkflowRun;
branch: string;
}) => {
const classes = useStyles();
if (error) return <Typography>Couldn't fetch latest {branch} run</Typography>;
if (loading) return <LinearProgress />;
return (
<StructuredMetadataTable
metadata={{
status: (
<>
<WorkflowRunStatus status={lastRun.status} />
</>
),
message: lastRun.message,
url: (
<Link href={lastRun.githubUrl} target="_blank">
See more on GitHub{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
),
}}
/>
);
};
export const Widget = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/'
).split('/');
const [{ runs, loading, error }] = useWorkflowRuns({
owner,
repo,
branch,
});
const lastRun = runs?.[0] ?? ({} as WorkflowRun);
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
return (
<InfoCard title={`Last ${branch} build`}>
<WidgetContent
error={error}
loading={loading}
branch={branch}
lastRun={lastRun}
/>
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* 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 { Widget } from './Widget';
@@ -0,0 +1,236 @@
/*
* 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 React from 'react';
import { useEntityCompoundName } from '@backstage/plugin-catalog';
import { useWorkflowRunsDetails } from './useWorkflowRunsDetails';
import { useWorkflowRunJobs } from './useWorkflowRunJobs';
import { useProjectName } from '../useProjectName';
import {
makeStyles,
Box,
TableRow,
TableCell,
ListItemText,
ExpansionPanel,
ExpansionPanelSummary,
Typography,
ExpansionPanelDetails,
TableContainer,
Table,
Paper,
TableBody,
LinearProgress,
CircularProgress,
Theme,
Link,
} from '@material-ui/core';
import { Jobs, Job, Step } from '../../api';
import moment from 'moment';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExternalLinkIcon from '@material-ui/icons/Launch';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
expansionPanelDetails: {
padding: 0,
},
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
},
externalLinkIcon: {
fontSize: 'inherit',
verticalAlign: 'bottom',
},
}));
const JobsList = ({ jobs }: { jobs?: Jobs }) => {
const classes = useStyles();
return (
<Box>
{jobs &&
jobs.total_count > 0 &&
jobs.jobs.map((job: Job) => (
<JobListItem
job={job}
className={
job.status !== 'success' ? classes.failed : classes.success
}
/>
))}
</Box>
);
};
const getElapsedTime = (start: string, end: string) => {
const diff = moment(moment(end || moment()).diff(moment(start)));
const timeElapsed = diff.format('m [minutes] s [seconds]');
return timeElapsed;
};
const StepView = ({ step }: { step: Step }) => {
return (
<TableRow>
<TableCell>
<ListItemText
primary={step.name}
secondary={getElapsedTime(step.started_at, step.completed_at)}
/>
</TableCell>
<TableCell>
<WorkflowRunStatus status={step.status.toUpperCase()} />
</TableCell>
</TableRow>
);
};
const JobListItem = ({ job, className }: { job: Job; className: string }) => {
const classes = useStyles();
return (
<ExpansionPanel
TransitionProps={{ unmountOnExit: true }}
className={className}
>
<ExpansionPanelSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel-${name}-content`}
id={`panel-${name}-header`}
IconButtonProps={{
className: classes.button,
}}
>
<Typography variant="button">
{job.name} ({getElapsedTime(job.started_at, job.completed_at)})
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.expansionPanelDetails}>
<TableContainer>
<Table>
{job.steps.map((step: Step) => (
<StepView step={step} />
))}
</Table>
</TableContainer>
</ExpansionPanelDetails>
</ExpansionPanel>
);
};
export const WorkflowRunDetails = () => {
let entityCompoundName = useEntityCompoundName();
if (!entityCompoundName.name) {
// TODO(shmidt-i): remove when is fully integrated
// into the entity view
entityCompoundName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
}
const projectName = useProjectName(entityCompoundName);
const [owner, repo] = projectName.value ? projectName.value.split('/') : [];
const details = useWorkflowRunsDetails(repo, owner);
const jobs = useWorkflowRunJobs(details.value?.jobs_url);
const error = projectName.error || (projectName.value && details.error);
const classes = useStyles();
if (error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {error.message}
</Typography>
);
} else if (projectName.loading || details.loading) {
return <LinearProgress />;
}
return (
<div className={classes.root}>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Branch</Typography>
</TableCell>
<TableCell>{details.value?.head_branch}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>{details.value?.head_commit.message}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{details.value?.head_commit.id}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<WorkflowRunStatus status={details.value?.status} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Author</Typography>
</TableCell>
<TableCell>{`${details.value?.head_commit.author.name} (${details.value?.head_commit.author.email})`}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
{details.value?.html_url && (
<Link target="_blank" href={details.value.html_url}>
Workflow runs on GitHub{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2}>
<Typography noWrap>Jobs</Typography>
{jobs.loading ? (
<CircularProgress />
) : (
<JobsList jobs={jobs.value} />
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
);
};
@@ -0,0 +1,16 @@
/*
* 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 { WorkflowRunDetails } from './WorkflowRunDetails';
@@ -24,12 +24,14 @@ export const useWorkflowRunsDetails = (repo: string, owner: string) => {
const { id } = useParams();
const details = useAsync(async () => {
const token = await auth.getAccessToken(['repo']);
return api.getWorkflowRun({
token,
owner,
repo,
id: parseInt(id, 10),
});
return repo && owner
? api.getWorkflowRun({
token,
owner,
repo,
id: parseInt(id, 10),
})
: Promise.reject('No repo/owner provided');
}, [repo, owner, id]);
return details;
};
@@ -14,28 +14,7 @@
* limitations under the License.
*/
import {
Button,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Theme,
Typography,
Box,
ExpansionPanelDetails,
ExpansionPanel,
ExpansionPanelSummary,
ListItemText,
CircularProgress,
Grid,
Breadcrumbs,
} from '@material-ui/core';
import moment from 'moment';
import { Typography, Grid, Breadcrumbs } from '@material-ui/core';
import React from 'react';
import {
@@ -48,133 +27,13 @@ import {
SupportButton,
pageTheme,
} from '@backstage/core';
import { Job, Step, Jobs } from '../../api/types';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { useWorkflowRunsDetails } from './useWorkflowRunsDetails';
import { useWorkflowRunJobs } from './useWorkflowRunJobs';
import { WorkflowRunStatusIcon } from '../WorkflowRunStatusIcon/WorkflowRunStatusIcon';
import { useProjectName } from '../useProjectName';
import GitHubIcon from '@material-ui/icons/GitHub';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
expansionPanelDetails: {
padding: 0,
},
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
},
}));
const JobsList = ({ jobs }: { jobs?: Jobs }) => {
const classes = useStyles();
return (
<Box>
{jobs &&
jobs.total_count > 0 &&
jobs.jobs.map((job: Job) => (
<JobListItem
job={job}
className={
job.status !== 'success' ? classes.failed : classes.success
}
/>
))}
</Box>
);
};
const getElapsedTime = (start: string, end: string) => {
const diff = moment(moment(end || moment()).diff(moment(start)));
const timeElapsed = diff.format('m [minutes] s [seconds]');
return timeElapsed;
};
const StepView = ({ step }: { step: Step }) => {
return (
<TableRow>
<TableCell>
<ListItemText
primary={step.name}
secondary={getElapsedTime(step.started_at, step.completed_at)}
/>
</TableCell>
<TableCell>
<WorkflowRunStatusIcon status={step.status.toUpperCase()} />
{step.status}
</TableCell>
</TableRow>
);
};
const JobListItem = ({ job, className }: { job: Job; className: string }) => {
const classes = useStyles();
return (
<ExpansionPanel
TransitionProps={{ unmountOnExit: true }}
className={className}
>
<ExpansionPanelSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel-${name}-content`}
id={`panel-${name}-header`}
IconButtonProps={{
className: classes.button,
}}
>
<Typography variant="button">
{job.name} ({getElapsedTime(job.started_at, job.completed_at)})
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.expansionPanelDetails}>
<TableContainer>
<Table>
{job.steps.map((step: Step) => (
<StepView step={step} />
))}
</Table>
</TableContainer>
</ExpansionPanelDetails>
</ExpansionPanel>
);
};
import { WorkflowRunDetails } from '../WorkflowRunDetails';
/**
* A component for Jobs visualization. Jobs are a property of a Workflow Run.
*/
export const WorkflowRunDetailsPage = () => {
const [owner, repo] = (
useProjectName({
kind: 'Component',
name: 'backstage',
}) ?? '/'
).split('/');
const details = useWorkflowRunsDetails(repo, owner);
const jobs = useWorkflowRunJobs(details.value?.jobs_url);
const classes = useStyles();
if (details.loading) {
return <LinearProgress />;
} else if (details.error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {details.error.message}
</Typography>
);
}
return (
<Page theme={pageTheme.tool}>
<Header
@@ -197,77 +56,7 @@ export const WorkflowRunDetailsPage = () => {
</Breadcrumbs>
<Grid container spacing={3} direction="column">
<Grid item>
<div className={classes.root}>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Branch</Typography>
</TableCell>
<TableCell>{details.value?.head_branch}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>
{details.value?.head_commit.message}
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{details.value?.head_commit.id}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<WorkflowRunStatusIcon status={details.value?.status} />{' '}
{details.value?.status.toUpperCase()}
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Author</Typography>
</TableCell>
<TableCell>{`${details.value?.head_commit.author.name} (${details.value?.head_commit.author.email})`}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
{details.value?.html_url && (
<a href={details.value.html_url}>
<Button
variant="contained"
color="default"
startIcon={<GitHubIcon />}
>
Workflow runs on GitHub
</Button>
</a>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={2}>
<Typography noWrap>Jobs</Typography>
{jobs.loading ? (
<CircularProgress />
) : (
<JobsList jobs={jobs.value} />
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
<WorkflowRunDetails />
</Grid>
</Grid>
</Content>
@@ -17,7 +17,7 @@
import { StatusPending, StatusRunning, StatusOK } from '@backstage/core';
import React from 'react';
export const WorkflowRunStatusIcon = ({
export const WorkflowRunStatus = ({
status,
}: {
status: string | undefined;
@@ -25,12 +25,28 @@ export const WorkflowRunStatusIcon = ({
if (status === undefined) return null;
switch (status.toLowerCase()) {
case 'queued':
return <StatusPending />;
return (
<>
<StatusPending /> Queued
</>
);
case 'in_progress':
return <StatusRunning />;
return (
<>
<StatusRunning /> In progress
</>
);
case 'completed':
return <StatusOK />;
return (
<>
<StatusOK /> Completed
</>
);
default:
return <StatusPending />;
return (
<>
<StatusPending /> Pending
</>
);
}
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunStatusIcon } from './WorkflowRunStatusIcon';
export { WorkflowRunStatus } from './WorkflowRunStatus';
@@ -17,16 +17,20 @@ import React, { FC } from 'react';
import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import GitHubIcon from '@material-ui/icons/GitHub';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { Table, TableColumn } from '@backstage/core';
import { useWorkflowRuns } from './useWorkflowRuns';
import { WorkflowRunStatusIcon } from '../WorkflowRunStatusIcon';
import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import SyncIcon from '@material-ui/icons/Sync';
import { buildRouteRef } from '../../plugin';
import { useEntityCompoundName } from '@backstage/plugin-catalog';
import { useProjectName } from '../useProjectName';
export type WorkflowRun = {
id: string;
message: string;
url?: string;
githubUrl?: string;
source: {
branchName: string;
commit: {
@@ -52,7 +56,7 @@ const generatedColumns: TableColumn[] = [
render: (row: Partial<WorkflowRun>) => (
<Link
component={RouterLink}
to={`/github-actions/workflow-run/${row.id}`}
to={generatePath(buildRouteRef.path, { id: row.id! })}
>
{row.message}
</Link>
@@ -69,13 +73,11 @@ const generatedColumns: TableColumn[] = [
},
{
title: 'Status',
width: '150px',
render: (row: Partial<WorkflowRun>) => (
<Box display="flex" alignItems="center">
<WorkflowRunStatusIcon status={row.status} />
<Box mr={1} />
<Typography variant="button" noWrap>
{row.status}
</Typography>
<WorkflowRunStatus status={row.status} />
</Box>
),
},
@@ -104,7 +106,7 @@ type Props = {
onChangePageSize: (pageSize: number) => void;
};
const WorkflowRunsTableView: FC<Props> = ({
export const WorkflowRunsTableView: FC<Props> = ({
projectName,
loading,
pageSize,
@@ -145,10 +147,28 @@ const WorkflowRunsTableView: FC<Props> = ({
};
export const WorkflowRunsTable = () => {
const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns();
let entityCompoundName = useEntityCompoundName();
if (!entityCompoundName.name) {
// TODO(shmidt-i): remove when is fully integrated
// into the entity view
entityCompoundName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
}
const { value: projectName, loading } = useProjectName(entityCompoundName);
const [owner, repo] = (projectName ?? '/').split('/');
const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({
owner,
repo,
});
return (
<WorkflowRunsTableView
{...tableProps}
loading={loading || tableProps.loading}
retry={retry}
onChangePageSize={setPageSize}
onChangePage={setPage}
@@ -13,5 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { WorkflowRunsTable } from './WorkflowRunsTable';
export { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable';
export type { WorkflowRun } from './WorkflowRunsTable';
@@ -21,11 +21,11 @@ import { useApi } from '@backstage/core';
export const useProjectName = (name: EntityCompoundName) => {
const catalogApi = useApi(catalogApiRef);
const { value } = useAsync<string>(async () => {
const { value, loading, error } = useAsync<string>(async () => {
const entity = await catalogApi.getEntityByName(name);
return (
entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? ''
);
});
return value;
return { value, loading, error };
};
@@ -15,13 +15,20 @@
*/
import { useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { WorkflowRun } from './WorkflowRunsTable';
import { githubActionsApiRef } from '../../api/GithubActionsApi';
import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable';
import { githubActionsApiRef } from '../api/GithubActionsApi';
import { useApi, githubAuthApiRef, errorApiRef } from '@backstage/core';
import { ActionsListWorkflowRunsForRepoResponseData } from '@octokit/types';
import { useProjectName } from '../useProjectName';
export function useWorkflowRuns() {
export function useWorkflowRuns({
owner,
repo,
branch,
}: {
owner: string;
repo: string;
branch?: string;
}) {
const api = useApi(githubActionsApiRef);
const auth = useApi(githubAuthApiRef);
@@ -31,19 +38,21 @@ export function useWorkflowRuns() {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const projectName = useProjectName({
kind: 'Component',
name: 'backstage',
});
const { loading, value: runs, retry } = useAsyncRetry<
const { loading, value: runs, retry, error } = useAsyncRetry<
WorkflowRun[]
>(async () => {
const token = await auth.getAccessToken(['repo']);
const [owner, repo] = (projectName ?? '/').split('/');
return (
api
// GitHub API pagination count starts from 1
.listWorkflowRuns({ token, owner, repo, pageSize, page: page + 1 })
.listWorkflowRuns({
token,
owner,
repo,
pageSize,
page: page + 1,
branch,
})
.then(
(
workflowRunsData: ActionsListWorkflowRunsForRepoResponseData,
@@ -77,11 +86,12 @@ export function useWorkflowRuns() {
},
status: run.status,
url: run.url,
githubUrl: run.html_url,
}));
},
)
);
}, [page, pageSize, projectName]);
}, [page, pageSize, repo, owner]);
return [
{
@@ -89,8 +99,9 @@ export function useWorkflowRuns() {
pageSize,
loading,
runs,
projectName: projectName ?? '',
projectName: `${owner}/${repo}`,
total,
error,
},
{
runs,
+1
View File
@@ -16,3 +16,4 @@
export { plugin } from './plugin';
export * from './api';
export { Widget } from './components/Widget';