summaryrefslogtreecommitdiff
path: root/web/src
diff options
context:
space:
mode:
authorTristan Cacqueray <tdecacqu@redhat.com>2018-10-03 07:05:06 +0000
committerMonty Taylor <mordred@inaugust.com>2018-10-06 10:42:31 -0500
commit68d11898715970f47c4d86c261dc64232a841c85 (patch)
tree974b0e60a21d8262b67c0310777d14ee48c056f5 /web/src
parent6f0f36aab00104321d548554f74e969064b1fe34 (diff)
downloadzuul-68d11898715970f47c4d86c261dc64232a841c85.tar.gz
Revert "Revert "web: rewrite interface in react""
This reverts commit 3dba813c643ec8f4b3323c2a09c6aecf8ad4d338. Change-Id: I233797a9b4e3485491c49675da2c2efbdba59449
Diffstat (limited to 'web/src')
-rw-r--r--web/src/App.jsx162
-rw-r--r--web/src/App.test.jsx102
-rw-r--r--web/src/api.js133
-rw-r--r--web/src/containers/TableFilters.jsx237
-rw-r--r--web/src/containers/status/Change.jsx99
-rw-r--r--web/src/containers/status/ChangePanel.jsx317
-rw-r--r--web/src/containers/status/ChangePanel.test.jsx64
-rw-r--r--web/src/containers/status/ChangeQueue.jsx54
-rw-r--r--web/src/containers/status/Pipeline.jsx136
-rw-r--r--web/src/images/line-angle.pngbin0 -> 262 bytes
-rw-r--r--web/src/images/line-t.pngbin0 -> 204 bytes
-rw-r--r--web/src/images/line.pngbin0 -> 183 bytes
-rw-r--r--web/src/images/logo.pngbin0 -> 930 bytes
-rw-r--r--web/src/images/logo.svg83
-rw-r--r--web/src/index.css122
-rw-r--r--web/src/index.js40
-rw-r--r--web/src/pages/Builds.jsx159
-rw-r--r--web/src/pages/Jobs.jsx99
-rw-r--r--web/src/pages/Status.jsx289
-rw-r--r--web/src/pages/Stream.jsx158
-rw-r--r--web/src/pages/Tenants.jsx79
-rw-r--r--web/src/reducers.js94
-rw-r--r--web/src/registerServiceWorker.js119
-rw-r--r--web/src/routes.js52
24 files changed, 2598 insertions, 0 deletions
diff --git a/web/src/App.jsx b/web/src/App.jsx
new file mode 100644
index 000000000..ad6f3993c
--- /dev/null
+++ b/web/src/App.jsx
@@ -0,0 +1,162 @@
+// 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.
+
+// The App is the parent component of every pages. Each page content is
+// rendered by the Route object according to the current location.
+
+import React from 'react'
+import PropTypes from 'prop-types'
+import { matchPath, withRouter } from 'react-router'
+import { Link, Redirect, Route, Switch } from 'react-router-dom'
+import { connect } from 'react-redux'
+import { Masthead } from 'patternfly-react'
+
+import logo from './images/logo.png'
+import { routes } from './routes'
+import { setTenantAction } from './reducers'
+
+
+class App extends React.Component {
+ static propTypes = {
+ info: PropTypes.object,
+ tenant: PropTypes.object,
+ location: PropTypes.object,
+ dispatch: PropTypes.func
+ }
+
+ constructor() {
+ super()
+ this.menu = routes()
+ }
+
+ renderMenu() {
+ const { location } = this.props
+ const activeItem = this.menu.find(
+ item => location.pathname === item.to
+ )
+ return (
+ <ul className='nav navbar-nav navbar-primary'>
+ {this.menu.filter(item => item.title).map(item => (
+ <li key={item.to} className={item === activeItem ? 'active' : ''}>
+ <Link to={this.props.tenant.linkPrefix + item.to}>
+ {item.title}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ )
+ }
+
+ renderContent = () => {
+ const { tenant } = this.props
+ const allRoutes = []
+ this.menu
+ // Do not include '/tenants' route in white-label setup
+ .filter(item =>
+ (tenant.whiteLabel && !item.globalRoute) || !tenant.whiteLabel)
+ .forEach((item, index) => {
+ allRoutes.push(
+ <Route
+ key={index}
+ path={item.globalRoute ? item.to : tenant.routePrefix + item.to}
+ component={item.component}
+ exact
+ />
+ )
+ })
+ return (
+ <Switch>
+ {allRoutes}
+ <Redirect from='*' to={tenant.defaultRoute} key='default-route' />
+ </Switch>
+ )
+ }
+
+ componentDidUpdate() {
+ // This method is called when info property is updated
+ const { tenant, info } = this.props
+ if (info.capabilities) {
+ let tenantName, whiteLabel
+
+ if (info.tenant) {
+ // White label
+ whiteLabel = true
+ tenantName = info.tenant
+ } else if (!info.tenant) {
+ // Multi tenant, look for tenant name in url
+ whiteLabel = false
+
+ const match = matchPath(
+ this.props.location.pathname, {path: '/t/:tenant'})
+
+ if (match) {
+ tenantName = match.params.tenant
+ } else {
+ tenantName = ''
+ }
+ }
+ // Set tenant only if it changed to prevent DidUpdate loop
+ if (typeof tenant.name === 'undefined' || tenant.name !== tenantName) {
+ this.props.dispatch(setTenantAction(tenantName, whiteLabel))
+ }
+ }
+ }
+
+ render() {
+ const { tenant } = this.props
+ if (typeof tenant.name === 'undefined') {
+ return (<h2>Loading...</h2>)
+ }
+
+ return (
+ <React.Fragment>
+ <Masthead
+ iconImg={logo}
+ navToggle
+ thin
+ >
+ <div className='collapse navbar-collapse'>
+ {tenant.name && this.renderMenu()}
+ <ul className='nav navbar-nav navbar-utility'>
+ <li>
+ <a href='https://zuul-ci.org/docs'
+ rel='noopener noreferrer' target='_blank'>
+ Documentation
+ </a>
+ </li>
+ {tenant.name && (
+ <li>
+ <Link to={tenant.defaultRoute}>
+ <strong>Tenant</strong> {tenant.name}
+ </Link>
+ </li>
+ )}
+ </ul>
+ </div>
+ </Masthead>
+ <div className='container-fluid container-cards-pf'>
+ {this.renderContent()}
+ </div>
+ </React.Fragment>
+ )
+ }
+}
+
+// This connect the info state from the store to the info property of the App.
+export default withRouter(connect(
+ state => ({
+ info: state.info,
+ tenant: state.tenant
+ })
+)(App))
diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx
new file mode 100644
index 000000000..a5dc77df6
--- /dev/null
+++ b/web/src/App.test.jsx
@@ -0,0 +1,102 @@
+/* global Promise, expect, jest, it, location */
+// 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 ReactTestUtils from 'react-dom/test-utils'
+import ReactDOM from 'react-dom'
+import { Link, BrowserRouter as Router } from 'react-router-dom'
+import { Provider } from 'react-redux'
+
+import { createZuulStore, fetchInfoAction } from './reducers'
+import App from './App'
+import TenantsPage from './pages/Tenants'
+import StatusPage from './pages/Status'
+import * as api from './api'
+
+api.fetchInfo = jest.fn()
+api.fetchTenants = jest.fn()
+api.fetchStatus = jest.fn()
+
+
+it('renders without crashing', () => {
+ const div = document.createElement('div')
+ const store = createZuulStore()
+ ReactDOM.render(<Provider store={store}><Router><App /></Router></Provider>,
+ div)
+ ReactDOM.unmountComponentAtNode(div)
+})
+
+it('renders multi tenant', () => {
+ api.fetchInfo.mockImplementation(
+ () => Promise.resolve({data: {
+ info: {capabilities: {}}
+ }})
+ )
+ api.fetchTenants.mockImplementation(
+ () => Promise.resolve({data: [{name: 'openstack'}]})
+ )
+ const store = createZuulStore()
+ const application = ReactTestUtils.renderIntoDocument(
+ <Provider store={store}><Router><App /></Router></Provider>
+ )
+ store.dispatch(fetchInfoAction()).then(() => {
+ // Link should be tenant scoped
+ const topMenuLinks = ReactTestUtils.scryRenderedComponentsWithType(
+ application, Link)
+ expect(topMenuLinks[0].props.to).toEqual('/t/openstack/status')
+ expect(topMenuLinks[1].props.to).toEqual('/t/openstack/jobs')
+ // Location should be /tenants
+ expect(location.pathname).toEqual('/tenants')
+ // Info should tell multi tenants
+ expect(store.getState().info.tenant).toEqual(undefined)
+ // Tenants list has been rendered
+ expect(ReactTestUtils.findRenderedComponentWithType(
+ application, TenantsPage)).not.toEqual(null)
+ // Fetch tenants has been called
+ expect(api.fetchTenants).toBeCalled()
+ })
+})
+
+it('renders single tenant', () => {
+ api.fetchInfo.mockImplementation(
+ () => Promise.resolve({data: {
+ info: {capabilities: {}, tenant: 'openstack'}
+ }})
+ )
+ api.fetchStatus.mockImplementation(
+ () => Promise.resolve({data: {pipelines: []}})
+ )
+ const store = createZuulStore()
+ const application = ReactTestUtils.renderIntoDocument(
+ <Provider store={store}><Router><App /></Router></Provider>
+ )
+
+ store.dispatch(fetchInfoAction()).then(() => {
+ // Link should be white-label scoped
+ const topMenuLinks = ReactTestUtils.scryRenderedComponentsWithType(
+ application, Link)
+ expect(topMenuLinks[0].props.to).toEqual('/status')
+ expect(topMenuLinks[1].props.to).toEqual('/jobs')
+ // Location should be /status
+ expect(location.pathname).toEqual('/status')
+ // Info should tell white label tenant openstack
+ expect(store.getState().info.tenant).toEqual('openstack')
+ // Status page has been rendered
+ expect(ReactTestUtils.findRenderedComponentWithType(
+ application, StatusPage)).not.toEqual(null)
+ // Fetch status has been called
+ expect(api.fetchStatus).toBeCalled()
+ })
+})
diff --git a/web/src/api.js b/web/src/api.js
new file mode 100644
index 000000000..9af4adb8e
--- /dev/null
+++ b/web/src/api.js
@@ -0,0 +1,133 @@
+/* global process, window */
+// 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 Axios from 'axios'
+
+function getHomepageUrl (url) {
+ //
+ // Discover serving location from href.
+ //
+ // This is only needed for sub-directory serving.
+ // Serving the application from '/' may simply default to '/'
+ //
+ // Note that this is not enough for sub-directory serving,
+ // The static files location also needs to be adapted with the 'homepage'
+ // settings of the package.json file.
+ //
+ // This homepage url is used for the Router and Link resolution logic
+ //
+ let baseUrl
+ if (url) {
+ baseUrl = url
+ } else {
+ baseUrl = window.location.href
+ }
+ // Get dirname of the current url
+ baseUrl = baseUrl.replace(/\\/g, '/').replace(/\/[^/]*$/, '/')
+
+ // Remove any query strings
+ if (baseUrl.includes('?')) {
+ baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('?'))
+ }
+ // Remove any hash anchor
+ if (baseUrl.includes('/#')) {
+ baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('/#') + 1)
+ }
+
+ // Remove known sub-path
+ const subDir = [
+ '/build/',
+ '/job/',
+ '/project/',
+ '/stream/',
+ ]
+ subDir.forEach(path => {
+ if (baseUrl.includes(path)) {
+ baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf(path) + 1)
+ }
+ })
+
+ // Remove tenant scope
+ if (baseUrl.includes('/t/')) {
+ baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('/t/') + 1)
+ }
+ if (! baseUrl.endsWith('/')) {
+ baseUrl = baseUrl + '/'
+ }
+ // console.log('Homepage url is ', baseUrl)
+ return baseUrl
+}
+
+function getZuulUrl () {
+ // Return the zuul root api absolute url
+ const ZUUL_API = process.env.REACT_APP_ZUUL_API
+ let apiUrl
+
+ if (ZUUL_API) {
+ // Api url set at build time, use it
+ apiUrl = ZUUL_API
+ } else {
+ // Api url is relative to homepage path
+ apiUrl = getHomepageUrl () + 'api/'
+ }
+ if (! apiUrl.endsWith('/')) {
+ apiUrl = apiUrl + '/'
+ }
+ if (! apiUrl.endsWith('/api/')) {
+ apiUrl = apiUrl + 'api/'
+ }
+ // console.log('Api url is ', apiUrl)
+ return apiUrl
+}
+const apiUrl = getZuulUrl()
+
+
+function getStreamUrl (apiPrefix) {
+ const streamUrl = (apiUrl + apiPrefix)
+ .replace(/(http)(s)?:\/\//, 'ws$2://') + 'console-stream'
+ // console.log('Stream url is ', streamUrl)
+ return streamUrl
+}
+
+// Direct APIs
+function fetchInfo () {
+ return Axios.get(apiUrl + 'info')
+}
+function fetchTenants () {
+ return Axios.get(apiUrl + 'tenants')
+}
+function fetchStatus (apiPrefix) {
+ return Axios.get(apiUrl + apiPrefix + 'status')
+}
+function fetchBuilds (apiPrefix, queryString) {
+ let path = 'builds'
+ if (queryString) {
+ path += '?' + queryString.slice(1)
+ }
+ return Axios.get(apiUrl + apiPrefix + path)
+}
+function fetchJobs (apiPrefix) {
+ return Axios.get(apiUrl + apiPrefix + 'jobs')
+}
+
+export {
+ getHomepageUrl,
+ getStreamUrl,
+ fetchStatus,
+ fetchBuilds,
+ fetchJobs,
+ fetchTenants,
+ fetchInfo
+}
diff --git a/web/src/containers/TableFilters.jsx b/web/src/containers/TableFilters.jsx
new file mode 100644
index 000000000..a2a23dc63
--- /dev/null
+++ b/web/src/containers/TableFilters.jsx
@@ -0,0 +1,237 @@
+/* global URLSearchParams */
+// 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.
+
+// Boiler plate code to manage table filtering
+
+import * as React from 'react'
+import PropTypes from 'prop-types'
+import { Button, Filter, FormControl, Toolbar } from 'patternfly-react'
+
+
+class TableFilters extends React.Component {
+ static propTypes = {
+ location: PropTypes.object
+ }
+
+ getFilterFromUrl = () => {
+ const urlParams = new URLSearchParams(this.props.location.search)
+ let activeFilters = []
+ this.filterTypes.forEach(item => {
+ urlParams.getAll(item.id).forEach(param => {
+ activeFilters.push({
+ label: item.title + ': ' + param,
+ key: item.id,
+ value: param})
+ })
+ })
+ this.setState({activeFilters: activeFilters})
+ return activeFilters
+ }
+
+ updateUrl (activeFilters) {
+ let path = this.props.location.pathname
+ if (activeFilters.length > 0) {
+ path += '?'
+ activeFilters.forEach((item, idx) => {
+ if (idx > 0) {
+ path += '&'
+ }
+ path += (
+ encodeURIComponent(item.key)
+ + '=' +
+ encodeURIComponent(item.value)
+ )
+ })
+ }
+ window.history.pushState({path: path}, '', path)
+ }
+
+ filterAdded = (field, value) => {
+ let filterText = ''
+ if (field.title) {
+ filterText = field.title
+ } else {
+ filterText = field
+ }
+ filterText += ': '
+
+ if (value.filterCategory) {
+ filterText +=
+ (value.filterCategory.title || value.filterCategory) +
+ '-' +
+ (value.filterValue.title || value.filterValue)
+ } else if (value.title) {
+ filterText += value.title
+ } else {
+ filterText += value
+ }
+
+ let activeFilters = [...this.state.activeFilters, {
+ label: filterText,
+ key: field.id,
+ value: value
+ }]
+ this.setState({ activeFilters: activeFilters })
+ this.updateData(activeFilters)
+ this.updateUrl(activeFilters)
+ }
+
+ selectFilterType = filterType => {
+ const { currentFilterType } = this.state
+ if (currentFilterType !== filterType) {
+ this.setState(prevState => {
+ return {
+ currentValue: '',
+ currentFilterType: filterType,
+ filterCategory:
+ filterType.filterType === 'complex-select'
+ ? undefined
+ : prevState.filterCategory,
+ categoryValue:
+ filterType.filterType === 'complex-select'
+ ? ''
+ : prevState.categoryValue
+ }
+ })
+ }
+ }
+
+ filterValueSelected = filterValue => {
+ const { currentFilterType, currentValue } = this.state
+
+ if (filterValue !== currentValue) {
+ this.setState({ currentValue: filterValue })
+ if (filterValue) {
+ this.filterAdded(currentFilterType, filterValue)
+ }
+ }
+ }
+
+ filterCategorySelected = category => {
+ const { filterCategory } = this.state
+ if (filterCategory !== category) {
+ this.setState({ filterCategory: category, currentValue: '' })
+ }
+ }
+
+ categoryValueSelected = value => {
+ const { currentValue, currentFilterType, filterCategory } = this.state
+
+ if (filterCategory && currentValue !== value) {
+ this.setState({ currentValue: value })
+ if (value) {
+ let filterValue = {
+ filterCategory: filterCategory,
+ filterValue: value
+ }
+ this.filterAdded(currentFilterType, filterValue)
+ }
+ }
+ }
+
+ updateCurrentValue = event => {
+ this.setState({ currentValue: event.target.value })
+ }
+
+ onValueKeyPress = keyEvent => {
+ const { currentValue, currentFilterType } = this.state
+
+ if (keyEvent.key === 'Enter' && currentValue && currentValue.length > 0) {
+ this.setState({ currentValue: '' })
+ this.filterAdded(currentFilterType, currentValue)
+ keyEvent.stopPropagation()
+ keyEvent.preventDefault()
+ }
+ }
+
+ removeFilter = filter => {
+ const { activeFilters } = this.state
+
+ let index = activeFilters.indexOf(filter)
+ if (index > -1) {
+ let updated = [
+ ...activeFilters.slice(0, index),
+ ...activeFilters.slice(index + 1)
+ ]
+ this.setState({ activeFilters: updated })
+ this.updateData(updated)
+ this.updateUrl(updated)
+ }
+ }
+
+ clearFilters = () => {
+ this.setState({ activeFilters: [] })
+ this.updateData()
+ this.updateUrl([])
+ }
+
+ renderFilterInput() {
+ const { currentFilterType, currentValue } = this.state
+ if (!currentFilterType) {
+ return null
+ }
+ return (
+ <FormControl
+ type={currentFilterType.filterType}
+ value={currentValue}
+ placeholder={currentFilterType.placeholder}
+ onChange={e => this.updateCurrentValue(e)}
+ onKeyPress={e => this.onValueKeyPress(e)}
+ />
+ )
+ }
+
+ renderFilter = () => {
+ const { currentFilterType, activeFilters } = this.state
+ return (
+ <React.Fragment>
+ <div style={{ width: 300 }}>
+ <Filter>
+ <Filter.TypeSelector
+ filterTypes={this.filterTypes}
+ currentFilterType={currentFilterType}
+ onFilterTypeSelected={this.selectFilterType}
+ />
+ {this.renderFilterInput()}
+ </Filter>
+ </div>
+ {activeFilters && activeFilters.length > 0 && (
+ <Toolbar.Results>
+ <Filter.ActiveLabel>{'Active Filters:'}</Filter.ActiveLabel>
+ <Filter.List>
+ {activeFilters.map((item, index) => {
+ return (
+ <Filter.Item
+ key={index}
+ onRemove={this.removeFilter}
+ filterData={item}
+ >
+ {item.label}
+ </Filter.Item>
+ )
+ })}
+ </Filter.List>
+ <Button onClick={e => {
+ e.preventDefault()
+ this.clearFilters()
+ }}>Clear All Filters</Button>
+ </Toolbar.Results>
+ )}
+ </React.Fragment>
+ )
+ }
+}
+
+export default TableFilters
diff --git a/web/src/containers/status/Change.jsx b/web/src/containers/status/Change.jsx
new file mode 100644
index 000000000..ddd82a9ae
--- /dev/null
+++ b/web/src/containers/status/Change.jsx
@@ -0,0 +1,99 @@
+// 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 LineAngleImage from '../../images/line-angle.png'
+import LineTImage from '../../images/line-t.png'
+import ChangePanel from './ChangePanel'
+
+
+class Change extends React.Component {
+ static propTypes = {
+ change: PropTypes.object.isRequired,
+ queue: PropTypes.object.isRequired,
+ expanded: PropTypes.bool.isRequired
+ }
+
+ renderStatusIcon (change) {
+ let iconGlyph = 'pficon pficon-ok'
+ let iconTitle = 'Succeeding'
+ if (change.active !== true) {
+ iconGlyph = 'pficon pficon-pending'
+ iconTitle = 'Waiting until closer to head of queue to' +
+ ' start jobs'
+ } else if (change.live !== true) {
+ iconGlyph = 'pficon pficon-info'
+ iconTitle = 'Dependent change required for testing'
+ } else if (change.failing_reasons &&
+ change.failing_reasons.length > 0) {
+ let reason = change.failing_reasons.join(', ')
+ iconTitle = 'Failing because ' + reason
+ if (reason.match(/merge conflict/)) {
+ iconGlyph = 'pficon pficon-error-circle-o zuul-build-merge-conflict'
+ } else {
+ iconGlyph = 'pficon pficon-error-circle-o'
+ }
+ }
+ return (
+ <span className={'zuul-build-status ' + iconGlyph}
+ title={iconTitle} />
+ )
+ }
+
+ renderLineImg (change, i) {
+ let image = LineTImage
+ if (change._tree_branches.indexOf(i) === change._tree_branches.length - 1) {
+ // Angle line
+ image = LineAngleImage
+ }
+ return <img alt="Line" src={image} style={{verticalAlign: 'baseline'}} />
+ }
+
+ render () {
+ const { change, queue, expanded } = this.props
+ let row = []
+ let i
+ for (i = 0; i < queue._tree_columns; i++) {
+ let className = ''
+ if (i < change._tree.length && change._tree[i] !== null) {
+ className = ' zuul-change-row-line'
+ }
+ row.push(
+ <td key={i} className={'zuul-change-row' + className}>
+ {i === change._tree_index ? this.renderStatusIcon(change) : ''}
+ {change._tree_branches.indexOf(i) !== -1 ? (
+ this.renderLineImg(change, i)) : ''}
+ </td>)
+ }
+ let changeWidth = 360 - 16 * queue._tree_columns
+ row.push(
+ <td key={i + 1}
+ className="zuul-change-cell"
+ style={{width: changeWidth + 'px'}}>
+ <ChangePanel change={change} globalExpanded={expanded} />
+ </td>
+ )
+ return (
+ <table className="zuul-change-box" style={{boxSizing: 'content-box'}}>
+ <tbody>
+ <tr>{row}</tr>
+ </tbody>
+ </table>
+ )
+ }
+}
+
+export default Change
diff --git a/web/src/containers/status/ChangePanel.jsx b/web/src/containers/status/ChangePanel.jsx
new file mode 100644
index 000000000..cb6cb5081
--- /dev/null
+++ b/web/src/containers/status/ChangePanel.jsx
@@ -0,0 +1,317 @@
+// 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'
+
+
+class ChangePanel extends React.Component {
+ static propTypes = {
+ globalExpanded: PropTypes.bool.isRequired,
+ change: PropTypes.object.isRequired,
+ tenant: PropTypes.object
+ }
+
+ constructor () {
+ super()
+ this.state = {
+ expanded: false
+ }
+ this.onClick = this.onClick.bind(this)
+ this.clicked = false
+ }
+
+ onClick () {
+ let expanded = this.state.expanded
+ if (!this.clicked) {
+ expanded = this.props.globalExpanded
+ }
+ this.clicked = true
+ this.setState({ expanded: !expanded })
+ }
+
+ time (ms, words) {
+ if (typeof (words) === 'undefined') {
+ words = false
+ }
+ let seconds = (+ms) / 1000
+ let minutes = Math.floor(seconds / 60)
+ let hours = Math.floor(minutes / 60)
+ seconds = Math.floor(seconds % 60)
+ minutes = Math.floor(minutes % 60)
+ let r = ''
+ if (words) {
+ if (hours) {
+ r += hours
+ r += ' hr '
+ }
+ r += minutes + ' min'
+ } else {
+ if (hours < 10) {
+ r += '0'
+ }
+ r += hours + ':'
+ if (minutes < 10) {
+ r += '0'
+ }
+ r += minutes + ':'
+ if (seconds < 10) {
+ r += '0'
+ }
+ r += seconds
+ }
+ return r
+ }
+
+ enqueueTime (ms) {
+ // Special format case for enqueue time to add style
+ let hours = 60 * 60 * 1000
+ let now = Date.now()
+ let delta = now - ms
+ let status = 'text-success'
+ let text = this.time(delta, true)
+ if (delta > (4 * hours)) {
+ status = 'text-danger'
+ } else if (delta > (2 * hours)) {
+ status = 'text-warning'
+ }
+ return <span className={status}>{text}</span>
+ }
+
+ renderChangeLink (change) {
+ let changeId = change.id || 'NA'
+ let changeTitle = changeId
+ let changeText = ''
+ if (change.url !== null) {
+ let githubId = changeId.match(/^([0-9]+),([0-9a-f]{40})$/)
+ if (githubId) {
+ changeTitle = githubId
+ changeText = '#' + githubId[1]
+ } else if (/^[0-9a-f]{40}$/.test(changeId)) {
+ changeText = changeId.slice(0, 7)
+ }
+ } else if (changeId.length === 40) {
+ changeText = changeId.slice(0, 7)
+ }
+ return (
+ <small>
+ <a href={change.url}>
+ {changeText !== '' ? (
+ <abbr title={changeTitle}>{changeText}</abbr>) : changeTitle}
+ </a>
+ </small>)
+ }
+
+ renderProgressBar (change) {
+ let jobPercent = Math.floor(100 / change.jobs.length)
+ return (
+ <div className='progress zuul-change-total-result'>
+ {change.jobs.map((job, idx) => {
+ let result = job.result ? job.result.toLowerCase() : null
+ if (result === null) {
+ result = job.url ? 'in progress' : 'queued'
+ }
+ if (result !== 'queued') {
+ let className = ''
+ switch (result) {
+ case 'success':
+ className = ' progress-bar-success'
+ break
+ case 'lost':
+ case 'failure':
+ className = ' progress-bar-danger'
+ break
+ case 'unstable':
+ className = ' progress-bar-warning'
+ break
+ case 'in progress':
+ case 'queued':
+ break
+ default:
+ break
+ }
+ return <div className={'progress-bar' + className}
+ key={idx}
+ title={job.name}
+ style={{width: jobPercent + '%'}}/>
+ } else {
+ return ''
+ }
+ })}
+ </div>
+ )
+ }
+
+ renderTimer (change) {
+ let remainingTime
+ if (change.remaining_time === null) {
+ remainingTime = 'unknown'
+ } else {
+ remainingTime = this.time(change.remaining_time, true)
+ }
+ return (
+ <React.Fragment>
+ <small title='Remaining Time' className='time'>
+ {remainingTime}
+ </small>
+ <br />
+ <small title='Elapsed Time' className='time'>
+ {this.enqueueTime(change.enqueue_time)}
+ </small>
+ </React.Fragment>
+ )
+ }
+
+ renderJobProgressBar (elapsedTime, remainingTime) {
+ let progressPercent = 100 * (elapsedTime / (elapsedTime +
+ remainingTime))
+ return (
+ <div className='progress zuul-job-result'>
+ <div className='progress-bar'
+ role='progressbar'
+ aria-valuenow={progressPercent}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ style={{'width': progressPercent + '%'}}
+ />
+ </div>
+ )
+ }
+
+ renderJobStatusLabel (result) {
+ let className
+ switch (result) {
+ case 'success':
+ className = 'label-success'
+ break
+ case 'failure':
+ className = 'label-danger'
+ break
+ case 'unstable':
+ className = 'label-warning'
+ break
+ case 'skipped':
+ className = 'label-info'
+ break
+ // 'in progress' 'queued' 'lost' 'aborted' ...
+ default:
+ className = 'label-default'
+ }
+
+ return (
+ <span className={'zuul-job-result label ' + className}>{result}</span>
+ )
+ }
+
+ renderJob (job) {
+ const { tenant } = this.props
+ let name = ''
+ if (job.result !== null) {
+ name = <a className='zuul-job-name' href={job.report_url}>{job.name}</a>
+ } else if (job.url !== null) {
+ let url = job.url
+ if (job.url.match('stream.html')) {
+ const buildUuid = job.url.split('?')[1].split('&')[0].split('=')[1]
+ const to = (
+ tenant.linkPrefix + '/stream/' + buildUuid + '?logfile=console.log'
+ )
+ name = <Link to={to}>{job.name}</Link>
+ } else {
+ name = <a className='zuul-job-name' href={url}>{job.name}</a>
+ }
+ } else {
+ name = <span className='zuul-job-name'>{job.name}</span>
+ }
+ let resultBar
+ let result = job.result ? job.result.toLowerCase() : null
+ if (result === null) {
+ if (job.url === null) {
+ result = 'queued'
+ } else if (job.paused !== null && job.paused) {
+ result = 'paused'
+ } else {
+ result = 'in progress'
+ }
+ }
+ if (result === 'in progress') {
+ resultBar = this.renderJobProgressBar(
+ job.elapsed_time, job.remaining_time)
+ } else {
+ resultBar = this.renderJobStatusLabel(result)
+ }
+
+ return (
+ <span>
+ {name}
+ {resultBar}
+ {job.voting === false ? (
+ <small className='zuul-non-voting-desc'> (non-voting)</small>) : ''}
+ <div style={{clear: 'both'}} />
+ </span>)
+ }
+
+ renderJobList (jobs) {
+ return (
+ <ul className='list-group zuul-patchset-body'>
+ {jobs.map((job, idx) => (
+ <li key={idx} className='list-group-item zuul-change-job'>
+ {this.renderJob(job)}
+ </li>
+ ))}
+ </ul>)
+ }
+
+ render () {
+ const { expanded } = this.state
+ const { change, globalExpanded } = this.props
+ let expand = globalExpanded
+ if (this.clicked) {
+ expand = expanded
+ }
+ const header = (
+ <div className='panel panel-default zuul-change' onClick={this.onClick}>
+ <div className='panel-heading zuul-patchset-header'>
+ <div className='row'>
+ <div className='col-xs-8'>
+ <span className='change_project'>{change.project}</span>
+ <div className='row'>
+ <div className='col-xs-4'>
+ {this.renderChangeLink(change)}
+ </div>
+ <div className='col-xs-8'>
+ {this.renderProgressBar(change)}
+ </div>
+ </div>
+ </div>
+ {change.live === true ? (
+ <div className='col-xs-4 text-right'>
+ {this.renderTimer(change)}
+ </div>
+ ) : ''}
+ </div>
+ </div>
+ {expand ? this.renderJobList(change.jobs) : ''}
+ </div>
+ )
+ return (
+ <React.Fragment>
+ {header}
+ </React.Fragment>
+ )
+ }
+}
+
+export default connect(state => ({tenant: state.tenant}))(ChangePanel)
diff --git a/web/src/containers/status/ChangePanel.test.jsx b/web/src/containers/status/ChangePanel.test.jsx
new file mode 100644
index 000000000..719cf810e
--- /dev/null
+++ b/web/src/containers/status/ChangePanel.test.jsx
@@ -0,0 +1,64 @@
+/* global expect, jest, it */
+// 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 ReactTestUtils from 'react-dom/test-utils'
+import { Link, BrowserRouter as Router } from 'react-router-dom'
+import { Provider } from 'react-redux'
+
+import { createZuulStore, setTenantAction } from '../../reducers'
+import ChangePanel from './ChangePanel'
+
+
+const fakeChange = {
+ project: 'org-project',
+ jobs: [{
+ name: 'job-name',
+ url: 'stream.html?build=42',
+ result: null
+ }]
+}
+
+it('change panel render multi tenant links', () => {
+ const store = createZuulStore()
+ store.dispatch(setTenantAction('tenant-one', false))
+ const application = ReactTestUtils.renderIntoDocument(
+ <Provider store={store}>
+ <Router>
+ <ChangePanel change={fakeChange} globalExpanded={true} />
+ </Router>
+ </Provider>
+ )
+ const jobLink = ReactTestUtils.findRenderedComponentWithType(
+ application, Link)
+ expect(jobLink.props.to).toEqual(
+ '/t/tenant-one/stream/42?logfile=console.log')
+})
+
+it('change panel render white-label tenant links', () => {
+ const store = createZuulStore()
+ store.dispatch(setTenantAction('tenant-one', true))
+ const application = ReactTestUtils.renderIntoDocument(
+ <Provider store={store}>
+ <Router>
+ <ChangePanel change={fakeChange} globalExpanded={true} />
+ </Router>
+ </Provider>
+ )
+ const jobLink = ReactTestUtils.findRenderedComponentWithType(
+ application, Link)
+ expect(jobLink.props.to).toEqual(
+ '/stream/42?logfile=console.log')
+})
diff --git a/web/src/containers/status/ChangeQueue.jsx b/web/src/containers/status/ChangeQueue.jsx
new file mode 100644
index 000000000..97218b76a
--- /dev/null
+++ b/web/src/containers/status/ChangeQueue.jsx
@@ -0,0 +1,54 @@
+// 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 Change from './Change'
+
+
+class ChangeQueue extends React.Component {
+ static propTypes = {
+ pipeline: PropTypes.string.isRequired,
+ queue: PropTypes.object.isRequired,
+ expanded: PropTypes.bool.isRequired
+ }
+
+ render () {
+ const { queue, pipeline, expanded } = this.props
+ let shortName = queue.name
+ if (shortName.length > 32) {
+ shortName = shortName.substr(0, 32) + '...'
+ }
+ let changesList = []
+ queue.heads.forEach((changes, changeIdx) => {
+ changes.forEach((change, idx) => {
+ changesList.push(
+ <Change
+ change={change}
+ queue={queue}
+ expanded={expanded}
+ key={changeIdx.toString() + idx}
+ />)
+ })
+ })
+ return (
+ <div className="change-queue" data-zuul-pipeline={pipeline}>
+ <p>Queue: <abbr title={queue.name}>{shortName}</abbr></p>
+ {changesList}
+ </div>)
+ }
+}
+
+export default ChangeQueue
diff --git a/web/src/containers/status/Pipeline.jsx b/web/src/containers/status/Pipeline.jsx
new file mode 100644
index 000000000..f3705da64
--- /dev/null
+++ b/web/src/containers/status/Pipeline.jsx
@@ -0,0 +1,136 @@
+// 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 { Badge } from 'patternfly-react'
+
+import ChangeQueue from './ChangeQueue'
+
+
+class Pipeline extends React.Component {
+ static propTypes = {
+ expanded: PropTypes.bool.isRequired,
+ pipeline: PropTypes.object.isRequired,
+ filter: PropTypes.string
+ }
+
+ createTree (pipeline) {
+ let count = 0
+ let pipelineMaxTreeColumns = 1
+ pipeline.change_queues.forEach(changeQueue => {
+ let tree = []
+ let maxTreeColumns = 1
+ let changes = []
+ let lastTreeLength = 0
+ changeQueue.heads.forEach(head => {
+ head.forEach((change, changeIndex) => {
+ changes[change.id] = change
+ change._tree_position = changeIndex
+ })
+ })
+ changeQueue.heads.forEach(head => {
+ head.forEach(change => {
+ if (change.live === true) {
+ count += 1
+ }
+ let idx = tree.indexOf(change.id)
+ if (idx > -1) {
+ change._tree_index = idx
+ // remove...
+ tree[idx] = null
+ while (tree[tree.length - 1] === null) {
+ tree.pop()
+ }
+ } else {
+ change._tree_index = 0
+ }
+ change._tree_branches = []
+ change._tree = []
+ if (typeof (change.items_behind) === 'undefined') {
+ change.items_behind = []
+ }
+ change.items_behind.sort(function (a, b) {
+ return (changes[b]._tree_position - changes[a]._tree_position)
+ })
+ change.items_behind.forEach(id => {
+ tree.push(id)
+ if (tree.length > lastTreeLength && lastTreeLength > 0) {
+ change._tree_branches.push(tree.length - 1)
+ }
+ })
+ if (tree.length > maxTreeColumns) {
+ maxTreeColumns = tree.length
+ }
+ if (tree.length > pipelineMaxTreeColumns) {
+ pipelineMaxTreeColumns = tree.length
+ }
+ change._tree = tree.slice(0) // make a copy
+ lastTreeLength = tree.length
+ })
+ })
+ changeQueue._tree_columns = maxTreeColumns
+ })
+ pipeline._tree_columns = pipelineMaxTreeColumns
+ return count
+ }
+
+ filterQueue(queue, filter) {
+ let found = false
+ queue.heads.forEach(changes => {
+ changes.forEach(change => {
+ if ((change.project && change.project.indexOf(filter) !== -1) ||
+ (change.id && change.id.indexOf(filter) !== -1)) {
+ found = true
+ return
+ }
+ })
+ if (found) {
+ return
+ }
+ })
+ return found
+ }
+
+ render () {
+ const { pipeline, filter, expanded } = this.props
+ const count = this.createTree(pipeline)
+ return (
+ <div className="zuul-pipeline col-md-4">
+ <div className="zuul-pipeline-header">
+ <h3>{pipeline.name} <Badge>{count}</Badge></h3>
+ {pipeline.description ? (
+ <small>
+ <p>{pipeline.description.split(/\r?\n\r?\n/)}</p>
+ </small>) : ''}
+ </div>
+ {pipeline.change_queues.filter(item => item.heads.length > 0)
+ .filter(item => (!filter || (
+ pipeline.name.indexOf(filter) !== -1 ||
+ this.filterQueue(item, filter)
+ )))
+ .map((changeQueue, idx) => (
+ <ChangeQueue
+ queue={changeQueue}
+ expanded={expanded}
+ pipeline={pipeline.name}
+ key={idx}
+ />
+ ))}
+ </div>
+ )
+ }
+}
+
+export default Pipeline
diff --git a/web/src/images/line-angle.png b/web/src/images/line-angle.png
new file mode 100644
index 000000000..fa748682a
--- /dev/null
+++ b/web/src/images/line-angle.png
Binary files differ
diff --git a/web/src/images/line-t.png b/web/src/images/line-t.png
new file mode 100644
index 000000000..cfd3111a3
--- /dev/null
+++ b/web/src/images/line-t.png
Binary files differ
diff --git a/web/src/images/line.png b/web/src/images/line.png
new file mode 100644
index 000000000..ace6bab3d
--- /dev/null
+++ b/web/src/images/line.png
Binary files differ
diff --git a/web/src/images/logo.png b/web/src/images/logo.png
new file mode 100644
index 000000000..640c38604
--- /dev/null
+++ b/web/src/images/logo.png
Binary files differ
diff --git a/web/src/images/logo.svg b/web/src/images/logo.svg
new file mode 100644
index 000000000..e270d664b
--- /dev/null
+++ b/web/src/images/logo.svg
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ version="1.1"
+ id="Layer_1"
+ x="0px"
+ y="0px"
+ viewBox="0 0 144 144"
+ style="enable-background:new 0 0 144 144;"
+ xml:space="preserve"
+ inkscape:version="0.91 r13725"
+ sodipodi:docname="logo.svg"
+ inkscape:export-filename="/data/logo.png"
+ inkscape:export-xdpi="8.7290258"
+ inkscape:export-ydpi="8.7290258"><metadata
+ id="metadata21"><rdf:RDF><cc:Work
+ rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+ id="defs19" /><sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1220"
+ inkscape:window-height="740"
+ id="namedview17"
+ showgrid="false"
+ showborder="false"
+ inkscape:zoom="0.81944444"
+ inkscape:cx="250.26215"
+ inkscape:cy="186.8512"
+ inkscape:window-x="252"
+ inkscape:window-y="337"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="Layer_1"><inkscape:grid
+ type="xygrid"
+ id="grid3360" /></sodipodi:namedview><style
+ type="text/css"
+ id="style3">
+ .st0{fill:#071D49;}
+</style><path
+ style="fill:#e6e6e6"
+ inkscape:connector-curvature="0"
+ id="path7"
+ d="m 12.8,102.6 118.5,0 -25.3,-43.8 0,-15 7,-9.2 -21,0 L 72,0 52,34.7 l -20.9,0 7,9.2 0,15 -25.3,43.7 z m 25.2,-6 -14.9,0 14.9,-25.8 0,25.8 z m 10.4,0 -4.4,0 0,-35.3 4.3,0 0,35.3 z m 0,-41.3 -4.4,0 0,-4.3 4.3,0 0,4.3 z m 20.6,41.3 -14.7,0 0,-35.3 14.7,0 0,35.3 z m 20.7,0 -14.7,0 0,-35.3 14.7,0 0,35.3 z m 0,-41.3 -35.4,0 0,-4.3 35.3,0 0,4.3 z m 10.3,41.3 -4.3,0 0,-35.3 4.3,0 0,35.3 z m 0,-41.3 -4.3,0 0,-4.3 4.3,0 0,4.3 z m 6,15.5 14.9,25.8 -14.9,0 0,-25.8 z M 72,12 85.1,34.7 58.9,34.7 72,12 Z m 28.9,28.7 -0.9,1.2 0,3.1 -56,0 0,-3.2 -0.9,-1.2 57.8,0 z"
+ class="st0"
+ inkscape:export-xdpi="14.038373"
+ inkscape:export-ydpi="14.038373" /><g
+ id="g3354"
+ transform="matrix(3,0,0,3,155.07357,-334.75)"
+ style="fill:#e6e6e6"
+ inkscape:export-xdpi="14.038373"
+ inkscape:export-ydpi="14.038373"><polygon
+ class="st0"
+ points="138.2,137.3 125.1,137.3 125.1,114.6 119.1,118.1 119.1,137.3 119.1,139.6 119.1,143.3 141.6,143.3 "
+ id="polygon9"
+ style="fill:#e6e6e6" /><path
+ class="st0"
+ d="m 99.1,131.5 0,0 0,0 c 0,3.6 -2.9,6.5 -6.5,6.5 -3.6,0 -6.5,-2.9 -6.5,-6.5 l 0,0 0,0 0,-16.9 -6,3.5 0,13.5 0,0 c 0,6.9 5.6,12.5 12.5,12.5 6.9,0 12.5,-5.6 12.5,-12.5 l 0,0 0,-16.9 -6,3.5 0,13.3 z"
+ id="path11"
+ inkscape:connector-curvature="0"
+ style="fill:#e6e6e6" /><path
+ class="st0"
+ d="m 60.2,131.5 0,0 0,0 c 0,3.6 -2.9,6.5 -6.5,6.5 -3.6,0 -6.5,-2.9 -6.5,-6.5 l 0,0 0,0 0,-16.9 -6,3.5 0,13.5 0,0 c 0,6.9 5.6,12.5 12.5,12.5 6.9,0 12.5,-5.6 12.5,-12.5 l 0,0 0,-16.9 -6,3.5 0,13.3 z"
+ id="path13"
+ inkscape:connector-curvature="0"
+ style="fill:#e6e6e6" /><polygon
+ class="st0"
+ points="2.4,143.3 23.8,143.3 27.3,137.3 12.7,137.3 25.8,114.6 25.4,114.6 18.9,114.6 5.8,114.6 2.4,120.6 15.5,120.6 "
+ id="polygon15"
+ style="fill:#e6e6e6" /></g></svg> \ No newline at end of file
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 000000000..4c030195b
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,122 @@
+body {
+ margin: 0;
+ padding: 0;
+ font-family: sans-serif;
+}
+a.refresh {
+ cursor: pointer;
+ border-bottom-style: none;
+ text-decoration: none;
+}
+
+/* Status page */
+.zuul-change {
+ margin-bottom: 10px;
+}
+
+.zuul-change-id {
+ float: right;
+}
+
+.zuul-job-result {
+ float: right;
+ width: 70px;
+ height: 15px;
+ margin: 2px 0 0 0;
+}
+
+.zuul-change-total-result {
+ height: 10px;
+ width: 100px;
+ margin: 0;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.zuul-spinner,
+.zuul-spinner:hover {
+ opacity: 0;
+ transition: opacity 0.5s ease-out;
+ cursor: default;
+ pointer-events: none;
+}
+
+.zuul-spinner-on,
+.zuul-spinner-on:hover {
+ opacity: 1;
+ transition-duration: 0.2s;
+ cursor: progress;
+}
+
+.zuul-change-cell {
+ padding-left: 5px;
+}
+
+.zuul-change-job {
+ padding: 2px 8px;
+}
+
+.zuul-job-name {
+ font-size: small;
+}
+
+.zuul-non-voting-desc {
+ font-size: smaller;
+}
+
+.zuul-patchset-header {
+ font-size: small;
+ padding: 8px 12px;
+}
+
+.form-inline > .form-group {
+ padding-right: 5px;
+}
+
+.zuul-change-row {
+ height: 100%;
+ padding: 0 0 10px 0;
+ margin: 0;
+ width: 16px;
+ min-width: 16px;
+ overflow: hidden;
+ vertical-align: top;
+}
+
+.zuul-build-status {
+ background: white;
+ font-size: 16px;
+}
+
+.zuul-build-merge-conflict:before {
+ color: black;
+}
+
+.zuul-change-row-line {
+ background-image: url('images/line.png');
+ background-repeat: 'repeat-y';
+}
+
+/* Stream page */
+#zuulstreamoverlay {
+ float: right;
+ position: fixed;
+ top: 70px;
+ right: 5px;
+ background-color: white;
+ padding: 2px 0px 0px 2px;
+ color: black;
+}
+
+pre#zuulstreamcontent {
+ font-family: monospace;
+ white-space: pre;
+ margin: 0px 10px;
+ background-color: black;
+ color: lightgrey;
+ border: none;
+}
+p.zuulstreamline {
+ margin: 0px 0px;
+ line-height: 1.4;
+}
diff --git a/web/src/index.js b/web/src/index.js
new file mode 100644
index 000000000..fb42857b4
--- /dev/null
+++ b/web/src/index.js
@@ -0,0 +1,40 @@
+// 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.
+
+// The index is the main of the project. The App is wrapped with
+// a Provider to share the redux store and a Router to manage the location.
+
+import React from 'react'
+import ReactDOM from 'react-dom'
+import { BrowserRouter as Router } from 'react-router-dom'
+import { Provider } from 'react-redux'
+import 'patternfly/dist/css/patternfly.min.css'
+import 'patternfly/dist/css/patternfly-additions.min.css'
+import './index.css'
+
+import { getHomepageUrl } from './api'
+import registerServiceWorker from './registerServiceWorker'
+import { createZuulStore, fetchInfoAction } from './reducers'
+import App from './App'
+
+// This calls the /api/info endpoint asynchronously, the App is connected
+// with redux and it will update the info prop when fetch succeed.
+const store = createZuulStore()
+store.dispatch(fetchInfoAction())
+
+ReactDOM.render(
+ <Provider store={store}>
+ <Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
+ </Provider>, document.getElementById('root'))
+registerServiceWorker()
diff --git a/web/src/pages/Builds.jsx b/web/src/pages/Builds.jsx
new file mode 100644
index 000000000..8f902ee83
--- /dev/null
+++ b/web/src/pages/Builds.jsx
@@ -0,0 +1,159 @@
+// 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 { Table } from 'patternfly-react'
+
+import { fetchBuilds } from '../api'
+import TableFilters from '../containers/TableFilters'
+
+
+class BuildsPage extends TableFilters {
+ static propTypes = {
+ tenant: PropTypes.object
+ }
+
+ constructor () {
+ super()
+
+ this.prepareTableHeaders()
+ this.state = {
+ builds: null,
+ currentFilterType: this.filterTypes[0],
+ activeFilters: [],
+ currentValue: ''
+ }
+ }
+
+ updateData = (filters) => {
+ let queryString = ''
+ if (filters) {
+ filters.forEach(item => queryString += '&' + item.key + '=' + item.value)
+ }
+ this.setState({builds: null})
+ fetchBuilds(this.props.tenant.apiPrefix, queryString).then(response => {
+ this.setState({builds: response.data})
+ })
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Build'
+ if (this.props.tenant.name) {
+ this.updateData(this.getFilterFromUrl())
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (this.props.tenant.name !== prevProps.tenant.name) {
+ this.updateData(this.getFilterFromUrl())
+ }
+ }
+
+ prepareTableHeaders() {
+ const headerFormat = value => <Table.Heading>{value}</Table.Heading>
+ const cellFormat = (value) => (
+ <Table.Cell>{value}</Table.Cell>)
+ const linkCellFormat = (value) => (
+ <Table.Cell>
+ <a href={value} target='_blank' rel='noopener noreferrer'>link</a>
+ </Table.Cell>
+ )
+ this.columns = []
+ this.filterTypes = []
+ const myColumns = [
+ 'job',
+ 'project',
+ 'branch',
+ 'pipeline',
+ 'change',
+ 'duration',
+ 'log',
+ 'start time',
+ 'result']
+ myColumns.forEach(column => {
+ let prop = column
+ let formatter = cellFormat
+ // Adapt column name and property name
+ if (column === 'job') {
+ prop = 'job_name'
+ } else if (column === 'start time') {
+ prop = 'start_time'
+ } else if (column === 'change') {
+ prop = 'ref_url'
+ formatter = linkCellFormat
+ } else if (column === 'log') {
+ prop = 'log_url'
+ formatter = linkCellFormat
+ }
+ const label = column.charAt(0).toUpperCase() + column.slice(1)
+ this.columns.push({
+ header: {label: label, formatters: [headerFormat]},
+ property: prop,
+ cell: {formatters: [formatter]}
+ })
+ if (prop !== 'start_time' && prop !== 'ref_url' && prop !== 'duration'
+ && prop !== 'log_url' && prop !== 'uuid') {
+ this.filterTypes.push({
+ id: prop,
+ title: label,
+ placeholder: 'Filter by ' + label,
+ filterType: 'text',
+ })
+ }
+ })
+ // Add build filter at the end
+ this.filterTypes.push({
+ id: 'uuid',
+ title: 'Build',
+ palceholder: 'Filter by Build UUID',
+ fileterType: 'text',
+ })
+ }
+
+ renderTable (builds) {
+ return (
+ <Table.PfProvider
+ striped
+ bordered
+ columns={this.columns}
+ >
+ <Table.Header/>
+ <Table.Body
+ rows={builds}
+ rowKey='uuid'
+ onRow={(row) => {
+ switch (row.result) {
+ case 'SUCCESS':
+ return { className: 'success' }
+ default:
+ return { className: 'warning' }
+ }
+ }} />
+ </Table.PfProvider>)
+ }
+
+ render() {
+ const { builds } = this.state
+ return (
+ <React.Fragment>
+ {this.renderFilter()}
+ {builds ? this.renderTable(builds) : <p>Loading...</p>}
+ </React.Fragment>
+ )
+ }
+}
+
+export default connect(state => ({tenant: state.tenant}))(BuildsPage)
diff --git a/web/src/pages/Jobs.jsx b/web/src/pages/Jobs.jsx
new file mode 100644
index 000000000..8ec0e3d72
--- /dev/null
+++ b/web/src/pages/Jobs.jsx
@@ -0,0 +1,99 @@
+// 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 { Table } from 'patternfly-react'
+
+import { fetchJobs } from '../api'
+
+
+class JobsPage extends React.Component {
+ static propTypes = {
+ tenant: PropTypes.object
+ }
+
+ state = {
+ jobs: null
+ }
+
+ updateData () {
+ fetchJobs(this.props.tenant.apiPrefix).then(response => {
+ this.setState({jobs: response.data})
+ })
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Jobs'
+ if (this.props.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (this.props.tenant.name !== prevProps.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ render () {
+ const { jobs } = this.state
+ if (!jobs) {
+ return (<p>Loading...</p>)
+ }
+
+ const headerFormat = value => <Table.Heading>{value}</Table.Heading>
+ const cellFormat = (value) => (
+ <Table.Cell>{value}</Table.Cell>)
+ const cellBuildFormat = (value) => (
+ <Table.Cell>
+ <Link to={this.props.tenant.linkPrefix + '/builds?job_name=' + value}>
+ builds
+ </Link>
+ </Table.Cell>)
+ const columns = []
+ const myColumns = ['name', 'description', 'Last builds']
+ myColumns.forEach(column => {
+ let formatter = cellFormat
+ let prop = column
+ if (column === 'Last builds') {
+ prop = 'name'
+ formatter = cellBuildFormat
+ }
+ columns.push({
+ header: {label: column,
+ formatters: [headerFormat]},
+ property: prop,
+ cell: {formatters: [formatter]}
+ })
+ })
+ return (
+ <Table.PfProvider
+ striped
+ bordered
+ hover
+ columns={columns}
+ >
+ <Table.Header/>
+ <Table.Body
+ rows={jobs}
+ rowKey="name"
+ />
+ </Table.PfProvider>)
+ }
+}
+
+export default connect(state => ({tenant: state.tenant}))(JobsPage)
diff --git a/web/src/pages/Status.jsx b/web/src/pages/Status.jsx
new file mode 100644
index 000000000..22a6754fb
--- /dev/null
+++ b/web/src/pages/Status.jsx
@@ -0,0 +1,289 @@
+/* global setTimeout, clearTimeout */
+// 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 {
+ Alert,
+ Checkbox,
+ Icon,
+ Form,
+ FormGroup,
+ FormControl,
+ Spinner
+} from 'patternfly-react'
+
+import { fetchStatus } from '../api'
+import Pipeline from '../containers/status/Pipeline'
+
+
+class StatusPage extends React.Component {
+ static propTypes = {
+ location: PropTypes.object,
+ tenant: PropTypes.object
+ }
+
+ state = {
+ status: null,
+ filter: null,
+ expanded: false,
+ error: null,
+ loading: false,
+ autoReload: true
+ }
+
+ visibilityListener = () => {
+ if (document[this.visibilityStateProperty] === 'visible') {
+ this.visible = true
+ this.updateData()
+ } else {
+ this.visible = false
+ }
+ }
+
+ constructor () {
+ super()
+
+ this.timer = null
+ this.visible = true
+
+ // Stop refresh when page is not visible
+ if (typeof document.hidden !== 'undefined') {
+ this.visibilityChangeEvent = 'visibilitychange'
+ this.visibilityStateProperty = 'visibilityState'
+ } else if (typeof document.mozHidden !== 'undefined') {
+ this.visibilityChangeEvent = 'mozvisibilitychange'
+ this.visibilityStateProperty = 'mozVisibilityState'
+ } else if (typeof document.msHidden !== 'undefined') {
+ this.visibilityChangeEvent = 'msvisibilitychange'
+ this.visibilityStateProperty = 'msVisibilityState'
+ } else if (typeof document.webkitHidden !== 'undefined') {
+ this.visibilityChangeEvent = 'webkitvisibilitychange'
+ this.visibilityStateProperty = 'webkitVisibilityState'
+ }
+ document.addEventListener(
+ this.visibilityChangeEvent, this.visibilityListener, false)
+ }
+
+ setCookie (name, value) {
+ document.cookie = name + '=' + value + '; path=/'
+ }
+
+ updateData = (force) => {
+ /* // Create fake delay
+ function sleeper(ms) {
+ return function(x) {
+ return new Promise(resolve => setTimeout(() => resolve(x), ms));
+ };
+ }
+ */
+
+ if (force || (this.visible && this.state.autoReload)) {
+ this.setState({error: null, loading: true})
+ fetchStatus(this.props.tenant.apiPrefix)
+ // .then(sleeper(2000))
+ .then(response => {
+ this.setState({status: response.data, loading: false})
+ }).catch(error => {
+ this.setState({error: error.message, status: null})
+ })
+ }
+ // Clear any running timer
+ if (this.timer) {
+ clearTimeout(this.timer)
+ this.timer = null
+ }
+ if (this.state.autoReload) {
+ this.timer = setTimeout(this.updateData, 5000)
+ }
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Status'
+ this.loadState()
+ if (this.props.tenant.name) {
+ this.updateData()
+ }
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ // When autoReload is set, also call updateData to retrigger the setTimeout
+ if (this.props.tenant.name !== prevProps.tenant.name || (
+ this.state.autoReload &&
+ this.state.autoReload !== prevState.autoReload)) {
+ this.updateData()
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.timer) {
+ clearTimeout(this.timer)
+ this.timer = null
+ }
+ document.removeEventListener(
+ this.visibilityChangeEvent, this.visibilityListener)
+ }
+
+ setFilter = (filter) => {
+ this.filter.value = filter
+ this.setState({filter: filter})
+ this.setCookie('zuul_filter_string', filter)
+ }
+
+ handleKeyPress = (e) => {
+ if (e.charCode === 13) {
+ this.setFilter(e.target.value)
+ e.preventDefault()
+ e.target.blur()
+ }
+ }
+
+ handleCheckBox = (e) => {
+ this.setState({expanded: e.target.checked})
+ this.setCookie('zuul_expand_by_default', e.target.checked)
+ }
+
+ loadState = () => {
+ function readCookie (name, defaultValue) {
+ let nameEQ = name + '='
+ let ca = document.cookie.split(';')
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i]
+ while (c.charAt(0) === ' ') {
+ c = c.substring(1, c.length)
+ }
+ if (c.indexOf(nameEQ) === 0) {
+ return c.substring(nameEQ.length, c.length)
+ }
+ }
+ return defaultValue
+ }
+ let filter = readCookie('zuul_filter_string', '')
+ let expanded = readCookie('zuul_expand_by_default', false)
+ if (typeof expanded === 'string') {
+ expanded = (expanded === 'true')
+ }
+
+ if (this.props.location.hash) {
+ filter = this.props.location.hash.slice(1)
+ }
+ if (filter || expanded) {
+ this.setState({
+ filter: filter,
+ expanded: expanded
+ })
+ }
+ }
+
+ renderStatusHeader (status) {
+ return (
+ <p>
+ Queue lengths: <span>{status.trigger_event_queue ?
+ status.trigger_event_queue.length : '0'
+ }</span> events,
+ <span>{status.management_event_queue ?
+ status.management_event_queue.length : '0'
+ }</span> management events,
+ <span>{status.result_event_queue ?
+ status.result_event_queue.length : '0'
+ }</span> results.
+ </p>
+ )
+ }
+
+ renderStatusFooter (status) {
+ return (
+ <React.Fragment>
+ <p>Zuul version: <span>{status.zuul_version}</span></p>
+ {status.last_reconfigured ? (
+ <p>Last reconfigured: <span>
+ {new Date(status.last_reconfigured).toString()}
+ </span></p>) : ''}
+ </React.Fragment>
+ )
+ }
+
+ render () {
+ const { autoReload, error, status, filter, expanded, loading } = this.state
+ if (error) {
+ return (<Alert>{this.state.error}</Alert>)
+ }
+ if (this.filter && filter) {
+ this.filter.value = filter
+ }
+ const statusControl = (
+ <Form inline>
+ <FormGroup controlId='status'>
+ <FormControl
+ type='text'
+ placeholder='change or project name'
+ defaultValue={filter}
+ inputRef={i => this.filter = i}
+ onKeyPress={this.handleKeyPress} />
+ {filter && (
+ <FormControl.Feedback>
+ <span
+ onClick={() => {this.setFilter('')}}
+ style={{cursor: 'pointer', zIndex: 10, pointerEvents: 'auto'}}
+ >
+ <Icon type='pf' title='Clear filter' name='delete' />
+ &nbsp;
+ </span>
+ </FormControl.Feedback>
+ )}
+ </FormGroup>
+ <FormGroup controlId='status'>
+ &nbsp; Expand by default:&nbsp;
+ <Checkbox
+ defaultChecked={expanded}
+ onChange={this.handleCheckBox} />
+ </FormGroup>
+ </Form>
+ )
+ return (
+ <React.Fragment>
+ <div className="pull-right" style={{display: 'flex'}}>
+ <Spinner loading={loading}>
+ <a className="refresh" onClick={() => {this.updateData(true)}}>
+ <Icon type="fa" name="refresh" /> refresh&nbsp;&nbsp;
+ </a>
+ </Spinner>
+ <Checkbox
+ defaultChecked={autoReload}
+ onChange={(e) => {this.setState({autoReload: e.target.checked})}}
+ style={{marginTop: '0px'}}>
+ auto reload
+ </Checkbox>
+ </div>
+
+ {status && this.renderStatusHeader(status)}
+ {statusControl}
+ <div className='row'>
+ {status && status.pipelines.map(item => (
+ <Pipeline
+ pipeline={item}
+ filter={filter}
+ expanded={expanded}
+ key={item.name}
+ />
+ ))}
+ </div>
+ {status && this.renderStatusFooter(status)}
+ </React.Fragment>)
+ }
+}
+
+export default connect(state => ({tenant: state.tenant}))(StatusPage)
diff --git a/web/src/pages/Stream.jsx b/web/src/pages/Stream.jsx
new file mode 100644
index 000000000..3ae7d3e9d
--- /dev/null
+++ b/web/src/pages/Stream.jsx
@@ -0,0 +1,158 @@
+/* global clearTimeout, setTimeout, JSON, URLSearchParams */
+// 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 { Checkbox, Form, FormGroup } from 'patternfly-react'
+import Sockette from 'sockette'
+
+import { getStreamUrl } from '../api'
+
+
+class StreamPage extends React.Component {
+ static propTypes = {
+ match: PropTypes.object.isRequired,
+ location: PropTypes.object.isRequired,
+ tenant: PropTypes.object
+ }
+
+ state = {
+ autoscroll: true,
+ }
+
+ constructor() {
+ super()
+ this.receiveBuffer = ''
+ this.displayRef = React.createRef()
+ this.lines = []
+ }
+
+ refreshLoop = () => {
+ if (this.displayRef.current) {
+ let newLine = false
+ this.lines.forEach(line => {
+ newLine = true
+ this.displayRef.current.appendChild(line)
+ })
+ this.lines = []
+ if (newLine) {
+ const { autoscroll } = this.state
+ if (autoscroll) {
+ this.messagesEnd.scrollIntoView({ behavior: 'instant' })
+ }
+ }
+ }
+ this.timer = setTimeout(this.refreshLoop, 250)
+ }
+
+ componentWillUnmount () {
+ if (this.timer) {
+ clearTimeout(this.timer)
+ this.timer = null
+ }
+ if (this.ws) {
+ console.log('Remove ws')
+ this.ws.close()
+ }
+ }
+
+ onLine = (line) => {
+ // Create dom elements
+ const lineDom = document.createElement('p')
+ lineDom.className = 'zuulstreamline'
+ lineDom.appendChild(document.createTextNode(line))
+ this.lines.push(lineDom)
+ }
+
+ onMessage = (message) => {
+ this.receiveBuffer += message
+ const lines = this.receiveBuffer.split('\n')
+ const lastLine = lines.slice(-1)[0]
+ // Append all completed lines
+ lines.slice(0, -1).forEach(line => {
+ this.onLine(line)
+ })
+ // Check if last chunk is completed
+ if (lastLine && this.receiveBuffer.slice(-1) === '\n') {
+ this.onLine(lastLine)
+ this.receiveBuffer = ''
+ } else {
+ this.receiveBuffer = lastLine
+ }
+ this.refreshLoop()
+ }
+
+ componentDidMount() {
+ const params = {
+ uuid: this.props.match.params.buildId
+ }
+ const urlParams = new URLSearchParams(this.props.location.search)
+ const logfile = urlParams.get('logfile')
+ if (logfile) {
+ params.logfile = logfile
+ }
+ document.title = 'Zuul Stream | ' + params.uuid.slice(0, 7)
+ this.ws = new Sockette(getStreamUrl(this.props.tenant.apiPrefix), {
+ timeout: 5e3,
+ maxAttempts: 3,
+ onopen: () => {
+ console.log('onopen')
+ this.ws.send(JSON.stringify(params))
+ },
+ onmessage: e => {
+ this.onMessage(e.data)
+ },
+ onreconnect: e => {
+ console.log('Reconnecting...', e)
+ },
+ onmaximum: e => {
+ console.log('Stop Attempting!', e)
+ },
+ onclose: e => {
+ console.log('onclose', e)
+ this.onMessage('\n--- END OF STREAM ---\n')
+ },
+ onerror: e => {
+ console.log('onerror:', e)
+ }
+ })
+ }
+
+ handleCheckBox = (e) => {
+ this.setState({autoscroll: e.target.checked})
+ }
+
+ render () {
+ return (
+ <React.Fragment>
+ <Form inline id='zuulstreamoverlay'>
+ <FormGroup controlId='stream'>
+ <Checkbox
+ checked={this.state.autoscroll}
+ onChange={this.handleCheckBox}>
+ autoscroll
+ </Checkbox>
+ </FormGroup>
+ </Form>
+ <pre id='zuulstreamcontent' ref={this.displayRef} />
+ <div ref={(el) => { this.messagesEnd = el }} />
+ </React.Fragment>
+ )
+ }
+}
+
+
+export default connect(state => ({tenant: state.tenant}))(StreamPage)
diff --git a/web/src/pages/Tenants.jsx b/web/src/pages/Tenants.jsx
new file mode 100644
index 000000000..4b6a03efe
--- /dev/null
+++ b/web/src/pages/Tenants.jsx
@@ -0,0 +1,79 @@
+// 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 { Link } from 'react-router-dom'
+import { Table } from 'patternfly-react'
+
+import { fetchTenants } from '../api'
+
+class TenantsPage extends React.Component {
+ constructor () {
+ super()
+
+ this.state = {
+ tenants: []
+ }
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Tenants'
+ fetchTenants().then(response => {
+ this.setState({tenants: response.data})
+ })
+ }
+
+ render () {
+ const { tenants } = this.state
+ if (tenants.length === 0) {
+ return (<p>Loading...</p>)
+ }
+ const headerFormat = value => <Table.Heading>{value}</Table.Heading>
+ const cellFormat = (value) => (
+ <Table.Cell>{value}</Table.Cell>)
+ const columns = []
+ const myColumns = ['name', 'status', 'jobs', 'builds', 'projects', 'queue']
+ myColumns.forEach(column => {
+ columns.push({
+ header: {label: column,
+ formatters: [headerFormat]},
+ property: column,
+ cell: {formatters: [cellFormat]}
+ })
+ })
+ tenants.forEach(tenant => {
+ tenant.status = (
+ <Link to={'/t/' + tenant.name + '/status'}>Status</Link>)
+ tenant.jobs = (
+ <Link to={'/t/' + tenant.name + '/jobs'}>Jobs</Link>)
+ tenant.builds = (
+ <Link to={'/t/' + tenant.name + '/builds'}>Builds</Link>)
+ })
+ return (
+ <Table.PfProvider
+ striped
+ bordered
+ hover
+ columns={columns}
+ >
+ <Table.Header/>
+ <Table.Body
+ rows={tenants}
+ rowKey="name"
+ />
+ </Table.PfProvider>)
+ }
+}
+
+export default TenantsPage
diff --git a/web/src/reducers.js b/web/src/reducers.js
new file mode 100644
index 000000000..0ce01c3b2
--- /dev/null
+++ b/web/src/reducers.js
@@ -0,0 +1,94 @@
+// 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.
+
+// Redux store enable to share global variables through state
+// To update the store, use a reducer and dispatch method,
+// see the App.setTenant method
+//
+// The store contains:
+// info: the info object, tenant is set when white-label api
+// tenant: the current tenant name, only used with multi-tenant api
+
+import { applyMiddleware, createStore, combineReducers } from 'redux'
+import thunk from 'redux-thunk'
+
+import { fetchInfo } from './api'
+
+const infoReducer = (state = {}, action) => {
+ switch (action.type) {
+ case 'FETCH_INFO_SUCCESS':
+ return action.info
+ default:
+ return state
+ }
+}
+
+const tenantReducer = (state = {}, action) => {
+ switch (action.type) {
+ case 'SET_TENANT':
+ return action.tenant
+ default:
+ return state
+ }
+}
+
+function createZuulStore() {
+ return createStore(combineReducers({
+ info: infoReducer,
+ tenant: tenantReducer
+ }), applyMiddleware(thunk))
+}
+
+// Reducer actions
+function fetchInfoAction () {
+ return (dispatch) => {
+ return fetchInfo()
+ .then(response => {
+ dispatch({type: 'FETCH_INFO_SUCCESS', info: response.data.info})
+ })
+ .catch(error => {
+ throw (error)
+ })
+ }
+}
+
+function setTenantAction (name, whiteLabel) {
+ let apiPrefix = ''
+ let linkPrefix = ''
+ let routePrefix = ''
+ let defaultRoute = '/status'
+ if (!whiteLabel) {
+ apiPrefix = 'tenant/' + name + '/'
+ linkPrefix = '/t/' + name
+ routePrefix = '/t/:tenant'
+ defaultRoute = '/tenants'
+ }
+ return {
+ type: 'SET_TENANT',
+ tenant: {
+ name: name,
+ whiteLabel: whiteLabel,
+ defaultRoute: defaultRoute,
+ linkPrefix: linkPrefix,
+ apiPrefix: apiPrefix,
+ routePrefix: routePrefix
+ }
+ }
+}
+
+export {
+ createZuulStore,
+ setTenantAction,
+ fetchInfoAction
+}
diff --git a/web/src/registerServiceWorker.js b/web/src/registerServiceWorker.js
new file mode 100644
index 000000000..4f9531442
--- /dev/null
+++ b/web/src/registerServiceWorker.js
@@ -0,0 +1,119 @@
+/* global process */
+// In production, we register a service worker to serve assets from local cache.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on the "N+1" visit to a page, since previously
+// cached resources are updated in the background.
+
+// To learn more about the benefits of this model, read
+// https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#making-a-progressive-web-app
+// This link also includes instructions on opting out of this behavior.
+
+const isLocalhost = Boolean(
+ window.location.hostname === 'localhost' ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === '[::1]' ||
+ // 127.0.0.1/8 is considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+)
+
+export default function register () {
+ if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
+ return
+ }
+
+ window.addEventListener('load', () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
+
+ if (isLocalhost) {
+ // This is running on localhost. Lets check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl)
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ 'This web app is being served cache-first by a service ' +
+ 'worker. To learn more, visit https://goo.gl/SC7cgQ'
+ )
+ })
+ } else {
+ // Is not local host. Just register service worker
+ registerValidSW(swUrl)
+ }
+ })
+ }
+}
+
+function registerValidSW (swUrl) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the old content will have been purged and
+ // the fresh content will have been added to the cache.
+ // It's the perfect time to display a "New content is
+ // available; please refresh." message in your web app.
+ console.log('New content is available; please refresh.')
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log('Content is cached for offline use.')
+ }
+ }
+ }
+ }
+ })
+ .catch(error => {
+ console.error('Error during service worker registration:', error)
+ })
+}
+
+function checkValidServiceWorker (swUrl) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl)
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ if (
+ response.status === 404 ||
+ response.headers.get('content-type').indexOf('javascript') === -1
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload()
+ })
+ })
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl)
+ }
+ })
+ .catch(() => {
+ console.log(
+ 'No internet connection found. App is running in offline mode.'
+ )
+ })
+}
+
+export function unregister () {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister()
+ })
+ }
+}
diff --git a/web/src/routes.js b/web/src/routes.js
new file mode 100644
index 000000000..408599eaf
--- /dev/null
+++ b/web/src/routes.js
@@ -0,0 +1,52 @@
+// 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 StatusPage from './pages/Status'
+import JobsPage from './pages/Jobs'
+import BuildsPage from './pages/Builds'
+import TenantsPage from './pages/Tenants'
+import StreamPage from './pages/Stream'
+
+// The Route object are created in the App component.
+// Object with a title are created in the menu.
+// Object with globalRoute are not tenant scoped.
+// Remember to update the api getHomepageUrl subDir list for route with params
+const routes = () => [
+ {
+ title: 'Status',
+ to: '/status',
+ component: StatusPage
+ },
+ {
+ title: 'Jobs',
+ to: '/jobs',
+ component: JobsPage
+ },
+ {
+ title: 'Builds',
+ to: '/builds',
+ component: BuildsPage
+ },
+ {
+ to: '/stream/:buildId',
+ component: StreamPage
+ },
+ {
+ to: '/tenants',
+ component: TenantsPage,
+ globalRoute: true
+ }
+]
+
+export { routes }