feat: GH Workflow Runs as Card View (#20939)

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* fix dagda1 github link in OWNERS.md

Signed-off-by: Paul Cowan <paul.cowan@cutting.scot>
Signed-off-by: blbose <lillian.bose@philips.com>

* Added description for publish:gerrit scaffolder actions

Signed-off-by: Andy Muldoon <andy.muldoon@ericsson.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* Add existing OpenCost plugin to the Backstage Plugin Directory

Signed-off-by: Matt Ray <github@mattray.dev>
Signed-off-by: blbose <lillian.bose@philips.com>

* Squared Penny the Pig logo

Signed-off-by: Matt Ray <github@mattray.dev>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/supertest to v2.0.15

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/stoppable to v1.1.2

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/rollup-plugin-peer-deps-external to v2.2.3

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/regression to v2.0.4

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/recursive-readdir to v2.2.3

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/recharts to v1.8.26

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/luxon to v3.3.3

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/swagger-ui-react to v4.18.2

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/tar to v6.1.7

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* chore(deps): update dependency @types/xml2js to v0.4.13

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* feat: GH Workflow Runs as Card View

Signed-off-by: blbose <lillian.bose@philips.com>

* file mode changes revert

Signed-off-by: blbose <lillian.bose@philips.com>

* api-report warning fix

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: UX perspective changes

Signed-off-by: blbose <lillian.bose@philips.com>

* fix:eslint warning removal

Signed-off-by: blbose <lillian.bose@philips.com>

* fix:avoiding of id based on acierto review

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: latest review comments

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: review comment changes from Freben

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: review comment changes from Freben

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflict

Signed-off-by: blbose <lillian.bose@philips.com>

* fix: rebase conflicts

Signed-off-by: blbose <lillian.bose@philips.com>

---------

Signed-off-by: blbose <lillian.bose@philips.com>
Signed-off-by: Paul Cowan <paul.cowan@cutting.scot>
Signed-off-by: Andy Muldoon <andy.muldoon@ericsson.com>
Signed-off-by: Matt Ray <github@mattray.dev>
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paul Cowan <paul.cowan@cutting.scot>
Co-authored-by: Andy Muldoon <andy.muldoon@ericsson.com>
Co-authored-by: Matt Ray <github@mattray.dev>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
This commit is contained in:
Lillian Bose
2023-11-28 00:25:41 +05:30
committed by GitHub
parent 90ffed5382
commit 08d7e4676a
12 changed files with 635 additions and 9 deletions
@@ -83,4 +83,21 @@ export type GithubActionsApi = {
}) => Promise<
RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data']
>;
listBranches: (options: {
hostname?: string;
owner: string;
repo: string;
page: number;
}) => Promise<
RestEndpointMethodTypes['repos']['listBranches']['response']['data']
>;
getDefaultBranch: (options: {
hostname?: string;
owner: string;
repo: string;
}) => Promise<
RestEndpointMethodTypes['repos']['get']['response']['data']['default_branch']
>;
};
@@ -174,4 +174,41 @@ export class GithubActionsClient implements GithubActionsApi {
return workflow.data;
}
async listBranches(options: {
hostname?: string;
owner: string;
repo: string;
page?: number;
}): Promise<
RestEndpointMethodTypes['repos']['listBranches']['response']['data']
> {
const { hostname, owner, repo, page = 0 } = options;
const octokit = await this.getOctokit(hostname);
const response = await octokit.rest.repos.listBranches({
owner,
repo,
per_page: 100,
page,
});
return response.data;
}
async getDefaultBranch(options: {
hostname?: string;
owner: string;
repo: string;
}): Promise<
RestEndpointMethodTypes['repos']['get']['response']['data']['default_branch']
> {
const { hostname, owner, repo } = options;
const octokit = await this.getOctokit(hostname);
const response = await octokit.rest.repos.get({
owner,
repo,
});
return response.data.default_branch;
}
}
+16
View File
@@ -49,3 +49,19 @@ export enum BuildStatus {
'pending',
'running',
}
/** @public */
export type Branch = {
name: string;
};
/** @public */
export type Branches = {
default_branch: string;
branches: Branch[];
};
/** @public */
export interface RouterProps {
view?: 'cards' | 'table';
}
@@ -23,15 +23,18 @@ import {
import { Routes, Route } from 'react-router-dom';
import { buildRouteRef } from '../routes';
import { WorkflowRunDetails } from './WorkflowRunDetails';
import { WorkflowRunsCard } from './WorkflowRunsCard';
import { WorkflowRunsTable } from './WorkflowRunsTable';
import { GITHUB_ACTIONS_ANNOTATION } from './getProjectNameFromEntity';
import { RouterProps } from '../api/types';
/** @public */
export const isGithubActionsAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]);
/** @public */
export const Router = () => {
export const Router = (props: RouterProps) => {
const { view = 'table' } = props;
const { entity } = useEntity();
if (!isGithubActionsAvailable(entity)) {
@@ -39,14 +42,21 @@ export const Router = () => {
<MissingAnnotationEmptyState annotation={GITHUB_ACTIONS_ANNOTATION} />
);
}
const workflowRunsComponent =
view === 'cards' ? (
<WorkflowRunsCard entity={entity} />
) : (
<WorkflowRunsTable entity={entity} />
);
return (
<Routes>
<Route path="/" element={<WorkflowRunsTable entity={entity} />} />
<Route path="/" element={workflowRunsComponent} />
<Route
path={`${buildRouteRef.path}`}
element={<WorkflowRunDetails entity={entity} />}
/>
)
</Routes>
);
};
@@ -0,0 +1,412 @@
/*
* Copyright 2020 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, { ChangeEvent, useEffect, useState } from 'react';
import {
Typography,
Box,
IconButton,
Tooltip,
Button,
Chip,
ButtonGroup,
Grid,
makeStyles,
createStyles,
Theme,
TablePagination,
Select,
MenuItem,
TextField,
CircularProgress,
} from '@material-ui/core';
import {
EmptyState,
Link,
MarkdownContent,
InfoCard,
} from '@backstage/core-components';
import GitHubIcon from '@material-ui/icons/GitHub';
import RetryIcon from '@material-ui/icons/Replay';
import SyncIcon from '@material-ui/icons/Sync';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import { useRouteRef } from '@backstage/core-plugin-api';
import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import { buildRouteRef } from '../../routes';
import { getProjectNameFromEntity } from '../getProjectNameFromEntity';
import { getHostnameFromEntity } from '../getHostnameFromEntity';
import { Alert, Color } from '@material-ui/lab';
import { Entity } from '@backstage/catalog-model';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
card: {
border: `1px solid ${theme.palette.divider}`,
boxShadow: theme.shadows[2],
borderRadius: '4px',
overflow: 'visible',
position: 'relative',
margin: theme.spacing(4, 1, 1),
flex: '1',
minWidth: '0px',
},
externalLinkIcon: {
fontSize: 'inherit',
verticalAlign: 'middle',
},
bottomline: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '-5px',
},
pagination: {
width: '100%',
},
}),
);
type WorkflowRunsCardViewProps = {
runs?: WorkflowRun[];
searchTerm: string;
loading: boolean;
onChangePageSize: (pageSize: number) => void;
onChangePage: (page: number) => void;
page: number;
total: number;
pageSize: number;
projectName: string;
};
const statusColors: Record<string, string> = {
skipped: 'warning',
canceled: 'info',
timed_out: 'error',
failure: 'error',
success: 'success',
};
const matchesSearchTerm = (run: WorkflowRun, searchTerm: string) => {
const lowerCaseSearchTerm = searchTerm.toLocaleLowerCase();
return (
run.workflowName?.toLocaleLowerCase().includes(lowerCaseSearchTerm) ||
run.source.branchName?.toLocaleLowerCase().includes(lowerCaseSearchTerm) ||
run.status?.toLocaleLowerCase().includes(lowerCaseSearchTerm) ||
run.id?.toLocaleLowerCase().includes(lowerCaseSearchTerm)
);
};
export const WorkflowRunsCardView = ({
runs,
searchTerm,
loading,
onChangePageSize,
onChangePage,
page,
total,
pageSize,
}: WorkflowRunsCardViewProps) => {
const classes = useStyles();
const routeLink = useRouteRef(buildRouteRef);
const filteredRuns = runs?.filter(run => matchesSearchTerm(run, searchTerm));
return (
<Grid container spacing={3}>
{filteredRuns && runs?.length !== 0 ? (
filteredRuns.map(run => (
<Grid key={run.id} item container xs={12} sm={4} md={4} xl={4}>
<Box className={classes.card}>
<Box
display="flex"
flexDirection="column"
m={3}
alignItems="center"
justifyContent="center"
>
<Box pt={2} sx={{ width: '100%' }} textAlign="center">
<Tooltip
title={run.status ?? 'No Status'}
placement="top-start"
>
<Alert
variant="outlined"
severity={
statusColors[
run.conclusion as keyof typeof statusColors
] as Color
}
style={{ alignItems: 'center' }}
>
<Typography variant="h6">
<Link to={routeLink({ id: run.id })}>
<Typography color="primary" variant="h6">
{run.workflowName}
</Typography>
</Link>
</Typography>
</Alert>
</Tooltip>
<Tooltip title={run.message ?? 'No run message'}>
<Typography
variant="body2"
component="span"
style={{ fontSize: 'smaller' }}
>
<MarkdownContent
content={`Commit ID : ${run.source.commit.hash!}`}
/>
</Typography>
</Tooltip>
{run.source.branchName && (
<MarkdownContent
content={`Branch : ${run.source.branchName}`}
/>
)}
<Chip
key={run.id}
size="small"
label={`Workflow ID : ${run.id}`}
/>
<Chip
size="small"
label={
<Box display="flex" alignItems="center">
<WorkflowRunStatus
status={run.status}
conclusion={run.conclusion}
/>
</Box>
}
/>
<div className={classes.bottomline}>
{run.githubUrl && (
<Link to={run.githubUrl}>
Workflow runs on GitHub{' '}
<ExternalLinkIcon
className={classes.externalLinkIcon}
/>
</Link>
)}
<ButtonGroup>
<Tooltip title="Rerun workflow">
<IconButton
onClick={run.onReRunClick}
style={{ fontSize: '12px' }}
>
<RetryIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
</div>
</Box>
</Box>
</Box>
</Grid>
))
) : (
<Box p={2}>
{loading ? <CircularProgress /> : 'No matching runs found.'}
</Box>
)}
<div className={classes.pagination}>
<TablePagination
component="div"
count={total}
page={page}
rowsPerPage={pageSize}
onPageChange={(_, newPage) => onChangePage(newPage)}
onRowsPerPageChange={event =>
onChangePageSize(parseInt(event.target.value, 6))
}
labelRowsPerPage="Workflows per page"
rowsPerPageOptions={[6, 12, 18, { label: 'All', value: -1 }]}
/>
</div>
</Grid>
);
};
type WorkflowRunsCardProps = {
entity: Entity;
};
const WorkflowRunsCardSearch = ({
searchTerm,
handleSearch,
retry,
}: {
searchTerm: string;
handleSearch: (event: ChangeEvent<HTMLInputElement>) => void;
retry: () => void;
}) => {
return (
<>
<Box flexGrow={1} />
<TextField
type="search"
label="Search"
value={searchTerm}
onChange={handleSearch}
data-testid="search-control"
style={{ marginRight: '20px' }}
/>
<ButtonGroup>
<Tooltip title="Reload workflow runs">
<IconButton onClick={retry}>
<SyncIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
</>
);
};
export const WorkflowRunsCard = ({ entity }: WorkflowRunsCardProps) => {
const projectName = getProjectNameFromEntity(entity);
const hostname = getHostnameFromEntity(entity);
const [owner, repo] = (projectName ?? '/').split('/');
const [branch, setBranch] = useState<string | undefined>('default');
const [runs, setRuns] = useState<WorkflowRun[] | undefined>([]);
const [searchTerm, setSearchTerm] = useState('');
const handleSearch = (event: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
};
const [
{ runs: runsData, branches, defaultBranch, ...cardProps },
{ retry, setPage, setPageSize },
] = useWorkflowRuns({
hostname,
owner,
repo,
branch: branch === 'all' ? undefined : branch,
});
const githubHost = hostname || 'github.com';
const hasNoRuns = !cardProps.loading && !runsData;
const handleMenuChange = (
event: ChangeEvent<{ name?: string; value: unknown }>,
) => {
const selectedValue = event.target.value as string;
setBranch(selectedValue);
setPage(0);
retry();
};
useEffect(() => {
setRuns(runsData);
}, [runsData, branch]);
useEffect(() => {
setBranch(defaultBranch);
}, [defaultBranch]);
return (
<Grid item>
{hasNoRuns ? (
<EmptyState
missing="data"
title="No Workflow Data"
description="This component has GitHub Actions enabled, but no data was found. Have you created any Workflows? Click the button below to create a new Workflow."
action={
<Button
variant="contained"
color="primary"
href={`https://${githubHost}/${projectName}/actions/new`}
>
Create new Workflow
</Button>
}
/>
) : (
<InfoCard
title={
<Box display="flex" alignItems="center">
<GitHubIcon />
<Box mr={1} />
<Typography variant="h6">{projectName}</Typography>
<Select
value={branch}
key={branch}
label="Branch"
onChange={handleMenuChange}
data-testid="menu-control"
style={{
marginLeft: '30px',
marginRight: '20px',
width: '230px',
}}
>
{branches.map(branchItem => (
<MenuItem key={branchItem.name} value={branchItem.name}>
{branchItem.name === defaultBranch ? (
<Typography variant="body2" component="span">
{branchItem.name}{' '}
<Typography
variant="body2"
component="span"
style={{ color: 'lightgray', fontSize: 'x-small' }}
>
(default)
</Typography>
</Typography>
) : (
branchItem.name
)}
</MenuItem>
))}
<MenuItem
value="all"
key="all"
style={{ color: 'lightGrey', fontSize: 'small' }}
>
select all branches
</MenuItem>
</Select>
<WorkflowRunsCardSearch
searchTerm={searchTerm}
handleSearch={handleSearch}
retry={retry}
/>
</Box>
}
>
<WorkflowRunsCardView
runs={runs}
loading={cardProps.loading}
onChangePageSize={setPageSize}
onChangePage={setPage}
page={cardProps.page}
total={cardProps.total}
pageSize={cardProps.pageSize}
searchTerm={searchTerm}
projectName={projectName}
/>
</InfoCard>
)}
</Grid>
);
};
export default WorkflowRunsCard;
@@ -0,0 +1,16 @@
/*
* Copyright 2020 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 { WorkflowRunsCard, WorkflowRunsCardView } from './WorkflowRunsCard';
@@ -17,6 +17,7 @@ import { useState } from 'react';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import { githubActionsApiRef } from '../api/GithubActionsApi';
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
import { Branch } from '../api';
export type WorkflowRun = {
workflowName?: string;
@@ -41,12 +42,12 @@ export function useWorkflowRuns({
owner,
repo,
branch,
initialPageSize = 5,
initialPageSize = 6,
}: {
hostname?: string;
owner: string;
repo: string;
branch?: string;
branch?: string | undefined;
initialPageSize?: number;
}) {
const api = useApi(githubActionsApiRef);
@@ -56,6 +57,8 @@ export function useWorkflowRuns({
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(initialPageSize);
const [branches, setBranches] = useState<Branch[]>([]);
const [defaultBranch, setDefaultBranch] = useState<string>('');
const {
loading,
@@ -63,6 +66,43 @@ export function useWorkflowRuns({
retry,
error,
} = useAsyncRetry<WorkflowRun[]>(async () => {
const fetchedDefaultBranch = await api.getDefaultBranch({
hostname,
owner,
repo,
});
setDefaultBranch(fetchedDefaultBranch);
let selectedBranch = branch;
if (branch === 'default') {
selectedBranch = fetchedDefaultBranch;
}
const fetchBranches = async () => {
let next = true;
let iteratePage = 0;
const branchSet: Branch[] = [];
while (next) {
const branchesData = await api.listBranches({
hostname,
owner,
repo,
page: iteratePage,
});
if (branchesData.length === 0) {
next = false;
}
iteratePage++;
branchSet.push(...branchesData);
}
return branchSet;
};
const branchSet = await fetchBranches();
setBranches(branchSet);
// GitHub API pagination count starts from 1
const workflowRunsData = await api.listWorkflowRuns({
hostname,
@@ -70,7 +110,7 @@ export function useWorkflowRuns({
repo,
pageSize,
page: page + 1,
branch,
branch: selectedBranch,
});
setTotal(workflowRunsData.total_count);
// Transformation here
@@ -88,7 +128,7 @@ export function useWorkflowRuns({
});
} catch (e) {
errorApi.post(
new Error(`Failed to rerun the workflow: ${e.message}`),
new Error(`Failed to rerun the workflow: ${(e as Error).message}`),
);
}
},
@@ -115,6 +155,8 @@ export function useWorkflowRuns({
pageSize,
loading,
runs,
branches,
defaultBranch,
projectName: `${owner}/${repo}`,
total,
error,