summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorJames E. Blair <jeblair@redhat.com>2019-07-20 11:08:05 -0700
committerJames E. Blair <jeblair@redhat.com>2019-07-24 09:25:13 -0700
commit8fdc387c83140305d2374b954916868f8fbf0e40 (patch)
tree3aeaad1019f779a8b6587e3a504c84417ce51cf8 /web
parentd48c2b82fc7e69dc9f2f493859236af342ec888e (diff)
downloadzuul-8fdc387c83140305d2374b954916868f8fbf0e40.tar.gz
Add log browsing to build page
This looks for a zuul_manifest artifact, and if it is present, it fetches it and shows a tree view of logs. Text logs are displayed in-app with some basic line anchoring. Part of the implementation of https://zuul-ci.org/docs/zuul/developer/specs/logs.html es6 added to .eslint to allow use of Promise. Change-Id: Ib04d013b4118005ba66a91d2bec0b0c429d12863
Diffstat (limited to 'web')
-rw-r--r--web/.eslintrc1
-rw-r--r--web/src/actions/build.js136
-rw-r--r--web/src/actions/logfile.js64
-rw-r--r--web/src/containers/build/Build.jsx49
-rw-r--r--web/src/containers/build/Manifest.jsx63
-rw-r--r--web/src/containers/build/View.jsx53
-rw-r--r--web/src/index.css10
-rw-r--r--web/src/pages/Build.jsx2
-rw-r--r--web/src/pages/View.jsx62
-rw-r--r--web/src/reducers/build.js53
-rw-r--r--web/src/reducers/index.js2
-rw-r--r--web/src/reducers/logfile.js44
-rw-r--r--web/src/routes.js5
13 files changed, 484 insertions, 60 deletions
diff --git a/web/.eslintrc b/web/.eslintrc
index 24a734c02..86ff5afee 100644
--- a/web/.eslintrc
+++ b/web/.eslintrc
@@ -21,3 +21,4 @@ settings:
env:
jest/globals: true
browser: true
+ es6: true
diff --git a/web/src/actions/build.js b/web/src/actions/build.js
index b291c88b8..a14945b22 100644
--- a/web/src/actions/build.js
+++ b/web/src/actions/build.js
@@ -19,7 +19,14 @@ import * as API from '../api'
export const BUILD_FETCH_REQUEST = 'BUILD_FETCH_REQUEST'
export const BUILD_FETCH_SUCCESS = 'BUILD_FETCH_SUCCESS'
export const BUILD_FETCH_FAIL = 'BUILD_FETCH_FAIL'
-export const BUILD_OUTPUT_FETCH_SUCCESS = 'BUILD_OUTPUT_FETCH_SUCCESS'
+
+export const BUILD_OUTPUT_REQUEST = 'BUILD_OUTPUT_REQUEST'
+export const BUILD_OUTPUT_SUCCESS = 'BUILD_OUTPUT_SUCCESS'
+export const BUILD_OUTPUT_FAIL = 'BUILD_OUTPUT_FAIL'
+
+export const BUILD_MANIFEST_REQUEST = 'BUILD_MANIFEST_REQUEST'
+export const BUILD_MANIFEST_SUCCESS = 'BUILD_MANIFEST_SUCCESS'
+export const BUILD_MANIFEST_FAIL = 'BUILD_MANIFEST_FAIL'
export const requestBuild = () => ({
type: BUILD_FETCH_REQUEST
@@ -32,6 +39,15 @@ export const receiveBuild = (buildId, build) => ({
receivedAt: Date.now()
})
+const failedBuild = error => ({
+ type: BUILD_FETCH_FAIL,
+ error
+})
+
+export const requestBuildOutput = () => ({
+ type: BUILD_OUTPUT_REQUEST
+})
+
const receiveBuildOutput = (buildId, output) => {
const hosts = {}
// Compute stats
@@ -70,58 +86,108 @@ const receiveBuildOutput = (buildId, output) => {
})
})
return {
- type: BUILD_OUTPUT_FETCH_SUCCESS,
+ type: BUILD_OUTPUT_SUCCESS,
buildId: buildId,
output: hosts,
receivedAt: Date.now()
}
}
-const failedBuild = error => ({
- type: BUILD_FETCH_FAIL,
+const failedBuildOutput = error => ({
+ type: BUILD_OUTPUT_FAIL,
error
})
-const fetchBuild = (tenant, build) => dispatch => {
+export const requestBuildManifest = () => ({
+ type: BUILD_MANIFEST_REQUEST
+})
+
+const receiveBuildManifest = (buildId, manifest) => {
+ const index = {}
+
+ const renderNode = (root, object) => {
+ const path = root + '/' + object.name
+
+ if ('children' in object && object.children) {
+ object.children.map(n => renderNode(path, n))
+ } else {
+ index[path] = object
+ }
+ }
+
+ manifest.tree.map(n => renderNode('', n))
+ return {
+ type: BUILD_MANIFEST_SUCCESS,
+ buildId: buildId,
+ manifest: {tree: manifest.tree, index: index},
+ receivedAt: Date.now()
+ }
+}
+
+const failedBuildManifest = error => ({
+ type: BUILD_MANIFEST_FAIL,
+ error
+})
+
+export const fetchBuild = (tenant, buildId, state, force) => dispatch => {
+ const build = state.build.builds[buildId]
+ if (!force && build) {
+ return Promise.resolve()
+ }
dispatch(requestBuild())
- return API.fetchBuild(tenant.apiPrefix, build)
+ return API.fetchBuild(tenant.apiPrefix, buildId)
.then(response => {
- dispatch(receiveBuild(build, response.data))
- if (response.data.log_url) {
- const url = response.data.log_url.substr(
- 0, response.data.log_url.lastIndexOf('/') + 1)
- Axios.get(url + 'job-output.json.gz')
- .then(response => dispatch(receiveBuildOutput(build, response.data)))
- .catch(error => {
- if (!error.request) {
- throw error
- }
- // Try without compression
- Axios.get(url + 'job-output.json')
- .then(response => dispatch(receiveBuildOutput(
- build, response.data)))
- })
- .catch(error => console.error(
- 'Couldn\'t decode job-output...', error))
- }
+ dispatch(receiveBuild(buildId, response.data))
})
.catch(error => dispatch(failedBuild(error)))
}
-const shouldFetchBuild = (buildId, state) => {
+const fetchBuildOutput = (buildId, state, force) => dispatch => {
const build = state.build.builds[buildId]
- if (!build) {
- return true
- }
- if (build.isFetching) {
- return false
+ const url = build.log_url.substr(0, build.log_url.lastIndexOf('/') + 1)
+ if (!force && build.output) {
+ return Promise.resolve()
}
- return false
+ dispatch(requestBuildOutput())
+ return Axios.get(url + 'job-output.json.gz')
+ .then(response => dispatch(receiveBuildOutput(buildId, response.data)))
+ .catch(error => {
+ if (!error.request) {
+ throw error
+ }
+ // Try without compression
+ Axios.get(url + 'job-output.json')
+ .then(response => dispatch(receiveBuildOutput(
+ buildId, response.data)))
+ })
+ .catch(error => dispatch(failedBuildOutput(error)))
}
-export const fetchBuildIfNeeded = (tenant, buildId, force) => (
- dispatch, getState) => {
- if (force || shouldFetchBuild(buildId, getState())) {
- return dispatch(fetchBuild(tenant, buildId))
+export const fetchBuildManifest = (buildId, state, force) => dispatch => {
+ const build = state.build.builds[buildId]
+ if (!force && build.manifest) {
+ return Promise.resolve()
+ }
+
+ dispatch(requestBuildManifest())
+ for (let artifact of build.artifacts) {
+ if ('metadata' in artifact &&
+ 'type' in artifact.metadata &&
+ artifact.metadata.type === 'zuul_manifest') {
+ return Axios.get(artifact.url)
+ .then(manifest => {
+ dispatch(receiveBuildManifest(buildId, manifest.data))
+ })
+ .catch(error => dispatch(failedBuildManifest(error)))
}
+ }
+ dispatch(failedBuildManifest('no manifest found'))
+}
+
+export const fetchBuildIfNeeded = (tenant, buildId, force) => (dispatch, getState) => {
+ dispatch(fetchBuild(tenant, buildId, getState(), force))
+ .then(() => {
+ dispatch(fetchBuildOutput(buildId, getState(), force))
+ dispatch(fetchBuildManifest(buildId, getState(), force))
+ })
}
diff --git a/web/src/actions/logfile.js b/web/src/actions/logfile.js
new file mode 100644
index 000000000..8e5d99fe3
--- /dev/null
+++ b/web/src/actions/logfile.js
@@ -0,0 +1,64 @@
+// 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'
+
+import {fetchBuild, fetchBuildManifest} from './build'
+
+export const LOGFILE_FETCH_REQUEST = 'LOGFILE_FETCH_REQUEST'
+export const LOGFILE_FETCH_SUCCESS = 'LOGFILE_FETCH_SUCCESS'
+export const LOGFILE_FETCH_FAIL = 'LOGFILE_FETCH_FAIL'
+
+export const requestLogfile = (url) => ({
+ type: LOGFILE_FETCH_REQUEST,
+ url: url,
+})
+
+const receiveLogfile = (data) => ({
+ type: LOGFILE_FETCH_SUCCESS,
+ data: data,
+ receivedAt: Date.now()
+})
+
+const failedLogfile = error => ({
+ type: LOGFILE_FETCH_FAIL,
+ error
+})
+
+const fetchLogfile = (buildId, file, state, force) => dispatch => {
+ const build = state.build.builds[buildId]
+ const item = build.manifest.index['/' + file]
+ const url = build.log_url + item.name
+
+ if (!force && state.logfile.url === url) {
+ return Promise.resolve()
+ }
+ dispatch(requestLogfile())
+ if (item.mimetype === 'text/plain') {
+ return Axios.get(url)
+ .then(response => dispatch(receiveLogfile(response.data)))
+ .catch(error => dispatch(failedLogfile(error)))
+ }
+ dispatch(failedLogfile(null))
+}
+
+export const fetchLogfileIfNeeded = (tenant, buildId, file, force) => (dispatch, getState) => {
+ dispatch(fetchBuild(tenant, buildId, getState(), force))
+ .then(() => {
+ dispatch(fetchBuildManifest(buildId, getState(), force))
+ .then(() => {
+ dispatch(fetchLogfile(buildId, file, getState(), force))
+ })
+ })
+}
diff --git a/web/src/containers/build/Build.jsx b/web/src/containers/build/Build.jsx
index a985d377a..1070fc3b5 100644
--- a/web/src/containers/build/Build.jsx
+++ b/web/src/containers/build/Build.jsx
@@ -17,9 +17,17 @@ import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { Panel } from 'react-bootstrap'
+import {
+ Nav,
+ NavItem,
+ TabContainer,
+ TabPane,
+ TabContent,
+} from 'patternfly-react'
import Artifact from './Artifact'
import BuildOutput from './BuildOutput'
+import Manifest from './Manifest'
class Build extends React.Component {
@@ -81,16 +89,37 @@ class Build extends React.Component {
<Panel>
<Panel.Heading>Build result {build.uuid}</Panel.Heading>
<Panel.Body>
- <table className="table table-striped table-bordered">
- <tbody>
- {rows.map(item => (
- <tr key={item.key}>
- <td>{item.key}</td>
- <td>{item.value}</td>
- </tr>
- ))}
- </tbody>
- </table>
+ <TabContainer id="zuul-project" defaultActiveKey={1}>
+ <div>
+ <Nav bsClass="nav nav-tabs nav-tabs-pf">
+ <NavItem eventKey={1}>
+ Summary
+ </NavItem>
+ {build.manifest &&
+ <NavItem eventKey={2}>
+ Logs
+ </NavItem>}
+ </Nav>
+ <TabContent>
+ <TabPane eventKey={1}>
+ <table className="table table-striped table-bordered">
+ <tbody>
+ {rows.map(item => (
+ <tr key={item.key}>
+ <td>{item.key}</td>
+ <td>{item.value}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </TabPane>
+ {build.manifest &&
+ <TabPane eventKey={2}>
+ <Manifest tenant={this.props.tenant} build={build}/>
+ </TabPane>}
+ </TabContent>
+ </div>
+ </TabContainer>
{build.output && <BuildOutput output={build.output}/>}
</Panel.Body>
</Panel>
diff --git a/web/src/containers/build/Manifest.jsx b/web/src/containers/build/Manifest.jsx
new file mode 100644
index 000000000..5cade70f3
--- /dev/null
+++ b/web/src/containers/build/Manifest.jsx
@@ -0,0 +1,63 @@
+// Copyright 2019 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+
+import React from 'react'
+import PropTypes from 'prop-types'
+import {
+ TreeView,
+} from 'patternfly-react'
+import { Link } from 'react-router-dom'
+
+const renderTree = (tenant, build, obj) => {
+ const node = {}
+ let name = obj.name
+
+ if ('children' in obj && obj.children) {
+ node.nodes = obj.children.map(n => renderTree(tenant, build, n))
+ }
+ if (obj.mimetype === 'application/directory') {
+ name = obj.name + '/'
+ } else {
+ node.icon = 'fa fa-file-o'
+ }
+ if (obj.mimetype === 'text/plain') {
+ node.text = (<Link to={tenant.linkPrefix + '/build/' + build.uuid + '/view/' + name}>{obj.name}</Link>)
+ } else {
+ node.text = (<a href={build.log_url + name}>{obj.name}</a>)
+ }
+ return node
+}
+
+class Manifest extends React.Component {
+ static propTypes = {
+ tenant: PropTypes.object.isRequired,
+ build: PropTypes.object.isRequired
+ }
+
+ render() {
+ const { tenant, build } = this.props
+
+ const nodes = build.manifest.tree.map(n => renderTree(tenant, build, n))
+
+ return (
+ <div className="tree-view-container">
+ <TreeView
+ nodes={nodes}
+ />
+ </div>
+ )
+ }
+}
+
+export default Manifest
diff --git a/web/src/containers/build/View.jsx b/web/src/containers/build/View.jsx
new file mode 100644
index 000000000..66a6d7338
--- /dev/null
+++ b/web/src/containers/build/View.jsx
@@ -0,0 +1,53 @@
+// 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 { Panel } from 'react-bootstrap'
+
+const linkify = (ln, num) => {
+ return (<a name={num} href={'#'+num}>{ln}</a>)
+}
+
+class View extends React.Component {
+ static propTypes = {
+ build: PropTypes.object,
+ item: PropTypes.object,
+ tenant: PropTypes.object,
+ data: PropTypes.object,
+ }
+
+ render () {
+ const { build, data } = this.props
+
+ return (
+ <Panel>
+ <Panel.Heading>Build result {build.uuid}</Panel.Heading>
+ <Panel.Body>
+ <pre className="zuul-log-output">
+ {data.split(/\r?\n/).map((line, idx) => (
+ <span key={idx}>
+ {linkify(line, idx)}{'\n'}
+ </span>
+ ))}
+ </pre>
+ </Panel.Body>
+ </Panel>
+ )
+ }
+}
+
+
+export default connect(state => ({tenant: state.tenant}))(View)
diff --git a/web/src/index.css b/web/src/index.css
index 614396d45..1c3d29dce 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -80,6 +80,16 @@ a.refresh {
padding: 8px 12px;
}
+.zuul-log-output {
+ color: black;
+}
+
+.zuul-log-output a,
+.zuul-log-output a:hover {
+ color: black;
+ text-decoration: none;
+}
+
.form-inline > .form-group {
padding-right: 5px;
}
diff --git a/web/src/pages/Build.jsx b/web/src/pages/Build.jsx
index 30d74de31..6aad08781 100644
--- a/web/src/pages/Build.jsx
+++ b/web/src/pages/Build.jsx
@@ -30,7 +30,7 @@ class BuildPage extends Refreshable {
updateData = (force) => {
this.props.dispatch(fetchBuildIfNeeded(
- this.props.tenant, this.props.match.params.buildId, force))
+ this.props.tenant, this.props.match.params.buildId, null, force))
}
componentDidMount () {
diff --git a/web/src/pages/View.jsx b/web/src/pages/View.jsx
new file mode 100644
index 000000000..fe2aba812
--- /dev/null
+++ b/web/src/pages/View.jsx
@@ -0,0 +1,62 @@
+// Copyright 2018 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations
+// under the License.
+
+import * as React from 'react'
+import { connect } from 'react-redux'
+import PropTypes from 'prop-types'
+
+import { fetchLogfileIfNeeded } from '../actions/logfile'
+import Refreshable from '../containers/Refreshable'
+import View from '../containers/build/View'
+
+
+class ViewPage extends Refreshable {
+ static propTypes = {
+ match: PropTypes.object.isRequired,
+ remoteData: PropTypes.object,
+ tenant: PropTypes.object,
+ }
+
+ updateData = (force) => {
+ this.props.dispatch(fetchLogfileIfNeeded(
+ this.props.tenant,
+ this.props.match.params.buildId,
+ this.props.match.params.file,
+ force))
+ }
+
+ componentDidMount () {
+ document.title = 'Zuul Build Viewer'
+ super.componentDidMount()
+ }
+
+ render () {
+ const { remoteData } = this.props
+ const build = this.props.build.builds[this.props.match.params.buildId]
+ return (
+ <React.Fragment>
+ <div style={{float: 'right'}}>
+ {this.renderSpinner()}
+ </div>
+ {remoteData.data && <View build={build} data={remoteData.data}/>}
+ </React.Fragment>
+ )
+ }
+}
+
+export default connect(state => ({
+ tenant: state.tenant,
+ remoteData: state.logfile,
+ build: state.build
+}))(ViewPage)
diff --git a/web/src/reducers/build.js b/web/src/reducers/build.js
index 244f5a356..3ef10e67a 100644
--- a/web/src/reducers/build.js
+++ b/web/src/reducers/build.js
@@ -18,27 +18,52 @@ import {
BUILD_FETCH_FAIL,
BUILD_FETCH_REQUEST,
BUILD_FETCH_SUCCESS,
- BUILD_OUTPUT_FETCH_SUCCESS
+
+ BUILD_OUTPUT_FAIL,
+ BUILD_OUTPUT_REQUEST,
+ BUILD_OUTPUT_SUCCESS,
+
+ BUILD_MANIFEST_FAIL,
+ BUILD_MANIFEST_REQUEST,
+ BUILD_MANIFEST_SUCCESS,
} from '../actions/build'
export default (state = {
isFetching: false,
+ isFetchingOutput: false,
+ isFetchingManifest: false,
builds: {},
}, action) => {
switch (action.type) {
- case BUILD_FETCH_REQUEST:
- return update(state, {$merge: {isFetching: true}})
- case BUILD_FETCH_SUCCESS:
- state.builds = update(
- state.builds, {$merge: {[action.buildId]: action.build}})
- return update(state, {$merge: {isFetching: false}})
- case BUILD_FETCH_FAIL:
- return update(state, {$merge: {isFetching: false}})
- case BUILD_OUTPUT_FETCH_SUCCESS:
- return update(
- state, {builds: {[action.buildId]: {$merge: {output: action.output}}}})
- default:
- return state
+ case BUILD_FETCH_REQUEST:
+ return update(state, {$merge: {isFetching: true}})
+ case BUILD_FETCH_SUCCESS:
+ state.builds = update(
+ state.builds, {$merge: {[action.buildId]: action.build}})
+ return update(state, {$merge: {isFetching: false}})
+ case BUILD_FETCH_FAIL:
+ return update(state, {$merge: {isFetching: false}})
+
+ case BUILD_OUTPUT_REQUEST:
+ return update(state, {$merge: {isFetchingOutput: true}})
+ case BUILD_OUTPUT_SUCCESS:
+ state.builds = update(
+ state.builds, {[action.buildId]: {$merge: {output: action.output}}})
+ return update(state, {$merge: {isFetchingOutput: false}})
+ case BUILD_OUTPUT_FAIL:
+ return update(state, {$merge: {isFetchingOutput: false}})
+
+ case BUILD_MANIFEST_REQUEST:
+ return update(state, {$merge: {isFetchingManifest: true}})
+ case BUILD_MANIFEST_SUCCESS:
+ state.builds = update(
+ state.builds, {[action.buildId]: {$merge: {manifest: action.manifest}}})
+ return update(state, {$merge: {isFetchingManifest: false}})
+ case BUILD_MANIFEST_FAIL:
+ return update(state, {$merge: {isFetchingManifest: false}})
+
+ default:
+ return state
}
}
diff --git a/web/src/reducers/index.js b/web/src/reducers/index.js
index a66822837..18abb44e3 100644
--- a/web/src/reducers/index.js
+++ b/web/src/reducers/index.js
@@ -22,6 +22,7 @@ import info from './info'
import job from './job'
import jobs from './jobs'
import labels from './labels'
+import logfile from './logfile'
import nodes from './nodes'
import project from './project'
import projects from './projects'
@@ -37,6 +38,7 @@ const reducers = {
job,
jobs,
labels,
+ logfile,
nodes,
project,
projects,
diff --git a/web/src/reducers/logfile.js b/web/src/reducers/logfile.js
new file mode 100644
index 000000000..4897cda2f
--- /dev/null
+++ b/web/src/reducers/logfile.js
@@ -0,0 +1,44 @@
+// 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 update from 'immutability-helper'
+
+import {
+ LOGFILE_FETCH_FAIL,
+ LOGFILE_FETCH_REQUEST,
+ LOGFILE_FETCH_SUCCESS,
+} from '../actions/logfile'
+
+
+export default (state = {
+ isFetching: false,
+ url: null,
+ data: null
+}, action) => {
+ switch (action.type) {
+ case LOGFILE_FETCH_REQUEST:
+ return update(state, {$merge: {isFetching: true,
+ url: action.url,
+ data: null}})
+ case LOGFILE_FETCH_SUCCESS:
+ return update(state, {$merge: {isFetching: false,
+ data: action.data}})
+ case LOGFILE_FETCH_FAIL:
+ return update(state, {$merge: {isFetching: false,
+ url: null,
+ data: null}})
+ default:
+ return state
+ }
+}
diff --git a/web/src/routes.js b/web/src/routes.js
index fe70535b5..0b3652681 100644
--- a/web/src/routes.js
+++ b/web/src/routes.js
@@ -21,6 +21,7 @@ import JobsPage from './pages/Jobs'
import LabelsPage from './pages/Labels'
import NodesPage from './pages/Nodes'
import BuildPage from './pages/Build'
+import ViewPage from './pages/View'
import BuildsPage from './pages/Builds'
import BuildsetsPage from './pages/Buildsets'
import ConfigErrorsPage from './pages/ConfigErrors'
@@ -89,6 +90,10 @@ const routes = () => [
component: BuildPage
},
{
+ to: '/build/:buildId/view/:file*',
+ component: ViewPage
+ },
+ {
to: '/config-errors',
component: ConfigErrorsPage,
},