Add labels and flavors to web

This adds an API endpoint for flavors, and updates the existing
API endpoint for labels to include niz-style labels.

If a client needs to differentiate, it can do so by detecting
the presesnce of a canonical_name field.  The web UI does not
need to differentiate; both types of labels will appear in the
existing labels tab.

A new tab is added to show flavors in the tenant.

The provider page is updated with sub-tabs for labels and flavors
relevant to the selected provider.

Change-Id: I40dd56d85c6f208929a7ce8339a9be2484df404d
This commit is contained in:
James E. Blair
2024-12-19 12:12:35 -08:00
parent 33eb08a3ee
commit a7c60944cf
14 changed files with 586 additions and 10 deletions

View File

@@ -1630,6 +1630,57 @@ class TestWebProviders(LauncherBaseTestCase, WebMixin):
dict(name='build-ubuntu-local-image', result='SUCCESS'),
], ordered=False)
@simple_layout('layouts/nodepool.yaml', enable_nodepool=True)
def test_web_flavors(self):
self.waitUntilSettled()
self.startWebServer()
resp = self.get_url('api/tenant/tenant-one/flavors')
data = resp.json()
self.assertEqual(4, len(data))
cc = 'review.example.com%2Forg%2Fcommon-config'
expected = [
{'canonical_name': f'{cc}/normal',
'description': None,
'name': 'normal'},
{'canonical_name': f'{cc}/large',
'description': None,
'name': 'large'},
{'canonical_name': f'{cc}/dedicated',
'description': None,
'name': 'dedicated'},
{'canonical_name': f'{cc}/invalid',
'description': None,
'name': 'invalid'},
]
self.assertEqual(expected, data)
@simple_layout('layouts/nodepool.yaml', enable_nodepool=True)
def test_web_labels(self):
self.waitUntilSettled()
self.startWebServer()
resp = self.get_url('api/tenant/tenant-one/labels')
data = resp.json()
self.assertEqual(5, len(data))
cc = 'review.example.com%2Forg%2Fcommon-config'
expected = [
{'name': 'label1'},
{'canonical_name': f'{cc}/debian-normal',
'description': None,
'name': 'debian-normal'},
{'canonical_name': f'{cc}/debian-large',
'description': None,
'name': 'debian-large'},
{'canonical_name': f'{cc}/debian-dedicated',
'description': None,
'name': 'debian-dedicated'},
{'canonical_name': f'{cc}/debian-invalid',
'description': None,
'name': 'debian-invalid'},
]
self.assertEqual(expected, data)
class TestWebStatusDisplayBranch(BaseTestWeb):
tenant_config_file = 'config/change-queues/main.yaml'

View File

@@ -0,0 +1,61 @@
// Copyright 2018 Red Hat, Inc
// Copyright 2022, 2024 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 FLAVORS_FETCH_REQUEST = 'FLAVORS_FETCH_REQUEST'
export const FLAVORS_FETCH_SUCCESS = 'FLAVORS_FETCH_SUCCESS'
export const FLAVORS_FETCH_FAIL = 'FLAVORS_FETCH_FAIL'
export const requestFlavors = () => ({
type: FLAVORS_FETCH_REQUEST
})
export const receiveFlavors = (tenant, json) => ({
type: FLAVORS_FETCH_SUCCESS,
tenant: tenant,
flavors: json,
receivedAt: Date.now()
})
const failedFlavors = error => ({
type: FLAVORS_FETCH_FAIL,
error
})
export const fetchFlavors = (tenant) => dispatch => {
dispatch(requestFlavors())
return API.fetchFlavors(tenant.apiPrefix)
.then(response => dispatch(receiveFlavors(tenant.name, response.data)))
.catch(error => dispatch(failedFlavors(error)))
}
const shouldFetchFlavors = (tenant, state) => {
const flavors = state.flavors.flavors[tenant.name]
if (!flavors) {
return true
}
if (flavors.isFetching) {
return false
}
return false
}
export const fetchFlavorsIfNeeded = (tenant) => (dispatch, getState) => {
if (shouldFetchFlavors(tenant, getState())) {
return dispatch(fetchFlavors(tenant))
}
return Promise.resolve()
}

View File

