UI: Add components page

This adds a new page /components that lists all components which are
retieved from the /components API endpoint.

For the look and feel this page has a similar design like the builds and
buildsets pages.

Change-Id: I38b3a9b456b71e49f02712b63417a905a0aa1397
This commit is contained in:
Felix Edel 2020-10-30 07:27:44 +01:00 committed by James E. Blair
parent 495418c350
commit 7c6af60a5d
5 changed files with 347 additions and 1 deletions

View File

@ -54,6 +54,7 @@ import {
BellIcon,
BookIcon,
CodeIcon,
ServiceIcon,
UsersIcon,
} from '@patternfly/react-icons'
@ -198,6 +199,11 @@ class App extends React.Component {
})
}
handleComponentsLink = () => {
const { history } = this.props
history.push('/components')
}
handleApiLink = () => {
const { history } = this.props
history.push('/openapi')
@ -306,6 +312,12 @@ class App extends React.Component {
const nav = this.renderMenu()
const kebabDropdownItems = [
<DropdownItem
key="components"
onClick={event => this.handleComponentsLink(event)}
>
<ServiceIcon /> Components
</DropdownItem>,
<DropdownItem key="api" onClick={event => this.handleApiLink(event)}>
<CodeIcon /> API
</DropdownItem>,
@ -335,6 +347,13 @@ class App extends React.Component {
<PageHeaderToolsGroup
visibility={{ default: 'hidden', lg: 'visible' }}
>
<PageHeaderToolsItem>
<Link to='/components'>
<Button variant={ButtonVariant.plain}>
<ServiceIcon /> Components
</Button>
</Link>
</PageHeaderToolsItem>
<PageHeaderToolsItem>
<Link to='/openapi'>
<Button variant={ButtonVariant.plain}>

View File

@ -0,0 +1,224 @@
// Copyright 2021 BMW Group
//
// 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, useState } from 'react'
import PropTypes from 'prop-types'
import { capitalize } from '@patternfly/react-core'
import {
expandable,
Table,
TableBody,
TableHeader,
TableVariant,
} from '@patternfly/react-table'
import {
CodeIcon,
OnRunningIcon,
OutlinedHddIcon,
PauseCircleIcon,
RunningIcon,
QuestionIcon,
StopCircleIcon,
} from '@patternfly/react-icons'
import { IconProperty } from '../build/Misc'
const STATE_ICON_CONFIGS = {
RUNNING: {
icon: RunningIcon,
color: 'var(--pf-global--success-color--100)',
},
PAUSED: {
icon: PauseCircleIcon,
color: 'var(--pf-global--warning-color--100)',
},
STOPPED: {
icon: StopCircleIcon,
color: 'var(--pf-global--danger-color--100)',
},
}
const DEFAULT_STATE_ICON_CONFIG = {
icon: QuestionIcon,
color: 'var(--pf-global--info-color--100)',
}
function ComponentStateIcon({ state }) {
const iconConfig =
STATE_ICON_CONFIGS[state.toUpperCase()] || DEFAULT_STATE_ICON_CONFIG
const Icon = iconConfig.icon
return (
<span style={{ color: iconConfig.color }}>
<Icon
size="sm"
style={{
marginRight: 'var(--pf-global--spacer--sm)',
verticalAlign: '-0.2em',
}}
/>
</span>
)
}
ComponentStateIcon.propTypes = {
state: PropTypes.string.isRequired,
}
function ComponentState({ state }) {
const iconConfig =
STATE_ICON_CONFIGS[state.toUpperCase()] || DEFAULT_STATE_ICON_CONFIG
return <span style={{ color: iconConfig.color }}>{state.toUpperCase()}</span>
}
ComponentState.propTypes = {
state: PropTypes.string.isRequired,
}
function ComponentTable({ components }) {
// We have to keep the rows in state to be complient to how the PF4
// expandable/collapsible table works (see the handleCollapse function).
const [rows, setRows] = useState([])
const sortComponents = (a, b) => {
if (a.hostname < b.hostname) {
return -1
}
if (a.hostname > b.hostname) {
return 1
}
return 0
}
useEffect(() => {
const createTableRows = () => {
const allRows = []
let i = 0
let sectionIndex = 0
for (const [kind, _components] of Object.entries(components)) {
sectionIndex = i
i++
const sectionRows = []
for (const component of [..._components].sort(sortComponents)) {
sectionRows.push(createComponentRow(kind, component, sectionIndex))
i++
}
allRows.push(createSectionRow(kind, sectionRows.length))
allRows.push(...sectionRows)
}
return allRows
}
setRows(createTableRows())
// Ensure that the effect is only called once and not after each
// render (which would happen if no dependency array is provided).
// But as we are changing the state during the effect, this would
// result in an infinite render loop as every state change
// re-renders the component.
// Side note: We could also pass an empty depdency array here, but
// eslint is complaining about that. So we provide the components
// variable which is provided via props and thus doesn't change
// during the lifetime of this react component.
}, [components])
// TODO (felix): We could change this to an expandable table and show some
// details about the component in the expandable row. E.g. similar to what
// OpenShift shows in for deployments and pods (metrics, performance,
// additional attributes).
const columns = [
{
title: <IconProperty icon={<OutlinedHddIcon />} value="Hostname" />,
dataLabel: 'Hostname',
cellFormaters: [expandable],
},
{
title: <IconProperty icon={<OnRunningIcon />} value="State" />,
dataLabel: 'State',
},
{
title: <IconProperty icon={<CodeIcon />} value="Version" />,
dataLabel: 'Version',
},
]
function createSectionRow(kind, childrenCount) {
return {
// Keep all sections open on initial page load. The handleCollapse()
// function will deal with open/closing sections.
isOpen: true,
cells: [`${capitalize(kind)} (${childrenCount})`],
}
}
function createComponentRow(kind, component, parent_id) {
return {
parent: parent_id,
cells: [
{
title: (
<>
<ComponentStateIcon state={component.state} />{' '}
{component.hostname}
</>
),
},
{
title: <ComponentState state={component.state} />,
},
component.version,
],
}
}
function handleCollapse(event, rowKey, isOpen) {
const _rows = [...rows]
/* Note from PF4:
* Please do not use rowKey as row index for more complex tables.
* Rather use some kind of identifier like ID passed with each row.
*/
rows[rowKey].isOpen = isOpen
setRows(_rows)
}
return (
/* NOTE (felix): The mobile version of this expandable table looks kind of
* broken, but the same applies to the example in the PF4 documentation:
* https://www.patternfly.org/2020.04/documentation/react/components/table#compact-expandable
*
* I don't think this is something we have to attract now, but we should
* keep this note as reference.
*/
<>
<Table
aria-label="Components Table"
variant={TableVariant.compact}
onCollapse={handleCollapse}
cells={columns}
rows={rows}
className="zuul-build-table"
>
<TableHeader />
<TableBody />
</Table>
</>
)
}
ComponentTable.propTypes = {
components: PropTypes.object.isRequired,
}
export default ComponentTable

View File

@ -61,6 +61,17 @@ a.refresh {
font-size: var(--pf-global--FontSize--md);
}
/* Align padding of compact expendable (child) rows. Without this there is
nearly no padding. */
.zuul-build-table .pf-c-table__expandable-row.pf-m-expanded {
--pf-c-table--cell--PaddingTop: var(
--pf-c-table--m-compact--cell--PaddingTop
);
--pf-c-table--cell--PaddingBottom: var(
--pf-c-table--m-compact--cell--PaddingBottom
);
}
/* Use the same hover effect on table rows like for the selectable data list */
.zuul-build-table tbody tr:hover {
box-shadow: var(--pf-global--BoxShadow--sm-top),

View File

@ -0,0 +1,86 @@
// Copyright 2021 BMW Group
//
// 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 {
EmptyState,
EmptyStateVariant,
EmptyStateIcon,
PageSection,
PageSectionVariants,
Text,
TextContent,
Title,
} from '@patternfly/react-core'
import { ServiceIcon } from '@patternfly/react-icons'
import { fetchComponents } from '../actions/component'
import { Fetching } from '../containers/Fetching'
import ComponentTable from '../containers/component/ComponentTable'
function ComponentsPage({ components, isFetching, fetchComponents }) {
useEffect(() => {
document.title = 'Zuul Components'
fetchComponents()
}, [fetchComponents])
// TODO (felix): Let the table handle the empty state and the fetching,
// similar to the builds table.
const content =
components === undefined || isFetching ? (
<Fetching />
) : Object.keys(components).length === 0 ? (
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon icon={ServiceIcon} />
<Title headingLevel="h4" size="lg">
It looks like no components are connected to ZooKeeper
</Title>
</EmptyState>
) : (
<ComponentTable components={components} />
)
return (
<>
<PageSection variant={PageSectionVariants.light}>
<TextContent>
<Text component="h1">Components</Text>
<Text component="p">
This page shows all Zuul components and their current state.
</Text>
</TextContent>
{content}
</PageSection>
</>
)
}
ComponentsPage.propTypes = {
components: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
fetchComponents: PropTypes.func.isRequired,
}
function mapStateToProps(state) {
return {
components: state.component.components,
isFetching: state.component.isFetching,
}
}
const mapDispatchToProps = { fetchComponents }
export default connect(mapStateToProps, mapDispatchToProps)(ComponentsPage)

View File

@ -12,6 +12,7 @@
// License for the specific language governing permissions and limitations
// under the License.
import ComponentsPage from './pages/Components'
import StatusPage from './pages/Status'
import ChangeStatusPage from './pages/ChangeStatus'
import ProjectPage from './pages/Project'
@ -127,7 +128,12 @@ const routes = () => [
to: '/openapi',
component: OpenApiPage,
noTenantPrefix: true,
}
},
{
to: '/components',
component: ComponentsPage,
noTenantPrefix: true,
},
]
export { routes }