Use xterm.js for live log streaming

We currently use a very simplistic self written mechanism for live log
streaming. This has severe performance drawbacks when dealing with
large live logs. The component xterm.js widely used out there that is
specialized for this task and handles huge logs just fine.

We also don't need the autoscroll checkbox anymore as this is handled
automagically by xterm.js. It stops following the stream when
scrolling and starts following again after scrolling to the bottom.

Further it makes it easy to have clickable links in the live log.

Change-Id: I3492e983bf248b4f286edc1bf9db3d52297da993
This commit is contained in:
Tobias Henkel 2019-03-30 22:17:27 +01:00
parent 5b7c278eb2
commit bb352a3559
No known key found for this signature in database
GPG Key ID: 03750DEC158E5FA2
4 changed files with 50 additions and 87 deletions

View File

@ -23,7 +23,8 @@
"react-scripts": "1.1.4",
"redux": "<4.0.0",
"redux-thunk": "^2.3.0",
"sockette": "^2.0.0"
"sockette": "^2.0.0",
"xterm": "^3.12.0"
},
"devDependencies": {
"eslint": "^5.3.0",

View File

@ -107,30 +107,6 @@ a.refresh {
animation: progress-bar-stripes 1s linear infinite;
}
/* Stream page */
#zuulstreamoverlay {
float: right;
position: fixed;
top: 70px;
right: 5px;
background-color: white;
padding: 2px 0px 0px 2px;
color: black;
}
pre#zuulstreamcontent {
font-family: monospace;
white-space: pre;
margin: 0px 10px;
background-color: black;
color: lightgrey;
border: none;
}
p.zuulstreamline {
margin: 0px 0px;
line-height: 1.4;
}
/* Job Tree View group gap */
div.tree-view-container ul.list-group {
margin: 0px 0px;

View File

@ -16,11 +16,17 @@
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 'xterm/dist/xterm.css'
import { Terminal } from 'xterm'
import * as fit from 'xterm/lib/addons/fit/fit'
import * as weblinks from 'xterm/lib/addons/webLinks/webLinks'
import { getStreamUrl } from '../api'
Terminal.applyAddon(fit)
Terminal.applyAddon(weblinks)
class StreamPage extends React.Component {
static propTypes = {
@ -29,10 +35,6 @@ class StreamPage extends React.Component {
tenant: PropTypes.object
}
state = {
autoscroll: true,
}
constructor() {
super()
this.receiveBuffer = ''
@ -40,59 +42,33 @@ class StreamPage extends React.Component {
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.term.write(message)
}
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
onResize = () => {
// Note: We call proposeGeometry to get the number of cols and rows that
// fit into the parent element. However the number of rows is not detected
// correctly so we derive this directly from the window height.
const geometry = this.term.proposeGeometry()
if (geometry) {
const cellHeight = this.term._core.renderer.dimensions.actualCellHeight
const height = window.innerHeight - this.term.element.offsetTop - 10
const rows = Math.max(Math.floor(height / cellHeight), 10)
const cols = Math.max(geometry.cols, 10)
if (this.term.rows !== rows || this.term.cols !== cols) {
this.term.resize(cols, rows)
}
}
this.refreshLoop()
}
componentDidMount() {
@ -105,6 +81,19 @@ class StreamPage extends React.Component {
params.logfile = logfile
}
document.title = 'Zuul Stream | ' + params.uuid.slice(0, 7)
const term = new Terminal()
term.webLinksInit()
term.setOption('fontSize', 12)
term.setOption('scrollback', 1000000)
term.setOption('disableStdin', true)
term.setOption('convertEol', true)
term.attachCustomKeyEventHandler(function () {return false})
term.open(this.terminal)
this.ws = new Sockette(getStreamUrl(this.props.tenant.apiPrefix), {
timeout: 5e3,
maxAttempts: 3,
@ -129,26 +118,18 @@ class StreamPage extends React.Component {
console.log('onerror:', e)
}
})
}
handleCheckBox = (e) => {
this.setState({autoscroll: e.target.checked})
this.term = term
term.element.style.padding = '5px'
this.onResize()
window.addEventListener('resize', this.onResize)
}
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 }} />
<div ref={ref => this.terminal = ref}/>
</React.Fragment>
)
}

View File

@ -7985,6 +7985,11 @@ xtend@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
xterm@^3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.12.0.tgz#74cc54013140cf0fd38a05a0d5d49e013e8a53bd"
integrity sha512-U5w1NJdrqAtnNju4W05uOxLzNgMD1sk0AnIkZ//Wa7xRdQTi9Dl1qkPdAaxWJ1a7A8xzNM4ogrX/4oSVl15qOw==
y18n@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"