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
This commit is contained in:
Matthieu Huin 2020-12-22 13:53:25 +01:00
parent 84ae08fb39
commit 491cf439fa
20 changed files with 943 additions and 81 deletions

View File

@ -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 }

View File

@ -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)))
}

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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 }

View File

@ -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"

View File

@ -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 = {

View File

@ -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 />

View File

@ -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'

View File

@ -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 />

View File

@ -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 }

View File

@ -33,7 +33,7 @@ import {
HistoryIcon,
} from '@patternfly/react-icons'
import { IconProperty } from '../build/Misc'
import { IconProperty } from '../../Misc'
const STATE_ICON_CONFIGS = {
RUNNING: {

View File

@ -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;
}
}

251
web/src/pages/Autohold.jsx Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
}
}

View File

@ -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,

View File

@ -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,