Web UI: Add "Create Autohold Request" form, improve API error messages

Add a "create autohold request" button on the autoholds page for admin
users.
Refactor autohold form modal so that it can be reused in autoholds and
build pages.
Improve error messages when using admin actions: if the API call returns
an error description, display it in the toast. Otherwise return the HTTP
error code.

Change-Id: I9d1cc8a1d751e7b4ce32e7abecc2ad9f3e96f676
This commit is contained in:
Matthieu Huin 2021-07-27 18:54:46 +02:00
parent 54635a4de7
commit 41d6008a3d
5 changed files with 312 additions and 118 deletions

View File

@ -17,22 +17,33 @@ export const ADMIN_ENQUEUE_FAIL = 'ADMIN_ENQUEUE_FAIL'
export const ADMIN_AUTOHOLD_FAIL = 'ADMIN_AUTOHOLD_FAIL'
export const ADMIN_PROMOTE_FAIL = 'ADMIN_PROMOTE_FAIL'
function parseAPIerror(error) {
if (error.response) {
let parser = new DOMParser()
let htmlError = parser.parseFromString(error.response.data, 'text/html')
let error_description = htmlError.getElementsByTagName('p')[0].innerText
return (error_description)
} else {
return (error)
}
}
export const addDequeueError = error => ({
type: ADMIN_DEQUEUE_FAIL,
notification: error
notification: parseAPIerror(error)
})
export const addEnqueueError = error => ({
type: ADMIN_ENQUEUE_FAIL,
notification: error
notification: parseAPIerror(error)
})
export const addAutoholdError = error => ({
type: ADMIN_AUTOHOLD_FAIL,
notification: error
notification: parseAPIerror(error)
})
export const addPromoteError = error => ({
type: ADMIN_PROMOTE_FAIL,
notification: error
notification: parseAPIerror(error)
})

View File

@ -43,8 +43,9 @@ import { Link } from 'react-router-dom'
import * as moment from 'moment'
import { autohold_delete } from '../../api'
import { addNotification } from '../../actions/notifications'
import { addAutoholdError } from '../../actions/adminActions'
import { fetchAutoholds } from '../../actions/autoholds'
import { addNotification, addApiError } from '../../actions/notifications'
import { IconProperty } from '../../Misc'
@ -98,7 +99,7 @@ function AutoholdTable(props) {
dispatch(fetchAutoholds(tenant))
})
.catch(error => {
dispatch(addApiError(error))
dispatch(addAutoholdError(error))
})
}

View File

