Web: convert config errors to table
This updates the config-errors page to show errors in table format along with a filter bar. This allows users to see all of the errors at a glance, or filter by project or error type. The table rows show a summary of error information, and each row can be expanded to show the full error message. Change-Id: Ie019d4193c730fe84f4bc2827106825093fa68a4
This commit is contained in:
parent
b01446e8a8
commit
bcae20f897
|
@ -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)
|
|
@ -21,26 +21,136 @@ import {
|
||||||
import {
|
import {
|
||||||
PageSection,
|
PageSection,
|
||||||
PageSectionVariants,
|
PageSectionVariants,
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
} from '@patternfly/react-core'
|
} from '@patternfly/react-core'
|
||||||
|
|
||||||
|
import {
|
||||||
|
FilterToolbar,
|
||||||
|
getFiltersFromUrl,
|
||||||
|
writeFiltersToUrl,
|
||||||
|
} from '../containers/FilterToolbar'
|
||||||
import { fetchConfigErrorsAction } from '../actions/configErrors'
|
import { fetchConfigErrorsAction } from '../actions/configErrors'
|
||||||
|
import ConfigErrorTable from '../containers/configerrors/ConfigErrorTable'
|
||||||
|
|
||||||
class ConfigErrorsPage extends React.Component {
|
class ConfigErrorsPage extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
configErrors: PropTypes.object,
|
configErrors: PropTypes.array,
|
||||||
|
configErrorsReady: PropTypes.bool,
|
||||||
tenant: PropTypes.object,
|
tenant: PropTypes.object,
|
||||||
dispatch: PropTypes.func,
|
dispatch: PropTypes.func,
|
||||||
preferences: PropTypes.object,
|
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 = () => {
|
updateData = () => {
|
||||||
this.props.dispatch(fetchConfigErrorsAction(this.props.tenant))
|
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 () {
|
render () {
|
||||||
const { configErrors } = this.props
|
const { configErrors, configErrorsReady, history } = this.props
|
||||||
return (
|
return (
|
||||||
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
|
<PageSection variant={this.props.preferences.darkMode ? PageSectionVariants.dark : PageSectionVariants.light}>
|
||||||
<div className="pull-right">
|
<div className="pull-right">
|
||||||
|
@ -50,24 +160,18 @@ class ConfigErrorsPage extends React.Component {
|
||||||
<Icon type="fa" name="refresh" /> refresh
|
<Icon type="fa" name="refresh" /> refresh
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="pull-left">
|
<FilterToolbar
|
||||||
<List isPlain isBordered>
|
filterCategories={this.filterCategories}
|
||||||
{configErrors.map((item, idx) => {
|
onFilterChange={this.handleFilterChange}
|
||||||
let ctxPath = item.source_context.path
|
filters={this.state.filters}
|
||||||
if (item.source_context.branch !== 'master') {
|
filterInputValidation={this.filterInputValidation}
|
||||||
ctxPath += ' (' + item.source_context.branch + ')'
|
/>
|
||||||
}
|
<ConfigErrorTable
|
||||||
return (
|
errors={this.filterErrors(configErrors, this.state.filters)}
|
||||||
<ListItem key={idx}>
|
fetching={!configErrorsReady}
|
||||||
<h3>{item.source_context.project} - {ctxPath}</h3>
|
onClearFilters={this.handleClearFilters}
|
||||||
<p style={{whiteSpace: 'pre-wrap'}}>
|
history={history}
|
||||||
{item.error}
|
/>
|
||||||
</p>
|
|
||||||
</ListItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
</PageSection>
|
</PageSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -76,5 +180,6 @@ class ConfigErrorsPage extends React.Component {
|
||||||
export default connect(state => ({
|
export default connect(state => ({
|
||||||
tenant: state.tenant,
|
tenant: state.tenant,
|
||||||
configErrors: state.configErrors.errors,
|
configErrors: state.configErrors.errors,
|
||||||
|
configErrorsReady: state.configErrors.ready,
|
||||||
preferences: state.preferences,
|
preferences: state.preferences,
|
||||||
}))(ConfigErrorsPage)
|
}))(ConfigErrorsPage)
|
||||||
|
|
Loading…
Reference in New Issue