diff --git a/app-config.yaml b/app-config.yaml index 5a5fdc1cab..c5c7a3674c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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 diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index de46418075..d07bfbcec1 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -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()); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index a0e69a647d..4f0e5df322 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx index c11844f722..c459b721ac 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -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<{}> = () => { - - + {entity.metadata?.annotations?.[ + 'backstage.io/jenkins-github-folder' + ] && ( + + + )} + {entity.metadata?.annotations?.[ + 'backstage.io/jenkins-github-folder' + ] && ( + + + + )} {entity.metadata?.annotations?.[ 'backstage.io/github-actions-id' ] && ( @@ -201,6 +210,12 @@ export const EntityPage: FC<{}> = () => { )} + + + +Folder results +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 diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 766a79a290..4861077fe9 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -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", diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts new file mode 100644 index 0000000000..fba45e3b88 --- /dev/null +++ b/plugins/jenkins/src/api/index.ts @@ -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({ + 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, + }; + } +} diff --git a/plugins/jenkins/src/assets/build-details.png b/plugins/jenkins/src/assets/build-details.png new file mode 100644 index 0000000000..396af428af Binary files /dev/null and b/plugins/jenkins/src/assets/build-details.png differ diff --git a/plugins/jenkins/src/assets/folder-results.png b/plugins/jenkins/src/assets/folder-results.png new file mode 100644 index 0000000000..2e14f3551f Binary files /dev/null and b/plugins/jenkins/src/assets/folder-results.png differ diff --git a/plugins/jenkins/src/assets/last-master-build.png b/plugins/jenkins/src/assets/last-master-build.png new file mode 100644 index 0000000000..517e7a4410 Binary files /dev/null and b/plugins/jenkins/src/assets/last-master-build.png differ diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx new file mode 100644 index 0000000000..c9f606f334 --- /dev/null +++ b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx @@ -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 ; +} diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx new file mode 100644 index 0000000000..f81d276a2e --- /dev/null +++ b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx @@ -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({ + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'bottom', + }, +}); + +const WidgetContent = ({ + loading, + lastRun, + }: { + loading?: boolean; + lastRun: any; + branch: string; +}) => { + const classes = useStyles(); + if (loading || !lastRun) return ; + return ( + + + + ), + build: lastRun.fullDisplayName, + url: ( + + See more on Jenkins{' '} + + + ), + }} + /> + ); +}; + +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 ( + + + + ); +}; diff --git a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx b/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx index 60b774713d..fa6e80f20f 100644 --- a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx +++ b/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx @@ -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 = ({ title = 'CircleCI' }) => { - const location = useLocation(); - const notRoot = !location.pathname.match(/\/circleci\/?$/); - +export const PluginHeader: FC = ({ title = 'Jenkins' }) => { return ( ( - {notRoot && ( - - - - )} {title} )} > - - This plugin allows you to view and interact with your builds in Jenkins. diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 3a0a0fe2d3..c7f2d8dc28 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { plugin, JenkinsBuildsWidget, JenkinsLastBuildWidget } from './plugin'; +export * from './api'; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx new file mode 100644 index 0000000000..4d2ddc24c8 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -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 }) => ( + + {build?.fullDisplayName} + + + + +); +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, + 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 = () => ( + + + + + +); + +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 ( + <> + + + + + } + cardClassName={classes.cardContent} + > + {loading ? : } + + + + + ); +}; + +const BuildsList: FC<{ build?: any }> = ({ build }) => ( + + {build && + build.steps && + build.steps.map(({ name, actions }: { name: string; actions: any[] }) => ( + + ))} + +); + +const ActionsList: FC<{ actions: any[]; name: string }> = ({ actions }) => { + const classes = useStyles(); + return ( + <> + {actions.map((action: any) => ( + + ))} + + ); +}; + +export default Page; +export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/components/App.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts similarity index 68% rename from plugins/jenkins/src/components/App.tsx rename to plugins/jenkins/src/pages/BuildWithStepsPage/index.ts index ca8bb4c454..fddff7088c 100644 --- a/plugins/jenkins/src/components/App.tsx +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts @@ -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 ( - <> - - } /> - - - ); -}; +export { + default as DetailedViewPage, + BuildWithSteps, +} from './BuildWithStepsPage'; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx new file mode 100644 index 0000000000..1143121a98 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -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 ( + + } + aria-controls={`panel-${name}-content`} + id={`panel-${name}-header`} + IconButtonProps={{ + className: classes.button, + }} + > + {name} + + + Nothing here... + + + ); +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/index.ts b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts similarity index 90% rename from plugins/jenkins/src/pages/BuildsPage/index.ts rename to plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts index 72b46d6bc9..7cf74c73f8 100644 --- a/plugins/jenkins/src/pages/BuildsPage/index.ts +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts @@ -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'; diff --git a/plugins/jenkins/src/pages/BuildsPage/BuildsPage.tsx b/plugins/jenkins/src/pages/BuildsPage/BuildsPage.tsx deleted file mode 100644 index 7b124c03c1..0000000000 --- a/plugins/jenkins/src/pages/BuildsPage/BuildsPage.tsx +++ /dev/null @@ -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<{}> = () => ( - - - - - -); - -const Builds = () => ( - <> - - - - - - - -); - -export default BuildsPage; -export { Builds }; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx index b14be76ddc..b08c0ac855 100644 --- a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx @@ -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
Builds go here
; + const [ + {total, loading, value, projectName, page, pageSize}, + {setPage, retry, setPageSize}, + ] = useBuilds(owner, repo); + return ( + + ); }; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx new file mode 100644 index 0000000000..85a6056427 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx @@ -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) => ( + + {row.buildName} + + ), + }, + { + title: 'Source', + render: (row: Partial) => ( + <> +

{row.source?.branchName}

+

{row.source?.commit.hash}

+ + ), + }, + { + title: 'Status', + render: (row: Partial) => { + return ( + + + + )}, + }, + { + title: 'Actions', + render: (row: Partial) => ( + + + + ), + 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 = ({ + projectName, + loading, + pageSize, + page, + retry, + builds, + onChangePage, + onChangePageSize, + total, +}) => { + return ( + , + tooltip: 'Refresh Data', + isFreeAction: true, + onClick: () => retry(), + }, + ]} + data={builds} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangePageSize} + title={ + + + + {projectName} + + } + columns={generatedColumns} + /> + ); +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts new file mode 100644 index 0000000000..358939e69f --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts @@ -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'; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx new file mode 100644 index 0000000000..8ac4880e38 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx @@ -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 ( + <> + Queued + + ); + case 'running': + return ( + <> + In progress + + ); + case 'unstable': + return ( + <> + Unstable + + ); + case 'failure': + return ( + <> + Failed + + ); + case 'success': + return ( + <> + Completed + + ); + case 'aborted': + return ( + <> + Aborted + + ); + default: + return ( + <> + {status} + + ); + } +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts b/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts new file mode 100644 index 0000000000..928d91c61d --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts @@ -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' diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index f5b2210257..62fd3a108e 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -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'; diff --git a/plugins/jenkins/src/setupTests.ts b/plugins/jenkins/src/setupTests.ts index e34bc46f4b..8553642152 100644 --- a/plugins/jenkins/src/setupTests.ts +++ b/plugins/jenkins/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/jenkins/src/state/index.ts b/plugins/jenkins/src/state/index.ts new file mode 100644 index 0000000000..d21a380c2a --- /dev/null +++ b/plugins/jenkins/src/state/index.ts @@ -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'; diff --git a/plugins/jenkins/src/state/useAsyncPolling.ts b/plugins/jenkins/src/state/useAsyncPolling.ts new file mode 100644 index 0000000000..7ea0755368 --- /dev/null +++ b/plugins/jenkins/src/state/useAsyncPolling.ts @@ -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, + interval: number, +) => { + const isPolling = useRef(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 }; +}; diff --git a/plugins/jenkins/src/state/useBuildWithSteps.ts b/plugins/jenkins/src/state/useBuildWithSteps.ts new file mode 100644 index 0000000000..a681438685 --- /dev/null +++ b/plugins/jenkins/src/state/useBuildWithSteps.ts @@ -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; +} diff --git a/plugins/jenkins/src/state/useBuilds.ts b/plugins/jenkins/src/state/useBuilds.ts new file mode 100644 index 0000000000..deb3125637 --- /dev/null +++ b/plugins/jenkins/src/state/useBuilds.ts @@ -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; +}