diff --git a/.changeset/perfect-cobras-bake.md b/.changeset/perfect-cobras-bake.md new file mode 100644 index 0000000000..bf66c12e83 --- /dev/null +++ b/.changeset/perfect-cobras-bake.md @@ -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. diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 85271462eb..2cf432343f 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -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; + } } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index eea067fab6..06a04d8266 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, jobFullName); + + response.json({ + build: build, + }); + }, + ); + router.post( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index d21fee2b8a..b62eaab514 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -5,7 +5,9 @@ Website: [https://jenkins.io/](https://jenkins.io/) Last master build Folder results Build details +Job builds records 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. diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index 2274c11e13..c1b8ad2b81 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -22,6 +22,9 @@ export const EntityJenkinsContent: (props: { columns?: TableColumn[] | 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; + // 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; getProjects(options: { entity: CompoundEntityRef; filter: { @@ -82,6 +92,11 @@ export class JenkinsClient implements JenkinsApi { buildNumber: string; }): Promise; // (undocumented) + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; + // (undocumented) getProjects(options: { entity: CompoundEntityRef; filter: { diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 83b38e63f0..1f6e7ec294 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[]; +} /** @public */ export interface Project { @@ -99,6 +123,11 @@ export interface JenkinsApi { buildNumber: string; }): Promise; + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; + 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 { + 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/assets/jobrun-table.png b/plugins/jenkins/src/assets/jobrun-table.png new file mode 100644 index 0000000000..9fff841c3b Binary files /dev/null and b/plugins/jenkins/src/assets/jobrun-table.png differ diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx index 733a60c1b2..f18d3e5b21 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx @@ -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 { + return { + title: 'Last Run Duration', + align: 'left', + render: (row: Partial) => ( + <> + + {row?.lastBuild?.duration + ? (row?.lastBuild?.duration / 1000) + .toFixed(1) + .toString() + .concat(' s') + : ''}{' '} + + + ), + }; + }, + createActionsColumn(): TableColumn { 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 ( -
+
{row.lastBuild?.url && ( @@ -240,6 +261,17 @@ export const columnFactories = Object.freeze({ )} + + + + + + +
); }; diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts b/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts index 7c0dda9970..828e035c8e 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts @@ -24,5 +24,6 @@ export const defaultCITableColumns: TableColumn[] = [ columnFactories.createBuildColumn(), columnFactories.createTestColumn(), columnFactories.createStatusColumn(), + columnFactories.createLastRunDuration(), columnFactories.createActionsColumn(), ]; diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx new file mode 100644 index 0000000000..78b66b98eb --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -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) => { + 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; + 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 ( + + + Jenkins logo + + {`${jobRuns?.displayName} Runs`} + + + + Average Build Time For Last {successfulJobCount} Successful jobs :{' '} + {avgTime || 0} + + + + } + columns={generatedColumns} + /> + ); +}; + +export const JobRunsTable = () => { + const { jobFullName } = useRouteRefParams(jobRunsRouteRef); + const [tableProps, { setPage, setPageSize }] = useJobRuns(jobFullName); + + 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/Router.tsx b/plugins/jenkins/src/components/Router.tsx index 364b5fde0b..30ce999f31 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -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[] }) => { } /> } /> + } /> ); }; diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts new file mode 100644 index 0000000000..d80e09b18b --- /dev/null +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -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; +} diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 7562026bdc..9d092014a0 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -25,6 +25,7 @@ export { jenkinsPlugin as plugin, EntityJenkinsContent, EntityLatestJenkinsRunCard, + EntityJobRunsTable, } from './plugin'; export { LatestRunCard } from './components/Cards'; export { diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 7dbb6bfde1..1b666da9a9 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -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), + }, + }), +);