jenkins JobRunTable added
Signed-off-by: Abhay-soni-developer <abhaysoni.developer@gmail.com>
This commit is contained in:
@@ -100,6 +100,7 @@ import {
|
||||
EntityJenkinsContent,
|
||||
EntityLatestJenkinsRunCard,
|
||||
isJenkinsAvailable,
|
||||
EntityJobRunsTable,
|
||||
} from '@backstage/plugin-jenkins';
|
||||
import { EntityKafkaContent } from '@backstage/plugin-kafka';
|
||||
import { EntityKubernetesContent } from '@backstage/plugin-kubernetes';
|
||||
@@ -245,6 +246,9 @@ export const cicdContent = (
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={isJenkinsAvailable}>
|
||||
<EntityJenkinsContent />
|
||||
<hr />
|
||||
<hr />
|
||||
<EntityJobRunsTable />
|
||||
</EntitySwitch.Case>
|
||||
|
||||
<EntitySwitch.Case if={isBuildkiteAvailable}>
|
||||
|
||||
@@ -64,6 +64,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 +339,15 @@ export class JenkinsApiImpl {
|
||||
const jobs = jobFullName.split('/');
|
||||
return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`;
|
||||
}
|
||||
|
||||
async getJobBuilds(jenkinsInfo: JenkinsInfo) {
|
||||
const client = await JenkinsApiImpl.getClient(jenkinsInfo);
|
||||
|
||||
const jobBuilds = await client.job.get({
|
||||
name: jenkinsInfo.jobFullName,
|
||||
tree: JenkinsApiImpl.jobBuildsTreeSpec.replace(/\s/g, ''),
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
response.json({
|
||||
build: build,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber',
|
||||
async (request, response) => {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
// standard Jenkins
|
||||
@@ -67,6 +91,10 @@ export interface Project {
|
||||
// added by us
|
||||
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
|
||||
onRestartClick: () => Promise<void>; // TODO rename to handle.* ? also, should this be on lastBuild?
|
||||
getJobBuilds(options: {
|
||||
entity: CompoundEntityRef;
|
||||
jobFullName: string;
|
||||
}): Promise<Job>;
|
||||
}
|
||||
|
||||
export interface JenkinsApi {
|
||||
@@ -212,4 +240,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
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">
|
||||
<IconButton href={row.url} target="_blank">
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <ActionWrapper />;
|
||||
},
|
||||
width: '10%',
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
loading: boolean;
|
||||
jobRuns?: Job;
|
||||
page: number;
|
||||
onChangePage: (page: number) => void;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onChangePageSize: (pageSize: number) => void;
|
||||
};
|
||||
|
||||
export const JobRunsTableView = ({
|
||||
loading,
|
||||
pageSize,
|
||||
page,
|
||||
jobRuns,
|
||||
onChangePage,
|
||||
onChangePageSize,
|
||||
total,
|
||||
}: Props) => {
|
||||
const builds = jobRuns?.builds.slice(
|
||||
page * pageSize,
|
||||
page * pageSize + pageSize,
|
||||
);
|
||||
let sumOfAllSuccessfullJobDuration = 0;
|
||||
|
||||
const successfullJobCount =
|
||||
builds?.reduce((count, build) => {
|
||||
if (!build.inProgress && build.result === 'SUCCESS') {
|
||||
sumOfAllSuccessfullJobDuration += build.duration;
|
||||
return count + 1;
|
||||
}
|
||||
return count;
|
||||
}, 0) || 0;
|
||||
|
||||
let avgTime;
|
||||
|
||||
if (successfullJobCount > 0) {
|
||||
avgTime = (sumOfAllSuccessfullJobDuration / successfullJobCount / 1000)
|
||||
.toFixed(1)
|
||||
.toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
isLoading={loading}
|
||||
options={{ paging: true, pageSize, padding: 'dense' }}
|
||||
totalCount={total}
|
||||
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">Job Runs</Typography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" mt={2}>
|
||||
<Typography variant="h6">
|
||||
Average Build Time For Last {successfullJobCount} Successfull jobs
|
||||
: {avgTime || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
columns={generatedColumns}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const JobRunsTable = () => {
|
||||
const [tableProps, { setPage, setPageSize }] = useJobRuns();
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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';
|
||||
import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants';
|
||||
|
||||
export enum ErrorType {
|
||||
CONNECTION_ERROR,
|
||||
NOT_FOUND,
|
||||
}
|
||||
|
||||
export function useJobRuns() {
|
||||
const { entity } = useEntity();
|
||||
const api = useApi(jenkinsApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
|
||||
const [error, setError] = useState<{
|
||||
message: string;
|
||||
errorType: ErrorType;
|
||||
}>();
|
||||
|
||||
const jobFullName =
|
||||
entity.metadata.annotations?.[JENKINS_ANNOTATION] ||
|
||||
entity.metadata.annotations?.[LEGACY_JENKINS_ANNOTATION] ||
|
||||
'';
|
||||
|
||||
const { loading, value: jobRuns } = useAsyncRetry(async () => {
|
||||
try {
|
||||
const jobBuilds = await api.getJobBuilds({
|
||||
entity: getCompoundEntityRef(entity),
|
||||
jobFullName,
|
||||
});
|
||||
|
||||
setTotal(jobBuilds.builds.length);
|
||||
|
||||
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,
|
||||
total,
|
||||
error,
|
||||
},
|
||||
{
|
||||
setPage,
|
||||
setPageSize,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
export {
|
||||
jenkinsPlugin,
|
||||
jenkinsPlugin as plugin,
|
||||
EntityJobRunsTable,
|
||||
EntityJenkinsContent,
|
||||
EntityLatestJenkinsRunCard,
|
||||
} from './plugin';
|
||||
|
||||
@@ -72,3 +72,13 @@ export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/** @public */
|
||||
export const EntityJobRunsTable = jenkinsPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityLatestJenkinsRunCard',
|
||||
component: {
|
||||
lazy: () => import('./components/JobRunsTable').then(m => m.JobRunsTable),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user