summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames E. Blair <jim@acmegating.com>2022-09-07 14:30:27 -0700
committerJames E. Blair <jim@acmegating.com>2022-09-21 06:50:12 -0700
commitfa590a9f507ac36d9c664cedbf8e717d92f7c03d (patch)
treed5338f2c19aab0936797649bf24a80bbd6d3ddc5
parent06cfe2cacde1f7d32bf7d60e43ee19e03ebf0e41 (diff)
downloadzuul-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.yaml5
-rw-r--r--web/public/openapi.yaml65
-rw-r--r--web/src/actions/semaphores.js61
-rw-r--r--web/src/api.js4
-rw-r--r--web/src/containers/semaphore/Semaphore.jsx88
-rw-r--r--web/src/containers/semaphore/SemaphoreTable.jsx166
-rw-r--r--web/src/pages/Semaphore.jsx69
-rw-r--r--web/src/pages/Semaphores.jsx61
-rw-r--r--web/src/reducers/index.js2
-rw-r--r--web/src/reducers/semaphores.js48
-rw-r--r--web/src/routes.js11
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,
},