web UI: allow a privileged user to re-enqueue a change

Add a "re-enqueue" action command on a Buildset page, displayed only
if the currently logged in user is an admin on the tenant. By
clicking this command, the user can re-enqueue the change with
the same parameters as the buildset.

Redux: turned the API error notifications into a more generic
notification system, that can now include success notifications.

Change-Id: I05b6b3deb912b121df8de207944d9ec26e7c92d1
This commit is contained in:
Matthieu Huin 2020-12-21 14:17:18 +01:00
parent 3152171aa5
commit c82619174c
8 changed files with 204 additions and 60 deletions

View File

@ -64,7 +64,7 @@ import { Fetching } from './containers/Fetching'
import SelectTz from './containers/timezone/SelectTz' import SelectTz from './containers/timezone/SelectTz'
import ConfigModal from './containers/config/Config' import ConfigModal from './containers/config/Config'
import logo from './images/logo.svg' import logo from './images/logo.svg'
import { clearError } from './actions/errors' import { clearNotification } from './actions/notifications'
import { fetchConfigErrorsAction } from './actions/configErrors' import { fetchConfigErrorsAction } from './actions/configErrors'
import { routes } from './routes' import { routes } from './routes'
import { setTenantAction } from './actions/tenant' import { setTenantAction } from './actions/tenant'
@ -72,7 +72,7 @@ import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
class App extends React.Component { class App extends React.Component {
static propTypes = { static propTypes = {
errors: PropTypes.array, notifications: PropTypes.array,
configErrors: PropTypes.array, configErrors: PropTypes.array,
info: PropTypes.object, info: PropTypes.object,
tenant: PropTypes.object, tenant: PropTypes.object,
@ -235,21 +235,34 @@ class App extends React.Component {
}) })
} }
renderErrors = (errors) => { renderNotifications = (notifications) => {
return ( return (
<ToastNotificationList> <ToastNotificationList>
{errors.map(error => ( {notifications.map(notification => {
<TimedToastNotification let notificationBody
key={error.id} if (notification.type === 'error') {
type='error' notificationBody = (
onDismiss={() => { this.props.dispatch(clearError(error.id)) }} <>
> <strong>{notification.text}</strong> {notification.status} &nbsp;
<span title={moment.utc(error.date).tz(this.props.timezone).format()}> {notification.url}
<strong>{error.text}</strong> ({error.status})&nbsp; </>
{error.url} )
</span> } else {
</TimedToastNotification> notificationBody = (<span>{notification.text}</span>)
))} }
return (
<TimedToastNotification
key={notification.id}
type={notification.type}
onDismiss={() => { this.props.dispatch(clearNotification(notification.id)) }}
>
<span title={moment.utc(notification.date).tz(this.props.timezone).format()}>
{notificationBody}
</span>
</TimedToastNotification>
)
}
)}
</ToastNotificationList> </ToastNotificationList>
) )
} }
@ -318,7 +331,7 @@ class App extends React.Component {
render() { render() {
const { isKebabDropdownOpen } = this.state const { isKebabDropdownOpen } = this.state
const { errors, configErrors, tenant } = this.props const { notifications, configErrors, tenant } = this.props
const nav = this.renderMenu() const nav = this.renderMenu()
@ -440,7 +453,7 @@ class App extends React.Component {
return ( return (
<React.Fragment> <React.Fragment>
{errors.length > 0 && this.renderErrors(errors)} {notifications.length > 0 && this.renderNotifications(notifications)}
{this.renderConfigErrors(configErrors)} {this.renderConfigErrors(configErrors)}
<Page header={pageHeader}> <Page header={pageHeader}>
<ErrorBoundary> <ErrorBoundary>
@ -455,7 +468,7 @@ class App extends React.Component {
// This connect the info state from the store to the info property of the App. // This connect the info state from the store to the info property of the App.
export default withRouter(connect( export default withRouter(connect(
state => ({ state => ({
errors: state.errors, notifications: state.notifications,
configErrors: state.configErrors, configErrors: state.configErrors,
info: state.info, info: state.info,
tenant: state.tenant, tenant: state.tenant,

View File

@ -13,9 +13,14 @@
// under the License. // under the License.
export const ADMIN_DEQUEUE_FAIL = 'ADMIN_DEQUEUE_FAIL' export const ADMIN_DEQUEUE_FAIL = 'ADMIN_DEQUEUE_FAIL'
export const ADMIN_ENQUEUE_FAIL = 'ADMIN_ENQUEUE_FAIL'
export const addDequeueError = error => ({ export const addDequeueError = error => ({
type: ADMIN_DEQUEUE_FAIL, type: ADMIN_DEQUEUE_FAIL,
error: error notification: error
})
export const addEnqueueError = error => ({
type: ADMIN_ENQUEUE_FAIL,
notification: error
}) })

View File

@ -12,38 +12,39 @@
// License for the specific language governing permissions and limitations // License for the specific language governing permissions and limitations
// under the License. // under the License.
export const ADD_ERROR = 'ADD_ERROR' export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'
export const CLEAR_ERROR = 'CLEAR_ERROR' export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'
export const CLEAR_ERRORS = 'CLEAR_ERRORS' export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
let errorId = 0 let notificationId = 0
export const addError = error => ({ export const addNotification = notification => ({
type: ADD_ERROR, type: ADD_NOTIFICATION,
id: errorId++, id: notificationId++,
error notification
}) })
export const addApiError = error => { export const addApiError = error => {
const d = { const d = {
url: (error && error.request && error.request.responseURL) || error.url url: (error && error.request && error.request.responseURL) || error.url,
type: 'error',
} }
if (error.response) { if (error.response) {
d.text = error.response.statusText d.text = error.response.statusText
d.status = error.response.status d.status = error.response.status
} else { } else {
d.status = 'Unable to fetch URL, check your network connectivity,' d.status = 'Unable to fetch URL, check your network connectivity,'
+ ' browser plugins, ad-blockers, or try to refresh this page' + ' browser plugins, ad-blockers, or try to refresh this page'
d.text = error.message d.text = error.message
} }
return addError(d) return addNotification(d)
} }
export const clearError = id => ({ export const clearNotification = id => ({
type: CLEAR_ERROR, type: CLEAR_NOTIFICATION,
id id
}) })
export const clearErrors = () => ({ export const clearNotifications = () => ({
type: CLEAR_ERRORS type: CLEAR_NOTIFICATIONS
}) })

View File

@ -181,7 +181,7 @@ function fetchUserAuthorizations(apiPrefix, token) {
return res return res
} }
function dequeue (apiPrefix, projectName, pipeline, change, token) { function dequeue(apiPrefix, projectName, pipeline, change, token) {
const instance = Axios.create({ const instance = Axios.create({
baseURL: apiUrl baseURL: apiUrl
}) })
@ -195,7 +195,7 @@ function dequeue (apiPrefix, projectName, pipeline, change, token) {
) )
return res return res
} }
function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) { function dequeue_ref(apiPrefix, projectName, pipeline, ref, token) {
const instance = Axios.create({ const instance = Axios.create({
baseURL: apiUrl baseURL: apiUrl
}) })
@ -210,6 +210,37 @@ function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) {
return res return res
} }
function enqueue(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 + '/enqueue',
{
pipeline: pipeline,
change: change,
}
)
return res
}
function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, token) {
const instance = Axios.create({
baseURL: apiUrl
})
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
let res = instance.post(
apiPrefix + 'project/' + projectName + '/enqueue',
{
pipeline: pipeline,
ref: ref,
oldrev: oldrev,
newrev: newrev,
}
)
return res
}
export { export {
apiUrl, apiUrl,
getHomepageUrl, getHomepageUrl,
@ -235,4 +266,6 @@ export {
fetchUserAuthorizations, fetchUserAuthorizations,
dequeue, dequeue,
dequeue_ref, dequeue_ref,
enqueue,
enqueue_ref,
} }

View File

@ -14,7 +14,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { connect } from 'react-redux' import { connect, useDispatch } from 'react-redux'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
Button, Button,
@ -23,6 +23,8 @@ import {
List, List,
ListItem, ListItem,
Title, Title,
Modal,
ModalVariant,
} from '@patternfly/react-core' } from '@patternfly/react-core'
import { import {
CodeIcon, CodeIcon,
@ -33,16 +35,19 @@ import {
StreamIcon, StreamIcon,
OutlinedCalendarAltIcon, OutlinedCalendarAltIcon,
OutlinedClockIcon, OutlinedClockIcon,
RedoAltIcon,
} from '@patternfly/react-icons' } from '@patternfly/react-icons'
import * as moment from 'moment' import * as moment from 'moment'
import 'moment-duration-format' import 'moment-duration-format'
import { buildExternalLink } from '../../Misc' import { buildExternalLink } from '../../Misc'
import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc' import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc'
import { enqueue, enqueue_ref } from '../../api'
import { addNotification, addApiError } from '../../actions/notifications'
import { ChartModal } from '../charts/ChartModal' import { ChartModal } from '../charts/ChartModal'
import BuildsetGanttChart from '../charts/GanttChart' import BuildsetGanttChart from '../charts/GanttChart'
function Buildset({ buildset, timezone, tenant }) { function Buildset({ buildset, timezone, tenant, user }) {
const buildset_link = buildExternalLink(buildset) const buildset_link = buildExternalLink(buildset)
const [isGanttChartModalOpen, setIsGanttChartModalOpen] = useState(false) const [isGanttChartModalOpen, setIsGanttChartModalOpen] = useState(false)
@ -127,13 +132,92 @@ function Buildset({ buildset, timezone, tenant }) {
) )
} }
const [showEnqueueModal, setShowEnqueueModal] = useState(false)
const dispatch = useDispatch()
function renderEnqueueButton() {
const value = (<span style={{
cursor: 'pointer',
color: 'var(--pf-global--primary-color--100)'
}}
title="Re-enqueue this change"
onClick={(event) => {
event.preventDefault()
setShowEnqueueModal(true)
}}
>
Re-enqueue buildset
</span>)
return (
<IconProperty
WrapElement={ListItem}
icon={<RedoAltIcon />}
value={value}
/>
)
}
function enqueueConfirm() {
let changeId = buildset.change ? buildset.change + ',' + buildset.patchset : buildset.newrev
setShowEnqueueModal(false)
if (/^[0-9a-f]{40}$/.test(changeId)) {
const oldrev = '0000000000000000000000000000000000000000'
enqueue_ref(tenant.apiPrefix, buildset.project, buildset.pipeline, buildset.ref, oldrev, changeId, user.token)
.then(() => {
dispatch(addNotification(
{
text: 'Change queued successfully.',
type: 'success',
status: '',
url: '',
}))
})
.catch(error => {
dispatch(addApiError(error))
})
} else {
enqueue(tenant.apiPrefix, buildset.project, buildset.pipeline, changeId, user.token)
.then(() => {
dispatch(addNotification(
{
text: 'Change queued successfully.',
type: 'success',
status: '',
url: '',
}))
})
.catch(error => {
dispatch(addApiError(error))
})
}
}
function renderEnqueueModal() {
let changeId = buildset.change ? buildset.change + ',' + buildset.patchset : buildset.newrev
const title = 'You are about to re-enqueue a change'
return (
<Modal
variant={ModalVariant.small}
// titleIconVariant={BullhornIcon}
isOpen={showEnqueueModal}
title={title}
onClose={() => { setShowEnqueueModal(false) }}
actions={[
<Button key="deq_confirm" variant="primary" onClick={enqueueConfirm}>Confirm</Button>,
<Button key="deq_cancel" variant="link" onClick={() => { setShowEnqueueModal(false) }}>Cancel</Button>,
]}>
<p>Please confirm that you want to re-enqueue <strong>all jobs</strong> for change <strong>{changeId}</strong> (project <strong>{buildset.project}</strong>) on pipeline <strong>{buildset.pipeline}</strong>.</p>
</Modal>
)
}
return ( return (
<> <>
<Title headingLevel="h2"> <Title headingLevel="h2">
<BuildResultWithIcon result={buildset.result} size="md"> <BuildResultWithIcon result={buildset.result} size="md">
Buildset result Buildset result
</BuildResultWithIcon> </BuildResultWithIcon>
<BuildResultBadge result={buildset.result} /> <BuildResultBadge result={buildset.result} /> &nbsp;
</Title> </Title>
{/* We handle the spacing for the body and the flex items by ourselves {/* We handle the spacing for the body and the flex items by ourselves
so they go hand in hand. By default, the flex items' spacing only so they go hand in hand. By default, the flex items' spacing only
@ -216,6 +300,10 @@ function Buildset({ buildset, timezone, tenant }) {
</> </>
} }
/> />
{(user.isAdmin && user.scope.indexOf(tenant.name) !== -1) &&
<>
{renderEnqueueButton()}
</>}
</List> </List>
</FlexItem> </FlexItem>
</Flex> </Flex>
@ -228,6 +316,7 @@ function Buildset({ buildset, timezone, tenant }) {
setIsGanttChartModalOpen(false) setIsGanttChartModalOpen(false)
}} }}
/> />
{renderEnqueueModal()}
</> </>
) )
} }
@ -236,9 +325,11 @@ Buildset.propTypes = {
buildset: PropTypes.object, buildset: PropTypes.object,
tenant: PropTypes.object, tenant: PropTypes.object,
timezone: PropTypes.string, timezone: PropTypes.string,
user: PropTypes.object,
} }
export default connect((state) => ({ export default connect((state) => ({
tenant: state.tenant, tenant: state.tenant,
timezone: state.timezone, timezone: state.timezone,
user: state.user,
}))(Buildset) }))(Buildset)

View File

@ -31,7 +31,7 @@ import {
import { dequeue, dequeue_ref } from '../../api' import { dequeue, dequeue_ref } from '../../api'
import { addDequeueError } from '../../actions/adminActions' import { addDequeueError } from '../../actions/adminActions'
import { addError } from '../../actions/errors' import { addNotification } from '../../actions/notifications'
import LineAngleImage from '../../images/line-angle.png' import LineAngleImage from '../../images/line-angle.png'
import LineTImage from '../../images/line-t.png' import LineTImage from '../../images/line-t.png'
@ -73,10 +73,11 @@ class Change extends React.Component {
this.props.dispatch(addDequeueError(error)) this.props.dispatch(addDequeueError(error))
}) })
} else { } else {
this.props.dispatch(addError({ this.props.dispatch(addNotification({
url: null, url: null,
status: 'Invalid change ' + changeRef + ' on project ' + projectName, status: 'Invalid change ' + changeRef + ' on project ' + projectName,
text: '' text: '',
type: 'error'
})) }))
} }
} }

View File

@ -18,7 +18,7 @@ import auth from './auth'
import configErrors from './configErrors' import configErrors from './configErrors'
import change from './change' import change from './change'
import component from './component' import component from './component'
import errors from './errors' import notifications from './notifications'
import build from './build' import build from './build'
import info from './info' import info from './info'
import job from './job' import job from './job'
@ -42,7 +42,7 @@ const reducers = {
change, change,
component, component,
configErrors, configErrors,
errors, notifications,
info, info,
job, job,
jobs, jobs,

View File

@ -13,34 +13,34 @@
// under the License. // under the License.
import { import {
ADD_ERROR, ADD_NOTIFICATION,
CLEAR_ERROR, CLEAR_NOTIFICATION,
CLEAR_ERRORS, CLEAR_NOTIFICATIONS,
addApiError, addApiError,
} from '../actions/errors' } from '../actions/notifications'
export default (state = [], action) => { export default (state = [], action) => {
// Intercept API failure // Intercept API failure
if (action.error && action.type.match(/.*_FETCH_FAIL$/)) { if (action.notification && action.type.match(/.*_FETCH_FAIL$/)) {
action = addApiError(action.error) action = addApiError(action.notification)
} }
// Intercept Admin API failures // Intercept Admin API failures
if (action.error && action.type.match(/ADMIN_.*_FAIL$/)) { if (action.notification && action.type.match(/ADMIN_.*_FAIL$/)) {
action = addApiError(action.error) action = addApiError(action.notification)
} }
switch (action.type) { switch (action.type) {
case ADD_ERROR: case ADD_NOTIFICATION:
if (state.filter(error => ( if (state.filter(notification => (
error.url === action.error.url && notification.url === action.notification.url &&
error.status === action.error.status)).length > 0) notification.status === action.notification.status)).length > 0)
return state return state
return [ return [
...state, ...state,
{ ...action.error, id: action.id, date: Date.now() }] { ...action.notification, id: action.id, date: Date.now() }]
case CLEAR_ERROR: case CLEAR_NOTIFICATION:
return state.filter(item => (item.id !== action.id)) return state.filter(item => (item.id !== action.id))
case CLEAR_ERRORS: case CLEAR_NOTIFICATIONS:
return [] return []
default: default:
return state return state