@@ -225,6 +225,10 @@ function deleteImageUpload(apiPrefix, uploadId) {
)
}
function fetchFlavors(apiPrefix) {
return makeRequest(apiPrefix + 'flavors')
}
function fetchPipelines(apiPrefix) {
return makeRequest(apiPrefix + 'pipelines')
}
@@ -382,6 +386,7 @@ export {
fetchChangeStatus,
fetchComponents,
fetchConfigErrors,
fetchFlavors,
fetchFreezeJob,
fetchImages,
fetchInfo,

View File

@@ -0,0 +1,113 @@
// Copyright 2020 Red Hat, Inc
// Copyright 2022, 2024 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 {
EmptyState,
EmptyStateBody,
Spinner,
Title,
} from '@patternfly/react-core'
import {
Table,
TableHeader,
TableBody,
TableVariant,
} from '@patternfly/react-table'
function FlavorTable(props) {
const { flavors, fetching } = props
const columns = [
{
title: 'Name',
dataLabel: 'Name',
},
]
function createFlavorRow(flavor) {
return {
cells: [
{
title: flavor.name,
},
]
}
}
function createFetchingRow() {
const rows = [
{
heightAuto: true,
cells: [
{
props: { colSpan: 8 },
title: (
<center>
<Spinner size="xl" />
</center>
),
},
],
},
]
return rows
}
const haveFlavors = flavors && flavors.length > 0
let rows = []
if (fetching) {
rows = createFetchingRow()
columns[0].dataLabel = ''
} else {
if (haveFlavors) {
rows = flavors.map((flavor) => createFlavorRow(flavor))
}
}
return (
<>
<Table
aria-label="Flavor 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 flavors but are also not
fetching */}
{!fetching && !haveFlavors && (
<EmptyState>
<Title headingLevel="h1">No flavors found</Title>
<EmptyStateBody>
Nothing to display.
</EmptyStateBody>
</EmptyState>
)}
</>
)
}
FlavorTable.propTypes = {
flavors: PropTypes.array,
fetching: PropTypes.bool.isRequired,
}
export default FlavorTable

View File

@@ -83,9 +83,6 @@ function ImageTable(props) {
return (
<>
<Title headingLevel="h3">
Images
</Title>
<Table
aria-label="Image Table"
variant={TableVariant.compact}

View File

@@ -0,0 +1,113 @@
// Copyright 2020 Red Hat, Inc
// Copyright 2022, 2024 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 {
EmptyState,
EmptyStateBody,
Spinner,
Title,
} from '@patternfly/react-core'
import {
Table,
TableHeader,
TableBody,
TableVariant,
} from '@patternfly/react-table'
function LabelTable(props) {
const { labels, fetching } = props
const columns = [
{
title: 'Name',
dataLabel: 'Name',
},
]
function createLabelRow(label) {
return {
cells: [
{
title: label.name,
},
]
}
}
function createFetchingRow() {
const rows = [
{
heightAuto: true,
cells: [
{
props: { colSpan: 8 },
title: (
<center>
<Spinner size="xl" />
</center>
),
},
],
},
]
return rows
}
const haveLabels = labels && labels.length > 0
let rows = []
if (fetching) {
rows = createFetchingRow()
columns[0].dataLabel = ''
} else {
if (haveLabels) {
rows = labels.map((label) => createLabelRow(label))
}
}
return (
<>
<Table
aria-label="Label 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 labels but are also not
fetching */}
{!fetching && !haveLabels && (
<EmptyState>
<Title headingLevel="h1">No labels found</Title>
<EmptyStateBody>
Nothing to display.
</EmptyStateBody>
</EmptyState>
)}
</>
)
}
LabelTable.propTypes = {
labels: PropTypes.array,
fetching: PropTypes.bool.isRequired,
}
export default LabelTable

69
web/src/pages/Flavors.jsx Normal file
View File

