diff options
author | Tristan Cacqueray <tdecacqu@redhat.com> | 2018-10-03 07:05:06 +0000 |
---|---|---|
committer | Monty Taylor <mordred@inaugust.com> | 2018-10-06 10:42:31 -0500 |
commit | 68d11898715970f47c4d86c261dc64232a841c85 (patch) | |
tree | 974b0e60a21d8262b67c0310777d14ee48c056f5 /web/src | |
parent | 6f0f36aab00104321d548554f74e969064b1fe34 (diff) | |
download | zuul-68d11898715970f47c4d86c261dc64232a841c85.tar.gz |
Revert "Revert "web: rewrite interface in react""
This reverts commit 3dba813c643ec8f4b3323c2a09c6aecf8ad4d338.
Change-Id: I233797a9b4e3485491c49675da2c2efbdba59449
Diffstat (limited to 'web/src')
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 Binary files differnew file mode 100644 index 000000000..fa748682a --- /dev/null +++ b/web/src/images/line-angle.png diff --git a/web/src/images/line-t.png b/web/src/images/line-t.png Binary files differnew file mode 100644 index 000000000..cfd3111a3 --- /dev/null +++ b/web/src/images/line-t.png diff --git a/web/src/images/line.png b/web/src/images/line.png Binary files differnew file mode 100644 index 000000000..ace6bab3d --- /dev/null +++ b/web/src/images/line.png diff --git a/web/src/images/logo.png b/web/src/images/logo.png Binary files differnew file mode 100644 index 000000000..640c38604 --- /dev/null +++ b/web/src/images/logo.png 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' /> + + </span> + </FormControl.Feedback> + )} + </FormGroup> + <FormGroup controlId='status'> + Expand by default: + <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 + </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 } |