Merge "PF4: Update build result page layout"

This commit is contained in:
Zuul 2020-07-22 07:13:20 +00:00 committed by Gerrit Code Review
commit 7c2707be02
6 changed files with 463 additions and 217 deletions

View File

@ -43,13 +43,13 @@ class Artifact extends React.Component {
class ArtifactList extends React.Component { class ArtifactList extends React.Component {
static propTypes = { static propTypes = {
build: PropTypes.object.isRequired artifacts: PropTypes.array.isRequired
} }
render() { render() {
const { build } = this.props const { artifacts } = this.props
const nodes = build.artifacts.map((artifact, index) => { const nodes = artifacts.map((artifact, index) => {
const node = {text: <a href={artifact.url}>{artifact.name}</a>, const node = {text: <a href={artifact.url}>{artifact.name}</a>,
icon: null} icon: null}
if (artifact.metadata) { if (artifact.metadata) {
@ -60,11 +60,14 @@ class ArtifactList extends React.Component {
}) })
return ( return (
<div className="tree-view-container"> <>
<TreeView <br/>
nodes={nodes} <div className="tree-view-container">
/> <TreeView
</div> nodes={nodes}
/>
</div>
</>
) )
} }
} }

View File

@ -16,68 +16,238 @@ import * as React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import {
Flex,
FlexItem,
List,
ListItem,
Title,
} from '@patternfly/react-core'
import {
BookIcon,
BuildIcon,
CodeBranchIcon,
CodeIcon,
CubeIcon,
FileCodeIcon,
FingerprintIcon,
HistoryIcon,
OutlinedCalendarAltIcon,
OutlinedClockIcon,
StreamIcon,
} from '@patternfly/react-icons'
import * as moment from 'moment'
import 'moment-duration-format'
import Summary from './Summary' import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc'
import Manifest from './Manifest' import { ExternalLink } from '../../Misc'
import Console from './Console'
class Build extends React.Component { function Build(props) {
static propTypes = { const { build, tenant, timezone, fetchable } = props
build: PropTypes.object, return (
tenant: PropTypes.object, <>
active: PropTypes.string, <Title
hash: PropTypes.array, headingLevel="h2"
} style={{
color: build.voting
render () { ? 'inherit'
const { build, active, hash } = this.props : 'var(--pf-global--disabled-color--100)',
}}
return ( >
<div> <BuildResultWithIcon
<h2>Build result {build.uuid}</h2> result={build.result}
<div> colored={build.voting}
<ul className="nav nav-tabs nav-tabs-pf"> size="md"
<li className={active==='summary'?'active':undefined}> >
<Link to={this.props.tenant.linkPrefix + '/build/' + build.uuid}> {build.job_name} {!build.voting && ' (non-voting)'}
Summary </BuildResultWithIcon>
<BuildResultBadge result={build.result} />
{fetchable}
</Title>
{/* We handle the spacing for the body and the flex items by ourselves
so they go hand in hand. By default, the flex items' spacing only
affects left/right margin, but not top or bottom (which looks
awkward when the items are stacked at certain breakpoints) */}
<Flex className="zuul-build-attributes">
<Flex flex={{ lg: 'flex_1' }}>
<FlexItem>
<List style={{ listStyle: 'none' }}>
{/* TODO (felix): What should we show for periodic builds
here? They don't provide a change, but the ref_url is
also not usable */}
{build.change && (
<IconProperty
WrapElement={ListItem}
icon={<CodeIcon />}
value={
<ExternalLink target={build.ref_url}>
<strong>Change </strong>
{build.change},{build.patchset}
</ExternalLink>
}
/>
)}
{/* TODO (felix): Link to project page in Zuul */}
<IconProperty
WrapElement={ListItem}
icon={<CubeIcon />}
value={
<>
<strong>Project </strong> {build.project}
</>
}
/>
<IconProperty
WrapElement={ListItem}
icon={<CodeBranchIcon />}
value={
<>
<strong>Branch </strong> {build.branch}
</>
}
/>
<IconProperty
WrapElement={ListItem}
icon={<StreamIcon />}
value={
<>
<strong>Pipeline </strong> {build.pipeline}
</>
}
/>
<IconProperty
WrapElement={ListItem}
icon={<FingerprintIcon />}
value={
<span>
<strong>UUID </strong> {build.uuid} <br />
<strong>Event ID </strong> {build.event_id} <br />
</span>
}
/>
</List>
</FlexItem>
</Flex>
<Flex flex={{ lg: 'flex_1' }}>
<FlexItem>
<List style={{ listStyle: 'none' }}>
<IconProperty
WrapElement={ListItem}
icon={<OutlinedCalendarAltIcon />}
value={
<span>
<strong>Started at </strong>
{moment
.utc(build.start_time)
.tz(timezone)
.format('YYYY-MM-DD HH:mm:ss')}
<br />
<strong>Completed at </strong>
{moment
.utc(build.end_time)
.tz(timezone)
.format('YYYY-MM-DD HH:mm:ss')}
</span>
}
/>
<IconProperty
WrapElement={ListItem}
icon={<OutlinedClockIcon />}
value={
<>
<strong>Took </strong>
{moment
.duration(build.duration, 'seconds')
.format('h [hr] m [min] s [sec]')}
</>
}
/>
</List>
</FlexItem>
</Flex>
<Flex flex={{ lg: 'flex_1' }}>
<FlexItem>
<List style={{ listStyle: 'none' }}>
<IconProperty
WrapElement={ListItem}
icon={<BookIcon />}
value={
<Link to={tenant.linkPrefix + '/job/' + build.job_name}>
View job documentation
</Link> </Link>
</li> }
{build.manifest && />
<li className={active==='logs'?'active':undefined}> <IconProperty
<Link to={this.props.tenant.linkPrefix + '/build/' + build.uuid + '/logs'}> WrapElement={ListItem}
Logs icon={<HistoryIcon />}
</Link> value={
</li>} <Link
{build.output && to={
<li className={active==='console'?'active':undefined}> tenant.linkPrefix +
<Link '/builds?job_name=' +
to={this.props.tenant.linkPrefix + '/build/' + build.uuid + '/console'}> build.job_name +
Console '&project=' +
</Link> build.project
</li>} }
</ul> title="See previous runs of this job inside current project."
<div> >
{/* NOTE (felix): Since I'm already working on a PF4 change for View build history
this file, I don't want to change too much here for now and </Link>
just make it compatible to the improved routing solution. }
*/} />
{active === 'summary' && <Summary build={build} />} {/* In some cases not all build data is available on initial
{active === 'logs' && build && build.manifest && ( page load (e.g. when we come from another page like the
<Manifest tenant={this.props.tenant} build={build}/> buildset result page). Thus, we have to check for the
)} buildset here. */}
{active === 'console' && build && build.output && ( {build.buildset && (
<Console <IconProperty
output={build.output} WrapElement={ListItem}
errorIds={build.errorIds} icon={<BuildIcon />}
displayPath={hash.length>0?hash:undefined} value={
/> <Link
)} to={
</div> tenant.linkPrefix + '/buildset/' + build.buildset.uuid
</div> }
</div> >
) View buildset result
} </Link>
}
/>
)}
<IconProperty
WrapElement={ListItem}
icon={<FileCodeIcon />}
value={
build.log_url ? (
<ExternalLink target={build.log_url}>View log</ExternalLink>
) : (
<span
style={{
color: 'var(--pf-global--disabled-color--100)',
}}
>
No log available
</span>
)
}
/>
</List>
</FlexItem>
</Flex>
</Flex>
</>
)
} }
Build.propTypes = {
build: PropTypes.object,
tenant: PropTypes.object,
hash: PropTypes.array,
timezone: PropTypes.string,
fetchable: PropTypes.node,
}
export default connect(state => ({tenant: state.tenant}))(Build) export default connect((state) => ({
tenant: state.tenant,
timezone: state.timezone,
}))(Build)

View File

@ -108,6 +108,7 @@ class BuildOutput extends React.Component {
const { output } = this.props const { output } = this.props
return ( return (
<React.Fragment> <React.Fragment>
<br />
<div key="tasks"> <div key="tasks">
{Object.entries(output) {Object.entries(output)
.filter(([, values]) => values.failed.length > 0) .filter(([, values]) => values.failed.length > 0)

View File

@ -1,131 +0,0 @@
// Copyright 2018 Red Hat, Inc
//
// 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 React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import ArtifactList from './Artifact'
import BuildOutput from './BuildOutput'
import * as moment from 'moment'
import 'moment-duration-format'
class Summary extends React.Component {
static propTypes = {
build: PropTypes.object,
tenant: PropTypes.object,
timezone: PropTypes.string,
}
render () {
const { build } = this.props
const rows = []
const myColumns = [
'job_name', 'result', 'buildset', 'voting',
'pipeline', 'start_time', 'end_time', 'duration',
'project', 'branch', 'change', 'patchset', 'oldrev', 'newrev',
'ref', 'new_rev', 'ref_url', 'log_url', 'event_id']
if (!build.buildset) {
// Safely handle missing buildset information
myColumns.splice(myColumns.indexOf('buildset'), 1)
}
myColumns.forEach(column => {
let label = column
let value = build[column]
if (column === 'job_name') {
label = 'job'
value = (
<React.Fragment>
<Link to={this.props.tenant.linkPrefix + '/job/' + value}>
{value}
</Link>
<span> &mdash; </span>
<Link to={this.props.tenant.linkPrefix + '/builds?job_name=' + value + '&project=' + build.project} title="See previous runs of this job inside current project.">
build history
</Link>
</React.Fragment>
)
}
if (column === 'buildset') {
value = (
<Link to={this.props.tenant.linkPrefix + '/buildset/' + value.uuid}>
{value.uuid}
</Link>
)
}
if (column === 'voting') {
if (value) {
value = 'true'
} else {
value = 'false'
}
}
if (column === 'start_time' || column === 'end_time') {
value = moment.utc(value).tz(this.props.timezone).format('YYYY-MM-DD HH:mm:ss')
}
if (column === 'duration') {
value = moment.duration(value, 'seconds')
.format('h [hr] m [min] s [sec]')
}
if (value && (column === 'log_url' || column === 'ref_url')) {
value = <a href={value}>{value}</a>
}
if (column === 'log_url') {
label = 'log url'
if (build.manifest && build.manifest.index_links) {
value = <a href={value + 'index.html'}>{value}</a>
} else {
value = <a href={value}>{value}</a>
}
}
if (column === 'ref_url') {
label = 'ref url'
value = <a href={value}>{value}</a>
}
if (column === 'event_id') {
label = 'event id'
}
if (value) {
rows.push({key: label, value: value})
}
})
return (
<React.Fragment>
<br/>
<table className="table table-striped table-bordered">
<tbody>
{rows.map(item => (
<tr key={item.key}>
<td>{item.key}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
<h3>Artifacts</h3>
<ArtifactList build={build}/>
<h3>Results</h3>
{build.hosts && <BuildOutput output={build.hosts}/>}
</React.Fragment>
)
}
}
export default connect(state => ({tenant: state.tenant, timezone: state.timezone}))(Summary)

View File

@ -14,13 +14,36 @@
import * as React from 'react' import * as React from 'react'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { PageSection, PageSectionVariants } from '@patternfly/react-core' import {
EmptyState,
EmptyStateVariant,
EmptyStateIcon,
PageSection,
PageSectionVariants,
Tab,
Tabs,
TabTitleIcon,
TabTitleText,
Title,
} from '@patternfly/react-core'
import {
BuildIcon,
FileArchiveIcon,
FileCodeIcon,
TerminalIcon,
PollIcon,
} from '@patternfly/react-icons'
import { fetchBuildIfNeeded } from '../actions/build' import { fetchBuildIfNeeded } from '../actions/build'
import { Fetchable } from '../containers/Fetching' import { EmptyPage } from '../containers/Errors'
import { Fetchable, Fetching } from '../containers/Fetching'
import ArtifactList from '../containers/build/Artifact'
import Build from '../containers/build/Build' import Build from '../containers/build/Build'
import BuildOutput from '../containers/build/BuildOutput'
import Console from '../containers/build/Console'
import Manifest from '../containers/build/Manifest'
class BuildPage extends React.Component { class BuildPage extends React.Component {
static propTypes = { static propTypes = {
@ -30,45 +53,220 @@ class BuildPage extends React.Component {
dispatch: PropTypes.func, dispatch: PropTypes.func,
activeTab: PropTypes.string.isRequired, activeTab: PropTypes.string.isRequired,
location: PropTypes.object, location: PropTypes.object,
history: PropTypes.object,
} }
updateData = (force) => { updateData = (force) => {
this.props.dispatch(fetchBuildIfNeeded( this.props.dispatch(
this.props.tenant, this.props.match.params.buildId, null, force)) fetchBuildIfNeeded(
this.props.tenant,
this.props.match.params.buildId,
null,
force
)
)
} }
componentDidMount () { componentDidMount() {
document.title = 'Zuul Build' document.title = 'Zuul Build'
if (this.props.tenant.name) { if (this.props.tenant.name) {
this.updateData() this.updateData()
} }
} }
componentDidUpdate (prevProps) { componentDidUpdate(prevProps) {
if (this.props.tenant.name !== prevProps.tenant.name) { if (this.props.tenant.name !== prevProps.tenant.name) {
this.updateData() this.updateData()
} }
} }
render () { handleTabClick = (tabIndex, build) => {
const { remoteData, activeTab, location } = this.props // Usually tabs should only be used to display content in-page and not link
// to other pages:
// "Tabs are used to present a set on tabs for organizing content on a
// .page. It must always be used together with a tab content component."
// https://www.patternfly.org/v4/documentation/react/components/tabs
// But as want to be able to reach every tab's content via a dedicated URL
// while having the look and feel of tabs, we could hijack this onClick
// handler to do the link/routing stuff.
const { history, tenant } = this.props
switch (tabIndex) {
case 'artifacts':
history.push(`${tenant.linkPrefix}/build/${build.uuid}/artifacts`)
break
case 'logs':
history.push(`${tenant.linkPrefix}/build/${build.uuid}/logs`)
break
case 'console':
history.push(`${tenant.linkPrefix}/build/${build.uuid}/console`)
break
default:
// results
history.push(`${tenant.linkPrefix}/build/${build.uuid}`)
}
}
render() {
const { remoteData, activeTab, location, tenant } = this.props
const build = remoteData.builds[this.props.match.params.buildId] const build = remoteData.builds[this.props.match.params.buildId]
const hash = location.hash.substring(1).split('/') const hash = location.hash.substring(1).split('/')
if (!build && remoteData.isFetching) {
return <Fetching />
}
if (!build) {
return (
<EmptyPage
title="This build does not exist"
icon={BuildIcon}
linkTarget={`${tenant.linkPrefix}/builds`}
linkText="Show all builds"
/>
)
}
const fetchable = (
<Fetchable
isFetching={remoteData.isFetching}
fetchCallback={this.updateData}
/>
)
const resultsTabContent =
!build.hosts && remoteData.isFetchingOutput ? (
<Fetching />
) : build.hosts ? (
<BuildOutput output={build.hosts} />
) : (
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon icon={PollIcon} />
<Title headingLevel="h4" size="lg">
This build does not provide any results
</Title>
</EmptyState>
)
const artifactsTabContent = build.artifacts.length ? (
<ArtifactList artifacts={build.artifacts} />
) : (
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon icon={FileArchiveIcon} />
<Title headingLevel="h4" size="lg">
This build does not provide any artifacts
</Title>
</EmptyState>
)
const logsTabContent =
!build.manifest && remoteData.isFetchingManifest ? (
<Fetching />
) : build.manifest ? (
<Manifest tenant={this.props.tenant} build={build} />
) : (
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon icon={FileCodeIcon} />
<Title headingLevel="h4" size="lg">
This build does not provide any logs
</Title>
</EmptyState>
)
const consoleTabContent =
!build.output && remoteData.isFetchingOutput ? (
<Fetching />
) : build.output ? (
<Console
output={build.output}
errorIds={build.errorIds}
displayPath={hash.length > 0 ? hash : undefined}
/>
) : (
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon icon={TerminalIcon} />
<Title headingLevel="h4" size="lg">
This build does not provide any console information
</Title>
</EmptyState>
)
return ( return (
<PageSection variant={PageSectionVariants.light}> <>
<PageSection style={{paddingRight: '5px'}}> <PageSection variant={PageSectionVariants.light}>
<Fetchable <Build
isFetching={remoteData.isFetching} build={build}
fetchCallback={this.updateData} active={activeTab}
hash={hash}
fetchable={fetchable}
/> />
</PageSection> </PageSection>
{build && <Build build={build} active={activeTab} hash={hash}/>} <PageSection variant={PageSectionVariants.light}>
</PageSection> <Tabs
isFilled
activeKey={activeTab}
onSelect={(event, tabIndex) => this.handleTabClick(tabIndex, build)}
>
<Tab
eventKey="results"
title={
<>
<TabTitleIcon>
<PollIcon />
</TabTitleIcon>
<TabTitleText>Results</TabTitleText>
</>
}
>
{resultsTabContent}
</Tab>
<Tab
eventKey="artifacts"
title={
<>
<TabTitleIcon>
<FileArchiveIcon />
</TabTitleIcon>
<TabTitleText>Artifacts</TabTitleText>
</>
}
>
{artifactsTabContent}
</Tab>
<Tab
eventKey="logs"
title={
<>
<TabTitleIcon>
<FileCodeIcon />
</TabTitleIcon>
<TabTitleText>Logs</TabTitleText>
</>
}
>
{logsTabContent}
</Tab>
<Tab
eventKey="console"
title={
<>
<TabTitleIcon>
<TerminalIcon />
</TabTitleIcon>
<TabTitleText>Console</TabTitleText>
</>
}
>
{consoleTabContent}
</Tab>
</Tabs>
</PageSection>
</>
) )
} }
} }
export default connect(state => ({ export default connect((state) => ({
tenant: state.tenant, tenant: state.tenant,
remoteData: state.build, remoteData: state.build,
}))(BuildPage) }))(withRouter(BuildPage))

View File

@ -89,7 +89,12 @@ const routes = () => [
{ {
to: '/build/:buildId', to: '/build/:buildId',
component: BuildPage, component: BuildPage,
props: {'activeTab': 'summary'}, props: {'activeTab': 'results'},
},
{
to: '/build/:buildId/artifacts',
component: BuildPage,
props: {'activeTab': 'artifacts'},
}, },
{ {
to: '/build/:buildId/logs', to: '/build/:buildId/logs',