Add Jenkins widgets

This commit is contained in:
Tim Jacomb
2020-07-28 15:38:08 +01:00
committed by Fredrik Adelöw
parent f080c3256f
commit 005df1df2b
30 changed files with 1086 additions and 111 deletions
+9
View File
@@ -16,6 +16,15 @@ proxy:
changeOrigin: true
pathRewrite:
'^/proxy/circleci/api/': '/'
'/jenkins/api':
target: 'http://localhost:8080'
changeOrigin: true
headers:
Authorization:
$secret:
env: JENKINS_BASIC_AUTH_HEADER
pathRewrite:
'^/proxy/jenkins/api/': '/'
organization:
name: Spotify
+7
View File
@@ -62,6 +62,8 @@ import {
GithubActionsClient,
githubActionsApiRef,
} from '@backstage/plugin-github-actions';
import {jenkinsApiRef, JenkinsApi} from "@backstage/plugin-jenkins";
import { TravisCIApi, travisCIApiRef } from '@roadiehq/backstage-plugin-travis-ci';
@@ -85,6 +87,11 @@ export const apis = (config: ConfigApi) => {
new CircleCIApi(`${backendUrl}/proxy/circleci/api`),
);
builder.add(
jenkinsApiRef,
new JenkinsApi(`${backendUrl}/proxy/jenkins/api`),
);
builder.add(githubActionsApiRef, new GithubActionsClient());
builder.add(featureFlagsApiRef, new FeatureFlags());
+1
View File
@@ -24,6 +24,7 @@
"@backstage/catalog-model": "^0.1.1-alpha.18",
"@backstage/core": "^0.1.1-alpha.18",
"@backstage/plugin-github-actions": "^0.1.1-alpha.18",
"@backstage/plugin-jenkins": "^0.1.1-alpha.18",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.18",
"@backstage/plugin-sentry": "^0.1.1-alpha.18",
"@backstage/theme": "^0.1.1-alpha.18",
@@ -29,6 +29,7 @@ import {
} from '@backstage/core';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions';
import { JenkinsBuildsWidget, JenkinsLastBuildWidget } from '@backstage/plugin-jenkins';
import { Grid, Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
@@ -187,12 +188,20 @@ export const EntityPage: FC<{}> = () => {
<Grid item sm={4}>
<EntityMetadataCard entity={entity} />
</Grid>
<Grid item sm={8}>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
{entity.metadata?.annotations?.[
'backstage.io/jenkins-github-folder'
] && (
<Grid item sm={4}>
<JenkinsLastBuildWidget entity={entity} branch='master' />
</Grid>
)}
{entity.metadata?.annotations?.[
'backstage.io/jenkins-github-folder'
] && (
<Grid item sm={8}>
<JenkinsBuildsWidget entity={entity} />
</Grid>
)}
{entity.metadata?.annotations?.[
'backstage.io/github-actions-id'
] && (
@@ -201,6 +210,12 @@ export const EntityPage: FC<{}> = () => {
</Grid>
)}
</Grid>
<Grid item sm={8}>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Content>
<UnregisterEntityDialog
+81 -8
View File
@@ -1,13 +1,86 @@
# jenkins
# Jenkins Plugin (Alpha)
Welcome to the jenkins plugin!
Website: [https://jenkins.io/](https://jenkins.io/)
_This plugin was created through the Backstage CLI_
<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 detials"/>
## Getting started
## Setup
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/jenkins](http://localhost:3000/jenkins).
1. If you have a standalone app (you didn't clone this repo), then do
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
```bash
yarn add @backstage/plugin-jenkins
```
2. Add plugin API to your Backstage instance:
```js
// packages/app/src/api.ts
import { JenkinsApi, jenkinsApiRef } from '@backstage/plugin-jenkins';
const builder = ApiRegistry.builder();
builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`));
```
2. Add plugin itself:
```js
// packages/app/src/plugins.ts
export { plugin as Jenkins } from '@backstage/plugin-jenkins';
```
3. Add proxy configuration to `app-config.yaml`
```yaml
proxy:
'/jenkins/api':
target: 'http://localhost:8080' # your Jenkins URL
changeOrigin: true
headers:
Authorization:
$secret:
env: JENKINS_BASIC_AUTH_HEADER
pathRewrite:
'^/proxy/jenkins/api/': '/'
```
4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password)
```shell
HEADER=$(echo -n user:api-token | base64)
export JENKINS_BASIC_AUTH_HEADER="Authorization: Basic $HEADER"
```
5. Run app with `yarn start`
6. Add the Jenkins folder annotation to your `component-info.yaml`, (note: currently this plugin only supports folders and Git SCM)
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'your-component'
description: 'a description'
annotations:
backstage.io/jenkins-github-folder: 'folder-name/job-name'
spec:
type: service
lifecycle: experimental
owner: your-name
```
7. Register your component
8. Click the component in the catalog you should now see Jenkins builds, and a last build result for your master build.
## Features
- View all runs inside a folder
- Last build status for specified branch
- View summary of a build
## Limitations
- Only works with projects that use the Git SCM
- It requires jobs to be organised into folders
+6 -4
View File
@@ -21,20 +21,22 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.18",
"@backstage/core": "^0.1.1-alpha.18",
"@backstage/theme": "^0.1.1-alpha.18",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"jenkins": "^0.28.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
"react-router-dom": "^6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@backstage/cli": "^0.1.1-alpha.16",
"@backstage/dev-utils": "^0.1.1-alpha.16",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
+124
View File
@@ -0,0 +1,124 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createApiRef } from '@backstage/core';
import { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable';
const jenkins = require('jenkins');
export const jenkinsApiRef = createApiRef<JenkinsApi>({
id: 'plugin.jenkins.service',
description: 'Used by the Jenkins plugin to make requests',
});
export class JenkinsApi {
apiUrl: string;
jenkins: any;
constructor(apiUrl: string) {
this.apiUrl = apiUrl;
this.jenkins = jenkins({ baseUrl: apiUrl, promisify: true });
}
async retry(buildName: string) {
// looks like current SDK may not support this or at least not very well
// can't see any support for replay
return await this.jenkins.job.build(buildName);
}
async getLastBuild(jobName: string) {
const job = await this.jenkins.job.get(jobName);
const lastBuild = await this.jenkins.build.get(
jobName,
job.lastBuild.number,
);
return lastBuild;
}
async getFolder(folderName: string) {
const folder = await this.jenkins.job.get(folderName);
const results = [];
for (const jobSummary of folder.jobs) {
const jobDetails = await this.jenkins.job.get(
`${folderName}/${jobSummary.name}`,
);
if (jobDetails.jobs) {
// skipping folders inside folders for now
} else {
for (const buildDetails of jobDetails.builds) {
const build = await this.jenkins.build.get(
`${folderName}/${jobSummary.name}`,
buildDetails.number,
);
const ciTable = this.mapJenkinsBuildToCITable(build);
results.push(ciTable);
}
}
}
return results;
}
private mapJenkinsBuildToCITable(jenkinsResult: 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();
return {
id: new URL(jenkinsResult.url).pathname,
buildName: jenkinsResult.fullDisplayName,
status: jenkinsResult.building ? 'running' : jenkinsResult.result,
onRestartClick: () => {},
source: source,
};
}
async getBuild(buildName: string) {
const { jobName, buildNumber } = this.extractJobDetailsFromBuildName(
buildName,
);
const buildResult = await this.jenkins.build.get(jobName, buildNumber);
return buildResult;
}
private 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 {
jobName,
buildNumber,
};
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import {Builds} from '../../pages/BuildsPage/lib/Builds';
import {Entity} from "@backstage/catalog-model";
export const JenkinsBuildsWidget = ({entity}: {
entity: Entity;
}) => {
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/'
).split('/');
return <Builds owner={owner} repo={repo} />;
}
@@ -0,0 +1,94 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { Entity } from '@backstage/catalog-model';
import {
Link,
Theme,
makeStyles,
LinearProgress,
} from '@material-ui/core';
import {
InfoCard,
StructuredMetadataTable,
} from '@backstage/core';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import {useBuilds} from "../../state";
import {JenkinsRunStatus} from "../../pages/BuildsPage/lib/Status";
const useStyles = makeStyles<Theme>({
externalLinkIcon: {
fontSize: 'inherit',
verticalAlign: 'bottom',
},
});
const WidgetContent = ({
loading,
lastRun,
}: {
loading?: boolean;
lastRun: any;
branch: string;
}) => {
const classes = useStyles();
if (loading || !lastRun) return <LinearProgress />;
return (
<StructuredMetadataTable
metadata={{
status: (
<>
<JenkinsRunStatus status={lastRun.building ? 'running': lastRun.result} />
</>
),
build: lastRun.fullDisplayName,
url: (
<Link href={lastRun.url} target="_blank">
See more on Jenkins{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
),
}}
/>
);
};
export const JenkinsLastBuildWidget = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/'
).split('/');
const [
{loading, value}
] = useBuilds(owner, repo, branch);
const lastRun = value ?? {} ;
return (
<InfoCard title={`Last ${branch} build`}>
<WidgetContent
loading={loading}
branch={branch}
lastRun={lastRun}
/>
</InfoCard>
);
};
@@ -14,41 +14,20 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { ContentHeader, SupportButton } from '@backstage/core';
import { Button, IconButton, Box, Typography } from '@material-ui/core';
import ArrowBack from '@material-ui/icons/ArrowBack';
import SettingsIcon from '@material-ui/icons/Settings';
import { Box, Typography } from '@material-ui/core';
export type Props = { title?: string };
export const PluginHeader: FC<Props> = ({ title = 'CircleCI' }) => {
const location = useLocation();
const notRoot = !location.pathname.match(/\/circleci\/?$/);
export const PluginHeader: FC<Props> = ({ title = 'Jenkins' }) => {
return (
<ContentHeader
title={title}
titleComponent={() => (
<Box alignItems="center" display="flex">
{notRoot && (
<IconButton component={RouterLink} to="/circleci">
<ArrowBack />
</IconButton>
)}
<Typography variant="h4">{title}</Typography>
</Box>
)}
>
<Button
onClick={() => {
// eslint-disable-next-line no-console
console.log('Not implemented yet');
}}
startIcon={<SettingsIcon />}
>
Settings
</Button>
<SupportButton>
This plugin allows you to view and interact with your builds in Jenkins.
</SupportButton>
+2 -1
View File
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export { plugin, JenkinsBuildsWidget, JenkinsLastBuildWidget } from './plugin';
export * from './api';
@@ -0,0 +1,161 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { FC, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Content, InfoCard, Progress } from '@backstage/core';
import { Grid, Box, Link, IconButton } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { PluginHeader } from '../../components/PluginHeader';
import { ActionOutput } from './lib/ActionOutput/ActionOutput';
import { Layout } from '../../components/Layout';
import LaunchIcon from '@material-ui/icons/Launch';
import { useBuildWithSteps } from '../../state/useBuildWithSteps';
const IconLink = IconButton as typeof Link;
const BuildName: FC<{ build?: any }> = ({ build }) => (
<Box display="flex" alignItems="center">
{build?.fullDisplayName}
<IconLink href={build?.url} target="_blank">
<LaunchIcon />
</IconLink>
</Box>
);
const useStyles = makeStyles(theme => ({
neutral: {},
failed: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
content: '""',
position: 'absolute',
top: 0,
right: 0,
left: 0,
bottom: 0,
boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`,
},
},
running: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
content: '""',
position: 'absolute',
top: 0,
right: 0,
left: 0,
bottom: 0,
boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`,
},
},
cardContent: {
backgroundColor: theme.palette.background.default,
},
success: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
content: '""',
position: 'absolute',
top: 0,
right: 0,
left: 0,
bottom: 0,
boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`,
},
},
}));
const pickClassName = (
classes: ReturnType<typeof useStyles>,
build: any = {},
) => {
if (build.result === 'UNSTABLE') return classes.failed;
if (build.result === 'FAILURE') return classes.failed;
if (build.building) return classes.running;
if (build.status === 'SUCCESS') return classes.success;
return classes.neutral;
};
const Page = () => (
<Layout>
<Content>
<BuildWithStepsView />
</Content>
</Layout>
);
const BuildWithStepsView: FC<{}> = () => {
const [searchParams] = useSearchParams();
const buildPath = searchParams.get('url') || '';
const classes = useStyles();
const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps(
buildPath,
);
useEffect(() => {
startPolling();
return () => stopPolling();
}, [buildPath, startPolling, stopPolling]);
return (
<>
<PluginHeader title="Build info" />
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard
className={pickClassName(classes, value)}
title={<BuildName build={value} />}
cardClassName={classes.cardContent}
>
{loading ? <Progress /> : <BuildsList build={value} />}
</InfoCard>
</Grid>
</Grid>
</>
);
};
const BuildsList: FC<{ build?: any }> = ({ build }) => (
<Box>
{build &&
build.steps &&
build.steps.map(({ name, actions }: { name: string; actions: any[] }) => (
<ActionsList name={name} actions={actions} />
))}
</Box>
);
const ActionsList: FC<{ actions: any[]; name: string }> = ({ actions }) => {
const classes = useStyles();
return (
<>
{actions.map((action: any) => (
<ActionOutput
className={action.failed ? classes.failed : classes.success}
action={action}
name={action.name}
url={action.output_url || ''}
/>
))}
</>
);
};
export default Page;
export { BuildWithStepsView as BuildWithSteps };
@@ -13,16 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Route, Routes } from 'react-router';
import { BuildsPage } from '../pages/BuildsPage';
export const App = () => {
return (
<>
<Routes>
<Route path="*" element={<BuildsPage />} />
</Routes>
</>
);
};
export {
default as DetailedViewPage,
BuildWithSteps,
} from './BuildWithStepsPage';
@@ -0,0 +1,67 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { useEffect, FC } from 'react';
import {
ExpansionPanel,
ExpansionPanelSummary,
Typography,
ExpansionPanelDetails,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles({
expansionPanelDetails: {
padding: 0,
},
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
},
});
export const ActionOutput: FC<{
url: string;
name: string;
className?: string;
action: any;
}> = ({ url, name, className }) => {
const classes = useStyles();
useEffect(() => {}, [url]);
return (
<ExpansionPanel
TransitionProps={{ unmountOnExit: true }}
className={className}
>
<ExpansionPanelSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel-${name}-content`}
id={`panel-${name}-header`}
IconButtonProps={{
className: classes.button,
}}
>
<Typography variant="button">{name}</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.expansionPanelDetails}>
Nothing here...
</ExpansionPanelDetails>
</ExpansionPanel>
);
};
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default as BuildsPage, Builds } from './BuildsPage';
export { ActionOutput } from './ActionOutput';
@@ -1,43 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { FC } from 'react';
import { Content } from '@backstage/core';
import { Grid } from '@material-ui/core';
import { Builds as BuildsComp } from './lib/Builds';
import { Layout } from '../../components/Layout';
import { PluginHeader } from '../../components/PluginHeader';
const BuildsPage: FC<{}> = () => (
<Layout>
<Content>
<Builds />
</Content>
</Layout>
);
const Builds = () => (
<>
<PluginHeader title="All builds" />
<Grid container spacing={3} direction="column">
<Grid item>
<BuildsComp />
</Grid>
</Grid>
</>
);
export default BuildsPage;
export { Builds };
@@ -13,14 +13,33 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
// import { useBuilds } from '../../../../state';
import React from 'react';
import {CITable} from '../CITable';
import {useBuilds} from '../../../../state';
export const Builds: FC<{}> = () => {
// const [
// { total, loading, value, projectName, page, pageSize },
// { setPage, retry, setPageSize },
// ] = useBuilds();
export const Builds = ({
owner,
repo
}: {
owner: string;
repo: string;
}) => {
return <div>Builds go here</div>;
const [
{total, loading, value, projectName, page, pageSize},
{setPage, retry, setPageSize},
] = useBuilds(owner, repo);
return (
<CITable
total={total}
loading={loading}
retry={retry}
builds={value ?? []}
projectName={projectName}
page={page}
onChangePage={setPage}
pageSize={pageSize}
onChangePageSize={setPageSize}
/>
);
};
@@ -0,0 +1,138 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { FC } from 'react';
import { Link, Typography, Box, IconButton } from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import GitHubIcon from '@material-ui/icons/GitHub';
import { Link as RouterLink } from 'react-router-dom';
import {
Table,
TableColumn,
} from '@backstage/core';
import {JenkinsRunStatus} from "../Status";
export type CITableBuildInfo = {
id: string;
buildName: string;
buildUrl?: string;
source: {
branchName: string;
commit: {
hash: string;
url: string;
};
};
status: string;
tests?: {
total: number;
passed: number;
skipped: number;
failed: number;
testUrl: string; // fixme better name
};
onRestartClick: () => void;
};
const generatedColumns: TableColumn[] = [
{
title: 'Build',
field: 'buildName',
highlight: true,
render: (row: Partial<CITableBuildInfo>) => (
<Link component={RouterLink} to={`/jenkins/job?url=${row.id}`}>
{row.buildName}
</Link>
),
},
{
title: 'Source',
render: (row: Partial<CITableBuildInfo>) => (
<>
<p>{row.source?.branchName}</p>
<p>{row.source?.commit.hash}</p>
</>
),
},
{
title: 'Status',
render: (row: Partial<CITableBuildInfo>) => {
return (
<Box display="flex" alignItems="center">
<JenkinsRunStatus status={row.status} />
</Box>
)},
},
{
title: 'Actions',
render: (row: Partial<CITableBuildInfo>) => (
<IconButton onClick={row.onRestartClick}>
<RetryIcon />
</IconButton>
),
width: '10%',
},
];
type Props = {
loading: boolean;
retry: () => void;
builds: CITableBuildInfo[];
projectName: string;
page: number;
onChangePage: (page: number) => void;
total: number;
pageSize: number;
onChangePageSize: (pageSize: number) => void;
};
export const CITable: FC<Props> = ({
projectName,
loading,
pageSize,
page,
retry,
builds,
onChangePage,
onChangePageSize,
total,
}) => {
return (
<Table
isLoading={loading}
options={{ paging: true, pageSize }}
totalCount={total}
page={page}
actions={[
{
icon: () => <RetryIcon />,
tooltip: 'Refresh Data',
isFreeAction: true,
onClick: () => retry(),
},
]}
data={builds}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangePageSize}
title={
<Box display="flex" alignItems="center">
<GitHubIcon />
<Box mr={1} />
<Typography variant="h6">{projectName}</Typography>
</Box>
}
columns={generatedColumns}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { CITable } from './CITable';
export type { CITableBuildInfo } from './CITable';
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {StatusPending, StatusRunning, StatusOK, StatusWarning, StatusAborted, StatusError} from '@backstage/core';
import React from 'react';
export const JenkinsRunStatus = ({
status,
}: {
status: string | undefined;
}) => {
if (status === undefined) return null;
switch (status.toLowerCase()) {
case 'queued':
case 'scheduled':
return (
<>
<StatusPending /> Queued
</>
);
case 'running':
return (
<>
<StatusRunning /> In progress
</>
);
case 'unstable':
return (
<>
<StatusWarning /> Unstable
</>
);
case 'failure':
return (
<>
<StatusError /> Failed
</>
);
case 'success':
return (
<>
<StatusOK /> Completed
</>
);
case 'aborted':
return (
<>
<StatusAborted /> Aborted
</>
);
default:
return (
<>
<StatusWarning /> {status}
</>
);
}
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { JenkinsRunStatus } from './JenkinsRunStatus'
+9 -5
View File
@@ -15,16 +15,20 @@
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { App } from './components/App';
import {DetailedViewPage} from "./pages/BuildWithStepsPage";
export const rootRouteRef = createRouteRef({
path: '/jenkins',
title: 'jenkins',
export const buildRouteRef = createRouteRef({
path: '/jenkins/job',
title: 'Jenkins run',
});
export const plugin = createPlugin({
id: 'jenkins',
register({ router }) {
router.addRoute(rootRouteRef, App, { exact: false });
router.addRoute(buildRouteRef, DetailedViewPage);
},
});
export { JenkinsBuildsWidget } from './components/JenkinsPluginWidget/JenkinsBuildsWidget';
export { JenkinsLastBuildWidget } from './components/JenkinsPluginWidget/JenkinsLastBuildWidget';
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * from './useBuilds';
export * from './useBuildWithSteps';
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { useRef } from 'react';
export const useAsyncPolling = (
pollingFn: () => Promise<any>,
interval: number,
) => {
const isPolling = useRef<boolean>(false);
const startPolling = async () => {
if (isPolling.current === true) return;
isPolling.current = true;
while (isPolling.current === true) {
await pollingFn();
await new Promise(resolve => setTimeout(resolve, interval));
}
};
const stopPolling = () => {
isPolling.current = false;
};
return { startPolling, stopPolling };
};
@@ -0,0 +1,63 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { errorApiRef, useApi } from '@backstage/core';
import { useCallback } from 'react';
import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api/index';
import { useAsyncPolling } from './useAsyncPolling';
const INTERVAL_AMOUNT = 1500;
export function useBuildWithSteps(buildName: string) {
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
const getBuildWithSteps = useCallback(async () => {
try {
const build = await api.getBuild(buildName);
return Promise.resolve(build);
} catch (e) {
errorApi.post(e);
return Promise.reject(e);
}
}, [buildName, api, errorApi]);
const restartBuild = async () => {
try {
await api.retry(buildName);
} catch (e) {
errorApi.post(e);
}
};
const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [
getBuildWithSteps,
]);
const { startPolling, stopPolling } = useAsyncPolling(
getBuildWithSteps,
INTERVAL_AMOUNT,
);
return [
{ loading, value, retry },
{
restartBuild,
getBuildWithSteps,
startPolling,
stopPolling,
},
] as const;
}
+82
View File
@@ -0,0 +1,82 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { errorApiRef, useApi } from '@backstage/core';
import { useCallback, useEffect, useState } from 'react';
import { useAsyncRetry } from 'react-use';
import { jenkinsApiRef } from '../api';
export function useBuilds(owner: string, repo: string, branch?: string) {
const api = useApi(jenkinsApiRef);
const errorApi = useApi(errorApiRef);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const getBuilds = useCallback(async () => {
try {
let build;
if (branch) {
build = await api.getLastBuild(`${owner}/${repo}/${branch}`);
} else {
build = await api.getFolder(`${owner}/${repo}`);
}
return build;
} catch (e) {
errorApi.post(e);
return Promise.reject(e);
}
}, [api, branch, errorApi, owner, repo]);
const restartBuild = async (buildName: string) => {
try {
await api.retry(buildName);
} catch (e) {
errorApi.post(e);
}
};
useEffect(() => {
getBuilds().then(b => {
const size = Array.isArray(b) ? b?.[0].build_num! : 1;
setTotal(size);
});
}, [repo, getBuilds]);
const { loading, value, retry } = useAsyncRetry(
() => getBuilds().then(builds => builds ?? [], restartBuild),
[page, pageSize, getBuilds],
);
const projectName = `${owner}/${repo}`;
return [
{
page,
pageSize,
loading,
value,
projectName,
total,
},
{
getBuilds,
setPage,
setPageSize,
restartBuild,
retry,
},
] as const;
}