Merge "Web: convert config errors to table"

This commit is contained in:
Zuul 2023-06-06 22:01:13 +00:00 committed by Gerrit Code Review
commit 0b5795b808
2 changed files with 344 additions and 22 deletions

View File

@ -0,0 +1,217 @@
// Copyright 2020 BMW Group
// Copyright 2023 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 {
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateSecondaryActions,
Spinner,
Title,
} from '@patternfly/react-core'
import {
InfoCircleIcon,
CodeBranchIcon,
CodeIcon,
CubeIcon,
StreamIcon,
FlagIcon,
} from '@patternfly/react-icons'
import {
Table,
TableHeader,
TableBody,
TableVariant,
truncate,
breakWord,
cellWidth,
expandable,
} from '@patternfly/react-table'
import { IconProperty } from '../../Misc'
function ConfigErrorTable({
errors,
fetching,
onClearFilters,
preferences,
}) {
const [expandedRows, setExpandedRows] = React.useState([])
const setRowExpanded = (idx, isExpanding = true) =>
setExpandedRows(prevExpanded => {
const otherExpandedRows = prevExpanded.filter(r => r !== idx)
return isExpanding ?
[...otherExpandedRows, idx] : otherExpandedRows
})
const isRowExpanded = idx => expandedRows.includes(idx)
let zuulOutputClass = 'zuul-build-output'
if (preferences.darkMode) {
zuulOutputClass = 'zuul-build-output-dark'
}
const columns = [
{
title: <IconProperty icon={<CubeIcon />} value="Project" />,
dataLabel: 'Project',
cellTransforms: [breakWord],
cellFormatters: [expandable],
},
{
title: <IconProperty icon={<CodeBranchIcon />} value="Branch" />,
dataLabel: 'Branch',
cellTransforms: [breakWord],
},
{
title: <IconProperty icon={<StreamIcon />} value="Severity" />,
dataLabel: 'Severity',
},
{
title: <IconProperty icon={<CodeIcon />} value="Name" />,
dataLabel: 'Name',
},
{
title: <IconProperty icon={<FlagIcon />} value="Message" />,
dataLabel: 'Message',
transforms: [cellWidth(20)],
cellTransforms: [truncate],
},
]
function createConfigErrorRow(rows, error) {
return {
id: rows.length,
isOpen: isRowExpanded(rows.length),
cells: [
{
title: error.source_context.project,
},
{
title: error.source_context.branch,
},
{
title: error.severity,
},
{
title: error.name,
},
{
title: error.short_error,
},
],
}
}
function createConfigErrorDetailRow(rows, error) {
return {
id: rows.length,
parent: rows.length - 1,
cells: [
{
title: <pre className={zuulOutputClass}>{error.error}</pre>,
},
],
}
}
function createFetchingRow() {
const rows = [
{
heightAuto: true,
cells: [
{
props: { colSpan: 8 },
title: (
<center>
<Spinner size="xl" />
</center>
),
},
],
},
]
return rows
}
let rows = []
if (fetching) {
rows = createFetchingRow()
// The dataLabel property is used to show the column header in a list-like
// format for smaller viewports. When we are fetching, we don't want the
// fetching row to be prepended by a "Job" column header. The other column
// headers are not relevant here since we only have a single cell in the
// fetcihng row.
columns[0].dataLabel = ''
} else {
rows = []
errors.forEach(error => {
rows.push(createConfigErrorRow(rows, error))
rows.push(createConfigErrorDetailRow(rows, error))
})
}
return (
<>
<Table
aria-label="Config Errors Table"
variant={TableVariant.compact}
cells={columns}
rows={rows}
className="zuul-table"
onCollapse={(_event, rowIndex, isOpen) => {
setRowExpanded(rowIndex, isOpen)
}}
expandId="expandable-table-toggle" contentId="expandable-table-content"
>
<TableHeader />
<TableBody />
</Table>
{/* Show an empty state in case we don't have any errors but are also not
fetching */}
{!fetching && errors.length === 0 && (
<EmptyState>
<EmptyStateIcon icon={InfoCircleIcon} />
<Title headingLevel="h1">No errors found</Title>
<EmptyStateBody>
No errors match this filter criteria. Remove some filters or clear
all to show results.
</EmptyStateBody>
<EmptyStateSecondaryActions>
<Button variant="link" onClick={onClearFilters}>
Clear all filters
</Button>
</EmptyStateSecondaryActions>
</EmptyState>
)}
</>
)
}
ConfigErrorTable.propTypes = {
errors: PropTypes.array.isRequired,
fetching: PropTypes.bool.isRequired,
onClearFilters: PropTypes.func.isRequired,
preferences: PropTypes.object.isRequired,
}
export default connect((state) => ({
preferences: state.preferences,
}))(ConfigErrorTable)