@@ -0,0 +1,69 @@
// Copyright 2024 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 { useSelector, useDispatch } from 'react-redux'
import { withRouter } from 'react-router-dom'
import {
Level,
LevelItem,
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core'
import { fetchFlavors, fetchFlavorsIfNeeded } from '../actions/flavors'
import FlavorTable from '../containers/provider/FlavorTable'
import { ReloadButton } from '../containers/Fetching'
function FlavorsPage() {
const tenant = useSelector((state) => state.tenant)
const flavors = useSelector((state) => state.flavors.flavors[tenant.name])
const isFetching = useSelector((state) => state.status.isFetching)
const darkMode = useSelector((state) => state.preferences.darkMode)
const dispatch = useDispatch()
useEffect(() => {
document.title = 'Zuul Flavors'
dispatch(fetchFlavorsIfNeeded(tenant))
}, [tenant, dispatch])
console.log(flavors)
return (
<>
<PageSection variant={darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<Level>
<LevelItem>
</LevelItem>
<LevelItem>
<ReloadButton
isReloading={isFetching}
reloadCallback={() => {dispatch(fetchFlavors(tenant))}}
/>
</LevelItem>
</Level>
<Title headingLevel="h2">
Flavors
</Title>
<FlavorTable
flavors={flavors}
fetching={isFetching}
linkPrefix={`${tenant.linkPrefix}/flavor`}
/>
</PageSection>
</>
)
}
export default withRouter(FlavorsPage)

View File

@@ -20,6 +20,7 @@ import {
LevelItem,
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core'
import { fetchImages, fetchImagesIfNeeded } from '../actions/images'
import ImageTable from '../containers/provider/ImageTable'
@@ -52,6 +53,9 @@ function ImagesPage() {
/>
</LevelItem>
</Level>
<Title headingLevel="h2">
Images
</Title>
<ImageTable
images={images}
fetching={isFetching}

View File

@@ -12,7 +12,7 @@
// License for the specific language governing permissions and limitations
// under the License.
import React, { useEffect, useMemo } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { withRouter } from 'react-router-dom'
import {
@@ -20,12 +20,17 @@ import {
LevelItem,
PageSection,
PageSectionVariants,
Tab,
Tabs,
TabTitleText,
Title,
} from '@patternfly/react-core'
import PropTypes from 'prop-types'
import { fetchProviders, fetchProvidersIfNeeded } from '../actions/providers'
import ProviderDetail from '../containers/provider/ProviderDetail'
import ImageTable from '../containers/provider/ImageTable'
import FlavorTable from '../containers/provider/FlavorTable'
import LabelTable from '../containers/provider/LabelTable'
import { ReloadButton } from '../containers/Fetching'
function ProviderPage(props) {
@@ -35,6 +40,7 @@ function ProviderPage(props) {
const isFetching = useSelector((state) => state.status.isFetching)
const darkMode = useSelector((state) => state.preferences.darkMode)
const dispatch = useDispatch()
const [activeTabKey, setActiveTabKey] = useState('images')
const provider = useMemo(() =>
providers?providers.find((e) => e.name === providerName):null,
@@ -45,6 +51,10 @@ function ProviderPage(props) {
dispatch(fetchProvidersIfNeeded(tenant))
}, [tenant, dispatch])
const handleTabClick = (event, tabIndex) => {
setActiveTabKey(tabIndex)
}
return (
<>
<PageSection variant={darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
@@ -64,12 +74,39 @@ function ProviderPage(props) {
{provider &&
<>
<ProviderDetail provider={provider}/>
{provider.images &&
<ImageTable
images={provider.images}
fetching={false}
linkPrefix={`${tenant.linkPrefix}/provider/${providerName}/image`}/>
}
<Tabs activeKey={activeTabKey} onSelect={handleTabClick}>
<Tab
eventKey="images"
title={<TabTitleText>Images</TabTitleText>}
>
{provider.images &&
<ImageTable
images={provider.images}
fetching={false}
linkPrefix={`${tenant.linkPrefix}/provider/${providerName}/image`}/>
}
</Tab>
<Tab
eventKey="flavors"
title={<TabTitleText>Flavors</TabTitleText>}
>
{provider.flavors &&
<FlavorTable
flavors={provider.flavors}
fetching={false}/>
}
</Tab>
<Tab
eventKey="labels"
title={<TabTitleText>Labels</TabTitleText>}
>
{provider.labels &&
<LabelTable
labels={provider.labels}
fetching={false}/>
}
</Tab>
</Tabs>
</>
}
</PageSection>

View File

@@ -20,6 +20,7 @@ import {
LevelItem,
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core'
import { fetchProviders, fetchProvidersIfNeeded } from '../actions/providers'
import ProviderTable from '../containers/provider/ProviderTable'
@@ -50,6 +51,9 @@ function ProvidersPage() {
/>
</LevelItem>
</Level>
<Title headingLevel="h2">
Providers
</Title>
<ProviderTable
providers={providers}
fetching={isFetching} />

View File

@@ -0,0 +1,48 @@
// Copyright 2018 Red Hat, Inc
// Copyright 2022, 2024 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 {
FLAVORS_FETCH_FAIL,
FLAVORS_FETCH_REQUEST,
FLAVORS_FETCH_SUCCESS
} from '../actions/flavors'
export default (state = {
isFetching: false,
flavors: {},
}, action) => {
switch (action.type) {
case FLAVORS_FETCH_REQUEST:
return {
isFetching: true,
flavors: state.flavors,
}
case FLAVORS_FETCH_SUCCESS:
return {
isFetching: false,
flavors: {
...state.flavors,
[action.tenant]: action.flavors
}
}
case FLAVORS_FETCH_FAIL:
return {
isFetching: false,
flavors: state.flavors,
}
default:
return state
}
}

View File

@@ -18,6 +18,7 @@ import auth from './auth'
import autoholds from './autoholds'
import change from './change'
import component from './component'
import flavors from './flavors'
import freezejob from './freezejob'
import notifications from './notifications'
import build from './build'
@@ -51,6 +52,7 @@ const reducers = {
change,
component,
tenantStatus,
flavors,
freezejob,
notifications,
images,

View File

@@ -26,6 +26,7 @@ import JobPage from './pages/Job'
import JobsPage from './pages/Jobs'
import ImagePage from './pages/Image'
import ImagesPage from './pages/Images'
import FlavorsPage from './pages/Flavors'
import LabelsPage from './pages/Labels'
import NodesPage from './pages/Nodes'
import OpenApiPage from './pages/OpenApi'
@@ -207,6 +208,13 @@ const routes = (info) => {
component: ImagePage,
}
)
ret.push(
{
title: 'Flavors',
to: '/flavors',
component: FlavorsPage,
}
)
}
return ret
}

View File

@@ -551,6 +551,48 @@ class ImageConverter:
})
class FlavorConverter:
# A class to encapsulate the conversion of Flavor objects to
# API output.
@staticmethod
def toDict(tenant, flavor):
ret = {
'name': flavor.name,
'canonical_name': flavor.canonical_name,
'description': flavor.description,
}
return ret
@staticmethod
def schema():
return Prop('The image', {
'name': str,
'canonical_name': str,
'description': str,
})
class LabelConverter:
# A class to encapsulate the conversion of Label objects to
# API output.
@staticmethod
def toDict(tenant, label):
ret = {
'name': label.name,
'canonical_name': label.canonical_name,
'description': label.description,
}
return ret
@staticmethod
def schema():
return Prop('The image', {
'name': str,
'canonical_name': str,
'description': str,
})
class APIError(cherrypy.HTTPError):
def __init__(self, code, json_doc=None, headers=None):
self._headers = headers or {}
@@ -2029,6 +2071,24 @@ class ZuulWebAPI(object):
upload.state = model.STATE_DELETING
cherrypy.response.status = 204
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
@openapi_response(
code=200,
content_type='application/json',
description='Returns the list of flavors',
schema=Prop('The list of flavors', [FlavorConverter.schema()]),
)
@openapi_response(404, 'Tenant not found')
def flavors(self, tenant_name, tenant, auth):
ret = []
for flavor in tenant.layout.flavors.values():
ret.append(FlavorConverter.toDict(tenant, flavor))
return ret
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@@ -2043,6 +2103,8 @@ class ZuulWebAPI(object):
launcher.supported_labels,
allowed_labels, disallowed_labels))
ret = [{'name': label} for label in sorted(labels)]
for label in tenant.layout.labels.values():
ret.append(LabelConverter.toDict(tenant, label))
return ret
@cherrypy.expose
@@ -2745,6 +2807,8 @@ class ZuulWeb(object):
controller=api, action='pipelines')
route_map.connect('api', '/api/tenant/{tenant_name}/images',
controller=api, action='images')
route_map.connect('api', '/api/tenant/{tenant_name}/flavors',
controller=api, action='flavors')
route_map.connect('api',
'/api/tenant/{tenant_name}/'
'image/{image_name}/build',