@ -0,0 +1,212 @@
// Copyright 2021 Red Hat, Inc
//
// 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, useDispatch } from 'react-redux'
import {
Button,
Modal,
ModalVariant,
Form,
FormGroup,
TextInput
} from '@patternfly/react-core'
import { autohold } from '../../api'
import { addAutoholdError } from '../../actions/adminActions'
import { addNotification } from '../../actions/notifications'
import { fetchAutoholds } from '../../actions/autoholds'
const AutoholdModal = props => {
const dispatch = useDispatch()
const { tenant, user, showAutoholdModal, setShowAutoholdModal } = props
const [change, setChange] = useState('')
const [changeRef, setChangeRef] = useState('')
const [project, setProject] = useState('some project')
const [job_name, setJob_name] = useState('some job')
const [reason, setReason] = useState('-')
const [count, setCount] = useState(1)
const [nodeHoldExpiration, setNodeHoldExpiration] = useState(86400)
// Override defaults if optional parameters were passed
useEffect(() => {
if (props.change) { setChange(props.change) }
if (props.changeRef) { setChangeRef(props.changeRef) }
if (props.project) { setProject(props.project) }
if (props.jobName) { setJob_name(props.jobName) }
if (props.reason) {
setReason(props.reason)
} else {
setReason(
user.data
? 'Requested from the web UI by ' + user.data.profile.preferred_username
: '-'
)
}
}, [props.change, props.changeRef, props.project, props.jobName, props.reason, user.data])
function handleConfirm() {
let ah_change = change === '' ? null : change
let ah_ref = changeRef === '' ? null : changeRef
autohold(tenant.apiPrefix, project, job_name, ah_change, ah_ref, reason, parseInt(count), parseInt(nodeHoldExpiration), user.token)
.then(() => {
/* TODO it looks like there is a delay in the registering of the autohold request
by the backend, meaning we sometimes do not get the newly created request after
the dispatch. A solution could be to make the autoholds page auto-refreshing
like the status page.*/
dispatch(fetchAutoholds(tenant))
dispatch(addNotification(
{
text: 'Autohold request set successfully.',
type: 'success',
status: '',
url: '',
}))
})
.catch(error => {
dispatch(addAutoholdError(error))
})
setShowAutoholdModal(false)
}
return (
<Modal
variant={ModalVariant.small}
isOpen={showAutoholdModal}
title='Create an Autohold Request'
onClose={() => { setShowAutoholdModal(false) }}
actions={[
<Button
key="autohold_confirm"
variant="primary"
onClick={() => handleConfirm()}>Create</Button>,
<Button
key="autohold_cancel"
variant="link"
onClick={() => { setShowAutoholdModal(false) }}>Cancel</Button>
]}>
<Form isHorizontal>
<FormGroup
label="Project"
isRequired
fieldId="ah-form-project"
helperText="The project for which to hold the next failing build">
<TextInput
value={project}
isRequired
type="text"
id="ah-form-ref"
name="project"
onChange={(value) => { setProject(value) }} />
</FormGroup>
<FormGroup
label="Job"
isRequired
fieldId="ah-form-job-name"
helperText="The job for which to hold the next failing build">
<TextInput
value={job_name}
isRequired
type="text"
id="ah-form-job-name"
name="job_name"
onChange={(value) => { setJob_name(value) }} />
</FormGroup>
<FormGroup
label="Change"
fieldId="ah-form-change"
helperText="The change for which to hold the next failing build">
<TextInput
value={change}
type="text"
id="ah-form-change"
name="change"
onChange={(value) => { setChange(value) }} />
</FormGroup>
<FormGroup
label="Ref"
fieldId="ah-form-ref"
helperText="The ref for which to hold the next failing build">
<TextInput
value={changeRef}
type="text"
id="ah-form-ref"
name="change"
onChange={(value) => { setChangeRef(value) }} />
</FormGroup>
<FormGroup
label="Reason"
isRequired
fieldId="ah-form-reason"
helperText="A descriptive reason for holding the next failing build">
<TextInput
value={reason}
isRequired
type="text"
id="ah-form-reason"
name="reason"
onChange={(value) => { setReason(value) }} />
</FormGroup>
<FormGroup
label="Count"
isRequired
fieldId="ah-form-count"
helperText="How many times a failing build should be held">
<TextInput
value={count}
isRequired
type="number"
id="ah-form-count"
name="count"
onChange={(value) => { setCount(value) }} />
</FormGroup>
<FormGroup
label="Node Hold Expires in (s)"
isRequired
fieldId="ah-form-nhe"
helperText="How long nodes should be kept in HELD state (seconds)">
<TextInput
value={nodeHoldExpiration}
isRequired
type="number"
id="ah-form-count"
name="nodeHoldExpiration"
onChange={(value) => { setNodeHoldExpiration(value) }} />
</FormGroup>
</Form>
</Modal>
)
}
AutoholdModal.propTypes = {
tenant: PropTypes.object,
user: PropTypes.object,
change: PropTypes.string,
changeRef: PropTypes.string,
project: PropTypes.string,
jobName: PropTypes.string,
reason: PropTypes.string,
showAutoholdModal: PropTypes.bool,
setShowAutoholdModal: PropTypes.func,
}
export default connect((state) => ({
tenant: state.tenant,
user: state.user,
}))(AutoholdModal)

View File