View File

@ -21,26 +21,136 @@ import {
import {
PageSection,
PageSectionVariants,
List,
ListItem,
} from '@patternfly/react-core'
import {
FilterToolbar,
getFiltersFromUrl,
writeFiltersToUrl,
} from '../containers/FilterToolbar'
import { fetchConfigErrorsAction } from '../actions/configErrors'
import ConfigErrorTable from '../containers/configerrors/ConfigErrorTable'
class ConfigErrorsPage extends React.Component {
static propTypes = {
configErrors: PropTypes.object,
configErrors: PropTypes.array,
configErrorsReady: PropTypes.bool,
tenant: PropTypes.object,
dispatch: PropTypes.func,
preferences: PropTypes.object,
history: PropTypes.object,
location: PropTypes.object,
}
constructor(props) {
super()
this.filterCategories = [
{
key: 'project',
title: 'Project',
placeholder: 'Filter by project...',
type: 'search',
},
{
key: 'branch',
title: 'Branch',
placeholder: 'Filter by branch...',
type: 'search',
},
{
key: 'severity',
title: 'Severity',
placeholder: 'Filter by severity...',
type: 'search',
},
{
key: 'name',
title: 'Name',
placeholder: 'Filter by name...',
type: 'search',
},
]
const _filters = getFiltersFromUrl(props.location, this.filterCategories)
this.state = {
filters: _filters,
}
}
componentDidMount () {
document.title = 'Zuul Configuration Errors'
if (this.props.tenant.name) {
this.updateData()
}
}
componentDidUpdate (prevProps) {
if (this.props.tenant.name !== prevProps.tenant.name) {
this.updateData()
}
}
updateData = () => {
this.props.dispatch(fetchConfigErrorsAction(this.props.tenant))
}
filterInputValidation = (filterKey, filterValue) => {
// Input value should not be empty for all cases
if (!filterValue) {
return {
success: false,
message: 'Input should not be empty'
}
}
return {
success: true
}
}
handleFilterChange = (newFilters) => {
const { location, history } = this.props
// We must update the URL parameters before the state. Otherwise, the URL
// will always be one filter selection behind the state. But as the URL
// reflects our state this should be ok.
writeFiltersToUrl(newFilters, location, history)
this.setState({filters: newFilters})
}
handleClearFilters = () => {
// Delete the values for each filter category
const filters = this.filterCategories.reduce((filterDict, category) => {
filterDict[category.key] = []
return filterDict
}, {})
this.handleFilterChange(filters)
}
filterErrors = (errors, filters) => {
return errors.filter((error) => {
if (filters.project.length &&
!filters.project.includes(error.source_context.project)) {
return false
}
if (filters.branch.length &&
!filters.branch.includes(error.source_context.branch)) {
return false
}
if (filters.severity.length &&
!filters.severity.includes(error.severity)) {
return false
}
if (filters.name.length &&
!filters.name.includes(error.name)) {
return false
}
return true
})
}
render () {
const { configErrors } = this.props
const { configErrors, configErrorsReady, history } = this.props
return (
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
<div className="pull-right">
@ -50,24 +160,18 @@ class ConfigErrorsPage extends React.Component {
<Icon type="fa" name="refresh" /> refresh&nbsp;&nbsp;
</a>
</div>
<div className="pull-left">
<List isPlain isBordered>
{configErrors.map((item, idx) => {
let ctxPath = item.source_context.path
if (item.source_context.branch !== 'master') {
ctxPath += ' (' + item.source_context.branch + ')'
}
return (
<ListItem key={idx}>
<h3>{item.source_context.project} - {ctxPath}</h3>
<p style={{whiteSpace: 'pre-wrap'}}>
{item.error}
</p>
</ListItem>
)
})}
</List>
</div>
<FilterToolbar
filterCategories={this.filterCategories}
onFilterChange={this.handleFilterChange}
filters={this.state.filters}
filterInputValidation={this.filterInputValidation}
/>
<ConfigErrorTable
errors={this.filterErrors(configErrors, this.state.filters)}
fetching={!configErrorsReady}
onClearFilters={this.handleClearFilters}
history={history}
/>
</PageSection>
)
}
@ -76,5 +180,6 @@ class ConfigErrorsPage extends React.Component {
export default connect(state => ({
tenant: state.tenant,
configErrors: state.configErrors.errors,
configErrorsReady: state.configErrors.ready,
preferences: state.preferences,
}))(ConfigErrorsPage)