summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/public/openapi.yaml65
-rw-r--r--web/src/actions/semaphores.js61
-rw-r--r--web/src/api.js82
-rw-r--r--web/src/containers/build/Artifact.jsx13
-rw-r--r--web/src/containers/build/Console.jsx14
-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
12 files changed, 650 insertions, 30 deletions
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..de2275c7c 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -103,93 +103,130 @@ function getStreamUrl(apiPrefix) {
return streamUrl
}
+function getWithCorsHandling(url) {
+ // This performs a simple GET and tries to detect if CORS errors are
+ // due to proxy authentication errors.
+ const instance = Axios.create({
+ baseURL: apiUrl
+ })
+ // First try the request as normal
+ let res = instance.get(url).catch(err => {
+ if (err.response === undefined) {
+ // This is either a Network, DNS, or CORS error, but we can't tell which.
+ // If we're behind an authz proxy, it's possible our creds have timed out
+ // and the CORS error is because we're getting a redirect.
+ // Apache mod_auth_mellon (and possibly other authz proxies) will avoid
+ // issuing a redirect if X-Requested-With is set to 'XMLHttpRequest' and
+ // will instead issue a 403. We can use this to detect that case.
+ instance.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
+ let res2 = instance.get(url).catch(err2 => {
+ if (err2.response && err2.response.status === 403) {
+ // We might be getting a redirect or something else,
+ // so reload the page.
+ console.log('Received 403 after unknown error; reloading')
+ window.location.reload()
+ }
+ // If we're still getting an error, we don't know the cause,
+ // it could be a transient network error, so we won't reload, we'll just
+ // wait for it to clear.
+ throw (err2)
+ })
+ return res2
+ }
+ })
+ return res
+}
+
// Direct APIs
function fetchInfo() {
- return Axios.get(apiUrl + 'info')
+ return getWithCorsHandling('info')
}
function fetchComponents() {
- return Axios.get(apiUrl + 'components')
+ return getWithCorsHandling('components')
}
function fetchTenantInfo(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'info')
+ return getWithCorsHandling(apiPrefix + 'info')
}
function fetchOpenApi() {
return Axios.get(getHomepageUrl() + 'openapi.yaml')
}
function fetchTenants() {
- return Axios.get(apiUrl + 'tenants')
+ return getWithCorsHandling(apiUrl + 'tenants')
}
function fetchConfigErrors(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'config-errors')
+ return getWithCorsHandling(apiPrefix + 'config-errors')
}
function fetchStatus(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'status')
+ return getWithCorsHandling(apiPrefix + 'status')
}
function fetchChangeStatus(apiPrefix, changeId) {
- return Axios.get(apiUrl + apiPrefix + 'status/change/' + changeId)
+ return getWithCorsHandling(apiPrefix + 'status/change/' + changeId)
}
function fetchFreezeJob(apiPrefix, pipelineName, projectName, branchName, jobName) {
- return Axios.get(apiUrl + apiPrefix +
+ return getWithCorsHandling(apiPrefix +
'pipeline/' + pipelineName +
'/project/' + projectName +
'/branch/' + branchName +
'/freeze-job/' + jobName)
}
function fetchBuild(apiPrefix, buildId) {
- return Axios.get(apiUrl + apiPrefix + 'build/' + buildId)
+ return getWithCorsHandling(apiPrefix + 'build/' + buildId)
}
function fetchBuilds(apiPrefix, queryString) {
let path = 'builds'
if (queryString) {
path += '?' + queryString.slice(1)
}
- return Axios.get(apiUrl + apiPrefix + path)
+ return getWithCorsHandling(apiPrefix + path)
}
function fetchBuildset(apiPrefix, buildsetId) {
- return Axios.get(apiUrl + apiPrefix + 'buildset/' + buildsetId)
+ return getWithCorsHandling(apiPrefix + 'buildset/' + buildsetId)
}
function fetchBuildsets(apiPrefix, queryString) {
let path = 'buildsets'
if (queryString) {
path += '?' + queryString.slice(1)
}
- return Axios.get(apiUrl + apiPrefix + path)
+ return getWithCorsHandling(apiPrefix + path)
}
function fetchPipelines(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'pipelines')
+ return getWithCorsHandling(apiPrefix + 'pipelines')
}
function fetchProject(apiPrefix, projectName) {
- return Axios.get(apiUrl + apiPrefix + 'project/' + projectName)
+ return getWithCorsHandling(apiPrefix + 'project/' + projectName)
}
function fetchProjects(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'projects')
+ return getWithCorsHandling(apiPrefix + 'projects')
}
function fetchJob(apiPrefix, jobName) {
- return Axios.get(apiUrl + apiPrefix + 'job/' + jobName)
+ return getWithCorsHandling(apiPrefix + 'job/' + jobName)
}
function fetchJobGraph(apiPrefix, projectName, pipelineName, branchName) {
- return Axios.get(apiUrl + apiPrefix +
+ return getWithCorsHandling(apiPrefix +
'pipeline/' + pipelineName +
'/project/' + projectName +
'/branch/' + branchName +
'/freeze-jobs')
}
function fetchJobs(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'jobs')
+ return getWithCorsHandling(apiPrefix + 'jobs')
}
function fetchLabels(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'labels')
+ return getWithCorsHandling(apiPrefix + 'labels')
}
function fetchNodes(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'nodes')
+ return getWithCorsHandling(apiPrefix + 'nodes')
+}
+function fetchSemaphores(apiPrefix) {
+ return Axios.get(apiUrl + apiPrefix + 'semaphores')
}
function fetchAutoholds(apiPrefix) {
- return Axios.get(apiUrl + apiPrefix + 'autohold')
+ return getWithCorsHandling(apiPrefix + 'autohold')
}
function fetchAutohold(apiPrefix, requestId) {
- return Axios.get(apiUrl + apiPrefix + 'autohold/' + requestId)
+ return getWithCorsHandling(apiPrefix + 'autohold/' + requestId)
}
// token-protected API
@@ -332,6 +369,7 @@ export {
fetchLabels,
fetchNodes,
fetchOpenApi,
+ fetchSemaphores,
fetchTenants,
fetchInfo,
fetchComponents,
diff --git a/web/src/containers/build/Artifact.jsx b/web/src/containers/build/Artifact.jsx
index 4d4259440..3793222d9 100644
--- a/web/src/containers/build/Artifact.jsx
+++ b/web/src/containers/build/Artifact.jsx
@@ -17,6 +17,7 @@ import PropTypes from 'prop-types'
import {
TreeView,
} from 'patternfly-react'
+import ReactJson from 'react-json-view'
class Artifact extends React.Component {
@@ -32,7 +33,17 @@ class Artifact extends React.Component {
{Object.keys(artifact.metadata).map(key => (
<tr key={key}>
<td>{key}</td>
- <td style={{width:'100%'}}>{artifact.metadata[key]}</td>
+ <td style={{width:'100%'}}>
+ {typeof(artifact.metadata[key]) === 'object'?
+ <ReactJson
+ src={artifact.metadata[key]}
+ name={null}
+ collapsed={true}
+ sortKeys={true}
+ enableClipboard={false}
+ displayDataTypes={false}/>
+ :artifact.metadata[key].toString()}
+ </td>
</tr>
))}
</tbody>
diff --git a/web/src/containers/build/Console.jsx b/web/src/containers/build/Console.jsx
index 74f1274d7..f24d9cbc8 100644
--- a/web/src/containers/build/Console.jsx
+++ b/web/src/containers/build/Console.jsx
@@ -64,13 +64,13 @@ class TaskOutput extends React.Component {
renderResults(value) {
const interesting_results = []
- // This was written to assume "value" is an array of
- // key/value mappings to output. This seems to be a
- // good assumption for the most part, but "package:" for
- // whatever reason outputs a result that is just an array of
- // strings with what packages were installed. So, if we
- // see an array of strings as the value, we just swizzle
- // that into a key/value so it displays usefully.
+ // This was written to assume "value" is an array of key/value
+ // mappings to output. This seems to be a good assumption for the
+ // most part, but "package:" on at least some distros --
+ // RedHat/yum/dnf we've found -- outputs a result that is just an
+ // array of strings with what packages were installed. So, if we
+ // see an array of strings as the value, we just swizzle that into
+ // a key/value so it displays usefully.
const isAllStrings = value.every(i => typeof i === 'string')
if (isAllStrings) {
value = [ {output: [...value]} ]
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,
},