diff --git a/releasenotes/notes/semaphore-webui-66c41b6aa6c6cfb6.yaml b/releasenotes/notes/semaphore-webui-66c41b6aa6c6cfb6.yaml
new file mode 100644
index 0000000000..ffbcce9a9d
--- /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 b101c66e00..d69111cf85 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 0000000000..885a488e2d
--- /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 1ba39998ca..f429411d06 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 0000000000..831669c131
--- /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 (
+
+
+
+ )
+ }
+ if (!semaphore) {
+ return (
+
+ No semaphore found
+
+ )
+ }
+ 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:
+ {holder.job_name}
+ })
+ })
+ return (
+
+ {rows.map((item, idx) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+ )
+}
+
+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 0000000000..6f400d7b4a
--- /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: } value="Name" />,
+ dataLabel: 'Name',
+ },
+ {
+ title: } value="Current" />,
+ dataLabel: 'Current',
+ },
+ {
+ title: } value="Max" />,
+ dataLabel: 'Max',
+ },
+ {
+ title: } value="Global" />,
+ dataLabel: 'Global',
+ },
+ ]
+
+ function createSemaphoreRow(semaphore) {
+
+ return {
+ cells: [
+ {
+ title: (
+ {semaphore.name}
+ ),
+ },
+ {
+ title: semaphore.holders.count,
+ },
+ {
+ title: semaphore.max,
+ },
+ {
+ title: semaphore.global ? (
+
+ ) : ''
+ },
+ ]
+ }
+ }
+
+ function createFetchingRow() {
+ const rows = [
+ {
+ heightAuto: true,
+ cells: [
+ {
+ props: { colSpan: 8 },
+ title: (
+
+
+ {/* Show an empty state in case we don't have any semaphores but are also not
+ fetching */}
+ {!fetching && !haveSemaphores && (
+
+
+ No semaphores found
+
+ Nothing to display.
+
+
+ )}
+ >
+ )
+}
+
+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 0000000000..a0ae8ddcaf
--- /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 (
+
+
+ Details for Semaphore {semaphoreName}
+
+
+
+
+ )
+}
+
+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 0000000000..0ad0040c0b
--- /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 (
+ <>
+
+
+
+ >
+ )
+}
+
+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 83c86c1052..9a76049dc0 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 0000000000..5e98bcab76
--- /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 e1bfdae161..a60216a97d 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'
@@ -69,6 +71,11 @@ const routes = () => [
to: '/autoholds',
component: AutoholdsPage
},
+ {
+ title: 'Semaphores',
+ to: '/semaphores',
+ component: SemaphoresPage
+ },
{
title: 'Builds',
to: '/builds',
@@ -132,6 +139,10 @@ const routes = () => [
to: '/autohold/:requestId',
component: AutoholdPage
},
+ {
+ to: '/semaphore/:semaphoreName',
+ component: SemaphorePage
+ },
{
to: '/config-errors',
component: ConfigErrorsPage,