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 {
|
||||
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
|
||||
</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)
|
||||
|
|
Loading…
Reference in New Issue