diff --git a/releasenotes/notes/web-ui-admin-commands-40f8425b9590d063.yaml b/releasenotes/notes/web-ui-admin-commands-40f8425b9590d063.yaml new file mode 100644 index 0000000000..dd47a6e3e8 --- /dev/null +++ b/releasenotes/notes/web-ui-admin-commands-40f8425b9590d063.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Allow an authorized user to dequeue changes from the Web UI's status page. diff --git a/web/src/actions/adminActions.js b/web/src/actions/adminActions.js new file mode 100644 index 0000000000..a3cbc1a045 --- /dev/null +++ b/web/src/actions/adminActions.js @@ -0,0 +1,21 @@ +// Copyright 2020 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. + +export const ADMIN_DEQUEUE_FAIL = 'ADMIN_DEQUEUE_FAIL' + + +export const addDequeueError = error => ({ + type: ADMIN_DEQUEUE_FAIL, + error: error +}) diff --git a/web/src/api.js b/web/src/api.js index c032aa75e4..5bf8b5b06e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -181,6 +181,35 @@ function fetchUserAuthorizations(apiPrefix, token) { return res } +function dequeue (apiPrefix, projectName, pipeline, change, token) { + const instance = Axios.create({ + baseURL: apiUrl + }) + instance.defaults.headers.common['Authorization'] = 'Bearer ' + token + let res = instance.post( + apiPrefix + 'project/' + projectName + '/dequeue', + { + pipeline: pipeline, + change: change, + } + ) + return res +} +function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) { + const instance = Axios.create({ + baseURL: apiUrl + }) + instance.defaults.headers.common['Authorization'] = 'Bearer ' + token + let res = instance.post( + apiPrefix + 'project/' + projectName + '/dequeue', + { + pipeline: pipeline, + ref: ref, + } + ) + return res +} + export { apiUrl, getHomepageUrl, @@ -204,4 +233,6 @@ export { fetchComponents, fetchTenantInfo, fetchUserAuthorizations, + dequeue, + dequeue_ref, } diff --git a/web/src/containers/status/Change.jsx b/web/src/containers/status/Change.jsx index 30043d1036..ce5cb5ab64 100644 --- a/web/src/containers/status/Change.jsx +++ b/web/src/containers/status/Change.jsx @@ -17,6 +17,22 @@ import PropTypes from 'prop-types' import { connect } from 'react-redux' import { Link } from 'react-router-dom' +import { + Button, + Dropdown, + DropdownItem, + KebabToggle, + Modal, + ModalVariant +} from '@patternfly/react-core' +import { + BanIcon, +} from '@patternfly/react-icons' +import { dequeue, dequeue_ref } from '../../api' +import { addDequeueError } from '../../actions/adminActions' + +import { addError } from '../../actions/errors' + import LineAngleImage from '../../images/line-angle.png' import LineTImage from '../../images/line-t.png' import ChangePanel from './ChangePanel' @@ -27,10 +43,112 @@ class Change extends React.Component { change: PropTypes.object.isRequired, queue: PropTypes.object.isRequired, expanded: PropTypes.bool.isRequired, - tenant: PropTypes.object + pipeline: PropTypes.string, + tenant: PropTypes.object, + user: PropTypes.object, + dispatch: PropTypes.func } - renderStatusIcon (change) { + state = { + showDequeueModal: false, + showAdminActions: false, + } + + dequeueConfirm = () => { + const { tenant, user, change, pipeline } = this.props + let projectName = change.project + let changeId = change.id || 'N/A' + let changeRef = change.ref + this.setState(() => ({ showDequeueModal: false })) + // post-merge + if (/^[0-9a-f]{40}$/.test(changeId)) { + dequeue_ref(tenant.apiPrefix, projectName, pipeline.name, changeRef, user.token) + .catch(error => { + this.props.dispatch(addDequeueError(error)) + }) + // pre-merge, ie we have a change id + } else if (changeId !== 'N/A') { + dequeue(tenant.apiPrefix, projectName, pipeline.name, changeId, user.token) + .catch(error => { + this.props.dispatch(addDequeueError(error)) + }) + } else { + this.props.dispatch(addError({ + url: null, + status: 'Invalid change ' + changeRef + ' on project ' + projectName, + text: '' + })) + } + } + + dequeueCancel = () => { + this.setState(() => ({ showDequeueModal: false })) + } + + renderDequeueModal() { + const { showDequeueModal } = this.state + const { change } = this.props + let projectName = change.project + let changeId = change.id || change.ref + const title = 'You are about to dequeue a change' + return ( + Confirm, + , + ]}> +

