web: add job page

This change adds a /job/{job_name} web interface.

Change-Id: Idbeae3a11ec4180a193923def7dc7f9c53dc9043
This commit is contained in:
Tristan Cacqueray 2018-08-28 08:51:39 +00:00
parent 99c38c9375
commit 6cb6b73615
13 changed files with 656 additions and 1 deletions

View File

@ -0,0 +1,5 @@
---
features:
- |
A new Job page in the web interface enable browsing
through job configuration.

View File

@ -13,6 +13,8 @@
"prop-types": "^15.6.2",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-height": "^3.0.0",
"react-json-view": "^1.19.1",
"react-redux": "^5.0.7",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",

View File

@ -121,6 +121,9 @@ function fetchBuilds (apiPrefix, queryString) {
}
return Axios.get(apiUrl + apiPrefix + path)
}
function fetchJob (apiPrefix, jobName) {
return Axios.get(apiUrl + apiPrefix + 'job/' + jobName)
}
function fetchJobs (apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'jobs')
}
@ -131,6 +134,7 @@ export {
fetchStatus,
fetchBuild,
fetchBuilds,
fetchJob,
fetchJobs,
fetchTenants,
fetchInfo

View File

@ -0,0 +1,38 @@
// 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 React from 'react'
import PropTypes from 'prop-types'
class SourceContext extends React.Component {
static propTypes = {
context: PropTypes.object.isRequired,
showBranch: PropTypes.bool
}
render() {
const { context, showBranch } = this.props
return (
<span>
{context.project}
{showBranch && context.branch !== 'master' &&
' (' + context.branch + ')'}
: {context.path}
</span>
)
}
}
export default SourceContext

View File

@ -0,0 +1,100 @@
// 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 {
Nav,
NavItem,
TabContainer,
TabPane,
TabContent,
} from 'patternfly-react'
import JobVariant from './JobVariant'
class Job extends React.Component {
static propTypes = {
job: PropTypes.array.isRequired,
}
state = {
variantIdx: 0,
descriptionMaxHeight: 0
}
resetMaxHeight = () => {
this.setState({descriptionMaxHeight: 0})
}
componentDidUpdate (prevProps, prevState) {
if (prevState.descriptionMaxHeight > 0) {
this.resetMaxHeight()
}
}
renderVariantTitle (variant, selected) {
let title = variant.variant_description
if (!title) {
title = ''
variant.branches.forEach((item) => {
if (title) {
title += ', '
}
title += item
})
}
if (selected) {
title = <strong>{title}</strong>
}
return title
}
render () {
const { job } = this.props
const { variantIdx, descriptionMaxHeight } = this.state
return (
<React.Fragment>
<h2>{job[0].name}</h2>
<TabContainer id="zuul-job">
<div>
<Nav bsClass="nav nav-tabs nav-tabs-pf">
{job.map((variant, idx) => (
<NavItem
key={idx}
onClick={() => this.setState({variantIdx: idx})}>
<div>
{this.renderVariantTitle(variant, variantIdx === idx)}
</div>
</NavItem>
))}
</Nav>
<TabContent>
<TabPane>
<JobVariant
variant={job[variantIdx]}
descriptionMaxHeight={descriptionMaxHeight}
parent={this}
/>
</TabPane>
</TabContent>
</div>
</TabContainer>
</React.Fragment>
)
}
}
export default Job

View File

@ -0,0 +1,38 @@
// 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 React from 'react'
import PropTypes from 'prop-types'
class JobProject extends React.Component {
static propTypes = {
project: PropTypes.object.isRequired
}
render() {
const { project } = this.props
return (
<span>
{project.project_name}
{project.override_branch && (
' ( override-branch: ' + project.override_branch + ')')}
{project.override_checkout && (
' ( override-checkout: ' + project.override_checkout+ ')')}
</span>
)
}
}
export default JobProject

View File

