summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatthieu Huin <mhuin@redhat.com>2020-12-22 13:53:25 +0100
committerMatthieu Huin <mhuin@redhat.com>2021-11-18 16:41:23 +0000
commit491cf439fae68740dcaa20dbdf71f58937a60590 (patch)
tree97ccaf3ecf6aeee00a686bc417263f7d2d597328
parent84ae08fb399299614d7206b5391e7c9ac55906fb (diff)
downloadzuul-491cf439fae68740dcaa20dbdf71f58937a60590.tar.gz
Web UI: add Autoholds, Autohold page
A user can list active autoholds for a given tenant. If the user is a tenant admin, she can also discard a autohold request from the autoholds page. Add a autohold page holding information about a single autohold request, in particular links to held builds if any. Change-Id: I4f5f2cfdf8e46ce8fb7ac69e330d6e51bd8b19fe
-rw-r--r--web/src/Misc.jsx29
-rw-r--r--web/src/actions/autoholds.js89
-rw-r--r--web/src/api.js25
-rw-r--r--web/src/containers/autohold/AutoholdTable.jsx223
-rw-r--r--web/src/containers/autohold/HeldBuildList.jsx85
-rw-r--r--web/src/containers/autohold/Misc.jsx35
-rw-r--r--web/src/containers/build/Build.jsx6
-rw-r--r--web/src/containers/build/BuildList.jsx3
-rw-r--r--web/src/containers/build/BuildTable.jsx20
-rw-r--r--web/src/containers/build/Buildset.jsx4
-rw-r--r--web/src/containers/build/BuildsetTable.jsx20
-rw-r--r--web/src/containers/build/Misc.jsx23
-rw-r--r--web/src/containers/component/ComponentTable.jsx2
-rw-r--r--web/src/index.css12
-rw-r--r--web/src/pages/Autohold.jsx251
-rw-r--r--web/src/pages/Autoholds.jsx66
-rw-r--r--web/src/pages/Tenants.jsx50
-rw-r--r--web/src/reducers/autoholds.js68
-rw-r--r--web/src/reducers/index.js2
-rw-r--r--web/src/routes.js11
20 files changed, 943 insertions, 81 deletions
diff --git a/web/src/Misc.jsx b/web/src/Misc.jsx
index e209ed042..dae5a84bf 100644
--- a/web/src/Misc.jsx
+++ b/web/src/Misc.jsx
@@ -63,7 +63,7 @@ function buildExternalLink(buildish) {
return (
<ExternalLink target={buildish.ref_url}>
<strong>Revision </strong>
- {buildish.newrev.slice(0,7)}
+ {buildish.newrev.slice(0, 7)}
</ExternalLink>
)
}
@@ -84,7 +84,7 @@ function buildExternalTableLink(buildish) {
} else if (buildish.ref_url && buildish.newrev) {
return (
<ExternalLink target={buildish.ref_url}>
- {buildish.newrev.slice(0,7)}
+ {buildish.newrev.slice(0, 7)}
</ExternalLink>
)
}
@@ -92,9 +92,32 @@ function buildExternalTableLink(buildish) {
return null
}
+function IconProperty(props) {
+ const { icon, value, WrapElement = 'span' } = props
+ return (
+ <WrapElement style={{ marginLeft: '25px' }}>
+ <span
+ style={{
+ marginRight: 'var(--pf-global--spacer--sm)',
+ marginLeft: '-25px',
+ }}
+ >
+ {icon}
+ </span>
+ <span>{value}</span>
+ </WrapElement>
+ )
+}
+
+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 000000000..e6fc10335
--- /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 a66cbabd2..4c22e007b 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 000000000..4391483ea
--- /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: <IconProperty icon={<FingerprintIcon />} value="ID" />,
+ dataLabel: 'Request ID',
+ },
+ {
+ title: <IconProperty icon={<CubeIcon />} value="Project" />,
+ dataLabel: 'Project',
+ },
+ {
+ title: <IconProperty icon={<BuildIcon />} value="Job" />,
+ dataLabel: 'Job',
+ },
+ {
+ title: <IconProperty icon={<CodeBranchIcon />} value="Ref Filter" />,
+ dataLabel: 'Ref Filter',
+ },
+ {
+ title: <IconProperty icon={<HashtagIcon />} value="Triggers" />,
+ dataLabel: 'Triggers',
+ },
+ {
+ title: <IconProperty icon={<OutlinedQuestionCircleIcon />} value="Reason" />,
+ dataLabel: 'Reason',
+ },
+ {
+ title: <IconProperty icon={<OutlinedClockIcon />} 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 (
+ <TrashIcon
+ title="Delete Autohold request"
+ style={{
+ cursor: 'pointer',
+ color: 'var(--pf-global--danger-color--100)',
+ }}
+ onClick={(event) => {
+ 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: (
+ <Link to={`${tenant.linkPrefix}/autohold/${autohold.id}`}>{autohold.id}</Link>
+ ),
+ },
+ {
+ 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: (
+ <center>
+ <Spinner size="xl" />
+ </center>
+ ),
+ },
+ ],
+ },
+ ]
+ return rows
+ }
+
+ let rows = []
+ if (fetching) {
+ rows = createFetchingRow()
+ columns[0].dataLabel = ''
+ } else {
+ rows = autoholds.map((autohold) => createAutoholdRow(autohold))
+ }
+
+ return (
+ <>
+ <Table
+ aria-label="Autohold Requests Table"
+ variant={TableVariant.compact}
+ cells={columns}
+ rows={rows}
+ className="zuul-table"
+ >
+ <TableHeader />
+ <TableBody />
+ </Table>
+
+ {/* Show an empty state in case we don't have any autoholds but are also not
+ fetching */}
+ {!fetching && autoholds.length === 0 && (
+ <EmptyState>
+ <EmptyStateIcon icon={LockIcon} />
+ <Title headingLevel="h1">No autohold requests found</Title>
+ <EmptyStateBody>
+ Nothing to display.
+ </EmptyStateBody>
+ </EmptyState>
+ )}
+ </>
+ )
+}
+
+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 000000000..d2f45dd20
--- /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 (
+ <DataList
+ className="zuul-build-list"
+ isCompact
+ selectedDataListItemId={selectedBuildId}
+ onSelectDataListItem={this.handleSelectDataListItem}
+ style={{ fontSize: 'var(--pf-global--FontSize--md)' }}
+ >
+ {nodes.map((node) => (
+ <DataListItem key={node.build} id={node.build}>
+ <Link
+ to={`${tenant.linkPrefix}/build/${node.build}`}
+ style={{
+ textDecoration: 'none',
+ color: 'var(--pf-global--disabled-color--100)',
+ }}
+ >
+ <DataListItemRow>
+ <DataListItemCells
+ dataListCells={[
+ <DataListCell key={node.build} width={3}>
+ {node.build}
+ </DataListCell>,
+ ]}
+ />
+ </DataListItemRow>
+ </Link>
+ </DataListItem>
+ ))}
+ </DataList>
+ )
+ }
+}
+
+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 000000000..405e82ec2
--- /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 (
+ <span >
+ <Link to={link}>{build}</Link>
+ </span>
+ )
+}
+
+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 0b101e3db..26d62070c 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</Button>,
<Button
key="autohold_cancel"
diff --git a/web/src/containers/build/BuildList.jsx b/web/src/containers/build/BuildList.jsx
index 2902985ad..69f1795bf 100644
--- a/web/src/containers/build/BuildList.jsx
+++ b/web/src/containers/build/BuildList.jsx
@@ -34,7 +34,8 @@ import {
import 'moment-duration-format'
import * as moment from 'moment'
-import { BuildResult, BuildResultWithIcon, IconProperty } from './Misc'
+import { BuildResult, BuildResultWithIcon } from './Misc'
+import { IconProperty } from '../../Misc'
class BuildList extends React.Component {
static propTypes = {
diff --git a/web/src/containers/build/BuildTable.jsx b/web/src/containers/build/BuildTable.jsx
index 0dce8a81f..0664ac447 100644
--- a/web/src/containers/build/BuildTable.jsx
+++ b/web/src/containers/build/BuildTable.jsx
@@ -46,8 +46,8 @@ import {
import 'moment-duration-format'
import * as moment from 'moment'
-import { BuildResult, BuildResultWithIcon, IconProperty } from './Misc'
-import { buildExternalTableLink } from '../../Misc'
+import { BuildResult, BuildResultWithIcon } from './Misc'
+import { buildExternalTableLink, IconProperty } from '../../Misc'
function BuildTable({
builds,
@@ -147,13 +147,13 @@ function BuildTable({
.format('YYYY-MM-DD HH:mm:ss'),
},
{
- title: (
- <BuildResult
- result={build.result}
- link={`${tenant.linkPrefix}/build/${build.uuid}`}
- colored={build.voting}
- />
- ),
+ title: (
+ <BuildResult
+ result={build.result}
+ link={`${tenant.linkPrefix}/build/${build.uuid}`}
+ colored={build.voting}
+ />
+ ),
},
],
}
@@ -214,7 +214,7 @@ function BuildTable({
cells={columns}
rows={rows}
actions={actions}
- className="zuul-build-table"
+ className="zuul-table"
>
<TableHeader />
<TableBody />
diff --git a/web/src/containers/build/Buildset.jsx b/web/src/containers/build/Buildset.jsx
index 3b14f9785..cb542db01 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 9449038fc..bf077ce07 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: (
- <BuildResult
- result={buildset.result}
- link={`${tenant.linkPrefix}/buildset/${buildset.uuid}`}
- >
- </BuildResult>
- ),
+ title: (
+ <BuildResult
+ result={buildset.result}
+ link={`${tenant.linkPrefix}/buildset/${buildset.uuid}`}
+ >
+ </BuildResult>
+ ),
},
],
}
@@ -176,7 +176,7 @@ function BuildsetTable({
cells={columns}
rows={rows}
actions={actions}
- className="zuul-build-table"
+ className="zuul-table"
>
<TableHeader />
<TableBody />
diff --git a/web/src/containers/build/Misc.jsx b/web/src/containers/build/Misc.jsx
index d8b65c42e..53270d83b 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 (
- <WrapElement style={{ marginLeft: '25px' }}>
- <span
- style={{
- marginRight: 'var(--pf-global--spacer--sm)',
- marginLeft: '-25px',
- }}
- >
- {icon}
- </span>
- <span>{value}</span>
- </WrapElement>
- )
-}
-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 2934babf9..78edcb3ed 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 90130eb46..e8c67a372 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 000000000..0d0198b45
--- /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 />
+ }
+
+ // Fetching finished, but no autohold found
+ if (!autohold) {
+ return (
+ <EmptyPage
+ title="This autohold request does not exist"
+ icon={LockIcon}
+ linkTarget={`${tenant.linkPrefix}/autoholds`}
+ linkText="Show all autohold requests"
+ />
+ )
+ }
+
+ // Return the build list or an empty state if no builds triggered the autohold.
+ const buildsContent = autohold.nodes.length > 0 ? (
+ <span ><HeldBuildList nodes={autohold.nodes} /></span>
+ ) : (
+ <>
+ {/* 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. */}
+ <hr />
+ <EmptyState variant={EmptyStateVariant.small}>
+ <EmptyStateIcon icon={BuildIcon} />
+ <Title headingLevel="h4" size="lg">
+ This autohold request has not triggered yet.
+ </Title>
+ </EmptyState>
+ </>
+ )
+
+ 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 ?
+ (<>
+ <strong>Deletion scheduled in </strong> {moment.duration(timeToDeletion, 'seconds').humanize()}
+ </>) :
+ <span>This request is scheduled to be deleted automatically.</span>
+ deletionInfo = <IconProperty
+ WrapElement={ListItem}
+ icon={<TrashIcon />}
+ value={deletionInfoMsg}
+ />
+ } else {
+ deletionInfo = <></>
+ }
+
+ return (
+ <>
+ <PageSection variant={PageSectionVariants.light}>
+ <Title headingLevel="h2">Autohold Request {autohold.id}</Title>
+
+ <Flex className="zuul-autohold-attributes">
+ <Flex flex={{ default: 'flex_1' }}>
+ <FlexItem>
+ <List style={{ listStyle: 'none' }}>
+ <IconProperty
+ WrapElement={ListItem}
+ icon={<CubeIcon />}
+ value={
+ <>
+ <strong>Project </strong> <Link to={`${tenant.linkPrefix}/project/${autohold.project}`}>{autohold.project}</Link>
+ </>
+ }
+ />
+ <IconProperty
+ WrapElement={ListItem}
+ icon={<CodeIcon />}
+ value={
+ <>
+ <strong>Filter </strong> {autohold.ref_filter}
+ </>
+ }
+ />
+ <IconProperty
+ WrapElement={ListItem}
+ icon={<BuildIcon />}
+ value={
+ <>
+ <strong>Job </strong> <Link to={`${tenant.linkPrefix}/job/${autohold.job}`}>{autohold.job}</Link>
+ </>
+ }
+ />
+ <IconProperty
+ WrapElement={ListItem}
+ icon={<HashtagIcon />}
+ value={
+ <>
+ <strong>Trigger Count </strong> {autohold.current_count} out of {autohold.max_count}
+ </>
+ }
+ />
+ <IconProperty
+ WrapElement={ListItem}
+ icon={<OutlinedClockIcon />}
+ value={
+ <>
+ <strong>Hold Duration </strong> <span title={autohold.node_expiration + ' seconds'} >{node_expiration}</span>
+ </>
+ }
+ />
+ </List>
+ </FlexItem>
+ </Flex>
+ <Flex flex={{ default: 'flex_1' }}>
+ <FlexItem>
+ <List style={{ listStyle: 'none' }}>
+ <IconProperty
+ WrapElement={ListItem}
+ icon={<OutlinedCommentDotsIcon />}
+ value={
+ <>
+ <strong>Reason:</strong>
+ <pre>{autohold.reason}</pre>
+ </>
+ }
+ />
+ {deletionInfo}
+ </List>
+ </FlexItem>
+ </Flex>
+ </Flex>
+ </PageSection>
+ <PageSection variant={PageSectionVariants.light}>
+ <Title headingLevel="h3">
+ <BuildIcon
+ style={{
+ marginRight: 'var(--pf-global--spacer--sm)',
+ verticalAlign: '-0.1em',
+ }}
+ />{' '}
+ Held Builds
+ </Title>
+ {buildsContent}
+ </PageSection>
+ </>
+ )
+ }
+}
+
+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 000000000..ceb0a2d77
--- /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 (
+ <PageSection variant={PageSectionVariants.light}>
+ <AutoholdTable
+ autoholds={autoholds}
+ fetching={remoteData.isFetching} />
+ </PageSection>
+ )
+ }
+}
+
+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 fdffb8e62..bfe6daead 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 <Fetching />
@@ -64,46 +65,53 @@ class TenantsPage extends React.Component {
const tenants = remoteData.tenants.map((tenant) => {
return {
cells: [
- {title: (<b>{tenant.name}</b>)},
- {title: (<Link to={'/t/' + tenant.name + '/status'}>Status</Link>)},
- {title: (<Link to={'/t/' + tenant.name + '/projects'}>Projects</Link>)},
- {title: (<Link to={'/t/' + tenant.name + '/jobs'}>Jobs</Link>)},
- {title: (<Link to={'/t/' + tenant.name + '/builds'}>Builds</Link>)},
- {title: (<Link to={'/t/' + tenant.name + '/buildsets'}>Buildsets</Link>)},
+ { title: (<b>{tenant.name}</b>) },
+ { title: (<Link to={'/t/' + tenant.name + '/status'}>Status</Link>) },
+ { title: (<Link to={'/t/' + tenant.name + '/projects'}>Projects</Link>) },
+ { title: (<Link to={'/t/' + tenant.name + '/jobs'}>Jobs</Link>) },
+ { title: (<Link to={'/t/' + tenant.name + '/builds'}>Builds</Link>) },
+ { title: (<Link to={'/t/' + tenant.name + '/buildsets'}>Buildsets</Link>) },
+ { title: (<Link to={'/t/' + tenant.name + '/autoholds'}>Autoholds</Link>) },
tenant.projects,
tenant.queue
- ]}})
+ ]
+ }
+ })
const columns = [
{
- title: <IconProperty icon={<HomeIcon />} value="Name"/>,
+ title: <IconProperty icon={<HomeIcon />} value="Name" />,
dataLabel: 'Name',
},
{
- title: <IconProperty icon={<DesktopIcon />} value="Status"/>,
+ title: <IconProperty icon={<DesktopIcon />} value="Status" />,
dataLabel: 'Status',
},
{
- title: <IconProperty icon={<CubeIcon />} value="Projects"/>,
+ title: <IconProperty icon={<CubeIcon />} value="Projects" />,
dataLabel: 'Projects',
},
{
- title: <IconProperty icon={<BuildIcon />} value="Jobs"/>,
+ title: <IconProperty icon={<BuildIcon />} value="Jobs" />,
dataLabel: 'Jobs',
},
{
- title: <IconProperty icon={<FolderIcon />} value="Builds"/>,
+ title: <IconProperty icon={<FolderIcon />} value="Builds" />,
dataLabel: 'Builds',
},
{
- title: <IconProperty icon={<RepositoryIcon />} value="Buildsets"/>,
+ title: <IconProperty icon={<RepositoryIcon />} value="Buildsets" />,
dataLabel: 'Buildsets',
},
{
- title: <IconProperty icon={<CubesIcon />} value="Project count"/>,
+ title: <IconProperty icon={<ThumbtackIcon />} value="Autoholds" />,
+ dataLabel: 'Autoholds',
+ },
+ {
+ title: <IconProperty icon={<CubesIcon />} value="Project count" />,
dataLabel: 'Project count',
},
{
- title: <IconProperty icon={<TrendUpIcon />} value="Queue"/>,
+ title: <IconProperty icon={<TrendUpIcon />} 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 000000000..ef810af88
--- /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 0f357c55a..325e4f3dd 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 efbef2d04..c2c3a8584 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'
@@ -62,6 +64,11 @@ const routes = () => [
component: NodesPage
},
{
+ title: 'Autoholds',
+ to: '/autoholds',
+ component: AutoholdsPage
+ },
+ {
title: 'Builds',
to: '/builds',
component: BuildsPage
@@ -117,6 +124,10 @@ const routes = () => [
component: BuildsetPage
},
{
+ to: '/autohold/:requestId',
+ component: AutoholdPage
+ },
+ {
to: '/config-errors',
component: ConfigErrorsPage,
},