@ -14,17 +14,10 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { connect, useDispatch } from 'react-redux'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { Flex, FlexItem, List, ListItem, Title } from '@patternfly/react-core'
import {
Button,
Modal,
ModalVariant,
Form,
FormGroup,
TextInput
} from '@patternfly/react-core'
import {
BookIcon,
BuildIcon,
@ -46,39 +39,17 @@ import 'moment-duration-format'
import { BuildResultBadge, BuildResultWithIcon } from './Misc'
import { buildExternalLink, ExternalLink, IconProperty } from '../../Misc'
import { autohold } from '../../api'
import { addNotification, addApiError } from '../../actions/notifications'
import AutoholdModal from '../autohold/autoholdModal'
function Build({ build, tenant, timezone, user }) {
const [showAutoholdModal, setShowAutoholdModal] = useState(false)
let default_form_reason = user.data ? 'Requested from the web UI by ' + user.data.profile.preferred_username : '-'
const [ahFormReason, setAhFormReason] = useState(default_form_reason)
const [ahFormCount, setAhFormCount] = useState(1)
const [ahFormNodeHoldExpiration, setAhFormNodeHoldExpiration] = useState(86400)
const change = build.change ? build.change : ''
const ref = build.change ? '' : build.ref
const project = build.project
const job_name = build.job_name
const build_link = buildExternalLink(build)
const dispatch = useDispatch()
function handleConfirm() {
let change = build.change ? build.change : null
let ref = change ? null : build.ref
autohold(tenant.apiPrefix, build.project, build.job_name, change, ref, ahFormReason, parseInt(ahFormCount), parseInt(ahFormNodeHoldExpiration), user.token)
.then(() => {
dispatch(addNotification(
{
text: 'Autohold request set successfully.',
type: 'success',
status: '',
url: '',
}))
})
.catch(error => {
dispatch(addApiError(error))
})
}
function renderAutoholdButton() {
const value = (
<span style={{
@ -103,71 +74,6 @@ function Build({ build, tenant, timezone, user }) {
)
}
function renderAutoholdModal() {
return (
<Modal
variant={ModalVariant.small}
titleIconVariant={LockIcon}
isOpen={showAutoholdModal}
title='Create an Autohold Request'
onClose={() => { setShowAutoholdModal(false) }}
actions={[
<Button
key="autohold_confirm"
variant="primary"
onClick={() => {
handleConfirm()
setShowAutoholdModal(false)
}}>Create</Button>,
<Button
key="autohold_cancel"
variant="link"
onClick={() => { setShowAutoholdModal(false) }}>Cancel</Button>
]}>
<Form isHorizontal>
<FormGroup
label="Reason"
isRequired
fieldId="ah-form-reason"
helperText="A descriptive reason for holding the next failing build">
<TextInput
value={ahFormReason}
isRequired
type="text"
id="ah-form-reason"
name="ahFormReason"
onChange={(value) => { setAhFormReason(value) }} />
</FormGroup>
<FormGroup
label="Count"
isRequired
fieldId="ah-form-count"
helperText="How many times a failing build should be held">
<TextInput
value={ahFormCount}
isRequired
type="number"
id="ah-form-count"
name="ahFormCount"
onChange={(value) => { setAhFormCount(value) }} />
</FormGroup>
<FormGroup
label="Node Hold Expires in (s)"
isRequired
fieldId="ah-form-nhe"
helperText="How long nodes should be kept in HELD state (seconds)">
<TextInput
value={ahFormNodeHoldExpiration}
isRequired
type="number"
id="ah-form-count"
name="ahFormNodeHoldExpiration"
onChange={(value) => { setAhFormNodeHoldExpiration(value) }} />
</FormGroup>
</Form>
</Modal>
)
}
return (
<>
@ -364,7 +270,14 @@ function Build({ build, tenant, timezone, user }) {
</FlexItem>
</Flex>
</Flex>
{renderAutoholdModal()}
{<AutoholdModal
showAutoholdModal={showAutoholdModal}
setShowAutoholdModal={setShowAutoholdModal}
change={change}
changeRef={ref}
project={project}
jobName={job_name}
/>}
</>
)
}

View File

@ -15,47 +15,103 @@
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { PageSection, PageSectionVariants } from '@patternfly/react-core'
import { Flex, FlexItem, PageSection, PageSectionVariants } from '@patternfly/react-core'
import { fetchAutoholdsIfNeeded } from '../actions/autoholds'
import AutoholdTable from '../containers/autohold/AutoholdTable'
import {
AddCircleOIcon,
} from '@patternfly/react-icons'
import AutoholdModal from '../containers/autohold/autoholdModal'
import { IconProperty } from '../Misc'
class AutoholdsPage extends React.Component {
static propTypes = {
tenant: PropTypes.object,
user: PropTypes.object,
remoteData: PropTypes.object,
dispatch: PropTypes.func
}
constructor() {
super()
this.state = {
showAutoholdModal: false,
}
}
setShowAutoholdModal = (value) => {
this.setState(() => ({ showAutoholdModal: value }))
}
renderAutoholdButton() {
const value = (<span style={{
cursor: 'pointer',
color: 'var(--pf-global--primary-color--100)'
}}
title="Create a new autohold request to hold nodes in case of a build failure"
onClick={(event) => {
event.preventDefault()
this.setShowAutoholdModal(true)
}}
>
Create Request
</span>)
return (
<IconProperty
icon={<AddCircleOIcon />}
value={value}
/>
)
}
updateData = (force) => {
this.props.dispatch(fetchAutoholdsIfNeeded(this.props.tenant, force))
}
componentDidMount () {
componentDidMount() {
document.title = 'Zuul Autoholds'
if (this.props.tenant.name) {
this.updateData()
}
}
componentDidUpdate (prevProps) {
componentDidUpdate(prevProps) {
if (this.props.tenant.name !== prevProps.tenant.name) {
this.updateData()
}
}
render () {
const { remoteData } = this.props
render() {
const { tenant, user, remoteData } = this.props
const autoholds = remoteData.autoholds
const { showAutoholdModal } = this.state
return (
<PageSection variant={PageSectionVariants.light}>
<AutoholdTable
autoholds={autoholds}
fetching={remoteData.isFetching} />
</PageSection>
<>
{(user.isAdmin && user.scope.indexOf(tenant.name) !== -1) && (
<PageSection>
<Flex flex={{ default: 'flex_1' }}>
<FlexItem>
{this.renderAutoholdButton()}
</FlexItem>
</Flex>
</PageSection>
)}
<PageSection variant={PageSectionVariants.light}>
<AutoholdTable
autoholds={autoholds}
fetching={remoteData.isFetching} />
</PageSection>
{<AutoholdModal
showAutoholdModal={showAutoholdModal}
setShowAutoholdModal={this.setShowAutoholdModal}
/>}
</>
)
}
}
@ -63,4 +119,5 @@ class AutoholdsPage extends React.Component {
export default connect(state => ({
tenant: state.tenant,
remoteData: state.autoholds,
user: state.user,
}))(AutoholdsPage)