diff --git a/web/src/Misc.jsx b/web/src/Misc.jsx
index e209ed0421..dae5a84bf9 100644
--- a/web/src/Misc.jsx
+++ b/web/src/Misc.jsx
@@ -63,7 +63,7 @@ function buildExternalLink(buildish) {
return (
Revision
- {buildish.newrev.slice(0,7)}
+ {buildish.newrev.slice(0, 7)}
)
}
@@ -84,7 +84,7 @@ function buildExternalTableLink(buildish) {
} else if (buildish.ref_url && buildish.newrev) {
return (
- {buildish.newrev.slice(0,7)}
+ {buildish.newrev.slice(0, 7)}
)
}
@@ -92,9 +92,32 @@ function buildExternalTableLink(buildish) {
return null
}
+function IconProperty(props) {
+ const { icon, value, WrapElement = 'span' } = props
+ return (
+
+
+ {icon}
+
+ {value}
+
+ )
+}
+
+IconProperty.propTypes = {
+ icon: PropTypes.node,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ WrapElement: PropTypes.func,
+}
+
// https://github.com/kitze/conditional-wrap
// appears to be the first implementation of this pattern
const ConditionalWrapper = ({ condition, wrapper, children }) =>
condition ? wrapper(children) : children
-export { removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper }
+export { IconProperty, removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper }
diff --git a/web/src/actions/autoholds.js b/web/src/actions/autoholds.js
new file mode 100644
index 0000000000..e6fc10335e
--- /dev/null
+++ b/web/src/actions/autoholds.js
@@ -0,0 +1,89 @@
+// 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.
+
+import * as API from '../api'
+
+export const AUTOHOLDS_FETCH_REQUEST = 'AUTOHOLDS_FETCH_REQUEST'
+export const AUTOHOLDS_FETCH_SUCCESS = 'AUTOHOLDS_FETCH_SUCCESS'
+export const AUTOHOLDS_FETCH_FAIL = 'AUTOHOLDS_FETCH_FAIL'
+
+export const AUTOHOLD_FETCH_REQUEST = 'AUTOHOLD_FETCH_REQUEST'
+export const AUTOHOLD_FETCH_SUCCESS = 'AUTOHOLD_FETCH_SUCCESS'
+export const AUTOHOLD_FETCH_FAIL = 'AUTOHOLD_FETCH_FAIL'
+
+export const requestAutoholds = () => ({
+ type: AUTOHOLDS_FETCH_REQUEST
+})
+
+export const receiveAutoholds = (tenant, json) => ({
+ type: AUTOHOLDS_FETCH_SUCCESS,
+ autoholds: json,
+ receivedAt: Date.now()
+})
+
+const failedAutoholds = error => ({
+ type: AUTOHOLDS_FETCH_FAIL,
+ error
+})
+
+export const fetchAutoholds = (tenant) => dispatch => {
+ dispatch(requestAutoholds())
+ return API.fetchAutoholds(tenant.apiPrefix)
+ .then(response => dispatch(receiveAutoholds(tenant.name, response.data)))
+ .catch(error => dispatch(failedAutoholds(error)))
+}
+
+const shouldFetchAutoholds = (tenant, state) => {
+ const autoholds = state.autoholds
+ if (!autoholds || autoholds.autoholds.length === 0) {
+ return true
+ }
+ if (autoholds.isFetching) {
+ return false
+ }
+ if (Date.now() - autoholds.receivedAt > 60000) {
+ // Refetch after 1 minutes
+ return true
+ }
+ return false
+}
+
+export const fetchAutoholdsIfNeeded = (tenant, force) => (
+ dispatch, getState) => {
+ if (force || shouldFetchAutoholds(tenant, getState())) {
+ return dispatch(fetchAutoholds(tenant))
+ }
+}
+
+export const requestAutohold = () => ({
+ type: AUTOHOLD_FETCH_REQUEST
+})
+
+export const receiveAutohold = (tenant, json) => ({
+ type: AUTOHOLD_FETCH_SUCCESS,
+ autohold: json,
+ receivedAt: Date.now()
+})
+
+const failedAutohold = error => ({
+ type: AUTOHOLD_FETCH_FAIL,
+ error
+})
+
+export const fetchAutohold = (tenant, requestId) => dispatch => {
+ dispatch(requestAutohold())
+ return API.fetchAutohold(tenant.apiPrefix, requestId)
+ .then(response => dispatch(receiveAutohold(tenant.name, response.data)))
+ .catch(error => dispatch(failedAutohold(error)))
+}
diff --git a/web/src/api.js b/web/src/api.js
index a66cbabd22..4c22e007b0 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -47,6 +47,7 @@ function getHomepageUrl(url) {
// Remove known sub-path
const subDir = [
+ '/autohold/',
'/build/',
'/buildset/',
'/job/',
@@ -167,6 +168,12 @@ function fetchLabels(apiPrefix) {
function fetchNodes(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'nodes')
}
+function fetchAutoholds(apiPrefix) {
+ return Axios.get(apiUrl + apiPrefix + 'autohold')
+}
+function fetchAutohold(apiPrefix, requestId) {
+ return Axios.get(apiUrl + apiPrefix + 'autohold/' + requestId)
+}
// token-protected API
function fetchUserAuthorizations(apiPrefix, token) {
@@ -240,8 +247,8 @@ function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, toke
)
return res
}
-function autohold (apiPrefix, projectName, job, change, ref,
- reason, count, node_hold_expiration, token) {
+function autohold(apiPrefix, projectName, job, change, ref,
+ reason, count, node_hold_expiration, token) {
const instance = Axios.create({
baseURL: apiUrl
})
@@ -260,6 +267,17 @@ function autohold (apiPrefix, projectName, job, change, ref,
return res
}
+function autohold_delete(apiPrefix, requestId, token) {
+ const instance = Axios.create({
+ baseURL: apiUrl
+ })
+ instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
+ let res = instance.delete(
+ apiPrefix + '/autohold/' + requestId
+ )
+ return res
+}
+
export {
apiUrl,
@@ -284,7 +302,10 @@ export {
fetchComponents,
fetchTenantInfo,
fetchUserAuthorizations,
+ fetchAutoholds,
+ fetchAutohold,
autohold,
+ autohold_delete,
dequeue,
dequeue_ref,
enqueue,
diff --git a/web/src/containers/autohold/AutoholdTable.jsx b/web/src/containers/autohold/AutoholdTable.jsx
new file mode 100644
index 0000000000..4391483ea0
--- /dev/null
+++ b/web/src/containers/autohold/AutoholdTable.jsx
@@ -0,0 +1,223 @@
+// 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.
+
+import React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+import {
+ EmptyState,
+ EmptyStateBody,
+ EmptyStateIcon,
+ Spinner,
+ Title,
+} from '@patternfly/react-core'
+import {
+ OutlinedQuestionCircleIcon,
+ HashtagIcon,
+ BuildIcon,
+ CodeBranchIcon,
+ CubeIcon,
+ OutlinedClockIcon,
+ LockIcon,
+ TrashIcon,
+ FingerprintIcon,
+} from '@patternfly/react-icons'
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableVariant,
+} from '@patternfly/react-table'
+import { Link } from 'react-router-dom'
+import * as moment from 'moment'
+
+import { autohold_delete } from '../../api'
+import { fetchAutoholds } from '../../actions/autoholds'
+import { addNotification, addApiError } from '../../actions/notifications'
+
+import { IconProperty } from '../../Misc'
+
+function AutoholdTable(props) {
+ const { autoholds, fetching, tenant, user, dispatch } = props
+ const columns = [
+ {
+ title: } value="ID" />,
+ dataLabel: 'Request ID',
+ },
+ {
+ title: } value="Project" />,
+ dataLabel: 'Project',
+ },
+ {
+ title: } value="Job" />,
+ dataLabel: 'Job',
+ },
+ {
+ title: } value="Ref Filter" />,
+ dataLabel: 'Ref Filter',
+ },
+ {
+ title: } value="Triggers" />,
+ dataLabel: 'Triggers',
+ },
+ {
+ title: } value="Reason" />,
+ dataLabel: 'Reason',
+ },
+ {
+ title: } value="Hold for" />,
+ dataLabel: 'Hold for',
+ },
+ {
+ title: '',
+ dataLabel: 'Delete',
+ }
+ ]
+
+ function handleAutoholdDelete(requestId) {
+ autohold_delete(tenant.apiPrefix, requestId, user.token)
+ .then(() => {
+ dispatch(addNotification(
+ {
+ text: 'Autohold request deleted successfully.',
+ type: 'success',
+ status: '',
+ url: '',
+ }))
+ dispatch(fetchAutoholds(tenant))
+ })
+ .catch(error => {
+ dispatch(addApiError(error))
+ })
+ }
+
+ function renderAutoholdDeleteButton(requestId) {
+ return (
+ {
+ event.preventDefault()
+ handleAutoholdDelete(requestId)
+ }} />
+ )
+ }
+
+ function createAutoholdRow(autohold) {
+ const count = autohold.current_count + '/' + autohold.max_count
+ const node_expiration = (autohold.node_expiration === 0) ? 'Indefinitely' : moment.duration(autohold.node_expiration, 'seconds').humanize()
+ const delete_button = (user.isAdmin && user.scope.indexOf(tenant.name) !== -1) ? renderAutoholdDeleteButton(autohold.id) : ''
+
+ return {
+ cells: [
+ {
+ title: (
+ {autohold.id}
+ ),
+ },
+ {
+ title: autohold.project,
+ },
+ {
+ title: autohold.job,
+ },
+ {
+ title: autohold.ref_filter,
+ },
+ {
+ title: count
+ },
+ {
+ title: autohold.reason,
+ },
+ {
+ title: node_expiration,
+ },
+ {
+ title: delete_button
+ },
+ ]
+ }
+ }
+
+ function createFetchingRow() {
+ const rows = [
+ {
+ heightAuto: true,
+ cells: [
+ {
+ props: { colSpan: 8 },
+ title: (
+
+
+
+ ),
+ },
+ ],
+ },
+ ]
+ return rows
+ }
+
+ let rows = []
+ if (fetching) {
+ rows = createFetchingRow()
+ columns[0].dataLabel = ''
+ } else {
+ rows = autoholds.map((autohold) => createAutoholdRow(autohold))
+ }
+
+ return (
+ <>
+
+
+ {/* Show an empty state in case we don't have any autoholds but are also not
+ fetching */}
+ {!fetching && autoholds.length === 0 && (
+
+
+ No autohold requests found
+
+ Nothing to display.
+
+
+ )}
+ >
+ )
+}
+
+AutoholdTable.propTypes = {
+ autoholds: PropTypes.array.isRequired,
+ fetching: PropTypes.bool.isRequired,
+ tenant: PropTypes.object,
+ user: PropTypes.object,
+ dispatch: PropTypes.func,
+}
+
+export default connect((state) => ({
+ tenant: state.tenant,
+ user: state.user,
+}))(AutoholdTable)
diff --git a/web/src/containers/autohold/HeldBuildList.jsx b/web/src/containers/autohold/HeldBuildList.jsx
new file mode 100644
index 0000000000..d2f45dd209
--- /dev/null
+++ b/web/src/containers/autohold/HeldBuildList.jsx
@@ -0,0 +1,85 @@
+// Copyright 2021 Red Hat
+//
+// 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 React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+import { Link } from 'react-router-dom'
+import {
+ DataList,
+ DataListCell,
+ DataListItem,
+ DataListItemRow,
+ DataListItemCells,
+} from '@patternfly/react-core'
+
+class HeldBuildList extends React.Component {
+ static propTypes = {
+ nodes: PropTypes.array,
+ tenant: PropTypes.object,
+ }
+
+ constructor() {
+ super()
+ this.state = {
+ selectedBuildId: null,
+ }
+ }
+
+ handleSelectDataListItem = (buildId) => {
+ this.setState({
+ selectedBuildId: buildId,
+ })
+ }
+
+ /* TODO find a way to add some more useful info than just the build's UUID,
+ like a timestamp and a change number */
+ render() {
+ const { nodes, tenant } = this.props
+ const { selectedBuildId } = this.state
+ return (
+
+ )
+ }
+}
+
+export default connect((state) => ({ tenant: state.tenant }))(HeldBuildList)
diff --git a/web/src/containers/autohold/Misc.jsx b/web/src/containers/autohold/Misc.jsx
new file mode 100644
index 0000000000..405e82ec2b
--- /dev/null
+++ b/web/src/containers/autohold/Misc.jsx
@@ -0,0 +1,35 @@
+// 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 * as React from 'react'
+import PropTypes from 'prop-types'
+import { Link } from 'react-router-dom'
+
+
+function AutoholdNodes(props) {
+ const {build, link } = props
+
+ return (
+
+ {build}
+
+ )
+}
+
+AutoholdNodes.propTypes = {
+ build: PropTypes.string,
+ link: PropTypes.string,
+}
+
+export { AutoholdNodes }
diff --git a/web/src/containers/build/Build.jsx b/web/src/containers/build/Build.jsx
index 0b101e3db5..26d62070cf 100644
--- a/web/src/containers/build/Build.jsx
+++ b/web/src/containers/build/Build.jsx
@@ -43,8 +43,8 @@ import {
import * as moment from 'moment'
import 'moment-duration-format'
-import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc'
-import { buildExternalLink, ExternalLink } from '../../Misc'
+import { BuildResultBadge, BuildResultWithIcon } from './Misc'
+import { buildExternalLink, ExternalLink, IconProperty } from '../../Misc'
import { autohold } from '../../api'
import { addNotification, addApiError } from '../../actions/notifications'
@@ -117,7 +117,7 @@ function Build({ build, tenant, timezone, user }) {
variant="primary"
onClick={() => {
handleConfirm()
- setShowAutoholdModal()
+ setShowAutoholdModal(false)
}}>Create,
- ),
+ title: (
+
+ ),
},
],
}
@@ -214,7 +214,7 @@ function BuildTable({
cells={columns}
rows={rows}
actions={actions}
- className="zuul-build-table"
+ className="zuul-table"
>
diff --git a/web/src/containers/build/Buildset.jsx b/web/src/containers/build/Buildset.jsx
index 3b14f9785e..cb542db017 100644
--- a/web/src/containers/build/Buildset.jsx
+++ b/web/src/containers/build/Buildset.jsx
@@ -40,8 +40,8 @@ import {
import * as moment from 'moment'
import 'moment-duration-format'
-import { buildExternalLink } from '../../Misc'
-import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc'
+import { buildExternalLink, IconProperty } from '../../Misc'
+import { BuildResultBadge, BuildResultWithIcon } from './Misc'
import { enqueue, enqueue_ref } from '../../api'
import { addNotification, addApiError } from '../../actions/notifications'
import { ChartModal } from '../charts/ChartModal'
diff --git a/web/src/containers/build/BuildsetTable.jsx b/web/src/containers/build/BuildsetTable.jsx
index 9449038fcb..bf077ce07a 100644
--- a/web/src/containers/build/BuildsetTable.jsx
+++ b/web/src/containers/build/BuildsetTable.jsx
@@ -42,8 +42,8 @@ import {
cellWidth,
} from '@patternfly/react-table'
-import { BuildResult, BuildResultWithIcon, IconProperty } from './Misc'
-import { buildExternalTableLink } from '../../Misc'
+import { BuildResult, BuildResultWithIcon } from './Misc'
+import { buildExternalTableLink, IconProperty } from '../../Misc'
function BuildsetTable({
buildsets,
@@ -109,13 +109,13 @@ function BuildsetTable({
title: changeOrRefLink && changeOrRefLink,
},
{
- title: (
-
-
- ),
+ title: (
+
+
+ ),
},
],
}
@@ -176,7 +176,7 @@ function BuildsetTable({
cells={columns}
rows={rows}
actions={actions}
- className="zuul-build-table"
+ className="zuul-table"
>
diff --git a/web/src/containers/build/Misc.jsx b/web/src/containers/build/Misc.jsx
index d8b65c42e3..53270d83b9 100644
--- a/web/src/containers/build/Misc.jsx
+++ b/web/src/containers/build/Misc.jsx
@@ -177,27 +177,6 @@ BuildResultWithIcon.propTypes = {
children: PropTypes.node,
}
-function IconProperty(props) {
- const { icon, value, WrapElement = 'span' } = props
- return (
-
-
- {icon}
-
- {value}
-
- )
-}
-IconProperty.propTypes = {
- icon: PropTypes.node,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- WrapElement: PropTypes.func,
-}
-export { BuildResult, BuildResultBadge, BuildResultWithIcon, IconProperty }
+export { BuildResult, BuildResultBadge, BuildResultWithIcon }
diff --git a/web/src/containers/component/ComponentTable.jsx b/web/src/containers/component/ComponentTable.jsx
index 2934babf9f..78edcb3ed2 100644
--- a/web/src/containers/component/ComponentTable.jsx
+++ b/web/src/containers/component/ComponentTable.jsx
@@ -33,7 +33,7 @@ import {
HistoryIcon,
} from '@patternfly/react-icons'
-import { IconProperty } from '../build/Misc'
+import { IconProperty } from '../../Misc'
const STATE_ICON_CONFIGS = {
RUNNING: {
diff --git a/web/src/index.css b/web/src/index.css
index 90130eb467..e8c67a3723 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -57,7 +57,7 @@ a.refresh {
}
/* Keep the normal font-size for compact tables */
-.zuul-build-table td {
+.zuul-table td {
font-size: var(--pf-global--FontSize--md);
}
@@ -73,7 +73,7 @@ a.refresh {
}
/* Use the same hover effect on table rows like for the selectable data list */
-.zuul-build-table tbody tr:hover {
+.zuul-table tbody tr:hover {
box-shadow: var(--pf-global--BoxShadow--sm-top),
var(--pf-global--BoxShadow--sm-bottom);
}
@@ -83,7 +83,7 @@ a.refresh {
show the column names. Thus, we fall back to the border to show the hover
effect. The drawback with that is, that we can't show a nice transition.
*/
- .zuul-build-table tbody tr:hover {
+ .zuul-table tbody tr:hover {
border-left-color: var(--pf-global--active-color--100);
border-left-width: var(--pf-global--BorderWidth--lg);
border-left-style: solid;
@@ -96,7 +96,7 @@ a.refresh {
/* For the larger screens (normal table layout) we can use the before
element on the first table cell to show the same hover effect like for
the data list */
- .zuul-build-table tbody tr td:first-child::before {
+ .zuul-table tbody tr td:first-child::before {
position: absolute;
top: 0;
bottom: 0;
@@ -107,14 +107,14 @@ a.refresh {
transition: var(--pf-global--Transition);
}
- .zuul-build-table tbody tr:hover td:first-child::before {
+ .zuul-table tbody tr:hover td:first-child::before {
background-color: var(--pf-global--active-color--100);
}
/* Hide the action column with the build link on larger screen. This is only
needed for the mobile version as we can't use the "magnifying-glass icon
on hover" effect there. */
- .zuul-build-table .pf-c-table__action {
+ .zuul-table .pf-c-table__action {
display: none;
}
}
diff --git a/web/src/pages/Autohold.jsx b/web/src/pages/Autohold.jsx
new file mode 100644
index 0000000000..0d0198b450
--- /dev/null
+++ b/web/src/pages/Autohold.jsx
@@ -0,0 +1,251 @@
+// 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 * as React from 'react'
+import { connect } from 'react-redux'
+import PropTypes from 'prop-types'
+import {
+ EmptyState,
+ EmptyStateIcon,
+ EmptyStateVariant,
+ PageSection,
+ PageSectionVariants,
+ Title,
+ Flex,
+ FlexItem,
+ List,
+ ListItem,
+} from '@patternfly/react-core'
+import {
+ LockIcon,
+ BuildIcon,
+ CubeIcon,
+ CodeIcon,
+ HashtagIcon,
+ OutlinedClockIcon,
+ OutlinedCommentDotsIcon,
+ TrashIcon,
+} from '@patternfly/react-icons'
+import { IconProperty } from '../Misc'
+
+import { Link } from 'react-router-dom'
+import * as moment from 'moment'
+
+import { fetchAutohold } from '../actions/autoholds'
+import { EmptyPage } from '../containers/Errors'
+import { Fetching } from '../containers/Fetching'
+import HeldBuildList from '../containers/autohold/HeldBuildList'
+
+
+// This is hard-coded in zuul/executor/server.py#3035
+const EXPIRED_HOLD_REQUEST_TTL = 24 * 60 * 60
+
+
+class AutoholdPage extends React.Component {
+ static propTypes = {
+ match: PropTypes.object.isRequired,
+ tenant: PropTypes.object.isRequired,
+ autohold: PropTypes.object,
+ isFetching: PropTypes.bool.isRequired,
+ fetchAutohold: PropTypes.func.isRequired,
+ }
+
+ updateData = () => {
+ if (!this.props.autohold) {
+ this.props.fetchAutohold(
+ this.props.tenant,
+ this.props.match.params.requestId
+ )
+ }
+ }
+
+ componentDidMount() {
+ document.title = 'Zuul Autohold Request'
+ if (this.props.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.tenant.name !== prevProps.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ render() {
+ const { autohold, isFetching, tenant } = this.props
+
+ // Initial page load
+ if (autohold === undefined || isFetching) {
+ return
+ }
+
+ // Fetching finished, but no autohold found
+ if (!autohold) {
+ return (
+
+ )
+ }
+
+ // Return the build list or an empty state if no builds triggered the autohold.
+ const buildsContent = autohold.nodes.length > 0 ? (
+
+ ) : (
+ <>
+ {/* Using an hr above the empty state ensures that the space between
+ heading (builds) and empty state is filled and the empty state
+ doesn't look like it's lost in space. */}
+
+
+
+
+ This autohold request has not triggered yet.
+
+
+ >
+ )
+
+ const node_expiration = (autohold.node_expiration === 0) ? 'Indefinitely' : moment.duration(autohold.node_expiration, 'seconds').humanize()
+ console.log(autohold.expired)
+ const elapsed = autohold.expired ? (Date.now() / 1000 - autohold.expired) : false
+ console.log(elapsed)
+ const timeToDeletion = autohold.node_expiration + EXPIRED_HOLD_REQUEST_TTL - elapsed
+ console.log(timeToDeletion)
+
+
+ let deletionInfo, deletionInfoMsg
+ if (autohold.node_expiration !== 0 && elapsed) {
+ deletionInfoMsg = timeToDeletion > 0 ?
+ (<>
+ Deletion scheduled in {moment.duration(timeToDeletion, 'seconds').humanize()}
+ >) :
+ This request is scheduled to be deleted automatically.
+ deletionInfo = }
+ value={deletionInfoMsg}
+ />
+ } else {
+ deletionInfo = <>>
+ }
+
+ return (
+ <>
+
+ Autohold Request {autohold.id}
+
+
+
+
+
+ }
+ value={
+ <>
+ Project {autohold.project}
+ >
+ }
+ />
+ }
+ value={
+ <>
+ Filter {autohold.ref_filter}
+ >
+ }
+ />
+ }
+ value={
+ <>
+ Job {autohold.job}
+ >
+ }
+ />
+ }
+ value={
+ <>
+ Trigger Count {autohold.current_count} out of {autohold.max_count}
+ >
+ }
+ />
+ }
+ value={
+ <>
+ Hold Duration {node_expiration}
+ >
+ }
+ />
+
+
+
+
+
+
+ }
+ value={
+ <>
+ Reason:
+ {autohold.reason}
+ >
+ }
+ />
+ {deletionInfo}
+
+
+
+
+
+
+
+ {' '}
+ Held Builds
+
+ {buildsContent}
+
+ >
+ )
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ autohold: state.autoholds.autohold,
+ tenant: state.tenant,
+ isFetching: state.autoholds.isFetching,
+ }
+}
+
+const mapDispatchToProps = { fetchAutohold }
+
+export default connect(mapStateToProps, mapDispatchToProps)(AutoholdPage)
diff --git a/web/src/pages/Autoholds.jsx b/web/src/pages/Autoholds.jsx
new file mode 100644
index 0000000000..ceb0a2d77c
--- /dev/null
+++ b/web/src/pages/Autoholds.jsx
@@ -0,0 +1,66 @@
+// 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.
+
+import * as React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+import { PageSection, PageSectionVariants } from '@patternfly/react-core'
+
+import { fetchAutoholdsIfNeeded } from '../actions/autoholds'
+import AutoholdTable from '../containers/autohold/AutoholdTable'
+
+
+
+class AutoholdsPage extends React.Component {
+ static propTypes = {
+ tenant: PropTypes.object,
+ remoteData: PropTypes.object,
+ dispatch: PropTypes.func
+ }
+
+ updateData = (force) => {
+ this.props.dispatch(fetchAutoholdsIfNeeded(this.props.tenant, force))
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Autoholds'
+ if (this.props.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (this.props.tenant.name !== prevProps.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ render () {
+ const { remoteData } = this.props
+ const autoholds = remoteData.autoholds
+
+ return (
+
+
+
+ )
+ }
+}
+
+export default connect(state => ({
+ tenant: state.tenant,
+ remoteData: state.autoholds,
+}))(AutoholdsPage)
diff --git a/web/src/pages/Tenants.jsx b/web/src/pages/Tenants.jsx
index fdffb8e629..bfe6daead5 100644
--- a/web/src/pages/Tenants.jsx
+++ b/web/src/pages/Tenants.jsx
@@ -24,7 +24,8 @@ import {
FolderIcon,
HomeIcon,
RepositoryIcon,
- TrendUpIcon
+ TrendUpIcon,
+ ThumbtackIcon,
} from '@patternfly/react-icons'
import {
Table,
@@ -35,7 +36,7 @@ import {
import { Fetching } from '../containers/Fetching'
import { fetchTenantsIfNeeded } from '../actions/tenants'
import { PageSection, PageSectionVariants } from '@patternfly/react-core'
-import { IconProperty } from '../containers/build/Misc'
+import { IconProperty } from '../Misc'
class TenantsPage extends React.Component {
static propTypes = {
@@ -47,15 +48,15 @@ class TenantsPage extends React.Component {
this.props.dispatch(fetchTenantsIfNeeded(force))
}
- componentDidMount () {
+ componentDidMount() {
document.title = 'Zuul Tenants'
this.updateData()
}
// TODO: fix Refreshable class to work with tenant less page.
- componentDidUpdate () { }
+ componentDidUpdate() { }
- render () {
+ render() {
const { remoteData } = this.props
if (remoteData.isFetching) {
return
@@ -64,46 +65,53 @@ class TenantsPage extends React.Component {
const tenants = remoteData.tenants.map((tenant) => {
return {
cells: [
- {title: ({tenant.name})},
- {title: (Status)},
- {title: (Projects)},
- {title: (Jobs)},
- {title: (Builds)},
- {title: (Buildsets)},
+ { title: ({tenant.name}) },
+ { title: (Status) },
+ { title: (Projects) },
+ { title: (Jobs) },
+ { title: (Builds) },
+ { title: (Buildsets) },
+ { title: (Autoholds) },
tenant.projects,
tenant.queue
- ]}})
+ ]
+ }
+ })
const columns = [
{
- title: } value="Name"/>,
+ title: } value="Name" />,
dataLabel: 'Name',
},
{
- title: } value="Status"/>,
+ title: } value="Status" />,
dataLabel: 'Status',
},
{
- title: } value="Projects"/>,
+ title: } value="Projects" />,
dataLabel: 'Projects',
},
{
- title: } value="Jobs"/>,
+ title: } value="Jobs" />,
dataLabel: 'Jobs',
},
{
- title: } value="Builds"/>,
+ title: } value="Builds" />,
dataLabel: 'Builds',
},
{
- title: } value="Buildsets"/>,
+ title: } value="Buildsets" />,
dataLabel: 'Buildsets',
},
{
- title: } value="Project count"/>,
+ title: } value="Autoholds" />,
+ dataLabel: 'Autoholds',
+ },
+ {
+ title: } value="Project count" />,
dataLabel: 'Project count',
},
{
- title: } value="Queue"/>,
+ title: } value="Queue" />,
dataLabel: 'Queue',
}
]
@@ -125,4 +133,4 @@ class TenantsPage extends React.Component {
}
}
-export default connect(state => ({remoteData: state.tenants}))(TenantsPage)
+export default connect(state => ({ remoteData: state.tenants }))(TenantsPage)
diff --git a/web/src/reducers/autoholds.js b/web/src/reducers/autoholds.js
new file mode 100644
index 0000000000..ef810af884
--- /dev/null
+++ b/web/src/reducers/autoholds.js
@@ -0,0 +1,68 @@
+// 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.
+
+import {
+ AUTOHOLDS_FETCH_FAIL,
+ AUTOHOLDS_FETCH_REQUEST,
+ AUTOHOLDS_FETCH_SUCCESS,
+ AUTOHOLD_FETCH_FAIL,
+ AUTOHOLD_FETCH_REQUEST,
+ AUTOHOLD_FETCH_SUCCESS
+} from '../actions/autoholds'
+
+export default (state = {
+ receivedAt: 0,
+ isFetching: false,
+ autoholds: [],
+ autohold: null,
+}, action) => {
+ switch (action.type) {
+ case AUTOHOLDS_FETCH_REQUEST:
+ return {
+ ...state,
+ isFetching: true,
+ }
+ case AUTOHOLDS_FETCH_SUCCESS:
+ return {
+ ...state,
+ isFetching: false,
+ autoholds: action.autoholds,
+ receivedAt: action.receivedAt,
+ }
+ case AUTOHOLDS_FETCH_FAIL:
+ return {
+ ...state,
+ isFetching: false,
+ }
+ case AUTOHOLD_FETCH_REQUEST:
+ return {
+ ...state,
+ isFetching: true,
+ }
+ case AUTOHOLD_FETCH_SUCCESS:
+ return {
+ ...state,
+ isFetching: false,
+ autohold: action.autohold,
+ receivedAt: action.receivedAt,
+ }
+ case AUTOHOLD_FETCH_FAIL:
+ return {
+ ...state,
+ isFetching: false
+ }
+ default:
+ return state
+ }
+}
diff --git a/web/src/reducers/index.js b/web/src/reducers/index.js
index 0f357c55af..325e4f3dd6 100644
--- a/web/src/reducers/index.js
+++ b/web/src/reducers/index.js
@@ -15,6 +15,7 @@
import { combineReducers } from 'redux'
import auth from './auth'
+import autoholds from './autoholds'
import configErrors from './configErrors'
import change from './change'
import component from './component'
@@ -38,6 +39,7 @@ import user from './user'
const reducers = {
auth,
+ autoholds,
build,
change,
component,
diff --git a/web/src/routes.js b/web/src/routes.js
index efbef2d040..c2c3a8584a 100644
--- a/web/src/routes.js
+++ b/web/src/routes.js
@@ -21,6 +21,8 @@ import JobPage from './pages/Job'
import JobsPage from './pages/Jobs'
import LabelsPage from './pages/Labels'
import NodesPage from './pages/Nodes'
+import AutoholdsPage from './pages/Autoholds'
+import AutoholdPage from './pages/Autohold'
import BuildPage from './pages/Build'
import BuildsPage from './pages/Builds'
import BuildsetPage from './pages/Buildset'
@@ -61,6 +63,11 @@ const routes = () => [
to: '/nodes',
component: NodesPage
},
+ {
+ title: 'Autoholds',
+ to: '/autoholds',
+ component: AutoholdsPage
+ },
{
title: 'Builds',
to: '/builds',
@@ -116,6 +123,10 @@ const routes = () => [
to: '/buildset/:buildsetId',
component: BuildsetPage
},
+ {
+ to: '/autohold/:requestId',
+ component: AutoholdPage
+ },
{
to: '/config-errors',
component: ConfigErrorsPage,