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
This commit is contained in:
James E. Blair 2022-09-07 14:30:27 -07:00
parent 06cfe2cacd
commit fa590a9f50
11 changed files with 580 additions and 0 deletions

View File

@ -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.

View File

@ -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:

View File

@ -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()
}

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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
}
}

View File

@ -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,