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
This commit is contained in:
James E. Blair
2022-07-27 16:14:22 -07:00
parent 97376adc21
commit 2111c7cf96
10 changed files with 634 additions and 7 deletions
+84
View File
@@ -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()
}
+62
View File
@@ -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()
}
+12
View File
@@ -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,
@@ -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 <>
<Toolbar collapseListedFiltersBreakpoint="md">
<ToolbarContent>
<ToolbarToggleGroup breakpoint="md">
<ToolbarGroup variant="filter-group">
<ToolbarItem key="pipeline">
<Dropdown
onSelect={handlePipelineSelect}
position={DropdownPosition.left}
toggle={
<DropdownToggle
onToggle={handlePipelineToggle}
style={{ width: '100%' }}
>
Pipeline: {currentPipeline}
</DropdownToggle>
}
isOpen={isPipelineOpen}
dropdownItems={pipelines.map((pipeline) => (
<DropdownItem key={pipeline}>{pipeline}</DropdownItem>
))}
style={{ width: '100%' }}
menuAppendTo={document.body}
/>
</ToolbarItem>
<ToolbarItem key="project">
<TextInput
name="project"
id="project-input"
type="search"
placeholder="Project"
defaultValue={props.defaultProject}
onChange={handleProjectChange}
onKeyDown={(event) => handleInputSend(event)}
/>
</ToolbarItem>
<ToolbarItem key="branch">
<TextInput
name="branch"
id="branch-input"
type="search"
placeholder="Branch"
defaultValue={props.defaultBranch}
onChange={handleBranchChange}
onKeyDown={(event) => handleInputSend(event)}
/>
</ToolbarItem>
<ToolbarItem key="job">
<TextInput
name="job"
id="job-input"
type="search"
placeholder="Job"
defaultValue={props.defaultJob}
onChange={handleJobChange}
onKeyDown={(event) => handleInputSend(event)}
/>
</ToolbarItem>
<ToolbarItem key="button">
<Button
onClick={(event) => handleInputSend(event)}
>
Freeze Job
</Button>
</ToolbarItem>
</ToolbarGroup>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
</>
}
return (
<div>
{renderFreezeJobToolbar()}
</div>
)
}
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)
@@ -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, '&amp;')
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 && <GraphViz dot={dot}/>}
+149
View File
@@ -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 (
<span style={{whiteSpace: 'pre-wrap'}}>
<ReactJson
src={job}
name={null}
collapsed={false}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
</span>
)
}
return (
<>
<PageSection variant={PageSectionVariants.light}>
<TextContent>
<Text component="h1">Freeze Job</Text>
<Text component="p">
Freezing a job asks Zuul to combine all the
<i>project</i> and <i>job</i> 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.
</Text>
</TextContent>
<FreezeJobToolbar
onChange={onChange}
defaultPipeline={currentPipeline}
defaultProject={currentProject}
defaultBranch={currentBranch}
defaultJob={currentJob}
/>
{job && renderFrozenJob(job)}
</PageSection>
</>
)
}
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)
+55
View File
@@ -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
}
}
+4
View File
@@ -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,
+45
View File
@@ -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
}
}
+5
View File
@@ -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