diff options
author | James E. Blair <jim@acmegating.com> | 2022-09-07 14:30:27 -0700 |
---|---|---|
committer | James E. Blair <jim@acmegating.com> | 2022-09-21 06:50:12 -0700 |
commit | fa590a9f507ac36d9c664cedbf8e717d92f7c03d (patch) | |
tree | d5338f2c19aab0936797649bf24a80bbd6d3ddc5 | |
parent | 06cfe2cacde1f7d32bf7d60e43ee19e03ebf0e41 (diff) | |
download | zuul-fa590a9f507ac36d9c664cedbf8e717d92f7c03d.tar.gz |
Add semaphore support to web UI
This updates the OpenAPI docs to include the semaphores endpoint,
and adds a Semaphores tab to the web UI to show information about
semaphores within a tenant.
Change-Id: If78b27131ac76aff93c47a986fce6eae3e068668
-rw-r--r-- | releasenotes/notes/semaphore-webui-66c41b6aa6c6cfb6.yaml | 5 | ||||
-rw-r--r-- | web/public/openapi.yaml | 65 | ||||
-rw-r--r-- | web/src/actions/semaphores.js | 61 | ||||
-rw-r--r-- | web/src/api.js | 4 | ||||
-rw-r--r-- | web/src/containers/semaphore/Semaphore.jsx | 88 | ||||
-rw-r--r-- | web/src/containers/semaphore/SemaphoreTable.jsx | 166 | ||||
-rw-r--r-- | web/src/pages/Semaphore.jsx | 69 | ||||
-rw-r--r-- | web/src/pages/Semaphores.jsx | 61 | ||||
-rw-r--r-- | web/src/reducers/index.js | 2 | ||||
-rw-r--r-- | web/src/reducers/semaphores.js | 48 | ||||
-rw-r--r-- | web/src/routes.js | 11 |
11 files changed, 580 insertions, 0 deletions
diff --git a/releasenotes/notes/semaphore-webui-66c41b6aa6c6cfb6.yaml b/releasenotes/notes/semaphore-webui-66c41b6aa6c6cfb6.yaml new file mode 100644 index 000000000..ffbcce9a9 --- /dev/null +++ b/releasenotes/notes/semaphore-webui-66c41b6aa6c6cfb6.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Details about the configuration and current usage of semaphores + are now available in the web UI under the "Semaphores" tab. diff --git a/web/public/openapi.yaml b/web/public/openapi.yaml index b101c66e0..d69111cf8 100644 --- a/web/public/openapi.yaml +++ b/web/public/openapi.yaml @@ -283,6 +283,31 @@ paths: summary: Get a project public key tags: - tenant + /api/tenant/{tenant}/semaphores: + get: + operationId: list-semaphores + parameters: + - description: The tenant name + in: path + name: tenant + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + description: The list of semaphores + items: + $ref: '#/components/schemas/semaphore' + type: array + description: Returns the list of semaphores + '404': + description: Tenant not found + summary: List available semaphores + tags: + - tenant /api/tenant/{tenant}/status: get: operationId: get-status @@ -520,6 +545,46 @@ components: description: The pipeline name type: string type: object + semaphore: + description: A semaphore + properties: + name: + description: The semaphore name + type: string + global: + description: Whether the semaphore is global + type: boolean + max: + description: The maximum number of holders + type: integer + holders: + $ref: '#/components/schemas/semaphoreHolders' + type: object + semaphoreHolders: + description: Information about the holders of a semaphore + properties: + count: + description: The number of jobs currently holding this semaphore + type: integer + this_tenant: + description: Holders within this tenant + items: + $ref: '#/components/schemas/semaphoreHolder' + type: array + other_tenants: + description: The number of jobs in other tenants currently holding this semaphore + type: integer + type: object + semaphoreHolder: + description: Information about a holder of a semaphore + properties: + buildset_uuid: + description: The UUID of the job's buildset + type: string + job_name: + description: The name of the job + type: string + type: object statusJob: description: A job status properties: diff --git a/web/src/actions/semaphores.js b/web/src/actions/semaphores.js new file mode 100644 index 000000000..885a488e2 --- /dev/null +++ b/web/src/actions/semaphores.js @@ -0,0 +1,61 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 SEMAPHORES_FETCH_REQUEST = 'SEMAPHORES_FETCH_REQUEST' +export const SEMAPHORES_FETCH_SUCCESS = 'SEMAPHORES_FETCH_SUCCESS' +export const SEMAPHORES_FETCH_FAIL = 'SEMAPHORES_FETCH_FAIL' + +export const requestSemaphores = () => ({ + type: SEMAPHORES_FETCH_REQUEST +}) + +export const receiveSemaphores = (tenant, json) => ({ + type: SEMAPHORES_FETCH_SUCCESS, + tenant: tenant, + semaphores: json, + receivedAt: Date.now() +}) + +const failedSemaphores = error => ({ + type: SEMAPHORES_FETCH_FAIL, + error +}) + +const fetchSemaphores = (tenant) => dispatch => { + dispatch(requestSemaphores()) + return API.fetchSemaphores(tenant.apiPrefix) + .then(response => dispatch(receiveSemaphores(tenant.name, response.data))) + .catch(error => dispatch(failedSemaphores(error))) +} + +const shouldFetchSemaphores = (tenant, state) => { + const semaphores = state.semaphores.semaphores[tenant.name] + if (!semaphores || semaphores.length === 0) { + return true + } + if (semaphores.isFetching) { + return false + } + return false +} + +export const fetchSemaphoresIfNeeded = (tenant, force) => (dispatch, getState) => { + if (force || shouldFetchSemaphores(tenant, getState())) { + return dispatch(fetchSemaphores(tenant)) + } + return Promise.resolve() +} diff --git a/web/src/api.js b/web/src/api.js index 1ba39998c..f429411d0 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -185,6 +185,9 @@ function fetchLabels(apiPrefix) { function fetchNodes(apiPrefix) { return Axios.get(apiUrl + apiPrefix + 'nodes') } +function fetchSemaphores(apiPrefix) { + return Axios.get(apiUrl + apiPrefix + 'semaphores') +} function fetchAutoholds(apiPrefix) { return Axios.get(apiUrl + apiPrefix + 'autohold') } @@ -332,6 +335,7 @@ export { fetchLabels, fetchNodes, fetchOpenApi, + fetchSemaphores, fetchTenants, fetchInfo, fetchComponents, diff --git a/web/src/containers/semaphore/Semaphore.jsx b/web/src/containers/semaphore/Semaphore.jsx new file mode 100644 index 000000000..831669c13 --- /dev/null +++ b/web/src/containers/semaphore/Semaphore.jsx @@ -0,0 +1,88 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 { Link } from 'react-router-dom' +import { + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, + Spinner, +} from '@patternfly/react-core' + +function Semaphore({ semaphore, tenant, fetching }) { + if (fetching && !semaphore) { + return ( + <center> + <Spinner size="xl" /> + </center> + ) + } + if (!semaphore) { + return ( + <div> + No semaphore found + </div> + ) + } + const rows = [] + rows.push({label: 'Name', value: semaphore.name}) + rows.push({label: 'Current Holders', value: semaphore.holders.count}) + rows.push({label: 'Max', value: semaphore.max}) + rows.push({label: 'Global', value: semaphore.global ? 'Yes' : 'No'}) + if (semaphore.global) { + rows.push({label: 'Holders in Other Tenants', + value: semaphore.holders.other_tenants}) + } + semaphore.holders.this_tenant.forEach(holder => { + rows.push({label: 'Held By', + value: <Link to={`${tenant.linkPrefix}/buildset/${holder.buildset_uuid}`}> + {holder.job_name} + </Link>}) + }) + return ( + <DescriptionList isHorizontal + style={{'--pf-c-description-list--RowGap': '0.5rem'}} + className='pf-u-m-xl'> + {rows.map((item, idx) => ( + <DescriptionListGroup key={idx}> + <DescriptionListTerm> + {item.label} + </DescriptionListTerm> + <DescriptionListDescription> + {item.value} + </DescriptionListDescription> + </DescriptionListGroup> + ))} + </DescriptionList> + ) +} + +Semaphore.propTypes = { + semaphore: PropTypes.object.isRequired, + fetching: PropTypes.bool.isRequired, + tenant: PropTypes.object.isRequired, +} + +function mapStateToProps(state) { + return { + tenant: state.tenant, + } +} + +export default connect(mapStateToProps)(Semaphore) diff --git a/web/src/containers/semaphore/SemaphoreTable.jsx b/web/src/containers/semaphore/SemaphoreTable.jsx new file mode 100644 index 000000000..6f400d7b4 --- /dev/null +++ b/web/src/containers/semaphore/SemaphoreTable.jsx @@ -0,0 +1,166 @@ +// Copyright 2020 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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, + Label, +} from '@patternfly/react-core' +import { + ResourcesFullIcon, + TachometerAltIcon, + LockIcon, + TenantIcon, + FingerprintIcon, +} from '@patternfly/react-icons' +import { + Table, + TableHeader, + TableBody, + TableVariant, +} from '@patternfly/react-table' +import { Link } from 'react-router-dom' + +import { IconProperty } from '../../Misc' + +function SemaphoreTable(props) { + const { semaphores, fetching, tenant } = props + const columns = [ + { + title: <IconProperty icon={<FingerprintIcon />} value="Name" />, + dataLabel: 'Name', + }, + { + title: <IconProperty icon={<TachometerAltIcon />} value="Current" />, + dataLabel: 'Current', + }, + { + title: <IconProperty icon={<ResourcesFullIcon />} value="Max" />, + dataLabel: 'Max', + }, + { + title: <IconProperty icon={<TenantIcon />} value="Global" />, + dataLabel: 'Global', + }, + ] + + function createSemaphoreRow(semaphore) { + + return { + cells: [ + { + title: ( + <Link to={`${tenant.linkPrefix}/semaphore/${semaphore.name}`}>{semaphore.name}</Link> + ), + }, + { + title: semaphore.holders.count, + }, + { + title: semaphore.max, + }, + { + title: semaphore.global ? ( + <Label + style={{ + marginLeft: 'var(--pf-global--spacer--sm)', + verticalAlign: '0.15em', + }} + > + Global + </Label> + ) : '' + }, + ] + } + } + + function createFetchingRow() { + const rows = [ + { + heightAuto: true, + cells: [ + { + props: { colSpan: 8 }, + title: ( + <center> + <Spinner size="xl" /> + </center> + ), + }, + ], + }, + ] + return rows + } + + const haveSemaphores = semaphores && semaphores.length > 0 + + let rows = [] + if (fetching) { + rows = createFetchingRow() + columns[0].dataLabel = '' + } else { + if (haveSemaphores) { + rows = semaphores.map((semaphore) => createSemaphoreRow(semaphore)) + } + } + + return ( + <> + <Table + aria-label="Semaphore 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 semaphores but are also not + fetching */} + {!fetching && !haveSemaphores && ( + <EmptyState> + <EmptyStateIcon icon={LockIcon} /> + <Title headingLevel="h1">No semaphores found</Title> + <EmptyStateBody> + Nothing to display. + </EmptyStateBody> + </EmptyState> + )} + </> + ) +} + +SemaphoreTable.propTypes = { + semaphores: PropTypes.array, + fetching: PropTypes.bool.isRequired, + tenant: PropTypes.object, + user: PropTypes.object, + dispatch: PropTypes.func, +} + +export default connect((state) => ({ + tenant: state.tenant, + user: state.user, +}))(SemaphoreTable) diff --git a/web/src/pages/Semaphore.jsx b/web/src/pages/Semaphore.jsx new file mode 100644 index 000000000..a0ae8ddca --- /dev/null +++ b/web/src/pages/Semaphore.jsx @@ -0,0 +1,69 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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, { useEffect } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' + +import { + Title, +} from '@patternfly/react-core' +import { PageSection, PageSectionVariants } from '@patternfly/react-core' + +import { fetchSemaphoresIfNeeded } from '../actions/semaphores' +import Semaphore from '../containers/semaphore/Semaphore' + +function SemaphorePage({ match, semaphores, tenant, fetchSemaphoresIfNeeded, isFetching }) { + + const semaphoreName = match.params.semaphoreName + + useEffect(() => { + document.title = `Zuul Semaphore | ${semaphoreName}` + fetchSemaphoresIfNeeded(tenant, true) + }, [fetchSemaphoresIfNeeded, tenant, semaphoreName]) + + const semaphore = semaphores[tenant.name] ? semaphores[tenant.name].find( + e => e.name === semaphoreName) : undefined + + return ( + <PageSection variant={PageSectionVariants.light}> + <Title headingLevel="h2"> + Details for Semaphore <span style={{color: 'var(--pf-global--primary-color--100)'}}>{semaphoreName}</span> + </Title> + + <Semaphore semaphore={semaphore} + fetching={isFetching} /> + </PageSection> + ) +} + +SemaphorePage.propTypes = { + match: PropTypes.object.isRequired, + semaphores: PropTypes.object.isRequired, + tenant: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + fetchSemaphoresIfNeeded: PropTypes.func.isRequired, +} +const mapDispatchToProps = { fetchSemaphoresIfNeeded } + +function mapStateToProps(state) { + return { + tenant: state.tenant, + semaphores: state.semaphores.semaphores, + isFetching: state.semaphores.isFetching, + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(SemaphorePage) diff --git a/web/src/pages/Semaphores.jsx b/web/src/pages/Semaphores.jsx new file mode 100644 index 000000000..0ad0040c0 --- /dev/null +++ b/web/src/pages/Semaphores.jsx @@ -0,0 +1,61 @@ +// Copyright 2021 BMW Group +// Copyright 2022 Acme Gating, LLC +// +// 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, { useEffect } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { + PageSection, + PageSectionVariants, +} from '@patternfly/react-core' + +import { fetchSemaphoresIfNeeded } from '../actions/semaphores' +import SemaphoreTable from '../containers/semaphore/SemaphoreTable' + +function SemaphoresPage({ tenant, semaphores, isFetching, fetchSemaphoresIfNeeded }) { + useEffect(() => { + document.title = 'Zuul Semaphores' + fetchSemaphoresIfNeeded(tenant, true) + }, [fetchSemaphoresIfNeeded, tenant]) + + return ( + <> + <PageSection variant={PageSectionVariants.light}> + <SemaphoreTable + semaphores={semaphores[tenant.name]} + fetching={isFetching} /> + </PageSection> + </> + ) +} + +SemaphoresPage.propTypes = { + tenant: PropTypes.object.isRequired, + semaphores: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + fetchSemaphoresIfNeeded: PropTypes.func.isRequired, +} + +function mapStateToProps(state) { + return { + tenant: state.tenant, + semaphores: state.semaphores.semaphores, + isFetching: state.semaphores.isFetching, + } +} + +const mapDispatchToProps = { fetchSemaphoresIfNeeded } + +export default connect(mapStateToProps, mapDispatchToProps)(SemaphoresPage) diff --git a/web/src/reducers/index.js b/web/src/reducers/index.js index 83c86c105..9a76049dc 100644 --- a/web/src/reducers/index.js +++ b/web/src/reducers/index.js @@ -34,6 +34,7 @@ import project from './project' import pipelines from './pipelines' import projects from './projects' import preferences from './preferences' +import semaphores from './semaphores' import status from './status' import tenant from './tenant' import tenants from './tenants' @@ -60,6 +61,7 @@ const reducers = { pipelines, project, projects, + semaphores, status, tenant, tenants, diff --git a/web/src/reducers/semaphores.js b/web/src/reducers/semaphores.js new file mode 100644 index 000000000..5e98bcab7 --- /dev/null +++ b/web/src/reducers/semaphores.js @@ -0,0 +1,48 @@ +// Copyright 2018 Red Hat, Inc +// Copyright 2022 Acme Gating, LLC +// +// 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 { + SEMAPHORES_FETCH_FAIL, + SEMAPHORES_FETCH_REQUEST, + SEMAPHORES_FETCH_SUCCESS +} from '../actions/semaphores' + +export default (state = { + isFetching: false, + semaphores: {}, +}, action) => { + switch (action.type) { + case SEMAPHORES_FETCH_REQUEST: + return { + isFetching: true, + semaphores: state.semaphores, + } + case SEMAPHORES_FETCH_SUCCESS: + return { + isFetching: false, + semaphores: { + ...state.semaphores, + [action.tenant]: action.semaphores + } + } + case SEMAPHORES_FETCH_FAIL: + return { + isFetching: false, + semaphores: state.semaphores, + } + default: + return state + } +} diff --git a/web/src/routes.js b/web/src/routes.js index e1bfdae16..a60216a97 100644 --- a/web/src/routes.js +++ b/web/src/routes.js @@ -22,6 +22,8 @@ import JobPage from './pages/Job' import JobsPage from './pages/Jobs' import LabelsPage from './pages/Labels' import NodesPage from './pages/Nodes' +import SemaphorePage from './pages/Semaphore' +import SemaphoresPage from './pages/Semaphores' import AutoholdsPage from './pages/Autoholds' import AutoholdPage from './pages/Autohold' import BuildPage from './pages/Build' @@ -70,6 +72,11 @@ const routes = () => [ component: AutoholdsPage }, { + title: 'Semaphores', + to: '/semaphores', + component: SemaphoresPage + }, + { title: 'Builds', to: '/builds', component: BuildsPage @@ -133,6 +140,10 @@ const routes = () => [ component: AutoholdPage }, { + to: '/semaphore/:semaphoreName', + component: SemaphorePage + }, + { to: '/config-errors', component: ConfigErrorsPage, }, |