Please confirm that you want to cancel all ongoing builds on change {changeId} for project {projectName}.

+
+ ) + } + + renderAdminCommands(idx) { + const { showAdminActions } = this.state + const { queue } = this.props + const dropdownCommands = [ + } + description="Stop all jobs for this change" + onClick={(event) => { + event.preventDefault() + this.setState(() => ({ showDequeueModal: true })) + }} + >Dequeue, + ] + return ( + { + this.setState({ showAdminActions: !showAdminActions }) + const element = document.getElementById('toggle-id-' + idx + '-' + queue.uuid) + element.focus() + }} + dropdownItems={dropdownCommands} + isPlain + toggle={ + { + this.setState({ showAdminActions }) + }} + id={'toggle-id-' + idx + '-' + queue.uuid} /> + } + /> + ) + + } + + + renderStatusIcon(change) { let iconGlyph = 'pficon pficon-ok' let iconTitle = 'Succeeding' if (change.active !== true) { @@ -38,10 +156,10 @@ class Change extends React.Component { iconTitle = 'Waiting until closer to head of queue to' + ' start jobs' } else if (change.live !== true) { - iconGlyph = 'pficon pficon-info' + iconGlyph = 'pficon pficon-info' iconTitle = 'Dependent change required for testing' } else if (change.failing_reasons && - change.failing_reasons.length > 0) { + change.failing_reasons.length > 0) { let reason = change.failing_reasons.join(', ') iconTitle = 'Failing because ' + reason if (reason.match(/merge conflict/)) { @@ -66,18 +184,19 @@ class Change extends React.Component { } } - renderLineImg (change, i) { + renderLineImg(change, i) { let image = LineTImage if (change._tree_branches.indexOf(i) === change._tree_branches.length - 1) { // Angle line image = LineAngleImage } - return Line + return Line } - render () { - const { change, queue, expanded } = this.props + render() { + const { change, queue, expanded, pipeline, user, tenant } = this.props let row = [] + let adminMenuWidth = 15 let i for (i = 0; i < queue._tree_columns; i++) { let className = '' @@ -91,22 +210,39 @@ class Change extends React.Component { this.renderLineImg(change, i)) : ''} ) } - let changeWidth = 360 - 16 * queue._tree_columns + let changeWidth = (user.isAdmin && user.scope.indexOf(tenant.name) !== -1) + ? 360 - adminMenuWidth - 16 * queue._tree_columns + : 360 - 16 * queue._tree_columns row.push( - + style={{ width: changeWidth + 'px' }}> + ) + if (user.isAdmin && user.scope.indexOf(tenant.name) !== -1) { + row.push( + + {this.renderAdminCommands(i + 2)} + + ) + } + return ( - - - {row} - -
+ <> + + + {row} + +
+ {this.renderDequeueModal()} + ) } } -export default connect(state => ({tenant: state.tenant}))(Change) +export default connect(state => ({ + tenant: state.tenant, + user: state.user, +}))(Change) diff --git a/web/src/containers/status/ChangeQueue.jsx b/web/src/containers/status/ChangeQueue.jsx index 8761ee8e7b..319381f4ec 100644 --- a/web/src/containers/status/ChangeQueue.jsx +++ b/web/src/containers/status/ChangeQueue.jsx @@ -43,6 +43,7 @@ class ChangeQueue extends React.Component { change={change} queue={queue} expanded={expanded} + pipeline={pipeline} key={changeIdx.toString() + idx} />) }) diff --git a/web/src/pf4-migration.css b/web/src/pf4-migration.css index 8c3e4d5fe2..5c5cde809b 100644 --- a/web/src/pf4-migration.css +++ b/web/src/pf4-migration.css @@ -69,6 +69,12 @@ span.zuul-job-result.label { vertical-align: top; } +/* Restore the menuitem styling */ +.zuul-status-content .pf-c-content ul[role="menu"] { + padding-left: 0; + list-style: none; +} + /* Avoid duplicated lines/borders for the job items on the status page */ .zuul-status-content li+li { margin-top: 0; diff --git a/web/src/reducers/errors.js b/web/src/reducers/errors.js index c8b2689771..ae9632487c 100644 --- a/web/src/reducers/errors.js +++ b/web/src/reducers/errors.js @@ -25,6 +25,10 @@ export default (state = [], action) => { if (action.error && action.type.match(/.*_FETCH_FAIL$/)) { action = addApiError(action.error) } + // Intercept Admin API failures + if (action.error && action.type.match(/ADMIN_.*_FAIL$/)) { + action = addApiError(action.error) + } switch (action.type) { case ADD_ERROR: if (state.filter(error => (