diff --git a/web/src/Misc.jsx b/web/src/Misc.jsx index e209ed0421..dae5a84bf9 100644 --- a/web/src/Misc.jsx +++ b/web/src/Misc.jsx @@ -63,7 +63,7 @@ function buildExternalLink(buildish) { return ( Revision - {buildish.newrev.slice(0,7)} + {buildish.newrev.slice(0, 7)} ) } @@ -84,7 +84,7 @@ function buildExternalTableLink(buildish) { } else if (buildish.ref_url && buildish.newrev) { return ( - {buildish.newrev.slice(0,7)} + {buildish.newrev.slice(0, 7)} ) } @@ -92,9 +92,32 @@ function buildExternalTableLink(buildish) { return null } +function IconProperty(props) { + const { icon, value, WrapElement = 'span' } = props + return ( + + + {icon} + + {value} + + ) +} + +IconProperty.propTypes = { + icon: PropTypes.node, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + WrapElement: PropTypes.func, +} + // https://github.com/kitze/conditional-wrap // appears to be the first implementation of this pattern const ConditionalWrapper = ({ condition, wrapper, children }) => condition ? wrapper(children) : children -export { removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper } +export { IconProperty, removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper } diff --git a/web/src/actions/autoholds.js b/web/src/actions/autoholds.js new file mode 100644 index 0000000000..e6fc10335e --- /dev/null +++ b/web/src/actions/autoholds.js @@ -0,0 +1,89 @@ +// Copyright 2020 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as API from '../api' + +export const AUTOHOLDS_FETCH_REQUEST = 'AUTOHOLDS_FETCH_REQUEST' +export const AUTOHOLDS_FETCH_SUCCESS = 'AUTOHOLDS_FETCH_SUCCESS' +export const AUTOHOLDS_FETCH_FAIL = 'AUTOHOLDS_FETCH_FAIL' + +export const AUTOHOLD_FETCH_REQUEST = 'AUTOHOLD_FETCH_REQUEST' +export const AUTOHOLD_FETCH_SUCCESS = 'AUTOHOLD_FETCH_SUCCESS' +export const AUTOHOLD_FETCH_FAIL = 'AUTOHOLD_FETCH_FAIL' + +export const requestAutoholds = () => ({ + type: AUTOHOLDS_FETCH_REQUEST +}) + +export const receiveAutoholds = (tenant, json) => ({ + type: AUTOHOLDS_FETCH_SUCCESS, + autoholds: json, + receivedAt: Date.now() +}) + +const failedAutoholds = error => ({ + type: AUTOHOLDS_FETCH_FAIL, + error +}) + +export const fetchAutoholds = (tenant) => dispatch => { + dispatch(requestAutoholds()) + return API.fetchAutoholds(tenant.apiPrefix) + .then(response => dispatch(receiveAutoholds(tenant.name, response.data))) + .catch(error => dispatch(failedAutoholds(error))) +} + +const shouldFetchAutoholds = (tenant, state) => { + const autoholds = state.autoholds + if (!autoholds || autoholds.autoholds.length === 0) { + return true + } + if (autoholds.isFetching) { + return false + } + if (Date.now() - autoholds.receivedAt > 60000) { + // Refetch after 1 minutes + return true + } + return false +} + +export const fetchAutoholdsIfNeeded = (tenant, force) => ( + dispatch, getState) => { + if (force || shouldFetchAutoholds(tenant, getState())) { + return dispatch(fetchAutoholds(tenant)) + } +} + +export const requestAutohold = () => ({ + type: AUTOHOLD_FETCH_REQUEST +}) + +export const receiveAutohold = (tenant, json) => ({ + type: AUTOHOLD_FETCH_SUCCESS, + autohold: json, + receivedAt: Date.now() +}) + +const failedAutohold = error => ({ + type: AUTOHOLD_FETCH_FAIL, + error +}) + +export const fetchAutohold = (tenant, requestId) => dispatch => { + dispatch(requestAutohold()) + return API.fetchAutohold(tenant.apiPrefix, requestId) + .then(response => dispatch(receiveAutohold(tenant.name, response.data))) + .catch(error => dispatch(failedAutohold(error))) +} diff --git a/web/src/api.js b/web/src/api.js index a66cbabd22..4c22e007b0 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -47,6 +47,7 @@ function getHomepageUrl(url) { // Remove known sub-path const subDir = [ + '/autohold/', '/build/', '/buildset/', '/job/', @@ -167,6 +168,12 @@ function fetchLabels(apiPrefix) { function fetchNodes(apiPrefix) { return Axios.get(apiUrl + apiPrefix + 'nodes') } +function fetchAutoholds(apiPrefix) { + return Axios.get(apiUrl + apiPrefix + 'autohold') +} +function fetchAutohold(apiPrefix, requestId) { + return Axios.get(apiUrl + apiPrefix + 'autohold/' + requestId) +} // token-protected API function fetchUserAuthorizations(apiPrefix, token) { @@ -240,8 +247,8 @@ function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, toke ) return res } -function autohold (apiPrefix, projectName, job, change, ref, - reason, count, node_hold_expiration, token) { +function autohold(apiPrefix, projectName, job, change, ref, + reason, count, node_hold_expiration, token) { const instance = Axios.create({ baseURL: apiUrl }) @@ -260,6 +267,17 @@ function autohold (apiPrefix, projectName, job, change, ref, return res } +function autohold_delete(apiPrefix, requestId, token) { + const instance = Axios.create({ + baseURL: apiUrl + }) + instance.defaults.headers.common['Authorization'] = 'Bearer ' + token + let res = instance.delete( + apiPrefix + '/autohold/' + requestId + ) + return res +} + export { apiUrl, @@ -284,7 +302,10 @@ export { fetchComponents, fetchTenantInfo, fetchUserAuthorizations, + fetchAutoholds, + fetchAutohold, autohold, + autohold_delete, dequeue, dequeue_ref, enqueue, diff --git a/web/src/containers/autohold/AutoholdTable.jsx b/web/src/containers/autohold/AutoholdTable.jsx new file mode 100644 index 0000000000..4391483ea0 --- /dev/null +++ b/web/src/containers/autohold/AutoholdTable.jsx @@ -0,0 +1,223 @@ +// Copyright 2020 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Spinner, + Title, +} from '@patternfly/react-core' +import { + OutlinedQuestionCircleIcon, + HashtagIcon, + BuildIcon, + CodeBranchIcon, + CubeIcon, + OutlinedClockIcon, + LockIcon, + TrashIcon, + FingerprintIcon, +} from '@patternfly/react-icons' +import { + Table, + TableHeader, + TableBody, + TableVariant, +} from '@patternfly/react-table' +import { Link } from 'react-router-dom' +import * as moment from 'moment' + +import { autohold_delete } from '../../api' +import { fetchAutoholds } from '../../actions/autoholds' +import { addNotification, addApiError } from '../../actions/notifications' + +import { IconProperty } from '../../Misc' + +function AutoholdTable(props) { + const { autoholds, fetching, tenant, user, dispatch } = props + const columns = [ + { + title: } value="ID" />, + dataLabel: 'Request ID', + }, + { + title: } value="Project" />, + dataLabel: 'Project', + }, + { + title: } value="Job" />, + dataLabel: 'Job', + }, + { + title: } value="Ref Filter" />, + dataLabel: 'Ref Filter', + }, + { + title: } value="Triggers" />, + dataLabel: 'Triggers', + }, + { + title: } value="Reason" />, + dataLabel: 'Reason', + }, + { + title: } value="Hold for" />, + dataLabel: 'Hold for', + }, + { + title: '', + dataLabel: 'Delete', + } + ] + + function handleAutoholdDelete(requestId) { + autohold_delete(tenant.apiPrefix, requestId, user.token) + .then(() => { + dispatch(addNotification( + { + text: 'Autohold request deleted successfully.', + type: 'success', + status: '', + url: '', + })) + dispatch(fetchAutoholds(tenant)) + }) + .catch(error => { + dispatch(addApiError(error)) + }) + } + + function renderAutoholdDeleteButton(requestId) { + return ( + { + event.preventDefault() + handleAutoholdDelete(requestId) + }} /> + ) + } + + function createAutoholdRow(autohold) { + const count = autohold.current_count + '/' + autohold.max_count + const node_expiration = (autohold.node_expiration === 0) ? 'Indefinitely' : moment.duration(autohold.node_expiration, 'seconds').humanize() + const delete_button = (user.isAdmin && user.scope.indexOf(tenant.name) !== -1) ? renderAutoholdDeleteButton(autohold.id) : '' + + return { + cells: [ + { + title: ( + {autohold.id} + ), + }, + { + title: autohold.project, + }, + { + title: autohold.job, + }, + { + title: autohold.ref_filter, + }, + { + title: count + }, + { + title: autohold.reason, + }, + { + title: node_expiration, + }, + { + title: delete_button + }, + ] + } + } + + function createFetchingRow() { + const rows = [ + { + heightAuto: true, + cells: [ + { + props: { colSpan: 8 }, + title: ( +
+ +
+ ), + }, + ], + }, + ] + return rows + } + + let rows = [] + if (fetching) { + rows = createFetchingRow() + columns[0].dataLabel = '' + } else { + rows = autoholds.map((autohold) => createAutoholdRow(autohold)) + } + + return ( + <> + + + +
+ + {/* Show an empty state in case we don't have any autoholds but are also not + fetching */} + {!fetching && autoholds.length === 0 && ( + + + No autohold requests found + + Nothing to display. + + + )} + + ) +} + +AutoholdTable.propTypes = { + autoholds: PropTypes.array.isRequired, + fetching: PropTypes.bool.isRequired, + tenant: PropTypes.object, + user: PropTypes.object, + dispatch: PropTypes.func, +} + +export default connect((state) => ({ + tenant: state.tenant, + user: state.user, +}))(AutoholdTable) diff --git a/web/src/containers/autohold/HeldBuildList.jsx b/web/src/containers/autohold/HeldBuildList.jsx new file mode 100644 index 0000000000..d2f45dd209 --- /dev/null +++ b/web/src/containers/autohold/HeldBuildList.jsx @@ -0,0 +1,85 @@ +// Copyright 2021 Red Hat +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import { + DataList, + DataListCell, + DataListItem, + DataListItemRow, + DataListItemCells, +} from '@patternfly/react-core' + +class HeldBuildList extends React.Component { + static propTypes = { + nodes: PropTypes.array, + tenant: PropTypes.object, + } + + constructor() { + super() + this.state = { + selectedBuildId: null, + } + } + + handleSelectDataListItem = (buildId) => { + this.setState({ + selectedBuildId: buildId, + }) + } + + /* TODO find a way to add some more useful info than just the build's UUID, + like a timestamp and a change number */ + render() { + const { nodes, tenant } = this.props + const { selectedBuildId } = this.state + return ( + + {nodes.map((node) => ( + + + + + {node.build} + , + ]} + /> + + + + ))} + + ) + } +} + +export default connect((state) => ({ tenant: state.tenant }))(HeldBuildList) diff --git a/web/src/containers/autohold/Misc.jsx b/web/src/containers/autohold/Misc.jsx new file mode 100644 index 0000000000..405e82ec2b --- /dev/null +++ b/web/src/containers/autohold/Misc.jsx @@ -0,0 +1,35 @@ +// Copyright 2021 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' + + +function AutoholdNodes(props) { + const {build, link } = props + + return ( + + {build} + + ) +} + +AutoholdNodes.propTypes = { + build: PropTypes.string, + link: PropTypes.string, +} + +export { AutoholdNodes } diff --git a/web/src/containers/build/Build.jsx b/web/src/containers/build/Build.jsx index 0b101e3db5..26d62070cf 100644 --- a/web/src/containers/build/Build.jsx +++ b/web/src/containers/build/Build.jsx @@ -43,8 +43,8 @@ import { import * as moment from 'moment' import 'moment-duration-format' -import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc' -import { buildExternalLink, ExternalLink } from '../../Misc' +import { BuildResultBadge, BuildResultWithIcon } from './Misc' +import { buildExternalLink, ExternalLink, IconProperty } from '../../Misc' import { autohold } from '../../api' import { addNotification, addApiError } from '../../actions/notifications' @@ -117,7 +117,7 @@ function Build({ build, tenant, timezone, user }) { variant="primary" onClick={() => { handleConfirm() - setShowAutoholdModal() + setShowAutoholdModal(false) }}>Create,