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
This commit is contained in:
committed by
Péter Szilágyi
parent
2eedbe799f
commit
a9835c1816
@ -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<Props> {
|
||||
active={this.props.active}
|
||||
content={this.props.content}
|
||||
shouldUpdate={this.props.shouldUpdate}
|
||||
send={this.props.send}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -85,7 +85,7 @@ export type Props = {
|
||||
class CustomTooltip extends Component<Props> {
|
||||
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);
|
||||
|
@ -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 = <T>(limit: number, mapper = replacer) => (update: Array<T>, 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<Props, State> {
|
||||
this.state = {
|
||||
active: MENU.get('home').id,
|
||||
sideBar: true,
|
||||
content: defaultContent,
|
||||
content: defaultContent(),
|
||||
shouldUpdate: {},
|
||||
server: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -181,7 +189,7 @@ class Dashboard extends Component<Props, State> {
|
||||
// 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<Content> = JSON.parse(event.data);
|
||||
@ -192,10 +200,18 @@ class Dashboard extends Component<Props, State> {
|
||||
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<Content>) => {
|
||||
this.setState(prevState => ({
|
||||
@ -226,6 +242,7 @@ class Dashboard extends Component<Props, State> {
|
||||
active={this.state.active}
|
||||
content={this.state.content}
|
||||
shouldUpdate={this.state.shouldUpdate}
|
||||
send={this.send}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
310
dashboard/assets/components/Logs.jsx
Normal file
310
dashboard/assets/components/Logs.jsx
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<Record>) => {
|
||||
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 += '<span style="color:#ce3c23">Invalid log record</span><br />';
|
||||
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 += `<span style="color:${color}">${lvl}</span>[${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 += ` <span style="color:${color}">${key}</span>=${val}${p}`;
|
||||
}
|
||||
content += '<br />';
|
||||
});
|
||||
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<Props, State> {
|
||||
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 (
|
||||
<div ref={(ref) => { this.content = ref; }}>
|
||||
<List>
|
||||
{this.props.content.logs.chunks.map((c, index) => (
|
||||
<ListItem style={styles.logListItem} key={index}>
|
||||
<div style={styles.logChunk} dangerouslySetInnerHTML={{__html: c.content}} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Logs;
|
@ -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<Props> {
|
||||
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<Props> {
|
||||
children = <div>Work in progress.</div>;
|
||||
break;
|
||||
case MENU.get('logs').id:
|
||||
children = <div>{content.logs.log.map((log, index) => <div key={index}>{log}</div>)}</div>;
|
||||
children = (
|
||||
<Logs
|
||||
ref={(ref) => { this.content = ref; }}
|
||||
container={this.container}
|
||||
send={this.props.send}
|
||||
content={this.props.content}
|
||||
shouldUpdate={shouldUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.wrapper}>
|
||||
<div className={classes.content} style={styles.content}>{children}</div>
|
||||
<div
|
||||
className={classes.content}
|
||||
style={styles.content}
|
||||
ref={(ref) => { this.container = ref; }}
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Footer
|
||||
general={content.general}
|
||||
system={content.system}
|
||||
|
Reference in New Issue
Block a user