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
This commit is contained in:
Ian Wienand 2020-09-11 11:07:27 +10:00 committed by Felix Edel
parent 38f0c32cfb
commit 072bf45ff8
No known key found for this signature in database
GPG Key ID: E95717A102DD3030
10 changed files with 453 additions and 312 deletions

View File

@ -7,8 +7,8 @@
"license": "Apache-2.0",
"private": true,
"dependencies": {
"@patternfly/react-core": "^4.40.4",
"@patternfly/react-table": "^4.15.5",
"@patternfly/react-core": "4.63.3",
"@patternfly/react-table": "4.18.14",
"axios": "^0.19.0",
"immutability-helper": "^2.8.1",
"js-yaml": "^3.13.0",

View File

@ -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 &&
'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)))
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'
) {
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))
}
}

View File

@ -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()
if (item.mimetype !== 'text/plain') {
return dispatch(
failedLogfile(Error(`Logfile "${file}" has invalid mimetype`))
)
}
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))
}
}
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))
}
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))
})
})
}

View File

@ -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'
import {
Breadcrumb,
BreadcrumbItem,
Divider,
EmptyState,
EmptyStateVariant,
EmptyStateIcon,
Title,
ToggleGroup,
ToggleGroupItem,
} from '@patternfly/react-core'
import { FileCodeIcon } from '@patternfly/react-icons'
import { Fetching } from '../Fetching'
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
// 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])
}
}
}
window.location.hash = '#' + lines.sort().join('-')
}
}, [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' })
}
}
class LogFile extends React.Component {
static propTypes = {
build: PropTypes.object,
item: PropTypes.object,
tenant: PropTypes.object,
data: PropTypes.array,
severity: PropTypes.string
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.
}
render () {
const { build, data, severity } = this.props
function writeSeverityToUrl(severity) {
const urlParams = new URLSearchParams('')
urlParams.append('severity', severity)
history.push({
pathname: location.pathname,
search: urlParams.toString(),
})
}
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)
}
function renderLogfile(logfileContent, severity) {
return (
<React.Fragment>
<Panel>
<Panel.Heading>Build result {build.uuid}</Panel.Heading>
<Panel.Body>
<Link to="?">All</Link>&nbsp;
<Link to="?severity=1">Debug</Link>&nbsp;
<Link to="?severity=2">Info</Link>&nbsp;
<Link to="?severity=3">Warning</Link>&nbsp;
<Link to="?severity=4">Error</Link>&nbsp;
<Link to="?severity=5">Trace</Link>&nbsp;
<Link to="?severity=6">Audit</Link>&nbsp;
<Link to="?severity=7">Critical</Link>&nbsp;
</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,
}

View File

@ -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,

View File

@ -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)

View File

@ -6,4 +6,10 @@ export default {
isFetchingOutput: false,
isFetchingManifest: false,
},
logfile: {
// Store files by buildId->filename->content
files: {},
isFetching: false,
url: null,
},
}

View File

@ -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
}

View File

@ -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',

View File

