diff options
author | Tristan Cacqueray <tdecacqu@redhat.com> | 2018-08-14 05:13:15 +0000 |
---|---|---|
committer | Tristan Cacqueray <tdecacqu@redhat.com> | 2018-09-27 02:14:46 +0000 |
commit | 1082faae958bffa719ab333c3f5ae9776a8b26d7 (patch) | |
tree | 73b58bd8462d6d446f5c8caabb6a5f30695765f4 /web/src/pages | |
parent | a74dc74ea1975388dc9f38cafe2cfb68de53811f (diff) | |
download | zuul-1082faae958bffa719ab333c3f5ae9776a8b26d7.tar.gz |
web: rewrite interface in react
This change rewrites the web interface using React:
http://lists.zuul-ci.org/pipermail/zuul-discuss/2018-August/000528.html
Depends-On: https://review.openstack.org/591964
Change-Id: Ic6c33102ac3da69ebd0b8e9c6c8b431d51f3cfd4
Co-Authored-By: Monty Taylor <mordred@inaugust.com>
Co-Authored-By: James E. Blair <jeblair@redhat.com>
Diffstat (limited to 'web/src/pages')
-rw-r--r-- | web/src/pages/Builds.jsx | 159 | ||||
-rw-r--r-- | web/src/pages/Jobs.jsx | 99 | ||||
-rw-r--r-- | web/src/pages/Status.jsx | 289 | ||||
-rw-r--r-- | web/src/pages/Stream.jsx | 158 | ||||
-rw-r--r-- | web/src/pages/Tenants.jsx | 79 |
5 files changed, 784 insertions, 0 deletions
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 |