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

View File

@ -13,9 +13,14 @@
// under the License.
export const ADMIN_DEQUEUE_FAIL = 'ADMIN_DEQUEUE_FAIL'
export const ADMIN_ENQUEUE_FAIL = 'ADMIN_ENQUEUE_FAIL'
export const addDequeueError = error => ({
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
// under the License.
export const ADD_ERROR = 'ADD_ERROR'
export const CLEAR_ERROR = 'CLEAR_ERROR'
export const CLEAR_ERRORS = 'CLEAR_ERRORS'
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
let errorId = 0
let notificationId = 0
export const addError = error => ({
type: ADD_ERROR,
id: errorId++,
error
export const addNotification = notification => ({
type: ADD_NOTIFICATION,
id: notificationId++,
notification
})
export const addApiError = error => {
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) {
d.text = error.response.statusText
d.status = error.response.status
} else {
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
}
return addError(d)
return addNotification(d)
}
export const clearError = id => ({
type: CLEAR_ERROR,
export const clearNotification = id => ({
type: CLEAR_NOTIFICATION,
id
})
export const clearErrors = () => ({
type: CLEAR_ERRORS
export const clearNotifications = () => ({
type: CLEAR_NOTIFICATIONS
})

View File

@ -181,7 +181,7 @@ function fetchUserAuthorizations(apiPrefix, token) {
return res
}
function dequeue (apiPrefix, projectName, pipeline, change, token) {
function dequeue(apiPrefix, projectName, pipeline, change, token) {
const instance = Axios.create({
baseURL: apiUrl
})
@ -195,7 +195,7 @@ function dequeue (apiPrefix, projectName, pipeline, change, token) {
)
return res
}
function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) {
function dequeue_ref(apiPrefix, projectName, pipeline, ref, token) {
const instance = Axios.create({
baseURL: apiUrl
})
@ -210,6 +210,37 @@ function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) {
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 {
apiUrl,
getHomepageUrl,
@ -235,4 +266,6 @@ export {
fetchUserAuthorizations,
dequeue,
dequeue_ref,
enqueue,
enqueue_ref,
}

View File

@ -14,7 +14,7 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { connect, useDispatch } from 'react-redux'
import { Link } from 'react-router-dom'
import {
Button,
@ -23,6 +23,8 @@ import {
List,
ListItem,
Title,
Modal,
ModalVariant,
} from '@patternfly/react-core'
import {
CodeIcon,
@ -33,16 +35,19 @@ import {
StreamIcon,
OutlinedCalendarAltIcon,
OutlinedClockIcon,
RedoAltIcon,
} from '@patternfly/react-icons'
import * as moment from 'moment'
import 'moment-duration-format'
import { buildExternalLink } 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 BuildsetGanttChart from '../charts/GanttChart'
function Buildset({ buildset, timezone, tenant }) {
function Buildset({ buildset, timezone, tenant, user }) {
const buildset_link = buildExternalLink(buildset)
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 (
<>
<Title headingLevel="h2">
<BuildResultWithIcon result={buildset.result} size="md">
Buildset result
</BuildResultWithIcon>
<BuildResultBadge result={buildset.result} />
<BuildResultBadge result={buildset.result} /> &nbsp;
</Title>
{/* 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
@ -216,6 +300,10 @@ function Buildset({ buildset, timezone, tenant }) {
</>
}
/>
{(user.isAdmin && user.scope.indexOf(tenant.name) !== -1) &&
<>
{renderEnqueueButton()}
</>}
</List>
</FlexItem>
</Flex>
@ -228,6 +316,7 @@ function Buildset({ buildset, timezone, tenant }) {
setIsGanttChartModalOpen(false)
}}
/>
{renderEnqueueModal()}
</>
)
}
@ -236,9 +325,11 @@ Buildset.propTypes = {
buildset: PropTypes.object,
tenant: PropTypes.object,
timezone: PropTypes.string,
user: PropTypes.object,
}
export default connect((state) => ({
tenant: state.tenant,
timezone: state.timezone,
user: state.user,
}))(Buildset)

View File

@ -31,7 +31,7 @@ import {
import { dequeue, dequeue_ref } from '../../api'
import { addDequeueError } from '../../actions/adminActions'
import { addError } from '../../actions/errors'
import { addNotification } from '../../actions/notifications'
import LineAngleImage from '../../images/line-angle.png'
import LineTImage from '../../images/line-t.png'
@ -73,10 +73,11 @@ class Change extends React.Component {
this.props.dispatch(addDequeueError(error))
})
} else {
this.props.dispatch(addError({
this.props.dispatch(addNotification({
url: null,
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 change from './change'
import component from './component'
import errors from './errors'
import notifications from './notifications'
import build from './build'
import info from './info'
import job from './job'
@ -42,7 +42,7 @@ const reducers = {
change,
component,
configErrors,
errors,
notifications,
info,
job,
jobs,

View File

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