@ -1510,51 +1510,51 @@
dependencies:
"@types/node" ">= 8"
"@patternfly/patternfly@4.31.6":
version "4.31.6"
resolved "https://registry.yarnpkg.com/@patternfly/patternfly/-/patternfly-4.31.6.tgz#ef9919df610171760cd19920a904ca9b09a74593"
integrity sha512-gp8tpbE4Z6C1PIQwNiWMjO5XSr/UGjXs4InL/zmxgZbToyizUxsudwJyCObtdvDNoN57ZJp0gYWYy0tIuwEyMA==
"@patternfly/patternfly@4.50.4":
version "4.50.4"
resolved "https://registry.yarnpkg.com/@patternfly/patternfly/-/patternfly-4.50.4.tgz#8d5975be48f5cf7919cef9be1d4ab622b50d6d99"
integrity sha512-eoJ/U11m+1uJMt8HTFCJeUNazoHC58Ot6gzfNnJvbX5kibpDdvrMvLk2iuGhEfwzQmiH7BSrxjZqMyevbSZ2Cw==
"@patternfly/react-core@^4.40.4":
version "4.40.4"
resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-4.40.4.tgz#e4409f89327e2fcdcd07a08833c0149e6f2f6966"
integrity sha512-NQuUgIVEty7BBNJMJAVRXejOGRGpRQwgQ8Rw/J/JlgkhtOrCSFX5cEbpAXMXLYWkJrz0++XfRK/FQMoQbvS2hQ==
"@patternfly/react-core@4.63.3", "@patternfly/react-core@^4.63.3":
version "4.63.3"
resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-4.63.3.tgz#d4925e368de4ccdd4034b4664d56406a28c57a9d"
integrity sha512-GAxSCz9X8OxzWiXtZPipDa1PCy+RpSvesNBS6usCDO86SvgOilBr9r5QPcoN06bxm/PLPGLQ3q0Htl35meHJzg==
dependencies:
"@patternfly/react-icons" "^4.7.2"
"@patternfly/react-styles" "^4.7.2"
"@patternfly/react-tokens" "^4.9.4"
"@patternfly/react-icons" "^4.7.11"
"@patternfly/react-styles" "^4.7.8"
"@patternfly/react-tokens" "^4.9.12"
focus-trap "4.0.2"
react-dropzone "9.0.0"
tippy.js "5.1.2"
tslib "^1.11.1"
"@patternfly/react-icons@^4.7.2":
version "4.7.2"
resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.7.2.tgz#f4ad252cb5682bd95da474ce9ce6ddf7fb3a1ac1"
integrity sha512-r1yCVHxUtRSblo8VwfaUM0d49z4eToZXAI0VzOnfKPRgSmGZrn6l8soQgDDtyQsSDr534Qvm55y/qLrlR9JCnw==
"@patternfly/react-icons@^4.7.11":
version "4.7.11"
resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.7.11.tgz#630174e1a6e1ec4c5256198c4c897e1508a6cb74"
integrity sha512-kvOTwzv31krjXeMYUrmr9lABPCwtnuEWKWQCcGdrngdYzxB6mg1D0oIux8JzKqX6oitXzsI/OhvnWkIyCdirNw==
"@patternfly/react-styles@^4.7.2":
version "4.7.2"
resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.7.2.tgz#6671a243401ef55adddcb0e0922f5f5f4eea840e"
integrity sha512-r3zyrt1mXcqdXaEq+otl1cGsN0Ou1k8uIJSY+4EGe2A5jLGbX3vBTwUrpPKLB6tUdNL+mZriFf+3oKhWbVZDkw==
"@patternfly/react-styles@^4.7.8":
version "4.7.8"
resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.7.8.tgz#b15e8f9c1077f828d0ab0d95856c1114027a8217"
integrity sha512-d/OEE8vBz0+/7Bxa2WiRTvtQqf/GcFtfpNdORPYSvoifAggL9wt+/62g0GCAPOu1qOyZHuThB2Bjg77V87EkQg==
"@patternfly/react-table@^4.15.5":
version "4.15.5"
resolved "https://registry.yarnpkg.com/@patternfly/react-table/-/react-table-4.15.5.tgz#7fc3fcd37a6fd4dca00cc32d24c76199ee41a7f1"
integrity sha512-GlyKrEDMY+yLvczj5rWpNKcUp90Ib7alKV9JK8rVLOpTsukQ0QplXxYFsnIrombcaw2V54XVdflZGjsB0GoHEw==
"@patternfly/react-table@4.18.14":
version "4.18.14"
resolved "https://registry.yarnpkg.com/@patternfly/react-table/-/react-table-4.18.14.tgz#317074a32f1ed13edfea82d665ec4392e40ed171"
integrity sha512-UTmmypKVueYZRN8/ydxfdGjPabqtEGfBpSSsdn5FFXM0mNStDavyqWwBGJMW1vX4zGTAxREOTyul6ab85nrxWw==
dependencies:
"@patternfly/patternfly" "4.31.6"
"@patternfly/react-core" "^4.40.4"
"@patternfly/react-icons" "^4.7.2"
"@patternfly/react-styles" "^4.7.2"
"@patternfly/react-tokens" "^4.9.4"
"@patternfly/patternfly" "4.50.4"
"@patternfly/react-core" "^4.63.3"
"@patternfly/react-icons" "^4.7.11"
"@patternfly/react-styles" "^4.7.8"
"@patternfly/react-tokens" "^4.9.12"
lodash "^4.17.19"
tslib "^1.11.1"
"@patternfly/react-tokens@^4.9.4":
version "4.9.4"
resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-4.9.4.tgz#71ea3c33045fb29bcc8d98f2c0f07bfcdc89a12c"
integrity sha512-AJpcAvzWXvfThT2mx24rV7OJSHvZnIsOP1bVrXiubpFAJhi/Suq+LGe/lTPUnuSXaflwyDBRZDXWWmJb4yaWqg==
"@patternfly/react-tokens@^4.9.12":
version "4.9.12"
resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-4.9.12.tgz#ff56acc98823c723c1ccb7e46f38fcc8ea780f15"
integrity sha512-bO4eaZRZOYRDnO096RCFMhV1VHXE9woRFxIs+i9Q7eSTlxpJm2b8FAIb7RDtOURGAfBlJKd/d1dFgZnPq7xQ0g==
"@semantic-release/commit-analyzer@^6.1.0":
version "6.3.3"