From a9835c1816bc49ee54c82b4f2a5b05cbcd89881b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kurk=C3=B3=20Mih=C3=A1ly?= Date: Wed, 11 Jul 2018 10:59:04 +0300 Subject: cmd, dashboard, log: log collection and exploration (#17097) * cmd, dashboard, internal, log, node: logging feature * cmd, dashboard, internal, log: requested changes * dashboard, vendor: gofmt, govendor, use vendored file watcher * dashboard, log: gofmt -s -w, goimports * dashboard, log: gosimple --- dashboard/assets/common.jsx | 2 +- dashboard/assets/components/Body.jsx | 10 +- dashboard/assets/components/CustomTooltip.jsx | 2 +- dashboard/assets/components/Dashboard.jsx | 45 ++-- dashboard/assets/components/Logs.jsx | 310 ++++++++++++++++++++++++++ dashboard/assets/components/Main.jsx | 54 ++++- dashboard/assets/index.html | 3 + dashboard/assets/types/content.jsx | 60 +++-- dashboard/assets/yarn.lock | 195 +++++++++------- 9 files changed, 560 insertions(+), 121 deletions(-) create mode 100644 dashboard/assets/components/Logs.jsx (limited to 'dashboard/assets') diff --git a/dashboard/assets/common.jsx b/dashboard/assets/common.jsx index 08a7554ff..ed2f411fc 100644 --- a/dashboard/assets/common.jsx +++ b/dashboard/assets/common.jsx @@ -68,4 +68,4 @@ export const styles = { light: { color: 'rgba(255, 255, 255, 0.54)', }, -} +}; diff --git a/dashboard/assets/components/Body.jsx b/dashboard/assets/components/Body.jsx index 054e04064..abf8c2f0e 100644 --- a/dashboard/assets/components/Body.jsx +++ b/dashboard/assets/components/Body.jsx @@ -32,11 +32,12 @@ const styles = { }; export type Props = { - opened: boolean, + opened: boolean, changeContent: string => void, - active: string, - content: Content, - shouldUpdate: Object, + active: string, + content: Content, + shouldUpdate: Object, + send: string => void, }; // Body renders the body of the dashboard. @@ -52,6 +53,7 @@ class Body extends Component { active={this.props.active} content={this.props.content} shouldUpdate={this.props.shouldUpdate} + send={this.props.send} /> ); diff --git a/dashboard/assets/components/CustomTooltip.jsx b/dashboard/assets/components/CustomTooltip.jsx index 3405f9305..f597c1caf 100644 --- a/dashboard/assets/components/CustomTooltip.jsx +++ b/dashboard/assets/components/CustomTooltip.jsx @@ -85,7 +85,7 @@ export type Props = { class CustomTooltip extends Component { render() { const {active, payload, tooltip} = this.props; - if (!active || typeof tooltip !== 'function') { + if (!active || typeof tooltip !== 'function' || !Array.isArray(payload) || payload.length < 1) { return null; } return tooltip(payload[0].value); diff --git a/dashboard/assets/components/Dashboard.jsx b/dashboard/assets/components/Dashboard.jsx index 8e6bf9869..63c2186ad 100644 --- a/dashboard/assets/components/Dashboard.jsx +++ b/dashboard/assets/components/Dashboard.jsx @@ -24,6 +24,7 @@ import Header from './Header'; import Body from './Body'; import {MENU} from '../common'; import type {Content} from '../types/content'; +import {inserter as logInserter} from './Logs'; // deepUpdate updates an object corresponding to the given update data, which has // the shape of the same structure as the original object. updater also has the same @@ -75,8 +76,11 @@ const appender = (limit: number, mapper = replacer) => (update: Array, pre ...update.map(sample => mapper(sample)), ].slice(-limit); -// defaultContent is the initial value of the state content. -const defaultContent: Content = { +// defaultContent returns the initial value of the state content. Needs to be a function in order to +// instantiate the object again, because it is used by the state, and isn't automatically cleaned +// when a new connection is established. The state is mutated during the update in order to avoid +// the execution of unnecessary operations (e.g. copy of the log array). +const defaultContent: () => Content = () => ({ general: { version: null, commit: null, @@ -95,10 +99,14 @@ const defaultContent: Content = { diskRead: [], diskWrite: [], }, - logs: { - log: [], + logs: { + chunks: [], + endTop: false, + endBottom: true, + topChanged: 0, + bottomChanged: 0, }, -}; +}); // updaters contains the state updater functions for each path of the state. // @@ -122,9 +130,7 @@ const updaters = { diskRead: appender(200), diskWrite: appender(200), }, - logs: { - log: appender(200), - }, + logs: logInserter(5), }; // styles contains the constant styles of the component. @@ -151,10 +157,11 @@ export type Props = { }; type State = { - active: string, // active menu - sideBar: boolean, // true if the sidebar is opened - content: Content, // the visualized data - shouldUpdate: Object, // labels for the components, which need to re-render based on the incoming message + active: string, // active menu + sideBar: boolean, // true if the sidebar is opened + content: Content, // the visualized data + shouldUpdate: Object, // labels for the components, which need to re-render based on the incoming message + server: ?WebSocket, }; // Dashboard is the main component, which renders the whole page, makes connection with the server and @@ -165,8 +172,9 @@ class Dashboard extends Component { this.state = { active: MENU.get('home').id, sideBar: true, - content: defaultContent, + content: defaultContent(), shouldUpdate: {}, + server: null, }; } @@ -181,7 +189,7 @@ class Dashboard extends Component { // PROD is defined by webpack. const server = new WebSocket(`${((window.location.protocol === 'https:') ? 'wss://' : 'ws://')}${PROD ? window.location.host : 'localhost:8080'}/api`); server.onopen = () => { - this.setState({content: defaultContent, shouldUpdate: {}}); + this.setState({content: defaultContent(), shouldUpdate: {}, server}); }; server.onmessage = (event) => { const msg: $Shape = JSON.parse(event.data); @@ -192,10 +200,18 @@ class Dashboard extends Component { this.update(msg); }; server.onclose = () => { + this.setState({server: null}); setTimeout(this.reconnect, 3000); }; }; + // send sends a message to the server, which can be accessed only through this function for safety reasons. + send = (msg: string) => { + if (this.state.server != null) { + this.state.server.send(msg); + } + }; + // update updates the content corresponding to the incoming message. update = (msg: $Shape) => { this.setState(prevState => ({ @@ -226,6 +242,7 @@ class Dashboard extends Component { active={this.state.active} content={this.state.content} shouldUpdate={this.state.shouldUpdate} + send={this.send} /> ); diff --git a/dashboard/assets/components/Logs.jsx b/dashboard/assets/components/Logs.jsx new file mode 100644 index 000000000..203014276 --- /dev/null +++ b/dashboard/assets/components/Logs.jsx @@ -0,0 +1,310 @@ +// @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 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 = ctx[i]; + const val = 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; +}; + +// 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 = 0; + prev.bottomChanged = 0; + 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 = 1; + 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 = -1; + } + prev.chunks = [chunk, ...prev.chunks]; + prev.topChanged = 1; + 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 = -1; + } + prev.chunks = [...prev.chunks, chunk]; + prev.bottomChanged = 1; + return prev; +}; + +// styles contains the constant styles of the component. +const styles = { + logListItem: { + padding: 0, + }, + logChunk: { + color: 'white', + fontFamily: 'monospace', + whiteSpace: 'nowrap', + width: 0, + }, +}; + +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; + container.scrollTop = container.scrollHeight - container.clientHeight; + } + + // 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()) { + if (!logs.endTop) { + this.setState({requestAllowed: false}); + this.props.send(JSON.stringify({ + Logs: { + Name: logs.chunks[0].name, + Past: true, + }, + })); + } + } else if (this.atBottom()) { + if (!logs.endBottom) { + this.setState({requestAllowed: false}); + this.props.send(JSON.stringify({ + Logs: { + Name: logs.chunks[logs.chunks.length - 1].name, + Past: false, + }, + })); + } + } + }; + + // 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; + if (this.content && this.content.children[0] && this.content.children[0].children[0]) { + firstHeight = this.content.children[0].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 new + // chunk is inserted. + 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 isn't enough log to fill the container + // and the scroll bar doesn't appear. + if (!logs.endTop) { + this.setState({requestAllowed: false}); + this.props.send(JSON.stringify({ + Logs: { + Name: logs.chunks[0].name, + Past: true, + }, + })); + } + return; + } + const chunks = this.content.children[0].children; + let {scrollTop} = snapshot; + if (logs.topChanged > 0) { + scrollTop += chunks[0].clientHeight; + } else if (logs.bottomChanged > 0) { + if (logs.topChanged < 0) { + scrollTop -= snapshot.firstHeight; + } else if (logs.endBottom && this.atBottom()) { + scrollTop = container.scrollHeight - container.clientHeight; + } + } + container.scrollTop = scrollTop; + this.setState({requestAllowed: true}); + }; + + render() { + return ( +
{ this.content = ref; }}> + + {this.props.content.logs.chunks.map((c, index) => ( + +
+ + ))} + +
+ ); + } +} + +export default Logs; diff --git a/dashboard/assets/components/Main.jsx b/dashboard/assets/components/Main.jsx index fba8ca1f6..0018aaf75 100644 --- a/dashboard/assets/components/Main.jsx +++ b/dashboard/assets/components/Main.jsx @@ -21,6 +21,7 @@ import React, {Component} from 'react'; import withStyles from 'material-ui/styles/withStyles'; import {MENU} from '../common'; +import Logs from './Logs'; import Footer from './Footer'; import type {Content} from '../types/content'; @@ -32,7 +33,7 @@ const styles = { width: '100%', }, content: { - flex: 1, + flex: 1, overflow: 'auto', }, }; @@ -46,14 +47,40 @@ const themeStyles = theme => ({ }); export type Props = { - classes: Object, - active: string, - content: Content, + classes: Object, + active: string, + content: Content, shouldUpdate: Object, + send: string => void, }; // Main renders the chosen content. class Main extends Component { + constructor(props) { + super(props); + this.container = React.createRef(); + this.content = React.createRef(); + } + + getSnapshotBeforeUpdate() { + if (this.content && typeof this.content.beforeUpdate === 'function') { + return this.content.beforeUpdate(); + } + return null; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (this.content && typeof this.content.didUpdate === 'function') { + this.content.didUpdate(prevProps, prevState, snapshot); + } + } + + onScroll = () => { + if (this.content && typeof this.content.onScroll === 'function') { + this.content.onScroll(); + } + }; + render() { const { classes, active, content, shouldUpdate, @@ -69,12 +96,27 @@ class Main extends Component { children =
Work in progress.
; break; case MENU.get('logs').id: - children =
{content.logs.log.map((log, index) =>
{log}
)}
; + children = ( + { this.content = ref; }} + container={this.container} + send={this.props.send} + content={this.props.content} + shouldUpdate={shouldUpdate} + /> + ); } return (
-
{children}
+
{ this.container = ref; }} + onScroll={this.onScroll} + > + {children} +