summaryrefslogtreecommitdiff
path: root/web/src/pages
diff options
context:
space:
mode:
authorTristan Cacqueray <tdecacqu@redhat.com>2018-08-14 05:13:15 +0000
committerTristan Cacqueray <tdecacqu@redhat.com>2018-09-27 02:14:46 +0000
commit1082faae958bffa719ab333c3f5ae9776a8b26d7 (patch)
tree73b58bd8462d6d446f5c8caabb6a5f30695765f4 /web/src/pages
parenta74dc74ea1975388dc9f38cafe2cfb68de53811f (diff)
downloadzuul-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.jsx159
-rw-r--r--web/src/pages/Jobs.jsx99
-rw-r--r--web/src/pages/Status.jsx289
-rw-r--r--web/src/pages/Stream.jsx158
-rw-r--r--web/src/pages/Tenants.jsx79
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' />
+ &nbsp;
+ </span>
+ </FormControl.Feedback>
+ )}
+ </FormGroup>
+ <FormGroup controlId='status'>
+ &nbsp; Expand by default:&nbsp;
+ <Checkbox
+ defaultChecked={expanded}
+ onChange={this.handleCheckBox} />
+ </FormGroup>
+ </Form>
+ )
+ return (
+ <React.Fragment>
+ <div className="pull-right" style={{display: 'flex'}}>
+ <Spinner loading={loading}>
+ <a className="refresh" onClick={() => {this.updateData(true)}}>
+ <Icon type="fa" name="refresh" /> refresh&nbsp;&nbsp;
+ </a>
+ </Spinner>
+ <Checkbox
+ defaultChecked={autoReload}
+ onChange={(e) => {this.setState({autoReload: e.target.checked})}}
+ style={{marginTop: '0px'}}>
+ auto reload
+ </Checkbox>
+ </div>
+
+ {status && this.renderStatusHeader(status)}
+ {statusControl}
+ <div className='row'>
+ {status && status.pipelines.map(item => (
+ <Pipeline
+ pipeline={item}
+ filter={filter}
+ expanded={expanded}
+ key={item.name}
+ />
+ ))}
+ </div>
+ {status && this.renderStatusFooter(status)}
+ </React.Fragment>)
+ }
+}
+
+export default connect(state => ({tenant: state.tenant}))(StatusPage)
diff --git a/web/src/pages/Stream.jsx b/web/src/pages/Stream.jsx
new file mode 100644
index 000000000..3ae7d3e9d
--- /dev/null
+++ b/web/src/pages/Stream.jsx
@@ -0,0 +1,158 @@
+/* global clearTimeout, setTimeout, JSON, URLSearchParams */
+// Copyright 2018 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+
+import * as React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+import { Checkbox, Form, FormGroup } from 'patternfly-react'
+import Sockette from 'sockette'
+
+import { getStreamUrl } from '../api'
+
+
+class StreamPage extends React.Component {
+ static propTypes = {
+ match: PropTypes.object.isRequired,
+ location: PropTypes.object.isRequired,
+ tenant: PropTypes.object
+ }
+
+ state = {
+ autoscroll: true,
+ }
+
+ constructor() {
+ super()
+ this.receiveBuffer = ''
+ this.displayRef = React.createRef()
+ this.lines = []
+ }
+
+ refreshLoop = () => {
+ if (this.displayRef.current) {
+ let newLine = false
+ this.lines.forEach(line => {
+ newLine = true
+ this.displayRef.current.appendChild(line)
+ })
+ this.lines = []
+ if (newLine) {
+ const { autoscroll } = this.state
+ if (autoscroll) {
+ this.messagesEnd.scrollIntoView({ behavior: 'instant' })
+ }
+ }
+ }
+ this.timer = setTimeout(this.refreshLoop, 250)
+ }
+
+ componentWillUnmount () {
+ if (this.timer) {
+ clearTimeout(this.timer)
+ this.timer = null
+ }
+ if (this.ws) {
+ console.log('Remove ws')
+ this.ws.close()
+ }
+ }
+
+ onLine = (line) => {
+ // Create dom elements
+ const lineDom = document.createElement('p')
+ lineDom.className = 'zuulstreamline'
+ lineDom.appendChild(document.createTextNode(line))
+ this.lines.push(lineDom)
+ }
+
+ onMessage = (message) => {
+ this.receiveBuffer += message
+ const lines = this.receiveBuffer.split('\n')
+ const lastLine = lines.slice(-1)[0]
+ // Append all completed lines
+ lines.slice(0, -1).forEach(line => {
+ this.onLine(line)
+ })
+ // Check if last chunk is completed
+ if (lastLine && this.receiveBuffer.slice(-1) === '\n') {
+ this.onLine(lastLine)
+ this.receiveBuffer = ''
+ } else {
+ this.receiveBuffer = lastLine
+ }
+ this.refreshLoop()
+ }
+
+ componentDidMount() {
+ const params = {
+ uuid: this.props.match.params.buildId
+ }
+ const urlParams = new URLSearchParams(this.props.location.search)
+ const logfile = urlParams.get('logfile')
+ if (logfile) {
+ params.logfile = logfile
+ }
+ document.title = 'Zuul Stream | ' + params.uuid.slice(0, 7)
+ this.ws = new Sockette(getStreamUrl(this.props.tenant.apiPrefix), {
+ timeout: 5e3,
+ maxAttempts: 3,
+ onopen: () => {
+ console.log('onopen')
+ this.ws.send(JSON.stringify(params))
+ },
+ onmessage: e => {
+ this.onMessage(e.data)
+ },
+ onreconnect: e => {
+ console.log('Reconnecting...', e)
+ },
+ onmaximum: e => {
+ console.log('Stop Attempting!', e)
+ },
+ onclose: e => {
+ console.log('onclose', e)
+ this.onMessage('\n--- END OF STREAM ---\n')
+ },
+ onerror: e => {
+ console.log('onerror:', e)
+ }
+ })
+ }
+
+ handleCheckBox = (e) => {
+ this.setState({autoscroll: e.target.checked})
+ }
+
+ render () {
+ return (
+ <React.Fragment>
+ <Form inline id='zuulstreamoverlay'>
+ <FormGroup controlId='stream'>
+ <Checkbox
+ checked={this.state.autoscroll}
+ onChange={this.handleCheckBox}>
+ autoscroll
+ </Checkbox>
+ </FormGroup>
+ </Form>
+ <pre id='zuulstreamcontent' ref={this.displayRef} />
+ <div ref={(el) => { this.messagesEnd = el }} />
+ </React.Fragment>
+ )
+ }
+}
+
+
+export default connect(state => ({tenant: state.tenant}))(StreamPage)
diff --git a/web/src/pages/Tenants.jsx b/web/src/pages/Tenants.jsx
new file mode 100644
index 000000000..4b6a03efe
--- /dev/null
+++ b/web/src/pages/Tenants.jsx
@@ -0,0 +1,79 @@
+// Copyright 2018 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+
+import * as React from 'react'
+import { Link } from 'react-router-dom'
+import { Table } from 'patternfly-react'
+
+import { fetchTenants } from '../api'
+
+class TenantsPage extends React.Component {
+ constructor () {
+ super()
+
+ this.state = {
+ tenants: []
+ }
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Tenants'
+ fetchTenants().then(response => {
+ this.setState({tenants: response.data})
+ })
+ }
+
+ render () {
+ const { tenants } = this.state
+ if (tenants.length === 0) {
+ return (<p>Loading...</p>)
+ }
+ const headerFormat = value => <Table.Heading>{value}</Table.Heading>
+ const cellFormat = (value) => (
+ <Table.Cell>{value}</Table.Cell>)
+ const columns = []
+ const myColumns = ['name', 'status', 'jobs', 'builds', 'projects', 'queue']
+ myColumns.forEach(column => {
+ columns.push({
+ header: {label: column,
+ formatters: [headerFormat]},
+ property: column,
+ cell: {formatters: [cellFormat]}
+ })
+ })
+ tenants.forEach(tenant => {
+ tenant.status = (
+ <Link to={'/t/' + tenant.name + '/status'}>Status</Link>)
+ tenant.jobs = (
+ <Link to={'/t/' + tenant.name + '/jobs'}>Jobs</Link>)
+ tenant.builds = (
+ <Link to={'/t/' + tenant.name + '/builds'}>Builds</Link>)
+ })
+ return (
+ <Table.PfProvider
+ striped
+ bordered
+ hover
+ columns={columns}
+ >
+ <Table.Header/>
+ <Table.Body
+ rows={tenants}
+ rowKey="name"
+ />
+ </Table.PfProvider>)
+ }
+}
+
+export default TenantsPage