// @flow // Copyright 2018 The go-ethereum Authors // This file is part of the go-ethereum library. // // The go-ethereum library is free software: you can redistribute it and/or modify // it under the terms of the GNU Lesser General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // The go-ethereum library is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Lesser General Public License for more details. // // You should have received a copy of the GNU Lesser General Public License // along with the go-ethereum library. If not, see . import React, {Component} from 'react'; import List, {ListItem} from 'material-ui/List'; import escapeHtml from 'escape-html'; import type {Record, Content, LogsMessage, Logs as LogsType} from '../types/content'; // requestBand says how wide is the top/bottom zone, eg. 0.1 means 10% of the container height. const requestBand = 0.05; // fieldPadding is a global map with maximum field value lengths seen until now // to allow padding log contexts in a bit smarter way. const fieldPadding = new Map(); // createChunk creates an HTML formatted object, which displays the given array similarly to // the server side terminal. const createChunk = (records: Array) => { let content = ''; records.forEach((record) => { const {t, ctx} = record; let {lvl, msg} = record; let color = '#ce3c23'; switch (lvl) { case 'trace': case 'trce': lvl = 'TRACE'; color = '#3465a4'; break; case 'debug': case 'dbug': lvl = 'DEBUG'; color = '#3d989b'; break; case 'info': lvl = 'INFO '; color = '#4c8f0f'; break; case 'warn': lvl = 'WARN '; color = '#b79a22'; break; case 'error': case 'eror': lvl = 'ERROR'; color = '#754b70'; break; case 'crit': lvl = 'CRIT '; color = '#ce3c23'; break; default: lvl = ''; } const time = new Date(t); if (lvl === '' || !(time instanceof Date) || isNaN(time) || typeof msg !== 'string' || !Array.isArray(ctx)) { content += 'Invalid log record
'; return; } if (ctx.length > 0) { msg += ' '.repeat(Math.max(40 - msg.length, 0)); } const month = `0${time.getMonth() + 1}`.slice(-2); const date = `0${time.getDate()}`.slice(-2); const hours = `0${time.getHours()}`.slice(-2); const minutes = `0${time.getMinutes()}`.slice(-2); const seconds = `0${time.getSeconds()}`.slice(-2); content += `${lvl}[${month}-${date}|${hours}:${minutes}:${seconds}] ${msg}`; for (let i = 0; i < ctx.length; i += 2) { const key = escapeHtml(ctx[i]); const val = escapeHtml(ctx[i + 1]); let padding = fieldPadding.get(key); if (typeof padding !== 'number' || padding < val.length) { padding = val.length; fieldPadding.set(key, padding); } let p = ''; if (i < ctx.length - 2) { p = ' '.repeat(padding - val.length); } content += ` ${key}=${val}${p}`; } content += '
'; }); return content; }; // ADDED, SAME and REMOVED are used to track the change of the log chunk array. // The scroll position is set using these values. const ADDED = 1; const SAME = 0; const REMOVED = -1; // inserter is a state updater function for the main component, which inserts the new log chunk into the chunk array. // limit is the maximum length of the chunk array, used in order to prevent the browser from OOM. export const inserter = (limit: number) => (update: LogsMessage, prev: LogsType) => { prev.topChanged = SAME; prev.bottomChanged = SAME; if (!Array.isArray(update.chunk) || update.chunk.length < 1) { return prev; } if (!Array.isArray(prev.chunks)) { prev.chunks = []; } const content = createChunk(update.chunk); if (!update.source) { // In case of stream chunk. if (!prev.endBottom) { return prev; } if (prev.chunks.length < 1) { // This should never happen, because the first chunk is always a non-stream chunk. return [{content, name: '00000000000000.log'}]; } prev.chunks[prev.chunks.length - 1].content += content; prev.bottomChanged = ADDED; return prev; } const chunk = { content, name: update.source.name, }; if (prev.chunks.length > 0 && update.source.name < prev.chunks[0].name) { if (update.source.last) { prev.endTop = true; } if (prev.chunks.length >= limit) { prev.endBottom = false; prev.chunks.splice(limit - 1, prev.chunks.length - limit + 1); prev.bottomChanged = REMOVED; } prev.chunks = [chunk, ...prev.chunks]; prev.topChanged = ADDED; return prev; } if (update.source.last) { prev.endBottom = true; } if (prev.chunks.length >= limit) { prev.endTop = false; prev.chunks.splice(0, prev.chunks.length - limit + 1); prev.topChanged = REMOVED; } prev.chunks = [...prev.chunks, chunk]; prev.bottomChanged = ADDED; return prev; }; // styles contains the constant styles of the component. const styles = { logListItem: { padding: 0, lineHeight: 1.231, }, logChunk: { color: 'white', fontFamily: 'monospace', whiteSpace: 'nowrap', width: 0, }, waitMsg: { textAlign: 'center', color: 'white', fontFamily: 'monospace', }, }; export type Props = { container: Object, content: Content, shouldUpdate: Object, send: string => void, }; type State = { requestAllowed: boolean, }; // Logs renders the log page. class Logs extends Component { constructor(props: Props) { super(props); this.content = React.createRef(); this.state = { requestAllowed: true, }; } componentDidMount() { const {container} = this.props; if (typeof container === 'undefined') { return; } container.scrollTop = container.scrollHeight - container.clientHeight; const {logs} = this.props.content; if (typeof this.content === 'undefined' || logs.chunks.length < 1) { return; } if (this.content.clientHeight < container.clientHeight && !logs.endTop) { this.sendRequest(logs.chunks[0].name, true); } } // onScroll is triggered by the parent component's scroll event, and sends requests if the scroll position is // at the top or at the bottom. onScroll = () => { if (!this.state.requestAllowed || typeof this.content === 'undefined') { return; } const {logs} = this.props.content; if (logs.chunks.length < 1) { return; } if (this.atTop() && !logs.endTop) { this.sendRequest(logs.chunks[0].name, true); } else if (this.atBottom() && !logs.endBottom) { this.sendRequest(logs.chunks[logs.chunks.length - 1].name, false); } }; sendRequest = (name: string, past: boolean) => { this.setState({requestAllowed: false}); this.props.send(JSON.stringify({ Logs: { Name: name, Past: past, }, })); }; // atTop checks if the scroll position it at the top of the container. atTop = () => this.props.container.scrollTop <= this.props.container.scrollHeight * requestBand; // atBottom checks if the scroll position it at the bottom of the container. atBottom = () => { const {container} = this.props; return container.scrollHeight - container.scrollTop <= container.clientHeight + container.scrollHeight * requestBand; }; // beforeUpdate is called by the parent component, saves the previous scroll position // and the height of the first log chunk, which can be deleted during the insertion. beforeUpdate = () => { let firstHeight = 0; let chunkList = this.content.children[1]; if (chunkList && chunkList.children[0]) { firstHeight = chunkList.children[0].clientHeight; } return { scrollTop: this.props.container.scrollTop, firstHeight, }; }; // didUpdate is called by the parent component, which provides the container. Sends the first request if the // visible part of the container isn't full, and resets the scroll position in order to avoid jumping when a // chunk is inserted or removed. didUpdate = (prevProps, prevState, snapshot) => { if (typeof this.props.shouldUpdate.logs === 'undefined' || typeof this.content === 'undefined' || snapshot === null) { return; } const {logs} = this.props.content; const {container} = this.props; if (typeof container === 'undefined' || logs.chunks.length < 1) { return; } if (this.content.clientHeight < container.clientHeight) { // Only enters here at the beginning, when there aren't enough logs to fill the container // and the scroll bar doesn't appear. if (!logs.endTop) { this.sendRequest(logs.chunks[0].name, true); } return; } let {scrollTop} = snapshot; if (logs.topChanged === ADDED) { // It would be safer to use a ref to the list, but ref doesn't work well with HOCs. scrollTop += this.content.children[1].children[0].clientHeight; } else if (logs.bottomChanged === ADDED) { if (logs.topChanged === REMOVED) { scrollTop -= snapshot.firstHeight; } else if (this.atBottom() && logs.endBottom) { scrollTop = container.scrollHeight - container.clientHeight; } } container.scrollTop = scrollTop; this.setState({requestAllowed: true}); }; render() { return (
{ this.content = ref; }}>
{this.props.content.logs.endTop ? 'No more logs.' : 'Waiting for server...'}
{this.props.content.logs.chunks.map((c, index) => (
))} {this.props.content.logs.endBottom ||
Waiting for server...
}
); } } export default Logs;