From 2111c7cf96e11b5b2d96f3abda5526022639a4fe Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Wed, 27 Jul 2022 16:14:22 -0700 Subject: Add freeze job to web UI This adds a freeze-job page to the web UI. It can be navigated to by clicking a job in a job graph. Change-Id: Ibd07449314a9454295bc97f8d654347f09dc504c --- web/src/actions/freezejob.js | 84 +++++++++ web/src/actions/pipelines.js | 62 +++++++ web/src/api.js | 12 ++ web/src/containers/freezejob/FreezeJobToolbar.jsx | 200 ++++++++++++++++++++++ web/src/containers/jobgraph/JobGraphDisplay.jsx | 25 ++- web/src/pages/FreezeJob.jsx | 149 ++++++++++++++++ web/src/reducers/freezejob.js | 55 ++++++ web/src/reducers/index.js | 4 + web/src/reducers/pipelines.js | 45 +++++ web/src/routes.js | 5 + 10 files changed, 634 insertions(+), 7 deletions(-) create mode 100644 web/src/actions/freezejob.js create mode 100644 web/src/actions/pipelines.js create mode 100644 web/src/containers/freezejob/FreezeJobToolbar.jsx create mode 100644 web/src/pages/FreezeJob.jsx create mode 100644 web/src/reducers/freezejob.js create mode 100644 web/src/reducers/pipelines.js diff --git a/web/src/actions/freezejob.js b/web/src/actions/freezejob.js new file mode 100644 index 000000000..13b32f124 --- /dev/null +++ b/web/src/actions/freezejob.js @@ -0,0 +1,84 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 * as API from '../api' + +export const FREEZE_JOB_FETCH_REQUEST = 'FREEZE_JOB_FETCH_REQUEST' +export const FREEZE_JOB_FETCH_SUCCESS = 'FREEZE_JOB_FETCH_SUCCESS' +export const FREEZE_JOB_FETCH_FAIL = 'FREEZE_JOB_FETCH_FAIL' + +export const requestFreezeJob = () => ({ + type: FREEZE_JOB_FETCH_REQUEST +}) + +export function makeFreezeJobKey(pipeline, project, branch, job) { + return JSON.stringify({ + pipeline, project, branch, job + }) +} + +export const receiveFreezeJob = (tenant, freezeJobKey, freezeJob) => { + return { + type: FREEZE_JOB_FETCH_SUCCESS, + tenant: tenant, + freezeJobKey: freezeJobKey, + freezeJob: freezeJob, + receivedAt: Date.now(), + } +} + +const failedFreezeJob = error => ({ + type: FREEZE_JOB_FETCH_FAIL, + error +}) + +const fetchFreezeJob = (tenant, pipeline, project, branch, job) => dispatch => { + dispatch(requestFreezeJob()) + const freezeJobKey = makeFreezeJobKey(pipeline, project, branch, job) + return API.fetchFreezeJob(tenant.apiPrefix, + pipeline, + project, + branch, + job) + .then(response => dispatch(receiveFreezeJob( + tenant.name, freezeJobKey, response.data))) + .catch(error => dispatch(failedFreezeJob(error))) +} + +const shouldFetchFreezeJob = (tenant, pipeline, project, branch, job, state) => { + const freezeJobKey = makeFreezeJobKey(pipeline, project, branch, job) + const tenantFreezeJobs = state.freezejob.freezeJobs[tenant.name] + if (tenantFreezeJobs) { + const freezeJob = tenantFreezeJobs[freezeJobKey] + if (!freezeJob) { + return true + } + if (freezeJob.isFetching) { + return false + } + return false + } + return true +} + +export const fetchFreezeJobIfNeeded = (tenant, pipeline, project, branch, job, + force) => ( + dispatch, getState) => { + if (force || shouldFetchFreezeJob(tenant, pipeline, project, branch, job, + getState())) { + return dispatch(fetchFreezeJob(tenant, pipeline, project, branch, job)) + } + return Promise.resolve() +} diff --git a/web/src/actions/pipelines.js b/web/src/actions/pipelines.js new file mode 100644 index 000000000..7139524f2 --- /dev/null +++ b/web/src/actions/pipelines.js @@ -0,0 +1,62 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 * as API from '../api' + +export const PIPELINES_FETCH_REQUEST = 'PIPELINES_FETCH_REQUEST' +export const PIPELINES_FETCH_SUCCESS = 'PIPELINES_FETCH_SUCCESS' +export const PIPELINES_FETCH_FAIL = 'PIPELINES_FETCH_FAIL' + +export const requestPipelines = () => ({ + type: PIPELINES_FETCH_REQUEST +}) + +export const receivePipelines = (tenant, json) => ({ + type: PIPELINES_FETCH_SUCCESS, + tenant: tenant, + pipelines: json, + receivedAt: Date.now() +}) + +const failedPipelines = error => ({ + type: PIPELINES_FETCH_FAIL, + error +}) + +const fetchPipelines = (tenant) => dispatch => { + dispatch(requestPipelines()) + return API.fetchPipelines(tenant.apiPrefix) + .then(response => dispatch(receivePipelines(tenant.name, response.data))) + .catch(error => dispatch(failedPipelines(error))) +} + +const shouldFetchPipelines = (tenant, state) => { + const pipelines = state.pipelines.pipelines[tenant.name] + if (!pipelines || pipelines.length === 0) { + return true + } + if (pipelines.isFetching) { + return false + } + return false +} + +export const fetchPipelinesIfNeeded = (tenant, force) => ( + dispatch, getState) => { + if (force || shouldFetchPipelines(tenant, getState())) { + return dispatch(fetchPipelines(tenant)) + } + return Promise.resolve() +} diff --git a/web/src/api.js b/web/src/api.js index 8fcb2ec18..1ba39998c 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -130,6 +130,13 @@ function fetchStatus(apiPrefix) { function fetchChangeStatus(apiPrefix, changeId) { return Axios.get(apiUrl + apiPrefix + 'status/change/' + changeId) } +function fetchFreezeJob(apiPrefix, pipelineName, projectName, branchName, jobName) { + return Axios.get(apiUrl + apiPrefix + + 'pipeline/' + pipelineName + + '/project/' + projectName + + '/branch/' + branchName + + '/freeze-job/' + jobName) +} function fetchBuild(apiPrefix, buildId) { return Axios.get(apiUrl + apiPrefix + 'build/' + buildId) } @@ -150,6 +157,9 @@ function fetchBuildsets(apiPrefix, queryString) { } return Axios.get(apiUrl + apiPrefix + path) } +function fetchPipelines(apiPrefix) { + return Axios.get(apiUrl + apiPrefix + 'pipelines') +} function fetchProject(apiPrefix, projectName) { return Axios.get(apiUrl + apiPrefix + 'project/' + projectName) } @@ -312,6 +322,8 @@ export { fetchBuilds, fetchBuildset, fetchBuildsets, + fetchFreezeJob, + fetchPipelines, fetchProject, fetchProjects, fetchJob, diff --git a/web/src/containers/freezejob/FreezeJobToolbar.jsx b/web/src/containers/freezejob/FreezeJobToolbar.jsx new file mode 100644 index 000000000..c067b7205 --- /dev/null +++ b/web/src/containers/freezejob/FreezeJobToolbar.jsx @@ -0,0 +1,200 @@ +// Copyright 2020 BMW Group +// Copyright 2022 Acme Gating, LLC +// +// 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, useState } from 'react' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import { + Button, + TextInput, + Dropdown, + DropdownItem, + DropdownPosition, + DropdownToggle, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core' + +import { fetchPipelinesIfNeeded } from '../../actions/pipelines' + +function FreezeJobToolbar(props) { + const { tenant, fetchPipelinesIfNeeded } = props + + useEffect(() => { + fetchPipelinesIfNeeded(tenant) + }, [fetchPipelinesIfNeeded, tenant]) + + const tenantPipelines = props.pipelines.pipelines[tenant.name] + const pipelines = tenantPipelines ? tenantPipelines.map(p => { return p.name }) : [] + + const [isPipelineOpen, setIsPipelineOpen] = useState(false) + const [currentPipeline, setCurrentPipeline] = useState(props.defaultPipeline || '') + const [currentProject, setCurrentProject] = useState(props.defaultProject || '') + const [currentBranch, setCurrentBranch] = useState(props.defaultBranch || '') + const [currentJob, setCurrentJob] = useState(props.defaultJob || '') + + if (!currentPipeline && pipelines.length) { + // We may have gotten a list of pipelines after we loaded the page + setCurrentPipeline(pipelines[0]) + } + + function handlePipelineSelect(event) { + setCurrentPipeline(event.target.innerText) + setIsPipelineOpen(false) + } + + function handlePipelineToggle(isOpen) { + setIsPipelineOpen(isOpen) + } + + function handleProjectChange(newValue) { + setCurrentProject(newValue) + } + + function handleBranchChange(newValue) { + setCurrentBranch(newValue) + } + + function handleJobChange(newValue) { + setCurrentJob(newValue) + } + + function handleInputSend(event) { + // In case the event comes from a key press, only accept "Enter" + if (event.key && event.key !== 'Enter') { + return + } + + // Ignore empty values + if (!currentBranch || !currentProject || !currentJob) { + return + } + + // Notify the parent component about the filter change + props.onChange(currentPipeline, currentProject, currentBranch, currentJob) + } + + function renderFreezeJobToolbar () { + return <> + + + + + + + + Pipeline: {currentPipeline} + + } + isOpen={isPipelineOpen} + dropdownItems={pipelines.map((pipeline) => ( + {pipeline} + ))} + style={{ width: '100%' }} + menuAppendTo={document.body} + /> + + + + handleInputSend(event)} + /> + + + + handleInputSend(event)} + /> + + + + handleInputSend(event)} + /> + + + + + + + + + + + + } + + return ( +
+ {renderFreezeJobToolbar()} +
+ ) +} + +FreezeJobToolbar.propTypes = { + fetchPipelinesIfNeeded: PropTypes.func, + tenant: PropTypes.object, + pipelines: PropTypes.object, + onChange: PropTypes.func.isRequired, + defaultPipeline: PropTypes.string, + defaultProject: PropTypes.string, + defaultBranch: PropTypes.string, + defaultJob: PropTypes.string, +} + +function mapStateToProps(state) { + return { + tenant: state.tenant, + pipelines: state.pipelines, + } +} + +const mapDispatchToProps = { + fetchPipelinesIfNeeded +} + +export default connect(mapStateToProps, mapDispatchToProps)(FreezeJobToolbar) diff --git a/web/src/containers/jobgraph/JobGraphDisplay.jsx b/web/src/containers/jobgraph/JobGraphDisplay.jsx index 1fbcef332..8d55c8f54 100644 --- a/web/src/containers/jobgraph/JobGraphDisplay.jsx +++ b/web/src/containers/jobgraph/JobGraphDisplay.jsx @@ -20,11 +20,24 @@ import * as d3 from 'd3' import { makeJobGraphKey, fetchJobGraphIfNeeded } from '../../actions/jobgraph' import { graphviz } from 'd3-graphviz' -function makeDot(jobGraph) { - let ret = 'digraph job_graph {' +import { getHomepageUrl } from '../../api' + +function makeDot(tenant, pipeline, project, branch, jobGraph) { + let ret = 'digraph job_graph {\n' ret += ' rankdir=LR;\n' ret += ' node [shape=box];\n' jobGraph.forEach((job) => { + const searchParams = new URLSearchParams('') + searchParams.append('pipeline', pipeline) + searchParams.append('project', project.name) + searchParams.append('job', job.name) + searchParams.append('branch', branch) + const url = (getHomepageUrl() + tenant.linkPrefix + + 'freeze-job?' + searchParams.toString()) + // Escape ampersands to get it through graphviz and d3; these will + // appear unescaped in the DOM. + const escaped_url = url.replace(/&/g, '&') + ret += ' "' + job.name + '" [URL="' + escaped_url + '"];\n' if (job.dependencies.length) { job.dependencies.forEach((dep) => { let soft = ' [dir=back]' @@ -33,8 +46,6 @@ function makeDot(jobGraph) { } ret += ' "' + dep.name + '" -> "' + job.name + '"' + soft + ';\n' }) - } else { - ret += ' "' + job.name + '";\n' } }) ret += '}\n' @@ -80,16 +91,16 @@ function JobGraphDisplay(props) { fetchJobGraphIfNeeded(tenant, project.name, pipeline, branch) }, [fetchJobGraphIfNeeded, tenant, project, pipeline, branch]) - const tenantJobGraph = props.jobgraph.jobGraphs[props.tenant.name] + const tenantJobGraph = props.jobgraph.jobGraphs[tenant.name] const jobGraphKey = makeJobGraphKey(props.project.name, props.pipeline, props.branch) const jobGraph = tenantJobGraph ? tenantJobGraph[jobGraphKey] : undefined useEffect(() => { if (jobGraph) { - setDot(makeDot(jobGraph)) + setDot(makeDot(tenant, pipeline, project, branch, jobGraph)) } - }, [jobGraph]) + }, [tenant, pipeline, project, branch, jobGraph]) return ( <> {dot && } diff --git a/web/src/pages/FreezeJob.jsx b/web/src/pages/FreezeJob.jsx new file mode 100644 index 000000000..a78cc7b56 --- /dev/null +++ b/web/src/pages/FreezeJob.jsx @@ -0,0 +1,149 @@ +// Copyright 2022 Acme Gating, LLC +// +// 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, useState } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { useHistory, useLocation } from 'react-router-dom' +import { + PageSection, + PageSectionVariants, + Text, + TextContent, +} from '@patternfly/react-core' +import ReactJson from 'react-json-view' + +import FreezeJobToolbar from '../containers/freezejob/FreezeJobToolbar' +import { makeFreezeJobKey, fetchFreezeJobIfNeeded } from '../actions/freezejob' + +function FreezeJobPage(props) { + const { tenant, fetchFreezeJobIfNeeded } = props + + const [currentPipeline, setCurrentPipeline] = useState() + const [currentProject, setCurrentProject] = useState() + const [currentBranch, setCurrentBranch] = useState() + const [currentJob, setCurrentJob] = useState() + const history = useHistory() + const location = useLocation() + + if (!currentBranch) { + const urlParams = new URLSearchParams(location.search) + const pipeline = urlParams.get('pipeline') + const project = urlParams.get('project') + const branch = urlParams.get('branch') + const job = urlParams.get('job') + if (pipeline && branch && project && job) { + setCurrentPipeline(pipeline) + setCurrentProject(project) + setCurrentBranch(branch) + setCurrentJob(job) + } + } + + useEffect(() => { + document.title = 'Zuul Frozen Job' + if (currentPipeline && currentProject && currentBranch && currentJob) { + fetchFreezeJobIfNeeded(tenant, currentPipeline, currentProject, + currentBranch, currentJob) + } + }, [fetchFreezeJobIfNeeded, tenant, currentPipeline, currentProject, + currentBranch, currentJob]) + + function onChange(pipeline, project, branch, job) { + setCurrentPipeline(pipeline) + setCurrentProject(project) + setCurrentBranch(branch) + setCurrentJob(job) + + const searchParams = new URLSearchParams('') + searchParams.append('pipeline', pipeline) + searchParams.append('project', project) + searchParams.append('branch', branch) + searchParams.append('job', job) + history.push({ + pathname: location.pathname, + search: searchParams.toString(), + }) + + if (currentPipeline && currentProject && currentBranch && currentJob) { + fetchFreezeJobIfNeeded(tenant, currentPipeline, currentProject, + currentBranch, currentJob) + } + } + + const tenantJobs = props.freezejob.freezeJobs[tenant.name] + const freezeJobKey = makeFreezeJobKey(currentPipeline, + currentProject, + currentBranch, + currentJob) + const job = tenantJobs ? tenantJobs[freezeJobKey] : undefined + function renderFrozenJob() { + return ( + + + + ) + } + + return ( + <> + + + Freeze Job + + Freezing a job asks Zuul to combine all the + project and job configuration + stanzas for a job as if a change for a given + project and branch were to be enqueued into + a specific pipeline. The resulting job + configuration is displayed below. + + + + {job && renderFrozenJob(job)} + + + ) +} + +FreezeJobPage.propTypes = { + fetchFreezeJobIfNeeded: PropTypes.func, + tenant: PropTypes.object, + freezejob: PropTypes.object, +} + +function mapStateToProps(state) { + return { + tenant: state.tenant, + freezejob: state.freezejob, + } +} + +const mapDispatchToProps = { + fetchFreezeJobIfNeeded +} + +export default connect(mapStateToProps, mapDispatchToProps)(FreezeJobPage) diff --git a/web/src/reducers/freezejob.js b/web/src/reducers/freezejob.js new file mode 100644 index 000000000..6e7db4991 --- /dev/null +++ b/web/src/reducers/freezejob.js @@ -0,0 +1,55 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 { + FREEZE_JOB_FETCH_FAIL, + FREEZE_JOB_FETCH_REQUEST, + FREEZE_JOB_FETCH_SUCCESS +} from '../actions/freezejob' + +export default (state = { + isFetching: false, + freezeJobs: {}, +}, action) => { + let stateFreezeJobs + switch (action.type) { + case FREEZE_JOB_FETCH_REQUEST: + return { + isFetching: true, + freezeJobs: state.freezeJobs, + } + case FREEZE_JOB_FETCH_SUCCESS: + stateFreezeJobs = !state.freezeJobs[action.tenant] ? + { ...state.freezeJobs, [action.tenant]: {} } : + { ...state.freezeJobs } + return { + isFetching: false, + freezeJobs: { + ...stateFreezeJobs, + [action.tenant]: { + ...stateFreezeJobs[action.tenant], + [action.freezeJobKey]: action.freezeJob + } + } + } + case FREEZE_JOB_FETCH_FAIL: + return { + isFetching: false, + freezeJobs: state.freezeJobs, + } + default: + return state + } +} diff --git a/web/src/reducers/index.js b/web/src/reducers/index.js index 5e18dbbea..83c86c105 100644 --- a/web/src/reducers/index.js +++ b/web/src/reducers/index.js @@ -19,6 +19,7 @@ import autoholds from './autoholds' import configErrors from './configErrors' import change from './change' import component from './component' +import freezejob from './freezejob' import notifications from './notifications' import build from './build' import info from './info' @@ -30,6 +31,7 @@ import logfile from './logfile' import nodes from './nodes' import openapi from './openapi' import project from './project' +import pipelines from './pipelines' import projects from './projects' import preferences from './preferences' import status from './status' @@ -45,6 +47,7 @@ const reducers = { change, component, configErrors, + freezejob, notifications, info, job, @@ -54,6 +57,7 @@ const reducers = { logfile, nodes, openapi, + pipelines, project, projects, status, diff --git a/web/src/reducers/pipelines.js b/web/src/reducers/pipelines.js new file mode 100644 index 000000000..550352572 --- /dev/null +++ b/web/src/reducers/pipelines.js @@ -0,0 +1,45 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 { + PIPELINES_FETCH_FAIL, + PIPELINES_FETCH_REQUEST, + PIPELINES_FETCH_SUCCESS +} from '../actions/pipelines' + +export default (state = { + isFetching: false, + pipelines: {}, +}, action) => { + switch (action.type) { + case PIPELINES_FETCH_REQUEST: + return { + isFetching: true, + pipelines: state.pipelines, + } + case PIPELINES_FETCH_SUCCESS: + return { + isFetching: false, + pipelines: { ...state.pipelines, [action.tenant]: action.pipelines }, + } + case PIPELINES_FETCH_FAIL: + return { + isFetching: false, + pipelines: state.pipelines, + } + default: + return state + } +} diff --git a/web/src/routes.js b/web/src/routes.js index c2c3a8584..e1bfdae16 100644 --- a/web/src/routes.js +++ b/web/src/routes.js @@ -13,6 +13,7 @@ // under the License. import ComponentsPage from './pages/Components' +import FreezeJobPage from './pages/FreezeJob' import StatusPage from './pages/Status' import ChangeStatusPage from './pages/ChangeStatus' import ProjectPage from './pages/Project' @@ -78,6 +79,10 @@ const routes = () => [ to: '/buildsets', component: BuildsetsPage }, + { + to: '/freeze-job', + component: FreezeJobPage + }, { to: '/status/change/:changeId', component: ChangeStatusPage -- cgit v1.2.1