diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 25630253d4..6036f89310 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -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 = ( +
+
+
diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 85271462eb..4072eb6ca2 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -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; + } } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index eea067fab6..88777ff098 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -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) => { diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 04bd313227..4f083fdb9d 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -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; // TODO rename to handle.* ? also, should this be on lastBuild? + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; } 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 { + 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; + } } diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx new file mode 100644 index 0000000000..3bf36c2823 --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -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) => { + return ( + + + {row.number} + + + ); + }, + }, + { + title: 'Timestamp', + field: 'timestamp', + render: (row: Partial) => { + return ( + + + {row?.timestamp ? new Date(row?.timestamp).toLocaleString() : ' '} + + + ); + }, + }, + { + title: 'Result', + field: 'result', + render: (row: Partial) => { + return ( + + {row.inProgress ? ( + In Progress + ) : ( + + )} + + ); + }, + }, + { + title: 'Duration', + field: 'duration', + render: (row: Partial) => { + return ( + + + {row?.duration + ? (row.duration / 1000).toFixed(1).toString().concat(' s') + : ''} + + + ); + }, + }, + + { + title: 'Actions', + render: (row: Partial) => { + const ActionWrapper = () => { + return ( +
+ {row?.url && ( + + + + + + )} +
+ ); + }; + return ; + }, + 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 ( + + + Jenkins logo + + Job Runs + + + + Average Build Time For Last {successfullJobCount} Successfull jobs + : {avgTime || 0} + + + + } + columns={generatedColumns} + /> + ); +}; + +export const JobRunsTable = () => { + const [tableProps, { setPage, setPageSize }] = useJobRuns(); + + return ( + + ); +}; diff --git a/plugins/jenkins/src/components/JobRunsTable/index.ts b/plugins/jenkins/src/components/JobRunsTable/index.ts new file mode 100644 index 0000000000..b74beeb315 --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/index.ts @@ -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'; diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts new file mode 100644 index 0000000000..0724fd7495 --- /dev/null +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -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; +} diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 7562026bdc..7063485511 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -23,6 +23,7 @@ export { jenkinsPlugin, jenkinsPlugin as plugin, + EntityJobRunsTable, EntityJenkinsContent, EntityLatestJenkinsRunCard, } from './plugin'; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 7dbb6bfde1..6e0bd77f91 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -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), + }, + }), +);