@ -0,0 +1,200 @@
// 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 { ReactHeight } from 'react-height'
import ReactJson from 'react-json-view'
import {
Icon,
} from 'patternfly-react'
import SourceContext from '../SourceContext'
import Nodeset from './Nodeset'
import Role from './Role'
import JobProject from './JobProject'
class JobVariant extends React.Component {
static propTypes = {
descriptionMaxHeight: PropTypes.number.isRequired,
parent: PropTypes.object,
tenant: PropTypes.object,
variant: PropTypes.object.isRequired
}
renderStatus (variant) {
const status = [{
icon: variant.voting ? 'connected' : 'disconnected',
name: variant.voting ? 'Voting' : 'Non-voting'
}]
if (variant.abstract) {
status.push({
icon: 'infrastructure',
name: 'Abstract'
})
}
if (variant.final) {
status.push({
icon: 'infrastructure',
name: 'Final'
})
}
if (variant.post_review) {
status.push({
icon: 'locked',
name: 'Post review'
})
}
if (variant.protected) {
status.push({
icon: 'locked',
name: 'Protected'
})
}
return (
<div className="list-view-pf-additional-info">
{status.map((item, idx) => (
<div key={idx} className="list-view-pf-additional-info-item">
<Icon type='pf' name={item.icon} />
{item.name}
</div>
))}
</div>
)
}
render () {
const { tenant, variant, descriptionMaxHeight } = this.props
const rows = []
const jobInfos = [
'description', 'context', 'status',
'parent', 'attempts', 'timeout', 'semaphore', 'implied_branch',
'nodeset', 'variables',
]
jobInfos.forEach(key => {
let label = key
let value = variant[key]
if (label === 'context') {
value = (
<SourceContext
context={variant.source_context}
showBranch={true}/>
)
}
if (label === 'status') {
value = this.renderStatus(variant)
}
if (!value) {
return
}
if (label === 'nodeset') {
value = <Nodeset nodeset={value} />
}
if (label === 'parent') {
value = (
<Link to={tenant.linkPrefix + '/job/' + value}>
{value}
</Link>
)
}
if (label === 'variables') {
value = (
<span style={{whiteSpace: 'pre'}}>
<ReactJson
src={value}
sortKeys={true}
enableClipboard={false}
displayDataTypes={false}/>
</span>
)
}
if (label === 'description') {
const style = {
whiteSpace: 'pre'
}
if (descriptionMaxHeight > 0) {
style.minHeight = descriptionMaxHeight
}
value = (
<ReactHeight onHeightReady={height => {
if (height > descriptionMaxHeight) {
this.props.parent.setState({descriptionMaxHeight: height})
}
}}>
<div style={style}>
{value}
</div>
</ReactHeight>
)
}
rows.push({label: label, value: value})
})
const jobInfosList = [
'required_projects', 'dependencies', 'files', 'irrelevant_files', 'roles'
]
jobInfosList.forEach(key => {
let label = key
let values = variant[key]
if (values.length === 0) {
return
}
const items = (
<ul className='list-group'>
{values.map((value, idx) => {
let item
if (label === 'required_projects') {
item = <JobProject project={value} />
} else if (label === 'roles') {
item = <Role role={value} />
} else {
item = value
}
return (
<li className='list-group-item' key={idx}>
{item}
</li>
)
})}
</ul>
)
rows.push({label: label, value: items})
})
return (
<div>
<table className='table table-striped table-bordered'>
<tbody>
{rows.map(item => (
<tr key={item.label}>
<td style={{width: '10%'}}>{item.label}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
}
export default connect(state => ({tenant: state.tenant}))(JobVariant)

View File

@ -0,0 +1,90 @@
// 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 {
AggregateStatusCount,
AggregateStatusNotifications,
AggregateStatusNotification,
Card,
CardBody,
CardTitle,
Icon,
} from 'patternfly-react'
class Nodeset extends React.Component {
static propTypes = {
nodeset: PropTypes.object.isRequired
}
render () {
const { nodeset } = this.props
const nodes = (
<ul className="list-group">
{nodeset.nodes.map((item, idx) => {
const groups = []
nodeset.groups.forEach(group => {
if (group.nodes.indexOf(item.name) !== -1) {
groups.push(group.name)
}
})
return (
<li className="list-group-item" key={idx}>
<span title="Node name">
{item.name}
</span> -&nbsp;
<span title="Label name">
{item.label}
</span>
<span title="Groups">
{groups.length > 0 && ' (' + groups.map(item => (item)) + ') '}
</span>
</li>)
})}
</ul>
)
return (
<Card accented aggregated>
<CardTitle>
{nodeset.name}
</CardTitle>
<CardBody>
<AggregateStatusNotifications>
<AggregateStatusNotification>
<span title="Nodes">
<Icon type="pf" name="server" />
<AggregateStatusCount>
{nodeset.nodes.length}
</AggregateStatusCount>
</span>
</AggregateStatusNotification>
<AggregateStatusNotification>
<span title="Groups">
<Icon type="pf" name="server-group" />
<AggregateStatusCount>
{nodeset.groups.length}
</AggregateStatusCount>
</span>
</AggregateStatusNotification>
</AggregateStatusNotifications>
{nodes}
</CardBody>
</Card>
)
}
}
export default Nodeset

View File

@ -0,0 +1,34 @@
// 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 React from 'react'
import PropTypes from 'prop-types'
class Role extends React.Component {
static propTypes = {
role: PropTypes.object.isRequired
}
render() {
const { role } = this.props
return (
<span>
{role.target_name} ( {role.project_canonical_name})
</span>
)
}
}
export default Role

65
web/src/pages/Job.jsx Normal file
View File

@ -0,0 +1,65 @@
// 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 { connect } from 'react-redux'
import PropTypes from 'prop-types'
import Job from '../containers/job/Job'
import { fetchJob } from '../api'
class JobPage extends React.Component {
static propTypes = {
match: PropTypes.object.isRequired,
tenant: PropTypes.object
}
state = {
job: null
}
updateData = () => {
fetchJob(this.props.tenant.apiPrefix, this.props.match.params.jobName)
.then(response => {
this.setState({job: response.data})
})
}
componentDidMount () {
document.title = 'Zuul Job | ' + this.props.match.params.jobName
if (this.props.tenant.name) {
this.updateData()
}
}
componentDidUpdate (prevProps) {
if (this.props.tenant.name !== prevProps.tenant.name ||
this.props.match.params.jobName !== prevProps.match.params.jobName) {
this.updateData()
}
}
render () {
const { job } = this.state
if (!job) {
return (<p>Loading...</p>)
}
return (
<Job job={job} />
)
}
}
export default connect(state => ({tenant: state.tenant}))(JobPage)

View File

@ -58,6 +58,12 @@ class JobsPage extends React.Component {
const headerFormat = value => <Table.Heading>{value}</Table.Heading>
const cellFormat = (value) => (
<Table.Cell>{value}</Table.Cell>)
const cellJobFormat = (value) => (
<Table.Cell>
<Link to={this.props.tenant.linkPrefix + '/job/' + value}>
{value}
</Link>
</Table.Cell>)
const cellBuildFormat = (value) => (
<Table.Cell>
<Link to={this.props.tenant.linkPrefix + '/builds?job_name=' + value}>
@ -69,6 +75,9 @@ class JobsPage extends React.Component {
myColumns.forEach(column => {
let formatter = cellFormat
let prop = column
if (column === 'name') {
formatter = cellJobFormat
}
if (column === 'Last builds') {
prop = 'name'
formatter = cellBuildFormat

View File

@ -13,6 +13,7 @@
// under the License.
import StatusPage from './pages/Status'
import JobPage from './pages/Job'
import JobsPage from './pages/Jobs'
import BuildPage from './pages/Build'
import BuildsPage from './pages/Builds'
@ -43,6 +44,10 @@ const routes = () => [
to: '/stream/:buildId',
component: StreamPage
},
{
to: '/job/:jobName',
component: JobPage
},
{
to: '/build/:buildId',
component: BuildPage

View File

@ -1209,6 +1209,10 @@ balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
base16@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70"
base64-js@^1.0.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
@ -3082,7 +3086,13 @@ fb-watchman@^2.0.0:
dependencies:
bser "^2.0.0"
fbjs@^0.8.0, fbjs@^0.8.1, fbjs@^0.8.16:
fbemitter@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/fbemitter/-/fbemitter-2.1.1.tgz#523e14fdaf5248805bb02f62efc33be703f51865"
dependencies:
fbjs "^0.8.4"
fbjs@^0.8.0, fbjs@^0.8.1, fbjs@^0.8.16, fbjs@^0.8.4, fbjs@^0.8.9:
version "0.8.17"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
dependencies:
@ -3202,6 +3212,13 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
flux@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/flux/-/flux-3.1.3.tgz#d23bed515a79a22d933ab53ab4ada19d05b2f08a"
dependencies:
fbemitter "^2.0.0"
fbjs "^0.8.0"
follow-redirects@^1.0.0, follow-redirects@^1.3.0:
version "1.5.5"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.5.tgz#3c143ca599a2e22e62876687d68b23d55bad788b"
@ -4719,6 +4736,10 @@ lodash.cond@^4.3.0:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.cond/-/lodash.cond-4.5.2.tgz#f471a1da486be60f6ab955d17115523dd1d255d5"
lodash.curry@^4.0.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170"
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
@ -4727,6 +4748,10 @@ lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
lodash.flow@^3.3.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a"
lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@ -5986,6 +6011,12 @@ prop-types-extra@^1.0.1:
react-is "^16.3.2"
warning "^3.0.0"
prop-types@15.5.8:
version "15.5.8"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394"
dependencies:
fbjs "^0.8.9"
prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
@ -6034,6 +6065,10 @@ punycode@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
pure-color@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e"
q@^1.1.2:
version "1.5.1"
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@ -6114,6 +6149,15 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-base16-styling@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c"
dependencies:
base16 "^1.0.0"
lodash.curry "^4.0.1"
lodash.flow "^3.3.0"
pure-color "^1.2.0"
react-bootstrap-switch@^15.5.3:
version "15.5.3"
resolved "https://registry.yarnpkg.com/react-bootstrap-switch/-/react-bootstrap-switch-15.5.3.tgz#97287791d4ec0d1892d142542e7e5248002b1251"
@ -6210,10 +6254,25 @@ react-fontawesome@^1.6.1:
dependencies:
prop-types "^15.5.6"
react-height@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-height/-/react-height-3.0.0.tgz#fae322f9da64d3e9e25536f26b77c73954261524"
dependencies:
prop-types "15.5.8"
react-is@^16.3.2:
version "16.4.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.2.tgz#84891b56c2b6d9efdee577cc83501dfc5ecead88"
react-json-view@^1.19.1:
version "1.19.1"
resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.19.1.tgz#95d8e59e024f08a25e5dc8f076ae304eed97cf5c"
dependencies:
flux "^3.1.3"
react-base16-styling "^0.6.0"
react-lifecycles-compat "^3.0.4"
react-textarea-autosize "^6.1.0"
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
@ -6337,6 +6396,12 @@ react-scripts@1.1.4:
optionalDependencies:
fsevents "^1.1.3"
react-textarea-autosize@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-6.1.0.tgz#df91387f8a8f22020b77e3833c09829d706a09a5"
dependencies:
prop-types "^15.6.0"
react-transition-group@^2.0.0, react-transition-group@^2.2.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.4.0.tgz#1d9391fabfd82e016f26fabd1eec329dbd922b5a"