Merge pull request #19881 from Abhay-soni-developer/jenkins/jobrun-table

jenkins JobRunTable added
This commit is contained in:
Fredrik Adelöw
2023-10-14 17:10:22 +02:00
committed by GitHub
15 changed files with 484 additions and 4 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-jenkins-backend': minor
'@backstage/plugin-jenkins': minor
---
Added JobRunTable Component.
Added new Route and extended Api to get buildJobs.
Actions column has a new icon button, clicking on which takes us to page where we
can see all the job runs.
@@ -54,6 +54,7 @@ export class JenkinsApiImpl {
private static readonly jobTreeSpec = `actions[*],
${JenkinsApiImpl.lastBuildTreeSpec}
jobs{0,1},
url,
name,
fullName,
displayName,
@@ -64,6 +65,16 @@ export class JenkinsApiImpl {
${JenkinsApiImpl.jobTreeSpec}
]{0,50}`;
private static readonly jobBuildsTreeSpec = `
name,
description,
url,
fullName,
displayName,
fullDisplayName,
inQueue,
builds[*]`;
constructor(private readonly permissionApi?: PermissionEvaluator) {}
/**
@@ -329,4 +340,35 @@ export class JenkinsApiImpl {
const jobs = jobFullName.split('/');
return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`;
}
async getJobBuilds(jenkinsInfo: JenkinsInfo, jobFullName: string) {
let jobName = jobFullName;
if (jobFullName.includes('/')) {
const arr = jobFullName.split('/');
const multibranchJobName = arr.shift();
jobName = [
multibranchJobName,
'job',
encodeURIComponent(arr.join('/')),
].join('/');
}
const response = await fetch(
`${
jenkinsInfo.baseUrl
}/job/${jobName}/api/json?tree=${JenkinsApiImpl.jobBuildsTreeSpec.replace(
/\s/g,
'',
)}`,
{
method: 'get',
headers: jenkinsInfo.headers as HeaderInit,
},
);
const jobBuilds = await response.json();
return jobBuilds;
}
}
@@ -151,6 +151,32 @@ export async function createRouter(
},
);
router.get(
'/v1/entity/:namespace/:kind/:name/job/:jobFullName',
async (request, response) => {
const token = getBearerTokenFromAuthorizationHeader(
request.header('authorization'),
);
const { namespace, kind, name, jobFullName } = request.params;
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
entityRef: {
kind,
namespace,
name,
},
jobFullName,
backstageToken: token,
});
const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobFullName);
response.json({
build: build,
});
},
);
router.post(
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber',
async (request, response) => {
+10 -1
View File
@@ -5,7 +5,9 @@ Website: [https://jenkins.io/](https://jenkins.io/)
<img src="./src/assets/last-master-build.png" alt="Last master build"/>
<img src="./src/assets/folder-results.png" alt="Folder results"/>
<img src="./src/assets/build-details.png" alt="Build details"/>
<img src="./src/assets/jobrun-table.png" alt="Job builds records"/>
<img src="./src/assets/dynamic-columns.png" alt="Modify Table Columns"/>
## Setup
1. If you have a standalone app (you didn't clone this repo), then do
@@ -74,7 +76,9 @@ metadata:
name: 'your-component'
description: 'a description'
annotations:
jenkins.io/github-folder: 'folder-name/project-name'
jenkins.io/github-folder: 'folder-name/project-name' # deprecated
jenkins.io/job-full-name: 'folder-name/project-name' # use this instead
spec:
type: service
lifecycle: experimental
@@ -98,6 +102,11 @@ spec:
- No pagination support currently, limited to 50 projects - don't run this on a
Jenkins instance with lots of builds
## EntityJobRunsTable
- View all builds of a particular job
- shows average build time for successful builds
## Modify Columns of EntityJenkinsContent
- now you can pass down column props to show the columns/metadata as per your use case.
+15
View File
@@ -22,6 +22,9 @@ export const EntityJenkinsContent: (props: {
columns?: TableColumn<Project>[] | undefined;
}) => JSX_2.Element;
// @public (undocumented)
export const EntityJobRunsTable: () => JSX_2.Element;
// @public (undocumented)
export const EntityLatestJenkinsRunCard: (props: {
branch: string;
@@ -48,6 +51,13 @@ export interface JenkinsApi {
jobFullName: string;
buildNumber: string;
}): Promise<Build>;
// Warning: (ae-forgotten-export) The symbol "Job" needs to be exported by the entry point index.d.ts
//
// (undocumented)
getJobBuilds(options: {
entity: CompoundEntityRef;
jobFullName: string;
}): Promise<Job>;
getProjects(options: {
entity: CompoundEntityRef;
filter: {
@@ -82,6 +92,11 @@ export class JenkinsClient implements JenkinsApi {
buildNumber: string;
}): Promise<Build>;
// (undocumented)
getJobBuilds(options: {
entity: CompoundEntityRef;
jobFullName: string;
}): Promise<Job>;
// (undocumented)
getProjects(options: {
entity: CompoundEntityRef;
filter: {
+53
View File
@@ -56,6 +56,30 @@ export interface Build {
};
status: string; // == building ? 'running' : result,
}
export interface JobBuild {
timestamp: number;
building: boolean;
duration: number;
result?: string;
fullDisplayName: string;
displayName: string;
url: string;
number: number;
inProgress: boolean;
queueId: number;
id: number;
}
export interface Job {
name: string;
displayName: string;
description: string;
fullDisplayName: string;
inQueue: boolean;
fullName: string;
url: string;
builds: JobBuild[];
}
/** @public */
export interface Project {
@@ -99,6 +123,11 @@ export interface JenkinsApi {
buildNumber: string;
}): Promise<Build>;
getJobBuilds(options: {
entity: CompoundEntityRef;
jobFullName: string;
}): Promise<Job>;
retry(options: {
entity: CompoundEntityRef;
jobFullName: string;
@@ -213,4 +242,28 @@ export class JenkinsClient implements JenkinsApi {
const { token } = await this.identityApi.getCredentials();
return token;
}
async getJobBuilds(options: {
entity: CompoundEntityRef;
jobFullName: string;
}): Promise<Job> {
const { entity, jobFullName } = options;
const url = `${await this.discoveryApi.getBaseUrl(
'jenkins',
)}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent(
entity.kind,
)}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent(
jobFullName,
)}`;
const idToken = await this.getToken();
const response = await fetch(url, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return (await response.json()).build;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

@@ -19,9 +19,10 @@ import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
import { Box, IconButton, Tooltip, Typography } from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import VisibilityIcon from '@material-ui/icons/Visibility';
import HistoryIcon from '@material-ui/icons/History';
import { default as React, useState } from 'react';
import { Project } from '../../../../api/JenkinsApi';
import { buildRouteRef } from '../../../../plugin';
import { buildRouteRef, jobRunsRouteRef } from '../../../../plugin';
import { JenkinsRunStatus } from '../Status';
import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common';
@@ -186,6 +187,25 @@ export const columnFactories = Object.freeze({
};
},
createLastRunDuration(): TableColumn<Project> {
return {
title: 'Last Run Duration',
align: 'left',
render: (row: Partial<Project>) => (
<>
<Typography>
{row?.lastBuild?.duration
? (row?.lastBuild?.duration / 1000)
.toFixed(1)
.toString()
.concat(' s')
: ''}{' '}
</Typography>
</>
),
};
},
createActionsColumn(): TableColumn<Project> {
return {
title: 'Actions',
@@ -198,6 +218,7 @@ export const columnFactories = Object.freeze({
);
const alertApi = useApi(alertApiRef);
const jobRunsLink = useRouteRef(jobRunsRouteRef);
const onRebuild = async () => {
if (row.onRestartClick) {
@@ -221,7 +242,7 @@ export const columnFactories = Object.freeze({
};
return (
<div style={{ width: '98px' }}>
<div style={{ width: '148px' }}>
{row.lastBuild?.url && (
<Tooltip title="View build">
<IconButton href={row.lastBuild.url} target="_blank">
@@ -240,6 +261,17 @@ export const columnFactories = Object.freeze({
</IconButton>
</Tooltip>
)}
<Link
to={jobRunsLink({
jobFullName: encodeURIComponent(row.fullName || ''),
})}
>
<Tooltip title="View Runs">
<IconButton>
<HistoryIcon />
</IconButton>
</Tooltip>
</Link>
</div>
);
};
@@ -24,5 +24,6 @@ export const defaultCITableColumns: TableColumn<Project>[] = [
columnFactories.createBuildColumn(),
columnFactories.createTestColumn(),
columnFactories.createStatusColumn(),
columnFactories.createLastRunDuration(),
columnFactories.createActionsColumn(),
];
@@ -0,0 +1,187 @@
/*
* 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 { Link, Table, TableColumn } from '@backstage/core-components';
import { Box, IconButton, Tooltip, Typography } from '@material-ui/core';
import { default as React } from 'react';
import JenkinsLogo from './../../assets/JenkinsLogo.svg';
import { useJobRuns } from './../useJobRuns';
import { Job, JobBuild } from './../../api/JenkinsApi';
import { JenkinsRunStatus } from './../BuildsPage/lib/Status';
import VisibilityIcon from '@material-ui/icons/Visibility';
import { jobRunsRouteRef } from '../../plugin';
import { useRouteRefParams } from '@backstage/core-plugin-api';
const generatedColumns: TableColumn[] = [
{
title: 'Number',
field: 'number',
render: (row: Partial<JobBuild>) => {
return (
<Box display="flex" alignItems="center">
<Typography paragraph>
<Link to={row.url ?? ''}>{row.number}</Link>
</Typography>
</Box>
);
},
},
{
title: 'Timestamp',
field: 'timestamp',
render: (row: Partial<JobBuild>) => {
return (
<Box display="flex" alignItems="center">
<Typography>
{row?.timestamp ? new Date(row?.timestamp).toLocaleString() : ' '}
</Typography>
</Box>
);
},
},
{
title: 'Result',
field: 'result',
render: (row: Partial<JobBuild>) => {
return (
<Box display="flex" alignItems="center">
{row.inProgress ? (
<Typography>In Progress</Typography>
) : (
<JenkinsRunStatus status={row?.result} />
)}
</Box>
);
},
},
{
title: 'Duration',
field: 'duration',
render: (row: Partial<JobBuild>) => {
return (
<Box display="flex" alignItems="center">
<Typography>
{row?.duration
? (row.duration / 1000).toFixed(1).toString().concat(' s')
: ''}
</Typography>
</Box>
);
},
},
{
title: 'Actions',
render: (row: Partial<JobBuild>) => {
const ActionWrapper = () => {
return (
<div style={{ width: '98px' }}>
{row?.url && (
<Tooltip title="View build">
<Link component={IconButton} to={row.url}>
<VisibilityIcon />
</Link>
</Tooltip>
)}
</div>
);
};
return <ActionWrapper />;
},
width: '10%',
},
];
type Props = {
loading: boolean;
jobRuns?: Job;
page: number;
onChangePage: (page: number) => void;
pageSize: number;
onChangePageSize: (pageSize: number) => void;
};
export const JobRunsTableView = ({
loading,
pageSize,
page,
jobRuns,
onChangePage,
onChangePageSize,
}: Props) => {
const builds = jobRuns?.builds.slice(
page * pageSize,
page * pageSize + pageSize,
);
let sumOfAllSuccessfulJobDuration = 0;
const successfulJobCount =
builds?.reduce((count, build) => {
if (!build.inProgress && build.result === 'SUCCESS') {
sumOfAllSuccessfulJobDuration += build.duration;
return count + 1;
}
return count;
}, 0) || 0;
let avgTime;
if (successfulJobCount > 0) {
avgTime = (sumOfAllSuccessfulJobDuration / successfulJobCount / 1000)
.toFixed(1)
.toString();
}
return (
<Table
isLoading={loading}
options={{ paging: true, pageSize, padding: 'dense' }}
totalCount={jobRuns?.builds.length || 0}
page={page}
data={builds ?? []}
onPageChange={onChangePage}
onRowsPerPageChange={onChangePageSize}
title={
<Box>
<Box display="flex" alignItems="center">
<img src={JenkinsLogo} alt="Jenkins logo" height="50px" />
<Box mr={2} />
<Typography variant="h6">{`${jobRuns?.displayName} Runs`}</Typography>
</Box>
<Box display="flex" alignItems="center" mt={2}>
<Typography variant="h6">
Average Build Time For Last {successfulJobCount} Successful jobs :{' '}
{avgTime || 0}
</Typography>
</Box>
</Box>
}
columns={generatedColumns}
/>
);
};
export const JobRunsTable = () => {
const { jobFullName } = useRouteRefParams(jobRunsRouteRef);
const [tableProps, { setPage, setPageSize }] = useJobRuns(jobFullName);
return (
<JobRunsTableView
{...tableProps}
onChangePageSize={setPageSize}
onChangePage={setPage}
/>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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 { JobRunsTable } from './JobRunsTable';
+3 -1
View File
@@ -23,9 +23,10 @@ import { useEntity } from '@backstage/plugin-catalog-react';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants';
import { buildRouteRef } from '../plugin';
import { buildRouteRef, jobRunsRouteRef } from '../plugin';
import { CITable } from './BuildsPage/lib/CITable';
import { DetailedViewPage } from './BuildWithStepsPage/';
import { JobRunsTable } from './JobRunsTable';
import { Project } from '../api';
/** @public */
@@ -46,6 +47,7 @@ export const Router = (props: { columns?: TableColumn<Project>[] }) => {
<Routes>
<Route path="/" element={<CITable columns={columns} />} />
<Route path={`/${buildRouteRef.path}`} element={<DetailedViewPage />} />
<Route path={`/${jobRunsRouteRef.path}`} element={<JobRunsTable />} />
</Routes>
);
};
@@ -0,0 +1,70 @@
/*
* 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 { useState } from 'react';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import { jenkinsApiRef } from '../api';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { getCompoundEntityRef } from '@backstage/catalog-model';
export enum ErrorType {
CONNECTION_ERROR,
NOT_FOUND,
}
export function useJobRuns(jobFullName: string) {
const { entity } = useEntity();
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [error, setError] = useState<{
message: string;
errorType: ErrorType;
}>();
const { loading, value: jobRuns } = useAsyncRetry(async () => {
try {
const jobBuilds = await api.getJobBuilds({
entity: getCompoundEntityRef(entity),
jobFullName,
});
return jobBuilds;
} catch (e) {
const errorType = e.notFound
? ErrorType.NOT_FOUND
: ErrorType.CONNECTION_ERROR;
setError({ message: e.message, errorType });
throw e;
}
}, [api, errorApi, entity]);
return [
{
page,
pageSize,
loading,
jobRuns,
error,
},
{
setPage,
setPageSize,
},
] as const;
}
+1
View File
@@ -25,6 +25,7 @@ export {
jenkinsPlugin as plugin,
EntityJenkinsContent,
EntityLatestJenkinsRunCard,
EntityJobRunsTable,
} from './plugin';
export { LatestRunCard } from './components/Cards';
export {
+17
View File
@@ -38,6 +38,13 @@ export const buildRouteRef = createSubRouteRef({
parent: rootRouteRef,
});
/** @public */
export const jobRunsRouteRef = createSubRouteRef({
id: 'jenkins/job/runs',
path: '/builds/:jobFullName/runs',
parent: rootRouteRef,
});
/** @public */
export const jenkinsPlugin = createPlugin({
id: 'jenkins',
@@ -72,3 +79,13 @@ export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide(
},
}),
);
/** @public */
export const EntityJobRunsTable = jenkinsPlugin.provide(
createComponentExtension({
name: 'EntityJobRunsTable',
component: {
lazy: () => import('./components/JobRunsTable').then(m => m.JobRunsTable),
},
}),
);