Initial work on jenkins backend
Signed-off-by: Andrew Shirley <andrew.shirley@sainsburys.co.uk> Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -16,7 +16,11 @@ cd packages/app
|
||||
yarn add @backstage/plugin-jenkins
|
||||
```
|
||||
|
||||
2. Add the `EntityJenkinsContent` extension to the entity page in the app:
|
||||
2. Add and configure tha backend plugin according to it's instructions
|
||||
|
||||
3. Add the `EntityJenkinsContent` extension to the cicd page and `EntityLatestJenkinsRunCard` to the entity page in the app:
|
||||
|
||||
Note that if you configured a custom JenkinsInfoProvider in step 2, you may need a custom isJenkinsAvailable.
|
||||
|
||||
```tsx
|
||||
// In packages/app/src/components/catalog/EntityPage.tsx
|
||||
@@ -32,29 +36,11 @@ const serviceEntityPage = (
|
||||
</EntityLayout.Route>
|
||||
```
|
||||
|
||||
3. Add proxy configuration to `app-config.yaml`
|
||||
4. Run app with `yarn start`
|
||||
5. Add the Jenkins folder annotation to your `catalog-info.yaml`.
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/jenkins/api':
|
||||
target: 'http://localhost:8080' # your Jenkins URL
|
||||
changeOrigin: true
|
||||
headers:
|
||||
Authorization: Basic ${JENKINS_BASIC_AUTH_HEADER}
|
||||
```
|
||||
|
||||
4. Add an environment variable which contains the Jenkins credentials (NOTE:
|
||||
use an API token, not your password). Here `user` is the name of the user
|
||||
created in Jenkins.
|
||||
|
||||
```shell
|
||||
export JENKINS_BASIC_AUTH_HEADER=$(echo -n user:api-token | base64)
|
||||
```
|
||||
|
||||
5. Run the app with `yarn start`
|
||||
|
||||
6. Add the Jenkins folder annotation to your `catalog-info.yaml`, (NOTE:
|
||||
currently this plugin only supports folders and Git SCM)
|
||||
Currently, this plugin only supports folders and Git SCM.
|
||||
Note that if you configured a custom JenkinsInfoProvider in step 2, you may need to use a different annotation scheme here
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
@@ -75,21 +61,6 @@ spec:
|
||||
8. Click the component in the catalog. You should now see Jenkins builds, and a
|
||||
last build result for your master build.
|
||||
|
||||
Note: If you are not using environment variables, you can directly type the API
|
||||
token into `app-config.yaml`.
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/jenkins/api':
|
||||
target: 'http://localhost:8080' # your Jenkins URL
|
||||
changeOrigin: true
|
||||
headers:
|
||||
Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw==
|
||||
```
|
||||
|
||||
The string starting with `YWR...` is the base64 encoding of the user and their
|
||||
API token, e.g. `admin:11ec256e438501c3f5c76b751a7e47af83`.
|
||||
|
||||
## Features
|
||||
|
||||
- View all runs inside a folder
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"jenkins": "^0.28.0",
|
||||
"luxon": "^1.25.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -14,246 +14,160 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const jenkins = require('jenkins');
|
||||
import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { EntityName, EntityRef } from '@backstage/catalog-model';
|
||||
|
||||
export const jenkinsApiRef = createApiRef<JenkinsApi>({
|
||||
id: 'plugin.jenkins.service',
|
||||
id: 'plugin.jenkins.service2',
|
||||
description: 'Used by the Jenkins plugin to make requests',
|
||||
});
|
||||
|
||||
const DEFAULT_PROXY_PATH = '/jenkins/api';
|
||||
export interface Build {
|
||||
// standard Jenkins
|
||||
timestamp: number;
|
||||
building: boolean;
|
||||
duration: number;
|
||||
result?: string;
|
||||
fullDisplayName: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
number: number;
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
/**
|
||||
* Path to use for requests via the proxy, defaults to /jenkins/api
|
||||
*/
|
||||
proxyPath?: string;
|
||||
};
|
||||
|
||||
export class JenkinsApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly proxyPath: string;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH;
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
|
||||
return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true });
|
||||
}
|
||||
|
||||
async retry(buildName: string) {
|
||||
const client = await this.getClient();
|
||||
// looks like the current SDK only supports triggering a new build
|
||||
// can't see any support for replay (re-running the specific build with the same SCM info)
|
||||
return await client.job.build(buildName);
|
||||
}
|
||||
|
||||
async getLastBuild(jobName: string) {
|
||||
const client = await this.getClient();
|
||||
const job = await client.job.get(jobName);
|
||||
|
||||
const lastBuild = await client.build.get(jobName, job.lastBuild.number);
|
||||
return lastBuild;
|
||||
}
|
||||
|
||||
extractScmDetailsFromJob(jobDetails: any): any | undefined {
|
||||
const scmInfo = jobDetails.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return {
|
||||
url: action?.objectUrl,
|
||||
// https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html
|
||||
// branch name for regular builds, pull request title on pull requests
|
||||
displayName: action?.objectDisplayName,
|
||||
};
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (!scmInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const author = jobDetails.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class ===
|
||||
'jenkins.scm.api.metadata.ContributorMetadataAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return action.contributorDisplayName;
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (author) {
|
||||
scmInfo.author = author;
|
||||
}
|
||||
|
||||
return scmInfo;
|
||||
}
|
||||
|
||||
async getJob(jobName: string) {
|
||||
const client = await this.getClient();
|
||||
return client.job.get({
|
||||
name: jobName,
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async getFolder(folderName: string) {
|
||||
const client = await this.getClient();
|
||||
const folder = await client.job.get({
|
||||
name: folderName,
|
||||
// Filter only be the information we need, instead of loading all fields.
|
||||
// Limit to only show the latest build for each job and only load 50 jobs
|
||||
// at all.
|
||||
// Whitespaces are only included for readablity here and stripped out
|
||||
// before sending to Jenkins
|
||||
tree: `jobs[
|
||||
actions[*],
|
||||
builds[
|
||||
number,
|
||||
url,
|
||||
fullDisplayName,
|
||||
building,
|
||||
result,
|
||||
actions[
|
||||
*[
|
||||
*[
|
||||
*[
|
||||
*
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]{0,1},
|
||||
jobs{0,1},
|
||||
name
|
||||
]{0,50}
|
||||
`.replace(/\s/g, ''),
|
||||
});
|
||||
const results = [];
|
||||
|
||||
for (const jobDetails of folder.jobs) {
|
||||
const jobScmInfo = this.extractScmDetailsFromJob(jobDetails);
|
||||
if (jobDetails?.jobs) {
|
||||
// skipping folders inside folders for now
|
||||
} else {
|
||||
for (const buildDetails of jobDetails.builds) {
|
||||
const ciTable = this.mapJenkinsBuildToCITable(
|
||||
buildDetails,
|
||||
jobScmInfo,
|
||||
);
|
||||
results.push(ciTable);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private getTestReport(
|
||||
jenkinsResult: any,
|
||||
): {
|
||||
total: number;
|
||||
// added by us
|
||||
source?: {
|
||||
branchName: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
commit: {
|
||||
hash: string;
|
||||
};
|
||||
author: string;
|
||||
};
|
||||
tests: {
|
||||
passed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
testUrl: string;
|
||||
} {
|
||||
return jenkinsResult.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class === 'hudson.tasks.junit.TestResultAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return {
|
||||
total: action.totalCount,
|
||||
passed: action.totalCount - action.failCount - action.skipCount,
|
||||
skipped: action.skipCount,
|
||||
failed: action.failCount,
|
||||
testUrl: `${jenkinsResult.url}${action.urlName}/`,
|
||||
};
|
||||
})
|
||||
.pop();
|
||||
};
|
||||
status: string; // == building ? 'running' : result,
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
// standard Jenkins
|
||||
lastBuild: Build;
|
||||
displayName: string;
|
||||
fullDisplayName: string;
|
||||
fullName: string;
|
||||
inQueue: string;
|
||||
// added by us
|
||||
status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result,
|
||||
onRestartClick: () => void; // TODO handle.* ?
|
||||
}
|
||||
|
||||
export interface JenkinsApi {
|
||||
/**
|
||||
* Get the projects (jobs which have builds, not folders) including info about their lastBuild.
|
||||
*
|
||||
* Deciding what jobs are for an entity can be configured by the backstage _Integrator_ in the plugin-jenkins-backend setup
|
||||
* and by the _Software Engineer_ using annotations agreed with the _Integrator_.
|
||||
*
|
||||
* Typically, a folder job will be identified and the backend plugin will recursively look for projects (jobs with builds) within that folder.
|
||||
*
|
||||
* @param entity the entity whose jobs should be retrieved.
|
||||
* @param filter a filter on jobs. Currently this just takes a branch (and assumes certain structures in jenkins)
|
||||
*/
|
||||
getProjects(
|
||||
entity: EntityRef,
|
||||
filter: { branch?: string },
|
||||
): Promise<Project[]>;
|
||||
|
||||
/**
|
||||
* Get a single build.
|
||||
*
|
||||
* This takes an entity to support selecting between multiple jenkins instances.
|
||||
*
|
||||
* TODO: abstract jobName (so we could support differentiating between the same named job on multiple instances).
|
||||
* @param entity
|
||||
* @param jobName
|
||||
* @param buildNumber
|
||||
*/
|
||||
getBuild(
|
||||
entity: EntityName,
|
||||
jobName: string,
|
||||
buildNumber: string,
|
||||
): Promise<Build>;
|
||||
|
||||
retry(entity: EntityName, jobName: string, buildNumber: string): Promise<any>;
|
||||
}
|
||||
|
||||
export class JenkinsApiImpl implements JenkinsApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly identityApi: IdentityApi;
|
||||
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
}) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.identityApi = options.identityApi;
|
||||
}
|
||||
|
||||
mapJenkinsBuildToCITable(
|
||||
jenkinsResult: any,
|
||||
jobScmInfo?: any,
|
||||
): CITableBuildInfo {
|
||||
const source =
|
||||
jenkinsResult.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class === 'hudson.plugins.git.util.BuildData',
|
||||
)
|
||||
.map((action: any) => {
|
||||
const [first]: any = Object.values(action.buildsByBranchName);
|
||||
const branch = first.revision.branch[0];
|
||||
return {
|
||||
branchName: branch.name,
|
||||
commit: {
|
||||
hash: branch.SHA1.substring(0, 8),
|
||||
},
|
||||
};
|
||||
})
|
||||
.pop() || {};
|
||||
async getProjects(
|
||||
entity: EntityName,
|
||||
filter: { branch?: string },
|
||||
): Promise<any> {
|
||||
const url = new URL(
|
||||
`${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${
|
||||
entity.namespace
|
||||
}/${entity.kind}/${entity.name}/projects`,
|
||||
);
|
||||
|
||||
if (jobScmInfo) {
|
||||
source.url = jobScmInfo?.url;
|
||||
source.displayName = jobScmInfo?.displayName;
|
||||
source.author = jobScmInfo?.author;
|
||||
if (filter.branch) {
|
||||
url.searchParams.append('branch', filter.branch);
|
||||
}
|
||||
|
||||
const path = new URL(jenkinsResult.url).pathname;
|
||||
|
||||
return {
|
||||
id: path,
|
||||
buildNumber: jenkinsResult.number,
|
||||
buildUrl: jenkinsResult.url,
|
||||
buildName: jenkinsResult.fullDisplayName,
|
||||
status: jenkinsResult.building ? 'running' : jenkinsResult.result,
|
||||
onRestartClick: () => {
|
||||
// TODO: this won't handle non root context path, need a better way to get the job name
|
||||
const { jobName } = this.extractJobDetailsFromBuildName(path);
|
||||
return this.retry(jobName);
|
||||
const idToken = await this.identityApi.getIdToken();
|
||||
const response = await fetch(url.href, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(idToken && { Authorization: `Bearer ${idToken}` }),
|
||||
},
|
||||
source: source,
|
||||
tests: this.getTestReport(jenkinsResult),
|
||||
};
|
||||
});
|
||||
|
||||
return (await response.json()).projects;
|
||||
}
|
||||
|
||||
async getBuild(buildName: string) {
|
||||
const client = await this.getClient();
|
||||
const { jobName, buildNumber } = this.extractJobDetailsFromBuildName(
|
||||
buildName,
|
||||
);
|
||||
const buildResult = await client.build.get(jobName, buildNumber);
|
||||
return buildResult;
|
||||
}
|
||||
|
||||
extractJobDetailsFromBuildName(buildName: string) {
|
||||
const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, '');
|
||||
|
||||
const split = trimmedBuild.split('/');
|
||||
const buildNumber = parseInt(split[split.length - 1], 10);
|
||||
const jobName = trimmedBuild.slice(
|
||||
0,
|
||||
trimmedBuild.length - buildNumber.toString(10).length - 1,
|
||||
);
|
||||
|
||||
return {
|
||||
async getBuild(
|
||||
entity: EntityName,
|
||||
jobName: string,
|
||||
buildNumber: string,
|
||||
): Promise<any> {
|
||||
const url = `${await this.discoveryApi.getBaseUrl('jenkins')}/v1/entity/${
|
||||
entity.namespace
|
||||
}/${entity.kind}/${entity.name}/job/${encodeURIComponent(
|
||||
jobName,
|
||||
buildNumber,
|
||||
};
|
||||
)}/${buildNumber}`;
|
||||
|
||||
const idToken = await this.identityApi.getIdToken();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(idToken && { Authorization: `Bearer ${idToken}` }),
|
||||
},
|
||||
});
|
||||
|
||||
return (await response.json()).build;
|
||||
}
|
||||
|
||||
retry(
|
||||
// @ts-ignore unused because unimplemented
|
||||
entity: EntityName,
|
||||
// @ts-ignore unused because unimplemented
|
||||
jobName: string,
|
||||
// @ts-ignore unused because unimplemented
|
||||
buildNumber: string,
|
||||
): Promise<any> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { JenkinsApi, jenkinsApiRef } from './JenkinsApi';
|
||||
export { JenkinsApiImpl, jenkinsApiRef } from './JenkinsApi';
|
||||
|
||||
export type { JenkinsApi } from './JenkinsApi';
|
||||
|
||||
@@ -30,7 +30,6 @@ import React from 'react';
|
||||
import { buildRouteRef } from '../../plugin';
|
||||
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
|
||||
import { useBuildWithSteps } from '../useBuildWithSteps';
|
||||
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
|
||||
|
||||
import { Breadcrumbs, Content, Link } from '@backstage/core-components';
|
||||
import { useRouteRefParams } from '@backstage/core-plugin-api';
|
||||
@@ -49,17 +48,15 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
const BuildWithStepsView = () => {
|
||||
const projectName = useProjectSlugFromEntity();
|
||||
const { branch, buildNumber } = useRouteRefParams(buildRouteRef);
|
||||
const { jobName, buildNumber } = useRouteRefParams(buildRouteRef);
|
||||
const classes = useStyles();
|
||||
const buildPath = `${projectName}/${encodeURIComponent(
|
||||
branch,
|
||||
)}/${buildNumber}`;
|
||||
const [{ value }] = useBuildWithSteps(buildPath);
|
||||
|
||||
const [{ value }] = useBuildWithSteps(jobName, buildNumber);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
{/* TODO: don't hardcode this link */}
|
||||
<Link to="../../..">Projects</Link>
|
||||
<Typography>Run</Typography>
|
||||
</Breadcrumbs>
|
||||
@@ -104,7 +101,7 @@ const BuildWithStepsView = () => {
|
||||
<Typography noWrap>Jenkins</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaterialLink target="_blank" href={value?.buildUrl}>
|
||||
<MaterialLink target="_blank" href={value?.url}>
|
||||
View on Jenkins{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</MaterialLink>
|
||||
@@ -112,10 +109,11 @@ const BuildWithStepsView = () => {
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
{/* TODO: be SCM agnostic */}
|
||||
<Typography noWrap>GitHub</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaterialLink target="_blank" href={value?.source.url}>
|
||||
<MaterialLink target="_blank" href={value?.source?.url}>
|
||||
View on GitHub{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</MaterialLink>
|
||||
|
||||
@@ -20,34 +20,9 @@ import JenkinsLogo from '../../../../assets/JenkinsLogo.svg';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { JenkinsRunStatus } from '../Status';
|
||||
import { useBuilds } from '../../../useBuilds';
|
||||
import { useProjectSlugFromEntity } from '../../../useProjectSlugFromEntity';
|
||||
import { buildRouteRef } from '../../../../plugin';
|
||||
import { Table, TableColumn } from '@backstage/core-components';
|
||||
|
||||
export type CITableBuildInfo = {
|
||||
id: string;
|
||||
buildName: string;
|
||||
buildNumber: number;
|
||||
buildUrl: string;
|
||||
source: {
|
||||
branchName: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
author?: string;
|
||||
commit: {
|
||||
hash: string;
|
||||
};
|
||||
};
|
||||
status: string;
|
||||
tests?: {
|
||||
total: number;
|
||||
passed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
testUrl: string;
|
||||
};
|
||||
onRestartClick: () => void;
|
||||
};
|
||||
import { Project } from '../../../../api/JenkinsApi';
|
||||
|
||||
const FailCount = ({ count }: { count: number }): JSX.Element | null => {
|
||||
if (count !== 0) {
|
||||
@@ -107,44 +82,51 @@ const FailSkippedWidget = ({
|
||||
const generatedColumns: TableColumn[] = [
|
||||
{
|
||||
title: 'Build',
|
||||
field: 'buildName',
|
||||
field: 'fullName',
|
||||
highlight: true,
|
||||
render: (row: Partial<CITableBuildInfo>) => {
|
||||
if (!row.source?.branchName || !row.buildNumber) {
|
||||
return <>{row.buildName}</>;
|
||||
render: (row: Partial<Project>) => {
|
||||
if (!row.fullName || !row.lastBuild?.number) {
|
||||
return (
|
||||
<>
|
||||
{row.fullName ||
|
||||
row.fullDisplayName ||
|
||||
row.displayName ||
|
||||
'Unknown'}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(buildRouteRef.path, {
|
||||
branch: encodeURIComponent(row.source.branchName),
|
||||
buildNumber: row.buildNumber.toString(),
|
||||
jobName: row.fullName,
|
||||
buildNumber: row.lastBuild?.number.toString(),
|
||||
})}
|
||||
>
|
||||
{row.buildName}
|
||||
{row.fullName}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
field: 'source.branchName',
|
||||
render: (row: Partial<CITableBuildInfo>) => (
|
||||
field: 'lastBuild.source.branchName',
|
||||
render: (row: Partial<Project>) => (
|
||||
<>
|
||||
<p>
|
||||
<Link href={row.source?.url || ''} target="_blank">
|
||||
{row.source?.branchName}
|
||||
<Link href={row.lastBuild?.source?.url || ''} target="_blank">
|
||||
{row.lastBuild?.source?.branchName}
|
||||
</Link>
|
||||
</p>
|
||||
<p>{row.source?.commit?.hash}</p>
|
||||
<p>{row.lastBuild?.source?.commit?.hash}</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
field: 'status',
|
||||
render: (row: Partial<CITableBuildInfo>) => {
|
||||
render: (row: Partial<Project>) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="center">
|
||||
<JenkinsRunStatus status={row.status} />
|
||||
@@ -155,21 +137,22 @@ const generatedColumns: TableColumn[] = [
|
||||
{
|
||||
title: 'Tests',
|
||||
sorting: false,
|
||||
render: (row: Partial<CITableBuildInfo>) => {
|
||||
render: (row: Partial<Project>) => {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{row.tests && (
|
||||
<Link href={row.tests.testUrl || ''} target="_blank">
|
||||
{row.tests.passed} / {row.tests.total} passed
|
||||
{row.lastBuild?.tests && (
|
||||
<Link href={row.lastBuild?.tests.testUrl || ''} target="_blank">
|
||||
{row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '}
|
||||
passed
|
||||
<FailSkippedWidget
|
||||
skipped={row.tests.skipped}
|
||||
failed={row.tests.failed}
|
||||
skipped={row.lastBuild?.tests.skipped}
|
||||
failed={row.lastBuild?.tests.failed}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!row.tests && 'n/a'}
|
||||
{!row.lastBuild?.tests && 'n/a'}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
@@ -178,7 +161,7 @@ const generatedColumns: TableColumn[] = [
|
||||
{
|
||||
title: 'Actions',
|
||||
sorting: false,
|
||||
render: (row: Partial<CITableBuildInfo>) => (
|
||||
render: (row: Partial<Project>) => (
|
||||
<Tooltip title="Rerun build">
|
||||
<IconButton onClick={row.onRestartClick}>
|
||||
<RetryIcon />
|
||||
@@ -192,8 +175,7 @@ const generatedColumns: TableColumn[] = [
|
||||
type Props = {
|
||||
loading: boolean;
|
||||
retry: () => void;
|
||||
builds: CITableBuildInfo[];
|
||||
projectName: string;
|
||||
projects?: Project[];
|
||||
page: number;
|
||||
onChangePage: (page: number) => void;
|
||||
total: number;
|
||||
@@ -202,12 +184,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export const CITableView = ({
|
||||
projectName,
|
||||
loading,
|
||||
pageSize,
|
||||
page,
|
||||
retry,
|
||||
builds,
|
||||
projects,
|
||||
onChangePage,
|
||||
onChangePageSize,
|
||||
total,
|
||||
@@ -226,14 +207,14 @@ export const CITableView = ({
|
||||
onClick: () => retry(),
|
||||
},
|
||||
]}
|
||||
data={builds ?? []}
|
||||
data={projects ?? []}
|
||||
onChangePage={onChangePage}
|
||||
onChangeRowsPerPage={onChangePageSize}
|
||||
title={
|
||||
<Box display="flex" alignItems="center">
|
||||
<img src={JenkinsLogo} alt="Jenkins logo" height="50px" />
|
||||
<Box mr={2} />
|
||||
<Typography variant="h6">Project: {projectName}</Typography>
|
||||
<Typography variant="h6">Projects</Typography>
|
||||
</Box>
|
||||
}
|
||||
columns={generatedColumns}
|
||||
@@ -242,9 +223,7 @@ export const CITableView = ({
|
||||
};
|
||||
|
||||
export const CITable = () => {
|
||||
const projectName = useProjectSlugFromEntity();
|
||||
|
||||
const [tableProps, { setPage, retry, setPageSize }] = useBuilds(projectName);
|
||||
const [tableProps, { setPage, retry, setPageSize }] = useBuilds();
|
||||
|
||||
return (
|
||||
<CITableView
|
||||
|
||||
@@ -14,4 +14,3 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { CITable } from './CITable';
|
||||
export type { CITableBuildInfo } from './CITable';
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LatestRunCard } from './Cards';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { JenkinsApi, jenkinsApiRef } from '../../api';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { Project } from '../../api/JenkinsApi';
|
||||
|
||||
describe('<LatestRunCard />', () => {
|
||||
const entity = {
|
||||
@@ -33,7 +34,10 @@ describe('<LatestRunCard />', () => {
|
||||
};
|
||||
|
||||
const jenkinsApi: Partial<JenkinsApi> = {
|
||||
getLastBuild: () => Promise.resolve({ timestamp: 0, result: 'success' }),
|
||||
getProjects: () =>
|
||||
Promise.resolve([
|
||||
{ lastBuild: { timestamp: 0, status: 'success' } },
|
||||
] as Project[]),
|
||||
};
|
||||
|
||||
it('should show success status of latest build', async () => {
|
||||
@@ -52,7 +56,7 @@ describe('<LatestRunCard />', () => {
|
||||
|
||||
it('should show the appropriate error in case of a connection error', async () => {
|
||||
const jenkinsApiWithError: Partial<JenkinsApi> = {
|
||||
getLastBuild: () => Promise.reject(new Error('Unauthorized')),
|
||||
getProjects: () => Promise.reject(new Error('Unauthorized')),
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([[jenkinsApiRef, jenkinsApiWithError]]);
|
||||
@@ -71,7 +75,7 @@ describe('<LatestRunCard />', () => {
|
||||
|
||||
it('should show the appropriate error in case Jenkins project is not found', async () => {
|
||||
const jenkinsApiWithError: Partial<JenkinsApi> = {
|
||||
getLastBuild: () =>
|
||||
getProjects: () =>
|
||||
Promise.reject({
|
||||
notFound: true,
|
||||
message: 'jenkins-project not found',
|
||||
|
||||
@@ -19,13 +19,13 @@ import { DateTime, Duration } from 'luxon';
|
||||
import React from 'react';
|
||||
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
|
||||
import { ErrorType, useBuilds } from '../useBuilds';
|
||||
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
|
||||
import {
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
StructuredMetadataTable,
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { Project } from '../../api/JenkinsApi';
|
||||
|
||||
const useStyles = makeStyles<Theme>({
|
||||
externalLinkIcon: {
|
||||
@@ -39,16 +39,18 @@ const WidgetContent = ({
|
||||
latestRun,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
latestRun: any;
|
||||
latestRun?: Project;
|
||||
branch: string;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
if (loading || !latestRun) return <LinearProgress />;
|
||||
const displayDate = DateTime.fromMillis(latestRun.timestamp).toRelative();
|
||||
const displayDate = DateTime.fromMillis(
|
||||
latestRun.lastBuild.timestamp,
|
||||
).toRelative();
|
||||
const displayDuration =
|
||||
(latestRun.building ? 'Running for ' : '') +
|
||||
(latestRun.lastBuild.building ? 'Running for ' : '') +
|
||||
DateTime.local()
|
||||
.minus(Duration.fromMillis(latestRun.duration))
|
||||
.minus(Duration.fromMillis(latestRun.lastBuild.duration))
|
||||
.toRelative({ locale: 'en' })
|
||||
?.replace(' ago', '');
|
||||
|
||||
@@ -57,16 +59,14 @@ const WidgetContent = ({
|
||||
metadata={{
|
||||
status: (
|
||||
<>
|
||||
<JenkinsRunStatus
|
||||
status={latestRun.building ? 'running' : latestRun.result}
|
||||
/>
|
||||
<JenkinsRunStatus status={latestRun.lastBuild.status} />
|
||||
</>
|
||||
),
|
||||
build: latestRun.fullDisplayName,
|
||||
'latest run': displayDate,
|
||||
duration: displayDuration,
|
||||
link: (
|
||||
<Link href={latestRun.url} target="_blank">
|
||||
<Link href={latestRun.lastBuild.url} target="_blank">
|
||||
See more on Jenkins{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</Link>
|
||||
@@ -100,9 +100,8 @@ export const LatestRunCard = ({
|
||||
branch: string;
|
||||
variant?: InfoCardVariants;
|
||||
}) => {
|
||||
const projectName = useProjectSlugFromEntity();
|
||||
const [{ builds, loading, error }] = useBuilds(projectName, branch);
|
||||
const latestRun = builds ?? {};
|
||||
const [{ projects, loading, error }] = useBuilds(branch);
|
||||
const latestRun = projects?.[0];
|
||||
return (
|
||||
<InfoCard title={`Latest ${branch} build`} variant={variant}>
|
||||
{!error ? (
|
||||
|
||||
@@ -18,30 +18,34 @@ import { useAsyncRetry } from 'react-use';
|
||||
import { jenkinsApiRef } from '../api';
|
||||
import { useAsyncPolling } from './useAsyncPolling';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { getEntityName } from '@backstage/catalog-model';
|
||||
|
||||
const INTERVAL_AMOUNT = 1500;
|
||||
export function useBuildWithSteps(buildName: string) {
|
||||
|
||||
/**
|
||||
* Hook to expose a specific build.
|
||||
* @param jobName the full name of the project (job with builds, not a folder). e.g. "department-A/team-1/project-foo/master"
|
||||
* @param buildNumber the number of the build. e.g. "13"
|
||||
*/
|
||||
export function useBuildWithSteps(jobName: string, buildNumber: string) {
|
||||
const api = useApi(jenkinsApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const { entity } = useEntity();
|
||||
const entityName = getEntityName(entity);
|
||||
|
||||
const getBuildWithSteps = useCallback(async () => {
|
||||
try {
|
||||
const build = await api.getBuild(buildName);
|
||||
|
||||
const { jobName } = api.extractJobDetailsFromBuildName(buildName);
|
||||
const job = await api.getJob(jobName);
|
||||
const jobInfo = api.extractScmDetailsFromJob(job);
|
||||
|
||||
return Promise.resolve(api.mapJenkinsBuildToCITable(build, jobInfo));
|
||||
return api.getBuild(entityName, jobName, buildNumber);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}, [buildName, api, errorApi]);
|
||||
}, [buildNumber, jobName, entityName, api, errorApi]);
|
||||
|
||||
const restartBuild = async () => {
|
||||
try {
|
||||
await api.retry(buildName);
|
||||
await api.retry(entityName, jobName, buildNumber);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,23 @@ import { useState } from 'react';
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { jenkinsApiRef } from '../api';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { getEntityName } from '@backstage/catalog-model';
|
||||
|
||||
export enum ErrorType {
|
||||
CONNECTION_ERROR,
|
||||
NOT_FOUND,
|
||||
}
|
||||
|
||||
export function useBuilds(projectName: string, branch?: string) {
|
||||
/**
|
||||
* Hook to expose the latest build for all the pipelines/projects for an entity.
|
||||
* If `branch` is provided, the latest build for only that branch will be given (but still as a list)
|
||||
*
|
||||
* TODO: deprecate branch and add a generic filter concept.
|
||||
*/
|
||||
export function useBuilds(branch?: string) {
|
||||
const { entity } = useEntity();
|
||||
const entityName = getEntityName(entity);
|
||||
const api = useApi(jenkinsApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
@@ -35,27 +45,21 @@ export function useBuilds(projectName: string, branch?: string) {
|
||||
errorType: ErrorType;
|
||||
}>();
|
||||
|
||||
const restartBuild = async (buildName: string) => {
|
||||
const restartBuild = async (jobName: string, buildNumber: string) => {
|
||||
try {
|
||||
await api.retry(buildName);
|
||||
await api.retry(entityName, jobName, buildNumber);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
};
|
||||
|
||||
const { loading, value: builds, retry } = useAsyncRetry(async () => {
|
||||
const { loading, value: projects, retry } = useAsyncRetry(async () => {
|
||||
try {
|
||||
let build;
|
||||
if (branch) {
|
||||
build = await api.getLastBuild(`${projectName}/${branch}`);
|
||||
} else {
|
||||
build = await api.getFolder(`${projectName}`);
|
||||
}
|
||||
const build = await api.getProjects(getEntityName(entity), { branch });
|
||||
|
||||
const size = Array.isArray(build) ? build?.[0].build_num! : 1;
|
||||
setTotal(size);
|
||||
setTotal(build.length);
|
||||
|
||||
return build || [];
|
||||
return build;
|
||||
} catch (e) {
|
||||
const errorType = e.notFound
|
||||
? ErrorType.NOT_FOUND
|
||||
@@ -63,24 +67,22 @@ export function useBuilds(projectName: string, branch?: string) {
|
||||
setError({ message: e.message, errorType });
|
||||
throw e;
|
||||
}
|
||||
}, [api, errorApi, projectName, branch]);
|
||||
}, [api, errorApi, entity, branch]);
|
||||
|
||||
return [
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
loading,
|
||||
builds,
|
||||
projectName,
|
||||
projects,
|
||||
total,
|
||||
error,
|
||||
},
|
||||
{
|
||||
builds,
|
||||
setPage,
|
||||
setPageSize,
|
||||
restartBuild,
|
||||
retry,
|
||||
retry, // fetch data again
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* 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 { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { JENKINS_ANNOTATION } from '../constants';
|
||||
|
||||
export const useProjectSlugFromEntity = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
return entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? '';
|
||||
};
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
createRoutableExtension,
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { JenkinsApiImpl, jenkinsApiRef } from './api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '',
|
||||
@@ -30,9 +32,9 @@ export const rootRouteRef = createRouteRef({
|
||||
});
|
||||
|
||||
export const buildRouteRef = createRouteRef({
|
||||
path: 'run/:branch/:buildNumber',
|
||||
params: ['branch', 'buildNumber'],
|
||||
title: 'Jenkins run',
|
||||
path: 'build/:jobName/:buildNumber',
|
||||
params: ['jobName', 'buildNumber'],
|
||||
title: 'Jenkins build',
|
||||
});
|
||||
|
||||
export const jenkinsPlugin = createPlugin({
|
||||
@@ -40,8 +42,9 @@ export const jenkinsPlugin = createPlugin({
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: jenkinsApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef },
|
||||
factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }),
|
||||
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
|
||||
factory: ({ discoveryApi, identityApi }) =>
|
||||
new JenkinsApiImpl({ discoveryApi, identityApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
|
||||
Reference in New Issue
Block a user