diff options
author | Ian Wienand <iwienand@redhat.com> | 2020-09-11 11:07:27 +1000 |
---|---|---|
committer | Felix Edel <felix.edel@bmw.de> | 2020-11-04 09:19:07 +0100 |
commit | 072bf45ff8a534aebdbf8a1987d4bc902dd15b7a (patch) | |
tree | 2b6e08256db853850d433cd7687131c9a9183e20 /web/src | |
parent | 38f0c32cfb57e9228283a0e2fe86520d4a5558c7 (diff) | |
download | zuul-072bf45ff8a534aebdbf8a1987d4bc902dd15b7a.tar.gz |
PF4: Rework of log viewer page
I think the log-viewer page could do with some PF4-ness.
This incorporates the previous log-viewer page into the builds page.
When selecting a logfile from the listing in the logs tab, the log will
directly show inside the tab rather than on a new page. Breadcrumbs are
used to show the path to the current log file relative to the logs
directory.
Additionally, this change improves the state handling of log files in
redux and allows multiple log files to be stored in the redux state.
This enables fast switching between different logfiles without always
downloading them again.
To remove some boilerplate code, the LogFile component is changed into a
functional component rather than a class component.
The filters are moved from jammed-together links into a button
toggle-group, which is a mutually-exclusive set perfect for this
interface. This component requires the latest release of PF4, which
is why the packages have been updated.
Change-Id: Ibcbc2bd9497f1d8b75acd9e4979a289173d014b2
Diffstat (limited to 'web/src')
-rw-r--r-- | web/src/actions/build.js | 64 | ||||
-rw-r--r-- | web/src/actions/logfile.js | 60 | ||||
-rw-r--r-- | web/src/containers/logfile/LogFile.jsx | 328 | ||||
-rw-r--r-- | web/src/pages/Build.jsx | 69 | ||||
-rw-r--r-- | web/src/pages/LogFile.jsx | 134 | ||||
-rw-r--r-- | web/src/reducers/initialState.js | 6 | ||||
-rw-r--r-- | web/src/reducers/logfile.js | 32 | ||||
-rw-r--r-- | web/src/routes.js | 4 |
8 files changed, 419 insertions, 278 deletions
diff --git a/web/src/actions/build.js b/web/src/actions/build.js index 510e018f2..c7dc6e9d3 100644 --- a/web/src/actions/build.js +++ b/web/src/actions/build.js @@ -16,6 +16,8 @@ import Axios from 'axios' import * as API from '../api' +import { fetchLogfile } from './logfile' + export const BUILD_FETCH_REQUEST = 'BUILD_FETCH_REQUEST' export const BUILD_FETCH_SUCCESS = 'BUILD_FETCH_SUCCESS' export const BUILD_FETCH_FAIL = 'BUILD_FETCH_FAIL' @@ -288,12 +290,14 @@ function fetchBuildOutput(buildId, state) { // the build is in the state. Otherwise an error would have been thrown and // this function wouldn't be called. const build = state.build.builds[buildId] + if (build.output) { + return Promise.resolve() + } if (!build.log_url) { // Don't treat a missing log URL as failure as we don't want to show a // toast for that. The UI already informs about the missing log URL in // multiple places. - dispatch(buildOutputNotAvailable()) - return Promise.resolve() + return dispatch(buildOutputNotAvailable()) } const url = build.log_url.substr(0, build.log_url.lastIndexOf('/') + 1) dispatch(requestBuildOutput()) @@ -321,29 +325,40 @@ function fetchBuildOutput(buildId, state) { } } -export const fetchBuildManifest = (buildId, state) => (dispatch) => { - // As this function is only called after fetchBuild() we can assume that - // the build is in the state. Otherwise an error would have been thrown and - // this function wouldn't be called. - const build = state.build.builds[buildId] - dispatch(requestBuildManifest()) - for (let artifact of build.artifacts) { - if ('metadata' in artifact && +export function fetchBuildManifest(buildId, state) { + return async function(dispatch) { + // As this function is only called after fetchBuild() we can assume that + // the build is in the state. Otherwise an error would have been thrown and + // this function wouldn't be called. + const build = state.build.builds[buildId] + if (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, artifact.url))) + artifact.metadata.type === 'zuul_manifest' + ) { + try { + const response = await Axios.get(artifact.url) + return dispatch(receiveBuildManifest(buildId, response.data)) + } catch(error) { + dispatch(failedBuildManifest(error, artifact.url)) + // Raise the error again, so fetchBuildAllInfo() doesn't call + // fetchLogFile which needs an existing manifest file. + throw error + } + } } + // Don't treat a missing manifest file as failure as we don't want to show a + // toast for that. + dispatch(buildManifestNotAvailable()) } - // Don't treat a missing manifest file as failure as we don't want to show a - // toast for that. - dispatch(buildManifestNotAvailable()) } -export function fetchBuildAllInfo(tenant, buildId) { +export function fetchBuildAllInfo(tenant, buildId, logfileName) { // This wraps the calls to fetch the build, output and manifest together as // this is the common use case we have when loading the build info. return async function (dispatch, getState) { @@ -352,9 +367,14 @@ export function fetchBuildAllInfo(tenant, buildId) { // to the fetchBuildOutput and fetchBuildManifest so they can get the log // url from the fetched build. await dispatch(fetchBuild(tenant, buildId, getState())) + // Wait for the manifest info to be available as this is needed in case + // we also download a logfile. + await dispatch(fetchBuildManifest(buildId, getState())) dispatch(fetchBuildOutput(buildId, getState())) - dispatch(fetchBuildManifest(buildId, getState())) - } catch (error) { + if (logfileName) { + dispatch(fetchLogfile(buildId, logfileName, getState())) + } + } catch (error) { dispatch(failedBuild(error, tenant.apiPrefix)) } } diff --git a/web/src/actions/logfile.js b/web/src/actions/logfile.js index 034dac277..bfed3d49d 100644 --- a/web/src/actions/logfile.js +++ b/web/src/actions/logfile.js @@ -14,8 +14,6 @@ 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' @@ -42,7 +40,7 @@ const severityMap = { const OSLO_LOGMATCH = new RegExp(`^(${DATEFMT})(( \\d+)? (${STATUSFMT}).*)`) const SYSTEMD_LOGMATCH = new RegExp(`^(${SYSLOGDATE})( (\\S+) \\S+\\[\\d+\\]\\: (${STATUSFMT})?.*)`) -const receiveLogfile = (data) => { +const receiveLogfile = (buildId, file, data) => { const out = data.split(/\r?\n/).map((line, idx) => { let m = null @@ -66,7 +64,9 @@ const receiveLogfile = (data) => { }) return { type: LOGFILE_FETCH_SUCCESS, - data: out, + buildId, + fileName: file, + fileContent: out, receivedAt: Date.now() } } @@ -79,32 +79,34 @@ const failedLogfile = (error, url) => { } } -const fetchLogfile = (buildId, file, state, force) => dispatch => { - const build = state.build.builds[buildId] - const item = build.manifest.index['/' + file] +export function fetchLogfile(buildId, file, state) { + return async function(dispatch) { + const build = state.build.builds[buildId] + // Don't do anything if the logfile is already part of our local state + if (buildId in state.logfile.files && file in state.logfile.files[buildId]) { + return Promise.resolve() + } + const item = build.manifest.index['/' + file] - if (!item) - dispatch(failedLogfile(null)) - const url = build.log_url + file + if (!item) { + return dispatch( + failedLogfile(Error(`No manifest entry found for logfile "${file}"`)) + ) + } - if (!force && state.logfile.url === url) { - return Promise.resolve() - } - dispatch(requestLogfile()) - if (item.mimetype === 'text/plain') { - return Axios.get(url, {transformResponse: []}) - .then(response => dispatch(receiveLogfile(response.data))) - .catch(error => dispatch(failedLogfile(error, url))) - } - dispatch(failedLogfile(null)) -} + if (item.mimetype !== 'text/plain') { + return dispatch( + failedLogfile(Error(`Logfile "${file}" has invalid mimetype`)) + ) + } -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)) - }) - }) + const url = build.log_url + file + dispatch(requestLogfile()) + try { + const response = await Axios.get(url, { transformResponse: [] }) + dispatch(receiveLogfile(buildId, file, response.data)) + } catch(error) { + dispatch(failedLogfile(error, url)) + } + } } diff --git a/web/src/containers/logfile/LogFile.jsx b/web/src/containers/logfile/LogFile.jsx index bfd766912..a5acc0a70 100644 --- a/web/src/containers/logfile/LogFile.jsx +++ b/web/src/containers/logfile/LogFile.jsx @@ -12,83 +12,289 @@ // License for the specific language governing permissions and limitations // under the License. -import * as React from 'react' +import React, { useEffect, useState } from 'react' import PropTypes from 'prop-types' -import { connect } from 'react-redux' -import { Panel } from 'react-bootstrap' -import { Link } from 'react-router-dom' - - -function updateSelection (event) { - const lines = window.location.hash.substring(1).split('-').map(Number) - const lineClicked = Number(event.currentTarget.innerText) - if (!event.shiftKey || lines.length === 0) { - // First line clicked - lines[0] = [lineClicked] - lines.splice(1, 1) - } else { - // Second line shift-clicked - const distances = lines.map((pos) => (Math.abs(lineClicked - pos))) - // Adjust the range based on the edge distance - if (distances[0] < distances[1]) { - lines[0] = lineClicked - } else { - lines[1] = lineClicked +import { + Breadcrumb, + BreadcrumbItem, + Divider, + EmptyState, + EmptyStateVariant, + EmptyStateIcon, + Title, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core' +import { FileCodeIcon } from '@patternfly/react-icons' + +import { Fetching } from '../Fetching' + +// Helper function to sort list of numbers in ascending order +const sortNumeric = (a, b) => a - b + +// When scrolling down to a highlighted section, we don't want to want to keep a little bit of +// context. +const SCROLL_OFFSET = -50 + +export default function LogFile({ + logfileName, + logfileContent, + isFetching, + handleBreadcrumbItemClick, + location, + history, + ...props +}) { + const [severity, setSeverity] = useState(props.severity) + const [highlightStart, setHighlightStart] = useState(0) + const [highlightEnd, setHighlightEnd] = useState(0) + // We only want to scroll down to the highlighted section if the highlight + // fields we're populated from the URL parameters. Here we assume that we + // always want to scroll when the page is loaded and therefore disable the + // initialScroll parameter in the onClick handler that is called when a line + // or section is marked. + const [scrollOnPageLoad, setScrollOnPageLoad] = useState(true) + + useEffect(() => { + // Only highlight the lines if the log is present (otherwise it doesn't make + // sense). Although, scrolling to the selected section only works once the + // necessary log lines are part of the DOM tree. + if (!isFetching) { + // Get the line numbers to highlight from the URL and directly cast them to + // a number. The substring(1) removes the '#' character. + const lines = location.hash + .substring(1) + .split('-') + .map(Number) + .sort(sortNumeric) + if (lines.length > 1) { + setHighlightStart(lines[0]) + setHighlightEnd(lines[1]) + } else if (lines.length === 1) { + setHighlightStart(lines[0]) + setHighlightEnd(lines[0]) + } } + }, [location.hash, isFetching]) + + useEffect(() => { + const scrollToHighlightedLine = () => { + const elements = document.getElementsByClassName('ln-' + highlightStart) + if (elements.length > 0) { + // When scrolling down to the highlighted section keep some vertical + // offset so we can see some contextual log lines. + const y = + elements[0].getBoundingClientRect().top + + window.pageYOffset + + SCROLL_OFFSET + window.scrollTo({ top: y, behavior: 'smooth' }) + } + } + + if (scrollOnPageLoad) { + scrollToHighlightedLine() + } + }, [scrollOnPageLoad, highlightStart]) + + function handleItemClick(isSelected, event) { + const id = parseInt(event.currentTarget.id) + setSeverity(id) + writeSeverityToUrl(id) + // TODO (felix): Should we add a state for the toggling "progress", so we + // can show a spinner (like fetching) when the new log level lines are + // "calculated". As this might take some time, the UI is often unresponsive + // when clicking on a log level button. } - window.location.hash = '#' + lines.sort().join('-') -} + function writeSeverityToUrl(severity) { + const urlParams = new URLSearchParams('') + urlParams.append('severity', severity) + history.push({ + pathname: location.pathname, + search: urlParams.toString(), + }) + } -class LogFile extends React.Component { - static propTypes = { - build: PropTypes.object, - item: PropTypes.object, - tenant: PropTypes.object, - data: PropTypes.array, - severity: PropTypes.string + function updateSelection(event) { + const lines = window.location.hash.substring(1).split('-').map(Number) + const lineClicked = Number(event.currentTarget.innerText) + if (!event.shiftKey || lines.length === 0) { + // First line clicked + lines[0] = [lineClicked] + lines.splice(1, 1) + } else { + // Second line shift-clicked + const distances = lines.map((pos) => Math.abs(lineClicked - pos)) + // Adjust the range based on the edge distance + if (distances[0] < distances[1]) { + lines[0] = lineClicked + } else { + lines[1] = lineClicked + } + } + window.location.hash = '#' + lines.sort(sortNumeric).join('-') + // We don't want to scroll to that section if we just highlighted the lines + setScrollOnPageLoad(false) } - render () { - const { build, data, severity } = this.props + function renderLogfile(logfileContent, severity) { return ( - <React.Fragment> - <Panel> - <Panel.Heading>Build result {build.uuid}</Panel.Heading> - <Panel.Body> - <Link to="?">All</Link> - <Link to="?severity=1">Debug</Link> - <Link to="?severity=2">Info</Link> - <Link to="?severity=3">Warning</Link> - <Link to="?severity=4">Error</Link> - <Link to="?severity=5">Trace</Link> - <Link to="?severity=6">Audit</Link> - <Link to="?severity=7">Critical</Link> - </Panel.Body> - </Panel> + <> + <ToggleGroup aria-label="Log line severity filter"> + <ToggleGroupItem + buttonId="0" + isSelected={severity === 0} + onChange={handleItemClick} + > + All + </ToggleGroupItem> + <ToggleGroupItem + buttonId="1" + isSelected={severity === 1} + onChange={handleItemClick} + > + Debug + </ToggleGroupItem> + <ToggleGroupItem + buttonId="2" + isSelected={severity === 2} + onChange={handleItemClick} + > + Info + </ToggleGroupItem> + <ToggleGroupItem + buttonId="3" + isSelected={severity === 3} + onChange={handleItemClick} + > + Warning + </ToggleGroupItem> + <ToggleGroupItem + buttonId="4" + isSelected={severity === 4} + onChange={handleItemClick} + > + Error + </ToggleGroupItem> + <ToggleGroupItem + buttonId="5" + isSelected={severity === 5} + onChange={handleItemClick} + > + Trace + </ToggleGroupItem> + <ToggleGroupItem + buttonId="6" + isSelected={severity === 6} + onChange={handleItemClick} + > + Audit + </ToggleGroupItem> + <ToggleGroupItem + buttonId="7" + isSelected={severity === 7} + onChange={handleItemClick} + > + Critical + </ToggleGroupItem> + </ToggleGroup> + <Divider /> <pre className="zuul-log-output"> <table> <tbody> - {data.map((line) => ( - ((!severity || (line.severity >= severity)) && - <tr key={line.index} className={'ln-' + line.index}> - <td className="line-number" onClick={updateSelection}> - {line.index} - </td> - <td> - <span className={'zuul-log-sev-'+(line.severity||0)}> - {line.text+'\n'} - </span> - </td> - </tr> - )))} + {logfileContent.map((line) => { + // Highlight the line if it's part of the selected range + const highlightLine = + line.index >= highlightStart && line.index <= highlightEnd + return ( + line.severity >= severity && ( + <tr + key={line.index} + className={`ln-${line.index} ${ + highlightLine ? 'highlight' : '' + }`} + > + <td className="line-number" onClick={updateSelection}> + {line.index} + </td> + <td> + <span + className={`log-message zuul-log-sev-${ + line.severity || 0 + }`} + > + {line.text + '\n'} + </span> + </td> + </tr> + ) + ) + })} </tbody> </table> </pre> - </React.Fragment> + </> ) } + + // Split the logfile's name to show some breadcrumbs + const logfilePath = logfileName.split('/') + + const content = + !logfileContent && isFetching ? ( + <Fetching /> + ) : logfileContent ? ( + renderLogfile(logfileContent, severity) + ) : ( + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={FileCodeIcon} /> + <Title headingLevel="h4" size="lg"> + This logfile could not be found + </Title> + </EmptyState> + ) + + return ( + <> + <div style={{ padding: '1rem' }}> + <Breadcrumb> + <BreadcrumbItem + key={-1} + // Fake a link via the "to" property to get an appropriate CSS + // styling for the breadcrumb. The link itself is handled via a + // custom onClick handler to allow client-side routing with + // react-router. The BreadcrumbItem only allows us to specify a + // <a href=""> as link which would post-back to the server. + to="#" + onClick={() => handleBreadcrumbItemClick()} + > + Logs + </BreadcrumbItem> + {logfilePath.map((part, index) => ( + <BreadcrumbItem + key={index} + isActive={index === logfilePath.length - 1} + > + {part} + </BreadcrumbItem> + ))} + </Breadcrumb> + </div> + {content} + </> + ) } +LogFile.propTypes = { + logfileName: PropTypes.string.isRequired, + logfileContent: PropTypes.array, + severity: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + handleBreadcrumbItemClick: PropTypes.func.isRequired, + location: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, +} -export default connect(state => ({tenant: state.tenant}))(LogFile) +LogFile.defaultProps = { + severity: 0, +} diff --git a/web/src/pages/Build.jsx b/web/src/pages/Build.jsx index fcd9c2c95..18bd150aa 100644 --- a/web/src/pages/Build.jsx +++ b/web/src/pages/Build.jsx @@ -16,6 +16,7 @@ import * as React from 'react' import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' import PropTypes from 'prop-types' +import { parse } from 'query-string' import { EmptyState, EmptyStateVariant, @@ -37,6 +38,7 @@ import { } from '@patternfly/react-icons' import { fetchBuildAllInfo } from '../actions/build' +import { fetchLogfile } from '../actions/logfile' import { EmptyPage } from '../containers/Errors' import { Fetchable, Fetching } from '../containers/Fetching' import ArtifactList from '../containers/build/Artifact' @@ -44,14 +46,17 @@ import Build from '../containers/build/Build' import BuildOutput from '../containers/build/BuildOutput' import Console from '../containers/build/Console' import Manifest from '../containers/build/Manifest' +import LogFile from '../containers/logfile/LogFile' class BuildPage extends React.Component { static propTypes = { match: PropTypes.object.isRequired, build: PropTypes.object, + logfile: PropTypes.object, isFetching: PropTypes.bool.isRequired, isFetchingManifest: PropTypes.bool.isRequired, isFetchingOutput: PropTypes.bool.isRequired, + isFetchingLogfile: PropTypes.bool.isRequired, tenant: PropTypes.object.isRequired, fetchBuildAllInfo: PropTypes.func.isRequired, activeTab: PropTypes.string.isRequired, @@ -60,12 +65,13 @@ class BuildPage extends React.Component { } updateData = () => { - if (!this.props.build) { - this.props.fetchBuildAllInfo( - this.props.tenant, - this.props.match.params.buildId - ) - } + // The related fetchBuild...() methods won't do anything if the data is + // already available in the local state, so just call them. + this.props.fetchBuildAllInfo( + this.props.tenant, + this.props.match.params.buildId, + this.props.match.params.file + ) } componentDidMount() { @@ -103,22 +109,34 @@ class BuildPage extends React.Component { history.push(`${tenant.linkPrefix}/build/${build.uuid}/console`) break default: - // results + // task summary history.push(`${tenant.linkPrefix}/build/${build.uuid}`) } } + handleBreadcrumbItemClick = () => { + // Simply link back to the logs tab without an active logfile + this.handleTabClick('logs', this.props.build) + } + render() { const { build, + logfile, isFetching, isFetchingManifest, isFetchingOutput, + isFetchingLogfile, activeTab, + history, location, tenant, } = this.props const hash = location.hash.substring(1).split('/') + const severity = parseInt(parse(location.search).severity) + + // Get the logfile from react-routers URL parameters + const logfileName = this.props.match.params.file if (!build && isFetching) { return <Fetching /> @@ -164,12 +182,27 @@ class BuildPage extends React.Component { </EmptyState> ) - const logsTabContent = - !build.manifest && isFetchingManifest ? ( - <Fetching /> - ) : build.manifest ? ( - <Manifest tenant={this.props.tenant} build={build} /> - ) : ( + let logsTabContent = null + if (!build.manifest && isFetchingManifest) { + logsTabContent = <Fetching /> + } else if (logfileName) { + logsTabContent = ( + <LogFile + logfileContent={logfile} + logfileName={logfileName} + isFetching={isFetchingLogfile} + // We let the LogFile component itself handle the severity default + // value in case it's not set via the URL. + severity={severity ? severity : undefined} + handleBreadcrumbItemClick={this.handleBreadcrumbItemClick} + location={location} + history={history} + /> + ) + } else if (build.manifest) { + logsTabContent = <Manifest tenant={this.props.tenant} build={build} /> + } else { + logsTabContent = ( <EmptyState variant={EmptyStateVariant.small}> <EmptyStateIcon icon={FileCodeIcon} /> <Title headingLevel="h4" size="lg"> @@ -177,6 +210,7 @@ class BuildPage extends React.Component { </Title> </EmptyState> ) + } const consoleTabContent = !build.output && isFetchingOutput ? ( @@ -277,16 +311,23 @@ function mapStateToProps(state, ownProps) { buildId && Object.keys(state.build.builds).length > 0 ? state.build.builds[buildId] : null + const logfileName = ownProps.match.params.file + const logfile = + logfileName && Object.keys(state.logfile.files).length > 0 + ? state.logfile.files[buildId][logfileName] + : null return { build, + logfile, tenant: state.tenant, isFetching: state.build.isFetching, isFetchingManifest: state.build.isFetchingManifest, isFetchingOutput: state.build.isFetchingOutput, + isFetchingLogfile: state.logfile.isFetching, } } -const mapDispatchToProps = { fetchBuildAllInfo } +const mapDispatchToProps = { fetchBuildAllInfo, fetchLogfile } export default connect( mapStateToProps, diff --git a/web/src/pages/LogFile.jsx b/web/src/pages/LogFile.jsx deleted file mode 100644 index c8eb69996..000000000 --- a/web/src/pages/LogFile.jsx +++ /dev/null @@ -1,134 +0,0 @@ -// 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 { parse } from 'query-string' -import { PageSection, PageSectionVariants } from '@patternfly/react-core' - -import { fetchLogfileIfNeeded } from '../actions/logfile' -import { Fetching } from '../containers/Fetching' -import LogFile from '../containers/logfile/LogFile' - - -class LogFilePage extends React.Component { - static propTypes = { - match: PropTypes.object.isRequired, - remoteData: PropTypes.object, - tenant: PropTypes.object, - dispatch: PropTypes.func, - location: PropTypes.object, - build: PropTypes.object, - } - - state = { - lines: [], - initialScroll: false, - } - - 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 Logfile' - if (this.props.tenant.name) { - this.updateData() - } - } - - highlightDidUpdate = (lines) => { - const getLine = (nr) => { - return document.getElementsByClassName('ln-' + nr)[0] - } - const getEnd = (lines) => { - if (lines.length > 1 && lines[1] > lines[0]) { - return lines[1] - } else { - return lines[0] - } - } - const dehighlight = (lines) => { - const end = getEnd(lines) - for (let idx = lines[0]; idx <= end; idx++) { - getLine(idx).classList.remove('highlight') - } - } - const highlight = (lines) => { - const end = getEnd(lines) - for (let idx = lines[0]; idx <= end; idx++) { - getLine(idx).classList.add('highlight') - } - } - if (this.state.lines.length === 0 || - this.state.lines[0] !== lines[0] || - this.state.lines[1] !== lines[1]) { - if (this.state.lines.length > 0) { - // Reset previous selection - dehighlight(this.state.lines) - } - // Store the current lines selection, this trigger highlight update - this.setState({lines: lines, initialScroll: true}) - } else { - // Add highlight to the selected line - highlight(this.state.lines) - } - } - - componentDidUpdate () { - const lines = this.props.location.hash.substring(1).split('-').map(Number) - if (lines.length > 0) { - const element = document.getElementsByClassName('ln-' + lines[0]) - // Lines are loaded - if (element.length > 0) { - if (!this.state.initialScroll) { - // Move line into view - const header = document.getElementsByClassName('navbar') - if (header.length) { - element[0].scrollIntoView() - window.scroll(0, window.scrollY - header[0].offsetHeight - 8) - } - } - // Add highlight to the selection range - this.highlightDidUpdate(lines) - } - } - } - - render () { - const { remoteData } = this.props - if (remoteData.isFetching) { - return <Fetching /> - } - - const build = this.props.build.builds[this.props.match.params.buildId] - const severity = parse(this.props.location.search).severity - return ( - <PageSection variant={PageSectionVariants.light}> - {remoteData.data && <LogFile build={build} data={remoteData.data} severity={severity}/>} - </PageSection> - ) - } -} - -export default connect(state => ({ - tenant: state.tenant, - remoteData: state.logfile, - build: state.build -}))(LogFilePage) diff --git a/web/src/reducers/initialState.js b/web/src/reducers/initialState.js index 382f30975..9280b4ade 100644 --- a/web/src/reducers/initialState.js +++ b/web/src/reducers/initialState.js @@ -6,4 +6,10 @@ export default { isFetchingOutput: false, isFetchingManifest: false, }, + logfile: { + // Store files by buildId->filename->content + files: {}, + isFetching: false, + url: null, + }, } diff --git a/web/src/reducers/logfile.js b/web/src/reducers/logfile.js index 4897cda2f..9e729bf29 100644 --- a/web/src/reducers/logfile.js +++ b/web/src/reducers/logfile.js @@ -12,32 +12,32 @@ // 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' +import initialState from './initialState' -export default (state = { - isFetching: false, - url: null, - data: null -}, action) => { +export default (state = initialState.logfile, 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}}) + return { ...state, isFetching: true, url: action.url } + case LOGFILE_FETCH_SUCCESS: { + let filesForBuild = state.files[action.buildId] || {} + filesForBuild = { + ...filesForBuild, + [action.fileName]: action.fileContent, + } + return { + ...state, + isFetching: false, + files: { ...state.files, [action.buildId]: filesForBuild }, + } + } case LOGFILE_FETCH_FAIL: - return update(state, {$merge: {isFetching: false, - url: null, - data: null}}) + return { ...state, isFetching: false } default: return state } diff --git a/web/src/routes.js b/web/src/routes.js index 07569dc22..806d2290b 100644 --- a/web/src/routes.js +++ b/web/src/routes.js @@ -21,7 +21,6 @@ import JobsPage from './pages/Jobs' import LabelsPage from './pages/Labels' import NodesPage from './pages/Nodes' import BuildPage from './pages/Build' -import LogFilePage from './pages/LogFile' import BuildsPage from './pages/Builds' import BuildsetPage from './pages/Buildset' import BuildsetsPage from './pages/Buildsets' @@ -108,7 +107,8 @@ const routes = () => [ }, { to: '/build/:buildId/log/:file*', - component: LogFilePage + component: BuildPage, + props: {'activeTab': 'logs', 'logfile': true}, }, { to: '/buildset/:buildsetId', |