diff options
author | Matthieu Huin <mhuin@redhat.com> | 2020-12-22 13:53:25 +0100 |
---|---|---|
committer | Matthieu Huin <mhuin@redhat.com> | 2021-11-18 16:41:23 +0000 |
commit | 491cf439fae68740dcaa20dbdf71f58937a60590 (patch) | |
tree | 97ccaf3ecf6aeee00a686bc417263f7d2d597328 | |
parent | 84ae08fb399299614d7206b5391e7c9ac55906fb (diff) | |
download | zuul-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.jsx | 29 | ||||
-rw-r--r-- | web/src/actions/autoholds.js | 89 | ||||
-rw-r--r-- | web/src/api.js | 25 | ||||
-rw-r--r-- | web/src/containers/autohold/AutoholdTable.jsx | 223 | ||||
-rw-r--r-- | web/src/containers/autohold/HeldBuildList.jsx | 85 | ||||
-rw-r--r-- | web/src/containers/autohold/Misc.jsx | 35 | ||||
-rw-r--r-- | web/src/containers/build/Build.jsx | 6 | ||||
-rw-r--r-- | web/src/containers/build/BuildList.jsx | 3 | ||||
-rw-r--r-- | web/src/containers/build/BuildTable.jsx | 20 | ||||
-rw-r--r-- | web/src/containers/build/Buildset.jsx | 4 | ||||
-rw-r--r-- | web/src/containers/build/BuildsetTable.jsx | 20 | ||||
-rw-r--r-- | web/src/containers/build/Misc.jsx | 23 | ||||
-rw-r--r-- | web/src/containers/component/ComponentTable.jsx | 2 | ||||
-rw-r--r-- | web/src/index.css | 12 | ||||
-rw-r--r-- | web/src/pages/Autohold.jsx | 251 | ||||
-rw-r--r-- | web/src/pages/Autoholds.jsx | 66 | ||||
-rw-r--r-- | web/src/pages/Tenants.jsx | 50 | ||||
-rw-r--r-- | web/src/reducers/autoholds.js | 68 | ||||
-rw-r--r-- | web/src/reducers/index.js | 2 | ||||
-rw-r--r-- | web/src/routes.js | 11 |
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, }, |